#19 More fixes

Liitetty
Tilo15 liitetty 1 sitoutusta lähteestä clanker/goal/ui-ux kohteeseen clanker/web-stack 23 tuntia sitten

+ 112 - 0
src/pages/app.css

@@ -38,6 +38,118 @@
     margin-block: -0.2rem;
 }
 
+/* The topbar scrolls with the page (review round 3): spry ships it
+   sticky, which pinned a tall band (the 8rem emblem makes it very
+   tall) over content on mobile. Static here; the sticky consumers
+   (sub-nav, log toolbar) fall back to their --topbar-bottom default
+   which now measures 0 - their own top: math still works because
+   app.js only publishes the property when the bar is measurable, and
+   these rules keep their existing fallback values. */
+.topbar {
+    position: static;
+}
+
+/* Nav menu tops are BUTTONS now (review round 3): tapping an anchor
+   navigated away before a touch user could open the submenu. They
+   carry .nav-toggle and share the framework's .nav a grammar. */
+.nav .nav-toggle {
+    background: none;
+    border: 0;
+    color: var(--spry-fg);
+    cursor: pointer;
+    display: inline-flex;
+    align-items: center;
+    gap: 0.3rem;
+    font: inherit;
+    font-weight: 500;
+    padding: 0.35rem 0.75rem;
+    border-radius: var(--spry-radius-sm);
+}
+.nav .nav-toggle:hover {
+    background: var(--spry-bg-alt);
+    color: var(--spry-accent);
+}
+.nav .nav-toggle::after {
+    content: "\25BE";
+    font-size: 0.75em;
+    color: var(--spry-fg-muted);
+}
+.nav .nav-toggle[aria-expanded="true"] {
+    background: var(--spry-accent-soft);
+    color: var(--spry-accent);
+}
+
+/* Open state for the dropdown (touch/click): spry's hover/focus rules
+   stay for pointer users; .is-open is the click-toggle equivalent. */
+.has-submenu.is-open > .submenu {
+    opacity: 1;
+    visibility: visible;
+    transform: none;
+}
+
+/* Spry styles .nav/.menu lists but not the .submenu ul itself (review
+   round 3: bullets were showing in nav menus). */
+.submenu {
+    list-style: none;
+    margin: 0;
+    padding: 0.35rem;
+}
+.submenu li {
+    display: block;
+}
+
+/* Busy loaders (review round 3): statum's stm-busy-class adds .is-busy
+   and disables the element for the duration of an action. Buttons grow
+   an inline spinner; a busy FORM's submit button shows it instead
+   (the form element itself cannot). */
+.is-busy.btn,
+button.is-busy {
+    position: relative;
+    color: transparent !important;
+    pointer-events: none;
+}
+.is-busy.btn::after,
+button.is-busy::after {
+    border: 0.18em solid var(--spry-border);
+    border-radius: 50%;
+    border-top-color: var(--spry-accent);
+    content: "";
+    display: inline-block;
+    height: 1.1em;
+    left: 50%;
+    margin-left: -0.55em;
+    margin-top: -0.55em;
+    position: absolute;
+    top: 50%;
+    width: 1.1em;
+    animation: spry-spin 0.9s linear infinite;
+}
+form.is-busy > .dialog-footer .btn-primary,
+form.is-busy button[type="submit"],
+form.is-busy .btn-primary {
+    position: relative;
+    color: transparent !important;
+    pointer-events: none;
+}
+form.is-busy > .dialog-footer .btn-primary::after,
+form.is-busy button[type="submit"]::after,
+form.is-busy .btn-primary::after {
+    border: 0.18em solid var(--spry-border);
+    border-radius: 50%;
+    border-top-color: var(--spry-accent);
+    content: "";
+    display: inline-block;
+    height: 1.1em;
+    left: 50%;
+    margin-left: -0.55em;
+    margin-top: -0.55em;
+    position: absolute;
+    top: 50%;
+    width: 1.1em;
+    animation: spry-spin 0.9s linear infinite;
+}
+
+
 /* The account flyout sits at the nav's right edge; its panel
    right-aligns so it never leaves the viewport. */
 .nav-auth { display: inline-flex; gap: var(--spry-space-1); margin-inline-start: auto; }

+ 36 - 0
src/pages/app.js

@@ -355,6 +355,42 @@
     // statum.afterEvaluate re-runs the sync pass so the strip
     // re-derives its highlight and stuck state after every render.
     //
