Billy Barrow 1 månad sedan
förälder
incheckning
64b9562f18
5 ändrade filer med 537 tillägg och 443 borttagningar
  1. 7 1
      meson.build
  2. 1 0
      src/main.vala
  3. 74 1
      src/pages/app.css
  4. 428 0
      src/pages/app.js
  5. 27 441
      src/pages/main.html

+ 7 - 1
meson.build

@@ -146,6 +146,12 @@ app_css = custom_target('app-css',
     command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'app.css', '--class-name', 'AppCss',
               '--ns', 'ClankerSaddle', '-c', 'text/css', '@INPUT@'],
 )
+app_js = custom_target('app-js',
+    input: 'src/pages/app.js',
+    output: 'AppJs.vala',
+    command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'app.js', '--class-name', 'AppJs',
+              '--ns', 'ClankerSaddle', '-c', 'text/javascript', '@INPUT@'],
+)
 favicon_png = custom_target('favicon-png',
     input: 'src/clanker-saddle.png',
     output: 'FaviconResource.vala',
@@ -245,7 +251,7 @@ app_generated = [
 ]
 
 clanker_saddle_exe = executable('clanker-saddle',
-    app_sources + app_generated + [app_css, favicon_png, emblem_png, push_crypto],
+    app_sources + app_generated + [app_css, app_js, favicon_png, emblem_png, push_crypto],
     include_directories: include_directories('src/Push'),
     vala_args: ['--vapidir=' + join_paths(meson.current_source_dir(), 'vapi')],
     dependencies: [

+ 1 - 0
src/main.vala

@@ -194,6 +194,7 @@ int main(string[] args) {
 
         // spry:resources-begin
         statum.add_resource<ClankerSaddle.AppCss>();
+        statum.add_resource<ClankerSaddle.AppJs>();
         statum.add_resource<ClankerSaddle.FaviconResource>();
         statum.add_resource<ClankerSaddle.EmblemResource>();
         // spry:resources-end

+ 74 - 1
src/pages/app.css

@@ -48,7 +48,79 @@ header {
 
 header h1 { font-size: 1.25rem; margin: 0; display: flex; align-items: center; gap: 0.5rem; }
 header h1 .brand-emblem { height: 8rem; width: auto; margin-block: -0.2rem; }
-header nav { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--grid-1); }
+header nav { display: flex; align-items: center; flex-wrap: wrap; gap: var(--grid-1); }
+
+/* ---- Nav dropdown menus ---- */
+
+.nav-menu { display: inline-block; position: relative; }
+
+.nav-menu > summary {
+    border-radius: 6px;
+    cursor: pointer;
+    display: inline-flex;
+    align-items: center;
+    gap: 0.3rem;
+    list-style: none;
+    padding: 0.3rem 0.6rem;
+    user-select: none;
+}
+
+.nav-menu > summary::-webkit-details-marker { display: none; }
+
+.nav-menu > summary::after {
+    color: var(--ink-muted);
+    content: "▾";
+    font-size: 0.7em;
+    transition: transform 0.15s ease;
+}
+
+.nav-menu > summary:hover,
+.nav-menu[open] > summary { background: var(--line-soft); }
+
+.nav-menu[open] > summary::after { transform: rotate(180deg); }
+
+.nav-dropdown {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    box-shadow: 0 4px 16px #0002;
+    display: none;
+    flex-direction: column;
+    left: 0;
+    min-width: 9rem;
+    padding: 0.3rem;
+    position: absolute;
+    top: calc(100% + 0.3rem);
+    z-index: 50;
+}
+
+.nav-dropdown a {
+    border-radius: 6px;
+    padding: 0.35rem 0.6rem;
+    text-decoration: none;
+    white-space: nowrap;
+}
+
+.nav-dropdown a:hover { background: var(--line-soft); }
+
+/* Hover/focus surface the panel of a closed menu; [open] pins it.
+   The content-visibility override accompanies display because modern
+   engines hide a closed <details>' slot content that way. */
+.nav-menu:hover > .nav-dropdown,
+.nav-menu:focus-within > .nav-dropdown,
+.nav-menu[open] > .nav-dropdown {
+    content-visibility: visible;
+    display: flex;
+}
+
+.nav-auth {
+    align-items: baseline;
+    border-left: 1px solid var(--line);
+    display: inline-flex;
+    gap: var(--grid-1);
+    margin-left: var(--grid-1);
+    padding-left: var(--grid-2);
+}
 
 main { padding: var(--grid-2) 0 var(--grid-4); }
 
@@ -812,6 +884,7 @@ dialog#quote-comment-dialog blockquote {
 
 @media (max-width: 40rem) {
     header { flex-direction: column; gap: var(--grid-1); }
+    .nav-auth { border-left: none; margin-left: 0; padding-left: 0; }
     .cards { grid-template-columns: 1fr; }
     .grid-2 { grid-template-columns: 1fr; }
     th, td { padding: 0.4rem 0.5rem; font-size: 0.9rem; }

+ 428 - 0
src/pages/app.js

@@ -0,0 +1,428 @@
+        // 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 = 'primary';
+            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 = 'inline';
+                    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 <form> BOTH live in the page markup;
+    // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
+    // plan-remarks pattern) toggles which side shows. Why JS-applied
+    // values and not stm-attribute.value: statum's attribute binding
+    // sets the CONTENT attribute, which the browser ignores on
+    // <textarea> (its value lives in the text node) - so the textarea
+    // is re-prefilled from the card's data-value payload after every
+    // render cycle, while the page state carries the source of truth.
+    // The card contract:
+    //   [data-long-form]            the card root; data-field = slot key
+    //                               whose object carries the payload
+    //   [data-lf-view]              the rendered markdown half
+    //   [data-lf-edit]              the editor half (form)
+    //   textarea/input[data-lf-src] a state path ("task.description")
+    //                               whose current value prefills the
+    //                               control on every render
+    //   [data-lf-toggle]            the Edit / Cancel button
+    // Buttons INSIDE the form (Save) submit through the form's
+    // stm-action as usual; toggling is purely presentational, so no
+    // state round-trip and no flicker.
+    //
+    window.initLongFormEditors = function () {
+        if (!window.statum || !statum.afterEvaluate) return;
+
+        // Resolve a "type.field" state path: statum.read returns the
+        // SLOT's public object, so the first segment is the slot and
+        // the rest walks into it.
+        function readPath(path) {
+            if (!path) return '';
+            var parts = path.split('.');
+            var value = statum.read(parts[0]);
+            for (var i = 1; i < parts.length && value != null; i++) {
+                value = value[parts[i]];
+            }
+            return value === undefined || value === null ? '' : String(value);
+        }
+
+        function syncCard(card) {
+            var open = card.getAttribute('data-lf-open') === '1';
+            var view = card.querySelector('[data-lf-view]');
+            var editor = card.querySelector('[data-lf-edit]');
+            if (view) {
+                if (open) view.setAttribute('hidden', '');
+                else view.removeAttribute('hidden');
+            }
+            if (editor) {
+                if (open) editor.removeAttribute('hidden');
+                else editor.setAttribute('hidden', '');
+            }
+        }
+
+        // Prefill bookkeeping: applied ONCE per open - a statum
+        // re-render (pulse refresh, action response) rebinds
+        // attributes but never textarea VALUES, so re-applying on
+        // every cycle would clobber the user's in-progress typing.
+        // Closing the card clears the flag; the next open re-prefills
+        // from the (possibly updated) state.
+        var prefilled = {};
+        function applyPrefills() {
+            var cards = document.querySelectorAll('[data-long-form]');
+            Array.prototype.forEach.call(cards, function (card) {
+                syncCard(card);
+                var key = card.getAttribute('data-field') || '';
+                var open = card.getAttribute('data-lf-open') === '1';
+                if (open && !prefilled[key]) {
+                    var controls = card.querySelectorAll('[data-lf-src]');
+                    Array.prototype.forEach.call(controls, function (control) {
+                        var value = readPath(control.getAttribute('data-lf-src'));
+                        if (document.activeElement !== control) {
+                            control.value = value;
+                        }
+                    });
+                    prefilled[key] = true;
+                }
+                if (!open) delete prefilled[key];
+            });
+        }
+
+        document.addEventListener('click', function (event) {
+            if (!event.target.closest) return;
+            var button = event.target.closest('[data-lf-toggle]');
+            if (!button) return;
+            var card = button.closest('[data-long-form]');
+            if (!card) return;
+            event.preventDefault();
+            var open = card.getAttribute('data-lf-open') === '1';
+            if (open) {
+                card.setAttribute('data-lf-open', '0');
+                delete prefilled[card.getAttribute('data-field') || ''];
+            } else {
+                card.setAttribute('data-lf-open', '1');
+            }
+            syncCard(card);
+            // Prefill immediately (the render cycle applies the state
+            // bindings; the textarea's value needs this direct pass).
+            applyPrefills();
+            if (!open) {
+                var area = card.querySelector('textarea');
+                if (area && area.focus) area.focus();
+            }
+        });
+
+        statum.afterEvaluate(applyPrefills);
+    };
+
+    if (window.statum) {
+            (function () {
+                'use strict';
+
+                window.showToast = function (message, kind) {
+                    var stack = document.querySelector('.toast-stack');
+                    if (!stack) {
+                        stack = document.createElement('div');
+                        stack.className = 'toast-stack';
+                        document.body.appendChild(stack);
+                    }
+                    var toast = document.createElement('div');
+                    toast.className = 'toast ' + (kind || '');
+                    toast.textContent = message;
+                    stack.appendChild(toast);
+                    window.setTimeout(function () {
+                        toast.classList.add('leaving');
+                        window.setTimeout(function () { toast.remove(); }, 300);
+                    }, 5000);
+                };
+
+                statum.onNotifyHandler = function (kind, message) {
+                    showToast(message || kind, kind);
+                };
+
+                var quoteHandlers = [];
+                var quoteButton = null;
+                var quotePopover = null;
+                var quotedText = '';
+                var activeHandler = null;
+
+                window.initQuoteComments = function (handler) {
+                    quoteHandlers.push(handler);
+                };
+
+                document.addEventListener('mouseup', function (event) {
+                    if (quoteButton && quoteButton.contains(event.target)) return;
+                    var selection = window.getSelection();
+                    var text = selection ? String(selection.toString()).trim() : '';
+                    activeHandler = matchHandler(event.target);
+                    if (text.length === 0 || !activeHandler) {
+                        hideQuoteUi();
+                        return;
+                    }
+                    quotedText = text;
+                    showQuoteButton(event.pageX, event.pageY);
+                });
+
+                document.addEventListener('mousedown', function (event) {
+                    if (quoteButton && quoteButton.contains(event.target)) return;
+                    if (quotePopover && quotePopover.contains(event.target)) return;
+                    hideQuoteUi();
+                });
+
+                function matchHandler(node) {
+                    for (var i = 0; i < quoteHandlers.length; i++) {
+                        if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
+                    }
+                    return null;
+                }
+
+                function showQuoteButton(x, y) {
+                    hideQuoteUi();
+                    quoteButton = document.createElement('button');
+                    quoteButton.type = 'button';
+                    quoteButton.className = 'quote-button';
+                    quoteButton.textContent = 'Comment on this';
+                    quoteButton.style.left = Math.max(x - 60, 8) + 'px';
+                    quoteButton.style.top = (y - 36) + 'px';
+                    quoteButton.addEventListener('click', function (event) {
+                        event.preventDefault();
+                        openQuoteDialog();
+                    });
+                    document.body.appendChild(quoteButton);
+                }
+
+                function openQuoteDialog() {
+                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
+                    var dialog = document.getElementById('quote-comment-dialog');
+                    if (!dialog) return;
+                    var quote = dialog.querySelector('#quote-text');
+                    var field = dialog.querySelector('input[name="quote"]');
+                    if (quote) quote.textContent = quotedText;
+                    if (field) field.value = quotedText;
+                    var returnField = dialog.querySelector('input[name="return_to"]');
+                    if (returnField) returnField.value = location.pathname + location.search;
+                    if (typeof dialog.showModal === 'function') {
+                        dialog.showModal();
+                    } else {
+                        dialog.setAttribute('open', '');
+                    }
+                }
+
+                function hideQuoteUi() {
+                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
+                    if (quotePopover) { quotePopover.remove(); quotePopover = null; }
+                    activeHandler = null;
+                }
+            })();
+        }

+ 27 - 441
src/pages/main.html

@@ -10,436 +10,7 @@
     <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
     <link rel="apple-touch-icon" pstm-res-href="clanker-saddle.png">
     <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
-    <script>
-        // 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 = 'primary';
-            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 = 'inline';
-                    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 <form> BOTH live in the page markup;
-    // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
-    // plan-remarks pattern) toggles which side shows. Why JS-applied
-    // values and not stm-attribute.value: statum's attribute binding
-    // sets the CONTENT attribute, which the browser ignores on
-    // <textarea> (its value lives in the text node) - so the textarea
-    // is re-prefilled from the card's data-value payload after every
-    // render cycle, while the page state carries the source of truth.
-    // The card contract:
-    //   [data-long-form]            the card root; data-field = slot key
-    //                               whose object carries the payload
-    //   [data-lf-view]              the rendered markdown half
-    //   [data-lf-edit]              the editor half (form)
-    //   textarea/input[data-lf-src] a state path ("task.description")
-    //                               whose current value prefills the
-    //                               control on every render
-    //   [data-lf-toggle]            the Edit / Cancel button
-    // Buttons INSIDE the form (Save) submit through the form's
-    // stm-action as usual; toggling is purely presentational, so no
-    // state round-trip and no flicker.
-    //
-    window.initLongFormEditors = function () {
-        if (!window.statum || !statum.afterEvaluate) return;
-
-        // Resolve a "type.field" state path: statum.read returns the
-        // SLOT's public object, so the first segment is the slot and
-        // the rest walks into it.
-        function readPath(path) {
-            if (!path) return '';
-            var parts = path.split('.');
-            var value = statum.read(parts[0]);
-            for (var i = 1; i < parts.length && value != null; i++) {
-                value = value[parts[i]];
-            }
-            return value === undefined || value === null ? '' : String(value);
-        }
-
-        function syncCard(card) {
-            var open = card.getAttribute('data-lf-open') === '1';
-            var view = card.querySelector('[data-lf-view]');
-            var editor = card.querySelector('[data-lf-edit]');
-            if (view) {
-                if (open) view.setAttribute('hidden', '');
-                else view.removeAttribute('hidden');
-            }
-            if (editor) {
-                if (open) editor.removeAttribute('hidden');
-                else editor.setAttribute('hidden', '');
-            }
-        }
-
-        // Prefill bookkeeping: applied ONCE per open - a statum
-        // re-render (pulse refresh, action response) rebinds
-        // attributes but never textarea VALUES, so re-applying on
-        // every cycle would clobber the user's in-progress typing.
-        // Closing the card clears the flag; the next open re-prefills
-        // from the (possibly updated) state.
-        var prefilled = {};
-        function applyPrefills() {
-            var cards = document.querySelectorAll('[data-long-form]');
-            Array.prototype.forEach.call(cards, function (card) {
-                syncCard(card);
-                var key = card.getAttribute('data-field') || '';
-                var open = card.getAttribute('data-lf-open') === '1';
-                if (open && !prefilled[key]) {
-                    var controls = card.querySelectorAll('[data-lf-src]');
-                    Array.prototype.forEach.call(controls, function (control) {
-                        var value = readPath(control.getAttribute('data-lf-src'));
-                        if (document.activeElement !== control) {
-                            control.value = value;
-                        }
-                    });
-                    prefilled[key] = true;
-                }
-                if (!open) delete prefilled[key];
-            });
-        }
-
-        document.addEventListener('click', function (event) {
-            if (!event.target.closest) return;
-            var button = event.target.closest('[data-lf-toggle]');
-            if (!button) return;
-            var card = button.closest('[data-long-form]');
-            if (!card) return;
-            event.preventDefault();
-            var open = card.getAttribute('data-lf-open') === '1';
-            if (open) {
-                card.setAttribute('data-lf-open', '0');
-                delete prefilled[card.getAttribute('data-field') || ''];
-            } else {
-                card.setAttribute('data-lf-open', '1');
-            }
-            syncCard(card);
-            // Prefill immediately (the render cycle applies the state
-            // bindings; the textarea's value needs this direct pass).
-            applyPrefills();
-            if (!open) {
-                var area = card.querySelector('textarea');
-                if (area && area.focus) area.focus();
-            }
-        });
-
-        statum.afterEvaluate(applyPrefills);
-    };
-
-    if (window.statum) {
-            (function () {
-                'use strict';
-
-                window.showToast = function (message, kind) {
-                    var stack = document.querySelector('.toast-stack');
-                    if (!stack) {
-                        stack = document.createElement('div');
-                        stack.className = 'toast-stack';
-                        document.body.appendChild(stack);
-                    }
-                    var toast = document.createElement('div');
-                    toast.className = 'toast ' + (kind || '');
-                    toast.textContent = message;
-                    stack.appendChild(toast);
-                    window.setTimeout(function () {
-                        toast.classList.add('leaving');
-                        window.setTimeout(function () { toast.remove(); }, 300);
-                    }, 5000);
-                };
-
-                statum.onNotifyHandler = function (kind, message) {
-                    showToast(message || kind, kind);
-                };
-
-                var quoteHandlers = [];
-                var quoteButton = null;
-                var quotePopover = null;
-                var quotedText = '';
-                var activeHandler = null;
-
-                window.initQuoteComments = function (handler) {
-                    quoteHandlers.push(handler);
-                };
-
-                document.addEventListener('mouseup', function (event) {
-                    if (quoteButton && quoteButton.contains(event.target)) return;
-                    var selection = window.getSelection();
-                    var text = selection ? String(selection.toString()).trim() : '';
-                    activeHandler = matchHandler(event.target);
-                    if (text.length === 0 || !activeHandler) {
-                        hideQuoteUi();
-                        return;
-                    }
-                    quotedText = text;
-                    showQuoteButton(event.pageX, event.pageY);
-                });
-
-                document.addEventListener('mousedown', function (event) {
-                    if (quoteButton && quoteButton.contains(event.target)) return;
-                    if (quotePopover && quotePopover.contains(event.target)) return;
-                    hideQuoteUi();
-                });
-
-                function matchHandler(node) {
-                    for (var i = 0; i < quoteHandlers.length; i++) {
-                        if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
-                    }
-                    return null;
-                }
-
-                function showQuoteButton(x, y) {
-                    hideQuoteUi();
-                    quoteButton = document.createElement('button');
-                    quoteButton.type = 'button';
-                    quoteButton.className = 'quote-button';
-                    quoteButton.textContent = 'Comment on this';
-                    quoteButton.style.left = Math.max(x - 60, 8) + 'px';
-                    quoteButton.style.top = (y - 36) + 'px';
-                    quoteButton.addEventListener('click', function (event) {
-                        event.preventDefault();
-                        openQuoteDialog();
-                    });
-                    document.body.appendChild(quoteButton);
-                }
-
-                function openQuoteDialog() {
-                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
-                    var dialog = document.getElementById('quote-comment-dialog');
-                    if (!dialog) return;
-                    var quote = dialog.querySelector('#quote-text');
-                    var field = dialog.querySelector('input[name="quote"]');
-                    if (quote) quote.textContent = quotedText;
-                    if (field) field.value = quotedText;
-                    var returnField = dialog.querySelector('input[name="return_to"]');
-                    if (returnField) returnField.value = location.pathname + location.search;
-                    if (typeof dialog.showModal === 'function') {
-                        dialog.showModal();
-                    } else {
-                        dialog.setAttribute('open', '');
-                    }
-                }
-
-                function hideQuoteUi() {
-                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
-                    if (quotePopover) { quotePopover.remove(); quotePopover = null; }
-                    activeHandler = null;
-                }
-            })();
-        }
-    </script>
+    <script pstm-res-src="app.js"></script>
 </head>
 <body>
     <header>
@@ -447,18 +18,33 @@
         <nav>
             <a href="/">Dashboard</a>
             <!-- spry:nav-begin -->
-            <a href="/projects">Projects</a>
-            <a href="/tasks">Tasks</a>
-            <a href="/goals">Goals</a>
-            <a href="/saddles">Saddles</a>
-            <a href="/rules">Rules</a>
-            <a href="/stacks">Stacks</a>
-            <!-- The users link is display-guarded (the page's entrypoint
-                 guard is authoritative); auth.permissions rides the
-                 signed auth slot, so an admin sees it everywhere. -->
-            <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
+            <details class="nav-menu" name="nav-menu">
+                <summary>Work</summary>
+                <div class="nav-dropdown">
+                    <a href="/tasks">Tasks</a>
+                    <a class="nav-goal" href="/goals"><em class="goal-icon">🎯</em> Goals</a>
+                </div>
+            </details>
+            <details class="nav-menu" name="nav-menu">
+                <summary>Library</summary>
+                <div class="nav-dropdown">
+                    <a href="/projects">Projects</a>
+                    <a href="/rules">Rules</a>
+                    <a href="/stacks">Stacks</a>
+                </div>
+            </details>
+            <details class="nav-menu" name="nav-menu">
+                <summary>Admin</summary>
+                <div class="nav-dropdown">
+                    <a href="/saddles">Saddles</a>
+                    <!-- The users link is display-guarded (the page's entrypoint
+                         guard is authoritative); auth.permissions rides the
+                         signed auth slot, so an admin sees it everywhere. -->
+                    <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
+                </div>
+            </details>
             <!-- spry:nav-end -->
-            <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
+            <span stm-if="auth != null" class="nav-auth"><strong stm-text="auth.username">user</strong>
                 <a href="/profile" title="Your profile">Profile</a>
                 <button type="button" class="inline" stm-action="auth.logout">Log out</button>
                 <!-- spry:nav-auth-begin -->