// Shared page plumbing. showToast: slide-in notifications (used in // place of alert). initQuoteComments: registers a container matcher // for the text-highlight -> comment popover - selecting text inside // a matched container shows a floating button, then a popover that // posts the comment with the quoted text via the action the page // supplies. // Shared UI engines (parameterised per page): // // showToast - every page's toast corner. // // initQuoteComments - the text-selection quote popover + dialog // posting a comment with the quoted text; pages pass a `matches` // node predicate and the action accessor. // // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern // that lived duplicated between the task and goal pages, now // goal-only after the task-page plan review died with the poll // era): the plan/completion REVIEW remark queue. "Add remark" // appends to a per-block queue rendered at the bottom of the // review body, persists across statum re-renders (the maps live // outside the render cycle), rides the rejection NUMBERED (each // remark with its selection quote prefixed like a comment), and // clears on accept. Remark-less rejection is allowed when the // feedback textarea carries the substance (plan 16 S2). The // rejection body rides `feedback`; the SERVER wraps the per-kind // stable prefix (R4 - templates carry no verdict constants). The // page supplies `resolveBlock(id)` - how a [data-plan-review] // container's block row resolves from page state. window.initPlanReviews = function (config) { if (!window.statum || !statum.afterEvaluate) return; var resolveBlock = (config && config.resolveBlock) || function () { return null; }; var remarksByBlock = {}; var rejectOpenByBlock = {}; var remarkPopover = null; statum.afterEvaluate(syncPlanRemarks); document.addEventListener('click', function (event) { if (!event.target.closest) return; var accept = event.target.closest('[data-plan-accept]'); if (accept) { // Accept ignores/clears the queued remarks. var accepted = accept.closest('[data-plan-review]'); if (accepted) { var acceptId = accepted.getAttribute('data-block-id'); remarksByBlock[acceptId] = []; rejectOpenByBlock[acceptId] = false; } return; } var toggle = event.target.closest('[data-plan-toggle]'); if (toggle) { hideRemarkPopover(); var review = toggle.closest('[data-plan-review]'); var id = review && review.getAttribute('data-block-id'); if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id]; syncPlanRemarks(); return; } var submit = event.target.closest('[data-plan-submit]'); if (submit) { submitPlanRemarks(submit.closest('[data-plan-review]')); return; } var remove = event.target.closest('[data-plan-remove]'); if (remove) { var entry = remove.closest('[data-plan-remark-id]'); var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id'); var list = remarksByBlock[blockId] || []; var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10); if (!isNaN(index) && list[index]) list.splice(index, 1); remarksByBlock[blockId] = list; syncPlanRemarks(); } }); document.addEventListener('mouseup', function (event) { if (!event.target.closest) return; if (remarkPopover && remarkPopover.contains(event.target)) return; var body = event.target.closest('[data-plan-body]'); if (!body) { hideRemarkPopover(); return; } var review = body.closest('[data-plan-review]'); if (!review || review.getAttribute('data-plan-open') !== '1') return; var selection = window.getSelection(); var text = selection ? String(selection.toString()).trim() : ''; if (text.length === 0) { hideRemarkPopover(); return; } showRemarkPopover(event.pageX, event.pageY, text, review); }); document.addEventListener('mousedown', function (event) { if (remarkPopover && event.target.closest && event.target.closest('.plan-popover')) return; hideRemarkPopover(); }); function showRemarkPopover(x, y, quote, review) { hideRemarkPopover(); remarkPopover = document.createElement('div'); remarkPopover.className = 'plan-popover'; remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px'; remarkPopover.style.top = y + 'px'; var quoted = document.createElement('blockquote'); quoted.textContent = quote; remarkPopover.appendChild(quoted); var body = document.createElement('textarea'); body.placeholder = 'Your remark on the selected plan text'; remarkPopover.appendChild(body); var actions = document.createElement('div'); actions.className = 'actions'; var add = document.createElement('button'); add.type = 'button'; add.className = 'btn-primary btn-sm'; add.textContent = 'Add remark'; add.addEventListener('click', function () { if (body.value.trim().length === 0) return; var id = review.getAttribute('data-block-id'); var list = remarksByBlock[id] || []; list.push({ quote: quote, body: body.value.trim() }); remarksByBlock[id] = list; hideRemarkPopover(); syncPlanRemarks(); }); var cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = 'Cancel'; cancel.addEventListener('click', hideRemarkPopover); actions.appendChild(add); actions.appendChild(cancel); remarkPopover.appendChild(actions); document.body.appendChild(remarkPopover); body.focus(); } function hideRemarkPopover() { if (remarkPopover) { remarkPopover.remove(); remarkPopover = null; } } function submitPlanRemarks(review) { if (!review) return; var id = review.getAttribute('data-block-id'); var list = remarksByBlock[id] || []; var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]'); var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : ''; // Remark-less rejection is allowed when the feedback // textarea carries the review's substance - one of the // two must be non-empty (plan 16 S2). if (list.length === 0 && extraText.length === 0) { if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error'); return; } var block = resolveBlock(id); if (!block || !block.answer_action) return; // The rejection body keeps the historical shape: every // queued remark NUMBERED in queue order, each with its // selection quote prefixed like a comment, then the // free-text feedback. var feedback = list.map(function (item, index) { return (item.quote ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n' : '') + (index + 1) + '. ' + item.body; }).join('\n\n'); if (extraText.length > 0) { feedback += (feedback.length > 0 ? '\n\n' : '') + extraText; } remarksByBlock[id] = []; rejectOpenByBlock[id] = false; statum.act(block.answer_action, { feedback: feedback }).catch(function () { }); } function syncPlanRemarks() { document.querySelectorAll('[data-plan-review]').forEach(function (review) { var id = review.getAttribute('data-block-id'); var open = rejectOpenByBlock[id] ? '1' : '0'; review.setAttribute('data-plan-open', open); var feedback = review.querySelector('[data-plan-feedback]'); if (feedback) { if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden'); else feedback.setAttribute('hidden', ''); } var list = review.querySelector('[data-plan-remarks]'); if (!list) return; while (list.firstChild) list.removeChild(list.firstChild); (remarksByBlock[id] || []).forEach(function (item, index) { var entry = document.createElement('li'); entry.setAttribute('data-plan-remark-id', String(index)); if (item.quote) { var quoted = document.createElement('blockquote'); quoted.textContent = item.quote; entry.appendChild(quoted); } var body = document.createElement('div'); body.textContent = item.body; entry.appendChild(body); var remove = document.createElement('button'); remove.type = 'button'; remove.className = 'btn-ghost btn-sm'; remove.setAttribute('data-plan-remove', ''); remove.textContent = 'Remove'; entry.appendChild(remove); list.appendChild(entry); }); }); } }; // // initLongFormEditors (plan QoL task 12): in-place editors for the // long-form card fields (task mandate, goal brief). The rendered // markdown view and the editor