+    // Nav dropdown toggles (goal 41 review round 3): the menu TOPS are
+    // buttons, not links - tapping a link navigated away before a touch
+    // user could pick a submenu item. Click toggles .is-open on the
+    // .has-submenu li (spry's hover/focus-within rules still serve
+    // pointer users); a click outside, or Esc, closes. The engine binds
+    // once at document level (delegation), so statum re-renders never
+    // strand a menu.
+    (function initNavToggles() {
+        function closeAll(except) {
+            var open = document.querySelectorAll('.has-submenu.is-open');
+            for (var i = 0; i < open.length; i++) {
+                if (open[i] !== except) {
+                    open[i].classList.remove('is-open');
+                    var toggle = open[i].querySelector('.nav-toggle');
+                    if (toggle) toggle.setAttribute('aria-expanded', 'false');
+                }
+            }
+        }
+        document.addEventListener('click', function (event) {
+            var toggle = event.target.closest ? event.target.closest('.nav-toggle') : null;
+            if (toggle) {
+                var item = toggle.closest('.has-submenu');
+                var isOpen = item.classList.contains('is-open');
+                closeAll(item);
+                item.classList.toggle('is-open', !isOpen);
+                toggle.setAttribute('aria-expanded', String(!isOpen));
+                return;
+            }
+            // A click on a submenu item navigates; anything else closes.
+            if (!event.target.closest || !event.target.closest('.has-submenu')) closeAll(null);
+        });
+        document.addEventListener('keydown', function (event) {
+            if (event.key === 'Escape') closeAll(null);
+        });
+    })();
+
     window.initSubnav = function () {
         if (!window.statum || !statum.afterEvaluate) return;
         var FLAG = 'data-subnav-engine';

+ 1 - 1
src/pages/block_answer.html

@@ -11,7 +11,7 @@
     <pstm-name>block-answer</pstm-name>
 </head>
 <body>
-    <form stm-if="!block.answered && block.block_type == 'question'" stm-action="block.answer_action">
+    <form stm-if="!block.answered && block.block_type == 'question'" stm-action="block.answer_action" stm-busy-class="is-busy">
         <div class="options">
             <label stm-for-option-in="block.options" stm-key="option" class="option-card">
                 <input type="radio" name="option" stm-attribute.value="option">

+ 1 - 1
src/pages/block_reply.html

@@ -9,7 +9,7 @@
 </head>
 <body>
     <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review'"
-          stm-action="block.answer_action" class="cluster">
+          stm-action="block.answer_action" stm-busy-class="is-busy" class="cluster">
         <textarea name="answer" rows="3" placeholder="Your reply (delivered to the agent)"></textarea>
         <button type="submit" class="btn-primary">Reply</button>
     </form>

+ 1 - 1
src/pages/chat_detail.html

@@ -144,7 +144,7 @@
                         <!-- Goal 41 T9: delete is destructive - ghost it
                              out of the header's face (the confirm still
                              guards it). -->
-                        <button type="button" class="btn-ghost btn-sm chat-delete" stm-action="chat.delete"
+                        <button type="button" class="btn-ghost btn-sm chat-delete" stm-action="chat.delete" stm-busy-class="is-busy"
                                 stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
                     </form></p>
                 <p class="alert alert-warning" stm-if="!chat.advisor_configured">The advisor is not

+ 1 - 1
src/pages/chats.html

@@ -36,7 +36,7 @@
                                 <!-- Goal 41 T9: a destructive action should not
                                      be the row's loudest control - ghost it
                                      (the confirm still guards it). -->
-                                <button type="button" class="btn-ghost btn-sm" stm-action="chat.delete"
+                                <button type="button" class="btn-ghost btn-sm" stm-action="chat.delete" stm-busy-class="is-busy"
                                         stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
                             </form>
                         </td>

+ 2 - 2
src/pages/discoveries.html

@@ -75,7 +75,7 @@
                                 stm-text="discovery.updated_display">when</span></span></p>
                     </div>
                     <div class="card-body" data-lf-edit hidden>
-                        <form stm-action="discovery.update" class="form-rows">
+                        <form stm-action="discovery.update" stm-busy-class="is-busy" class="form-rows">
                             <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
                             <div class="field"><label>Body</label>
                                 <textarea name="body" rows="6"
@@ -88,7 +88,7 @@
                     </div>
                 </div>
                 <div class="card-body">
-                    <button type="button" class="btn-ghost btn-sm" stm-action="discovery.delete"
+                    <button type="button" class="btn-ghost btn-sm" stm-action="discovery.delete" stm-busy-class="is-busy"
                             stm-confirm="Delete this discovery?">Delete</button>
                 </div>
             </div>

+ 20 - 20
src/pages/goal_detail.html

@@ -178,21 +178,21 @@
                 </span>
                 <span class="goal-state-actions">
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.start != null" stm-action="goal.start">Start
+                            stm-if="goal.start != null" stm-action="goal.start" stm-busy-class="is-busy">Start
                         goal</button>
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.resume != null" stm-action="goal.resume">Resume</button>
+                            stm-if="goal.resume != null" stm-action="goal.resume" stm-busy-class="is-busy">Resume</button>
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.retry_run != null" stm-action="goal.retry_run">Retry
+                            stm-if="goal.retry_run != null" stm-action="goal.retry_run" stm-busy-class="is-busy">Retry
                         supervision</button>
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.pause != null" stm-action="goal.pause">Pause</button>
+                            stm-if="goal.pause != null" stm-action="goal.pause" stm-busy-class="is-busy">Pause</button>
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.requeue_run != null" stm-action="goal.requeue_run"
+                            stm-if="goal.requeue_run != null" stm-action="goal.requeue_run" stm-busy-class="is-busy"
                             stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue
                         to resume</button>
                     <button type="button" class="btn-primary btn-sm"
-                            stm-if="goal.force_wake != null" stm-action="goal.force_wake"
+                            stm-if="goal.force_wake != null" stm-action="goal.force_wake" stm-busy-class="is-busy"
                             stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force
                         wake</button>
                 </span>
@@ -237,7 +237,7 @@
                     <div class="md rfc-body goal-body" data-plan-body
                          stm-markdown="goal.plan_pending_block.body">revision</div>
                     <div class="actions">
-                        <form stm-action="goal.plan_pending_block.answer_action" class="cluster">
+                        <form stm-action="goal.plan_pending_block.answer_action" stm-busy-class="is-busy" class="cluster">
                             <input type="hidden" name="verdict" value="accept">
                             <button type="submit" class="btn-primary" data-plan-accept>Approve plan</button>
                         </form>
@@ -267,7 +267,7 @@
                     <div class="md rfc-body goal-body" data-plan-body
                          stm-markdown="goal.completion_pending_block.body">report</div>
                     <div class="actions">
-                        <form stm-action="goal.completion_pending_block.answer_action" class="cluster">
+                        <form stm-action="goal.completion_pending_block.answer_action" stm-busy-class="is-busy" class="cluster">
                             <input type="hidden" name="verdict" value="accept">
                             <button type="submit" class="btn-primary" data-plan-accept>Accept goal</button>
                         </form>
@@ -319,23 +319,23 @@
                         <span stm-text="goal.completed_display">when</span></span></p>
                 <p>
                     <button type="button" class="btn-primary" stm-if="goal.start != null"
-                            stm-action="goal.start">Start goal</button>
+                            stm-action="goal.start" stm-busy-class="is-busy">Start goal</button>
                     <button type="button" stm-if="goal.pause != null"
-                            stm-action="goal.pause">Pause</button>
+                            stm-action="goal.pause" stm-busy-class="is-busy">Pause</button>
                     <button type="button" stm-if="goal.resume != null"
-                            stm-action="goal.resume">Resume</button>
+                            stm-action="goal.resume" stm-busy-class="is-busy">Resume</button>
                     <button type="button" class="btn-primary" stm-if="goal.retry_run != null"
-                            stm-action="goal.retry_run">Retry supervision</button>
+                            stm-action="goal.retry_run" stm-busy-class="is-busy">Retry supervision</button>
                     <button type="button" stm-if="goal.requeue_run != null"
-                            stm-action="goal.requeue_run"
+                            stm-action="goal.requeue_run" stm-busy-class="is-busy"
                             stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
                     <!-- Goal 4 task 1: Force wake — the parked-agent
                          recovery for the no-pending-wake stall. -->
                     <button type="button" stm-if="goal.force_wake != null"
-                            stm-action="goal.force_wake"
+                            stm-action="goal.force_wake" stm-busy-class="is-busy"
                             stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force wake</button>
                     <button type="button" class="danger" stm-if="goal.cancel != null"
-                            stm-action="goal.cancel"
+                            stm-action="goal.cancel" stm-busy-class="is-busy"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
                 </p>
                 <p class="text-muted" stm-if="goal.status == 'draft'">Starting locks the brief and
@@ -495,7 +495,7 @@
                         <button type="button" class="dialog-close" aria-label="Close"
                                 onclick="document.getElementById('quote-comment-dialog').close()">×</button>
                     </div>
-                    <form stm-action="goal.comment">
+                    <form stm-action="goal.comment" stm-busy-class="is-busy">
                         <div class="dialog-body form-rows">
                             <blockquote id="quote-text" class="term-comment-quote"></blockquote>
                             <input type="hidden" name="quote">
@@ -580,7 +580,7 @@
                     <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
                 </div>
                 <div stm-if="goal.update != null">
-                    <form data-lf-edit hidden stm-action="goal.update">
+                    <form data-lf-edit hidden stm-action="goal.update" stm-busy-class="is-busy">
                         <div class="field"><label>Title</label>
                             <input type="text" name="title" data-lf-src="goal.title"></div>
                         <div class="field"><label>Brief (markdown)</label>
@@ -601,14 +601,14 @@
                 <p stm-if="goal.links.length > 0"><span stm-for-link-in="goal.links">
                     <span stm-attribute.class="'badge badge-' + link.relationship"
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
-                    <button type="button" class="btn-ghost" stm-action="link.remove"
+                    <button type="button" class="btn-ghost" stm-action="link.remove" stm-busy-class="is-busy"
                             stm-confirm="Remove this link?">&times;</button>
                     <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
                 <p stm-if="goal.links.length == 0" class="text-muted">Only the primary project.</p>
                 <details stm-if="!goal.terminal">
                     <summary>Link a project</summary>
-                    <form class="cluster" stm-action="goal.add_link">
+                    <form class="cluster" stm-action="goal.add_link" stm-busy-class="is-busy">
                         <select name="linked_project_id" required>
                             <option stm-for-project-in="goal.project_options" stm-attribute.value="project.id"
                                      stm-text="project.name">project</option>
@@ -619,7 +619,7 @@
                         </select>
                         <button type="submit">Link</button>
                     </form>
-                    <form class="cluster" stm-action="goal.apply_stack"
+                    <form class="cluster" stm-action="goal.apply_stack" stm-busy-class="is-busy"
                           stm-if="goal.stack_options.length > 0" style="margin-top: 0.5em;">
                         <label for="goal-stack">Apply stack</label>
                         <select id="goal-stack" name="stack_id" required>

+ 1 - 1
src/pages/goals.html

@@ -150,7 +150,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('goal-create-dialog')">×</button>
         </div>
-        <form stm-action="goals.create">
+        <form stm-action="goals.create" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="goal-project">Project</label>
                     <select id="goal-project" name="project_id" required>

+ 3 - 3
src/pages/home.html

@@ -326,7 +326,7 @@
                             <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
                             <tbody>
                                 <tr stm-for-note-in="dashboard.notes">
-                                    <td><button type="button" stm-action="note.toggle"
+                                    <td><button type="button" stm-action="note.toggle" stm-busy-class="is-busy"
                                                 stm-attribute.class="note.done ? 'approve' : ''"
                                                 stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
                                     <td><span stm-attribute.class="note.done ? 'footnote' : ''"
@@ -339,7 +339,7 @@
                                                stm-text="note.goal_title">goal</a>
                                         </span>
                                         <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
-                                              stm-action="note.promote">
+                                              stm-action="note.promote" stm-busy-class="is-busy">
                                             <select name="project_id" required>
                                                 <option stm-for-project-in="dashboard.projects"
                                                         stm-attribute.value="project.id"
@@ -350,7 +350,7 @@
                                             <button type="submit" class="btn-primary">Create goal</button>
                                         </form>
                                         <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
-                                        <form class="cluster" stm-action="note.delete">
+                                        <form class="cluster" stm-action="note.delete" stm-busy-class="is-busy">
                                             <button type="submit">Delete</button>
                                         </form>
                                     </td>

+ 1 - 1
src/pages/login.html

@@ -41,7 +41,7 @@
             <h1>ClankerSaddle</h1>
             <p class="text-muted">The agent works while you sleep.</p>
             <p stm-if="auth != null">Welcome <strong stm-text="auth.display_name">user</strong>!</p>
-            <form stm-if="auth == null" stm-action="page.login" class="stack">
+            <form stm-if="auth == null" stm-action="page.login" stm-busy-class="is-busy" class="stack">
                 <div class="field">
                     <label for="login-username">Username</label>
                     <input id="login-username" type="text" name="username" required autocomplete="username" autofocus>

+ 5 - 5
src/pages/main.html

@@ -27,7 +27,7 @@
             <!-- spry:nav-begin -->
             <ul class="nav" role="menubar">
                 <li class="has-submenu">
-                    <a href="/">Work</a>
+                    <button type="button" class="nav-toggle" aria-expanded="false">Work</button>
                     <ul class="submenu" role="menu">
                         <li><a href="/">Dashboard</a></li>
                         <li><a href="/tasks">Tasks</a></li>
@@ -36,7 +36,7 @@
                     </ul>
                 </li>
                 <li class="has-submenu">
-                    <a href="/projects">Library</a>
+                    <button type="button" class="nav-toggle" aria-expanded="false">Library</button>
                     <ul class="submenu" role="menu">
                         <li><a href="/projects">Projects</a></li>
                         <li><a href="/rules">Rules</a></li>
@@ -45,7 +45,7 @@
                     </ul>
                 </li>
                 <li class="has-submenu">
-                    <a href="/saddles">Admin</a>
+                    <button type="button" class="nav-toggle" aria-expanded="false">Admin</button>
                     <ul class="submenu" role="menu">
                         <li><a href="/saddles">Saddles</a></li>
                         <!-- The users and model links are display-guarded
@@ -62,11 +62,11 @@
             <span class="nav-auth">
                 <ul class="nav" role="menu">
                     <li class="has-submenu">
-                        <a href="/profile">Account</a>
+                        <button type="button" class="nav-toggle" aria-expanded="false">Account</button>
                         <ul class="submenu" role="menu">
                             <li class="nav-user" stm-text="auth.username">user</li>
                             <li><a href="/profile" title="Your profile">Profile</a></li>
-                            <li><button type="button" class="btn-ghost" stm-action="auth.logout">Log out</button></li>
+                            <li><button type="button" class="btn-ghost" stm-action="auth.logout" stm-busy-class="is-busy">Log out</button></li>
                         </ul>
                     </li>
                 </ul>

+ 1 - 1
src/pages/model_settings.html

@@ -26,7 +26,7 @@
             <p stm-if="model.dispatch_ready" class="alert alert-info"><span class="badge badge-done">ready</span>
                 Updated <span stm-text="model.updated_display">when</span>.</p>
 
-            <form stm-action="model.save" class="stack">
+            <form stm-action="model.save" stm-busy-class="is-busy" class="stack">
                 <section class="card">
                     <div class="card-header">
                         <h3>Main endpoint</h3>

+ 2 - 2
src/pages/profile.html

@@ -33,7 +33,7 @@
 
             <div class="card">
                 <h3>Appearance</h3>
-                <form stm-action="profile.set_theme" class="stack">
+                <form stm-action="profile.set_theme" stm-busy-class="is-busy" class="stack">
                     <div class="field">
                         <label>Theme</label>
                         <div class="choices">
@@ -104,7 +104,7 @@
 
             <div class="card">
                 <h3>Change password</h3>
-                <form stm-action="profile.change_password" class="stack">
+                <form stm-action="profile.change_password" stm-busy-class="is-busy" class="stack">
                     <div class="field"><label for="current-password">Current password</label>
                         <input id="current-password" type="password" name="current_password"
                                required autocomplete="current-password"></div>

+ 9 - 9
src/pages/project_detail.html

@@ -34,7 +34,7 @@
                     <p class="text-muted">Created <span stm-text="project.created_display">when</span></p>
                 </div>
                 <div class="card-body" data-lf-edit hidden>
-                    <form stm-action="project.update" class="form-rows">
+                    <form stm-action="project.update" stm-busy-class="is-busy" class="form-rows">
                         <input type="hidden" name="project_id" stm-attribute.value="project.id">
                         <div class="field"><label for="project-name">Name</label>
                             <input type="text" id="project-name" name="name"
@@ -64,12 +64,12 @@
                         <td><a stm-attribute.href="'/projects/' + link.project_id" stm-text="link.name">name</a></td>
                         <td stm-text="link.git_url">git_url</td>
                         <td><span stm-attribute.class="'badge badge-' + link.relationship" stm-text="link.relationship">relationship</span></td>
-                        <td><button type="button" stm-action="link.remove" stm-confirm="Remove this link?">Remove</button></td>
+                        <td><button type="button" stm-action="link.remove" stm-busy-class="is-busy" stm-confirm="Remove this link?">Remove</button></td>
                     </tr>
                     <tr stm-if="project.links.length == 0"><td colspan="4" class="text-muted">No linked projects.</td></tr>
                 </tbody>
             </table>
-            <form class="cluster" stm-action="project.add_link">
+            <form class="cluster" stm-action="project.add_link" stm-busy-class="is-busy">
                 <label for="link-target">Link project</label>
                 <select id="link-target" name="linked_project_id" required>
                     <option stm-for-option-in="project.linkable" stm-attribute.value="option.id" stm-text="option.name">project</option>
@@ -81,7 +81,7 @@
                 <button type="submit">Add link</button>
             </form>
 
-            <form class="cluster" stm-action="project.apply_stack" stm-if="project.stack_options.length > 0">
+            <form class="cluster" stm-action="project.apply_stack" stm-busy-class="is-busy" stm-if="project.stack_options.length > 0">
                 <label for="stack-target">Apply stack</label>
                 <select id="stack-target" name="stack_id" required>
                     <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
@@ -98,11 +98,11 @@
                 <h3 stm-text="rule.name">name</h3>
                 <div class="md" stm-markdown="rule.body">body</div>
                 <form class="cluster">
-                    <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                    <button type="button" stm-action="rule.detach" stm-busy-class="is-busy" stm-confirm="Detach this rule?">Detach</button>
                 </form>
             </div>
             <p stm-if="project.rules.length == 0" class="text-muted">No rules attached.</p>
-            <form class="cluster" stm-action="project.attach_rule" stm-if="project.rule_options.length > 0">
+            <form class="cluster" stm-action="project.attach_rule" stm-busy-class="is-busy" stm-if="project.rule_options.length > 0">
                 <label for="rule-target">Attach rule</label>
                 <select id="rule-target" name="rule_id" required>
                     <option stm-for-option-in="project.rule_options" stm-attribute.value="option.id"
@@ -126,7 +126,7 @@
             </div>
             <p class="text-muted" stm-if="project.understanding_current.body.length == 0">No understanding yet —
                 press Refresh to have an agent explore the project.</p>
-            <form class="cluster" stm-action="project.refresh_understanding">
+            <form class="cluster" stm-action="project.refresh_understanding" stm-busy-class="is-busy">
                 <button type="submit" stm-confirm="Spin up an agent task to refresh the understanding?">Refresh</button>
             </form>
             <div stm-for-revision-in="project.understanding_history" stm-key="revision.id" class="card">
@@ -149,7 +149,7 @@
                         <a stm-attribute.href="'/tasks/' + note.source_task_id" stm-text="'#' + note.source_task_id">task</a></span></p>
                 <div class="md" stm-markdown="note.body">body</div>
                 <form class="cluster">
-                    <button type="button" stm-action="note.delete" stm-confirm="Delete this note?">Delete</button>
+                    <button type="button" stm-action="note.delete" stm-busy-class="is-busy" stm-confirm="Delete this note?">Delete</button>
                 </form>
             </div>
             <p stm-if="project.notes.length == 0" class="text-muted">No notes yet — they appear as agents
@@ -232,7 +232,7 @@
                     will be refused until they are cancelled or finished.</p>
                 <p>Type the project name <strong stm-text="project.name">name</strong> to
                     confirm:</p>
-                <form stm-action="project.delete">
+                <form stm-action="project.delete" stm-busy-class="is-busy">
                     <input type="hidden" name="project_id" stm-attribute.value="project.id">
                     <input type="text" name="confirm_name" autocomplete="off"
                            stm-attribute.placeholder="'Type ' + project.name + ' to confirm'"

+ 1 - 1
src/pages/projects.html

@@ -54,7 +54,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('project-create-dialog')">×</button>
         </div>
-        <form stm-action="projects.create">
+        <form stm-action="projects.create" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="project-name">Name</label>
                     <input type="text" id="project-name" name="name" required placeholder="web-stack"></div>

+ 3 - 3
src/pages/rules.html

@@ -50,7 +50,7 @@
                              from stm-attribute.value. The lf engine's
                              prefill pass simply finds no data-lf-src
                              controls and leaves both as rendered. -->
-                        <form stm-action="rule.update" class="form-rows">
+                        <form stm-action="rule.update" stm-busy-class="is-busy" class="form-rows">
                             <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
                             <div class="field"><label>Name</label>
                                 <input type="text" name="name"
@@ -84,7 +84,7 @@
                     <p stm-if="rule.attachments.length == 0" class="text-muted">Not attached
                         anywhere — deleting it changes no dispatch.</p>
                     <button type="button" class="btn-ghost btn-sm"
-                            stm-action="rule.delete"
+                            stm-action="rule.delete" stm-busy-class="is-busy"
                             stm-confirm="Delete this rule everywhere? It detaches from every project and task listed above.">Delete rule…</button>
                 </div>
             </div>
@@ -97,7 +97,7 @@
                 <button type="button" class="dialog-close" aria-label="Close"
                         onclick="window.closeAppDialog('rule-create-dialog')">×</button>
             </div>
-            <form stm-action="rules.create">
+            <form stm-action="rules.create" stm-busy-class="is-busy">
                 <div class="dialog-body form-rows">
                     <div class="field"><label for="rule-new-name">Name</label>
                         <input type="text" id="rule-new-name" name="name" required

+ 8 - 8
src/pages/saddles.html

@@ -81,7 +81,7 @@
                                     it appears here once the daemon generates one.</p>
                                 <details>
                                     <summary>Git identity</summary>
-                                    <form stm-action="saddle.update_identity" class="cluster">
+                                    <form stm-action="saddle.update_identity" stm-busy-class="is-busy" class="cluster">
                                         <label>Committed as</label>
                                         <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
                                                placeholder="saddle-1">
@@ -93,7 +93,7 @@
                                 </details>
                                 <details>
                                     <summary>Prompt note</summary>
-                                    <form stm-action="saddle.update_prompt_note" class="cluster">
+                                    <form stm-action="saddle.update_prompt_note" stm-busy-class="is-busy" class="cluster">
                                         <label for="prompt-note">Served inside every assignment's system prompt</label>
                                         <textarea id="prompt-note" name="prompt_note" rows="3"
                                                   stm-attribute.placeholder="'e.g. You have root and may install development tooling via DNF'"
@@ -107,7 +107,7 @@
                                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                                             <span class="badge"><span stm-text="capability.name">tag</span>
                                                 <button type="button" class="btn-ghost chip-remove"
-                                                        stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
+                                                        stm-action="capability.detach" stm-busy-class="is-busy" stm-confirm="Remove this capability?">×</button></span>
                                         </span>
                                     </p>
                                     <p stm-if="saddle.capabilities.length == 0" class="text-muted">No capabilities — matches only tasks with none required.</p>
@@ -122,8 +122,8 @@
                                     </form>
                                 </div>
                                 <form class="cluster">
-                                    <button type="button" class="btn-sm" stm-action="saddle.refresh">Refresh status</button>
-                                    <button type="button" class="btn-sm btn-danger" stm-action="saddle.unregister"
+                                    <button type="button" class="btn-sm" stm-action="saddle.refresh" stm-busy-class="is-busy">Refresh status</button>
+                                    <button type="button" class="btn-sm btn-danger" stm-action="saddle.unregister" stm-busy-class="is-busy"
                                             stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
                                 </form>
                             </div>
@@ -165,7 +165,7 @@
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.task_count">0</td>
-                        <td stm-if="saddles.is_admin"><button type="button" class="btn-sm btn-danger" stm-action="capability.delete"
+                        <td stm-if="saddles.is_admin"><button type="button" class="btn-sm btn-danger" stm-action="capability.delete" stm-busy-class="is-busy"
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">
@@ -188,7 +188,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('saddle-register-dialog')">×</button>
         </div>
-        <form stm-action="saddles.register">
+        <form stm-action="saddles.register" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="saddle-name">Name</label>
                     <input type="text" id="saddle-name" name="name" required placeholder="saddle-2"></div>
@@ -210,7 +210,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('capability-create-dialog')">×</button>
         </div>
-        <form stm-action="saddles.create_capability">
+        <form stm-action="saddles.create_capability" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="new-capability">Name</label>
                     <input type="text" id="new-capability" name="name" required placeholder="gpu"></div>

+ 4 - 4
src/pages/stack_detail.html

@@ -40,7 +40,7 @@
                         <p class="text-muted" stm-if="stack.summary.length == 0">No summary.</p>
                         <p class="text-muted">Created <span stm-text="stack.created_display">when</span></p>
                     </div>
-                    <form data-lf-edit hidden stm-action="stack.update" class="form-rows">
+                    <form data-lf-edit hidden stm-action="stack.update" stm-busy-class="is-busy" class="form-rows">
                         <input type="hidden" name="stack_id" stm-attribute.value="stack.id">
                         <div class="field"><label for="stack-name">Name</label>
                             <input type="text" id="stack-name" name="name" data-lf-src="stack.name"
@@ -68,13 +68,13 @@
                                 <td><a stm-attribute.href="'/projects/' + member.project_id" stm-text="member.name">name</a></td>
                                 <td stm-text="member.git_url">git_url</td>
                                 <td><span stm-attribute.class="'badge badge-' + member.relationship" stm-text="member.relationship">relationship</span></td>
-                                <td><button type="button" class="btn-sm" stm-action="member.remove" stm-confirm="Remove this project from the stack?">Remove</button></td>
+                                <td><button type="button" class="btn-sm" stm-action="member.remove" stm-busy-class="is-busy" stm-confirm="Remove this project from the stack?">Remove</button></td>
                             </tr>
                             <tr stm-if="stack.members.length == 0"><td colspan="4" class="text-muted">No projects in this stack yet.</td></tr>
                         </tbody>
                     </table>
                 </div>
-                <form class="cluster" stm-action="stack.add_member" stm-if="stack.options.length > 0">
+                <form class="cluster" stm-action="stack.add_member" stm-busy-class="is-busy" stm-if="stack.options.length > 0">
                     <label for="member-target">Add project</label>
                     <select id="member-target" name="project_id" required>
                         <option stm-for-option-in="stack.options" stm-attribute.value="option.id" stm-text="option.name">project</option>
@@ -90,7 +90,7 @@
             <section class="card card-flat">
                 <div class="card-body cluster">
                     <span class="text-muted">Deleting a stack keeps any links already applied.</span>
-                    <button type="button" class="btn-danger btn-sm" stm-action="stack.delete"
+                    <button type="button" class="btn-danger btn-sm" stm-action="stack.delete" stm-busy-class="is-busy"
                             stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
                 </div>
             </section>

+ 1 - 1
src/pages/stacks.html

@@ -45,7 +45,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('stack-create-dialog')">×</button>
         </div>
-        <form stm-action="stacks.create">
+        <form stm-action="stacks.create" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="stack-name">Name</label>
                     <input type="text" id="stack-name" name="name" required placeholder="Web stack references"></div>

+ 17 - 17
src/pages/task_detail.html

@@ -164,11 +164,11 @@
                                     <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
                                        target="_blank" rel="noopener">· compare changes</a></p>
                                 <div class="actions">
-                                    <form stm-action="block.answer_action" class="cluster">
+                                    <form stm-action="block.answer_action" stm-busy-class="is-busy" class="cluster">
                                         <input type="hidden" name="verdict" value="accept">
                                         <button type="submit" class="btn-primary">Approve</button>
                                     </form>
-                                    <form stm-action="block.answer_action" class="cluster">
+                                    <form stm-action="block.answer_action" stm-busy-class="is-busy" class="cluster">
                                         <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
                                         <button type="submit" class="btn-danger">Request changes</button>
                                     </form>
@@ -253,27 +253,27 @@
                         <form class="cluster">
                             <input type="hidden" name="task_id" stm-attribute.value="task.id">
                             <button type="button" stm-if="task.draft" class="btn-primary"
-                                    stm-action="task.enqueue">Enqueue task</button>
+                                    stm-action="task.enqueue" stm-busy-class="is-busy">Enqueue task</button>
                             <button type="button" stm-if="task.draft"
-                                    stm-action="task.delete"
+                                    stm-action="task.delete" stm-busy-class="is-busy"
                                     stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
                             <button type="button" stm-if="task.status == 'failed'" class="btn-primary"
-                                    stm-action="task.retry">Retry task</button>
+                                    stm-action="task.retry" stm-busy-class="is-busy">Retry task</button>
                             <button type="button" stm-if="task.resume_available"
-                                    stm-action="task.resume"
+                                    stm-action="task.resume" stm-busy-class="is-busy"
                                     stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
                             <!-- Goal 4 task 1: Force wake — the parked-task
                                  recovery (a goal run routes to the goal's
                                  wake). -->
                             <button type="button" stm-if="task.force_wake != null"
-                                    stm-action="task.force_wake"
+                                    stm-action="task.force_wake" stm-busy-class="is-busy"
                                     stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
                             <button type="button" stm-if="!task.draft && !task.terminal"
-                                    stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
+                                    stm-action="task.cancel" stm-busy-class="is-busy" stm-confirm="Cancel this task?">Cancel task</button>
                         </form>
                         <form class="cluster">
                             <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                            <button type="button" stm-action="task.toggle_notify"
+                            <button type="button" stm-action="task.toggle_notify" stm-busy-class="is-busy"
                                     stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
                             <span class="text-muted">— this task&#39;s first start, failure and completion
                                 (questions and reviews always notify subscribed users)</span>
@@ -300,7 +300,7 @@
                             <p class="text-muted" stm-if="!task.editable_all && task.editable_priority">Work has begun — priority only.</p>
                         </div>
                         <div stm-if="task.editable_all">
-                            <form data-lf-edit hidden stm-action="task.update">
+                            <form data-lf-edit hidden stm-action="task.update" stm-busy-class="is-busy">
                                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                                 <div class="field"><label>Title</label>
                                     <input type="text" name="title" stm-attribute.value="task.title"></div>
@@ -329,7 +329,7 @@
                             </form>
                         </div>
                         <div stm-if="!task.editable_all && task.editable_priority">
-                            <form data-lf-edit hidden stm-action="task.update">
+                            <form data-lf-edit hidden stm-action="task.update" stm-busy-class="is-busy">
                                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                                 <div class="field"><label>Priority (1–9)</label>
                                     <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
@@ -380,7 +380,7 @@
                                 <button type="button" class="dialog-close" aria-label="Close"
                                         onclick="document.getElementById('quote-comment-dialog').close()">×</button>
                             </div>
-                            <form stm-action="task.comment">
+                            <form stm-action="task.comment" stm-busy-class="is-busy">
                                 <div class="dialog-body form-rows">
                                     <blockquote id="quote-text" class="term-comment-quote"></blockquote>
                                     <input type="hidden" name="task_id" stm-attribute.value="task.id">
@@ -418,7 +418,7 @@
                              stm-if branches, so the form inside keeps its
                              visibility JS-owned (initLongFormEditors). -->
                         <div stm-if="task.editable_all">
-                            <form data-lf-edit hidden stm-action="task.update">
+                            <form data-lf-edit hidden stm-action="task.update" stm-busy-class="is-busy">
                                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                                 <div class="field"><label>Mandate</label>
                                     <textarea name="description" rows="8"
@@ -464,11 +464,11 @@
                                         <h3 stm-text="rule.name">name</h3>
                                         <div class="md" stm-markdown="rule.body">body</div>
                                         <form class="cluster">
-                                            <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                                            <button type="button" stm-action="rule.detach" stm-busy-class="is-busy" stm-confirm="Detach this rule?">Detach</button>
                                         </form>
                                     </div>
                                     <p stm-if="task.rules.length == 0" class="text-muted">No task-specific rules.</p>
-                                    <form class="cluster" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
+                                    <form class="cluster" stm-action="task.attach_rule" stm-busy-class="is-busy" stm-if="task.rule_options.length > 0">
                                         <label for="task-rule-target">Attach rule</label>
                                         <select id="task-rule-target" name="rule_id" required>
                                             <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
@@ -485,11 +485,11 @@
                                         <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
                                             <span class="badge"><span stm-text="capability.name">tag</span>
                                                 <button type="button" class="btn-ghost chip-remove"
-                                                        stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
+                                                        stm-action="capability.detach" stm-busy-class="is-busy" stm-confirm="Remove this requirement?">×</button></span>
                                         </span>
                                     </p>
                                     <p stm-if="task.capabilities.length == 0" class="text-muted">No requirements — any idle saddle may run it.</p>
-                                    <form class="cluster" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
+                                    <form class="cluster" stm-action="task.attach_capability" stm-busy-class="is-busy" stm-if="task.capability_options.length > 0">
                                         <label for="task-capability-target">Require capability</label>
                                         <select id="task-capability-target" name="capability_id" required>
                                             <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"

+ 1 - 1
src/pages/tasks.html

@@ -449,7 +449,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('task-create-dialog')">×</button>
         </div>
-        <form stm-action="tasks.create">
+        <form stm-action="tasks.create" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="task-project">Project</label>
                     <!-- Goal 8 task 4: "No project" posts project_id 0 —

+ 6 - 6
src/pages/users.html

@@ -43,7 +43,7 @@
                             <div class="cluster">
                                 <details>
                                     <summary>Edit</summary>
-                                    <form stm-action="user.alter" class="form-rows">
+                                    <form stm-action="user.alter" stm-busy-class="is-busy" class="form-rows">
                                         <div class="field"><label>Username</label>
                                             <input type="text" name="username" required stm-attribute.value="user.username"></div>
                                         <div class="field"><label>Email</label>
@@ -66,11 +66,11 @@
                                         </div>
                                     </form>
                                 </details>
-                                <button type="button" class="btn-sm" stm-action="user.set_enabled"
+                                <button type="button" class="btn-sm" stm-action="user.set_enabled" stm-busy-class="is-busy"
                                         stm-text="user.enabled ? 'Disable' : 'Enable'">toggle</button>
-                                <button type="button" class="btn-sm" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
-                                <button type="button" class="btn-sm" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
-                                <button type="button" class="btn-sm btn-danger" stm-action="user.delete"
+                                <button type="button" class="btn-sm" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin" stm-busy-class="is-busy">Revoke admin</button>
+                                <button type="button" class="btn-sm" stm-if="!user.is_admin" stm-action="user.grant_admin" stm-busy-class="is-busy">Make admin</button>
+                                <button type="button" class="btn-sm btn-danger" stm-action="user.delete" stm-busy-class="is-busy"
                                         stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
                             </div>
                         </td>
@@ -92,7 +92,7 @@
             <button type="button" class="dialog-close" aria-label="Close"
                     onclick="window.closeAppDialog('user-create-dialog')">×</button>
         </div>
-        <form stm-action="users.create">
+        <form stm-action="users.create" stm-busy-class="is-busy">
             <div class="dialog-body form-rows">
                 <div class="field"><label for="user-username">Username</label>
                     <input type="text" id="user-username" name="username" required placeholder="ada"></div>