소스 검색

Merge remote-tracking branch 'origin/task/41-286' into goal/ui-ux

# Conflicts:
#	src/pages/app.css
saddle-ClankerSaddle 1 일 전
부모
커밋
526bb4d53a
8개의 변경된 파일과 665개의 추가작업 그리고 252개의 파일을 삭제
  1. 65 0
      src/entrypoints/SaddlesEntrypoint.vala
  2. 19 0
      src/pages/app.css
  3. 57 39
      src/pages/model_settings.html
  4. 187 107
      src/pages/saddles.html
  5. 81 48
      src/pages/stack_detail.html
  6. 82 57
      src/pages/users.html
  7. 173 0
      test/bundles/t8fleet.py
  8. 1 1
      test/harness.py

+ 65 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -28,6 +28,36 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? detach { get; set; }
     }
 
+    /**
+     * The one-line health verdict (goal 41 T8, quick win 8): the old
+     * card expressed health as three separate fragments (the badge,
+     * "daemon idle", "up 3d", "contacted 12s ago"); this composes
+     * them into ONE line the card head carries next to the badge.
+     * Pure display — the fragments stay on the row (the health panel
+     * assertions read them).
+     */
+    public string saddle_health_verdict(string health, string daemon_state,
+            string uptime_display, string last_contact) {
+        var parts = new string[] {};
+        if (daemon_state.length > 0) {
+            parts += @"daemon $(daemon_state)";
+        }
+        if (uptime_display.length > 0) {
+            parts += @"up $(uptime_display)";
+        }
+        parts += @"contacted $(last_contact) ago";
+        if (health == "stale") {
+            return string.joinv(", ", parts) + " - silent past the stale window";
+        }
+        if (health == "offline") {
+            return "offline - not responding to probes";
+        }
+        if (health == "unknown" && daemon_state.length == 0) {
+            return "no health report yet - contacted " + last_contact + " ago";
+        }
+        return string.joinv(", ", parts);
+    }
+
     /** A saddle card on the management page, with its current task and API token. */
     public class SaddleRowView : Object {
         public int64 id { get; set; }
@@ -38,6 +68,8 @@ namespace ClankerSaddle {
         public string daemon_state { get; set; default = ""; }
         public string uptime_display { get; set; default = ""; }
         public string last_contact_display { get; set; default = ""; }
+        /** One composed line (goal 41 T8): the fragments above, consolidated. */
+        public string health_verdict { get; set; default = ""; }
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
@@ -79,6 +111,14 @@ namespace ClankerSaddle {
         /** True when the viewer may see the admin controls (the actions
          * re-check the permission; this only shapes the page). */
         public bool is_admin { get; set; }
+        /**
+         * The fleet health summary line (goal 41 T8): non-empty when
+         * any saddle is offline/stale/fenced — "2 of 3 saddles stale
+         * (saddle-1, saddle-2) - the dispatcher may be starved". Empty
+         * means every saddle is healthy/unknown and the page shows
+         * nothing above the grid.
+         */
+        public string fleet_alert { get; set; default = ""; }
         public Statum.Model.ActionDto register { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
     }
@@ -183,6 +223,8 @@ namespace ClankerSaddle {
                     daemon_state = daemon_state,
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
                     last_contact_display = last_contact,
+                    health_verdict = saddle_health_verdict(health, daemon_state,
+                        uptime > 0 ? relative_time(uptime * 1000) : "", last_contact),
                     git_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
@@ -234,6 +276,29 @@ namespace ClankerSaddle {
 
             var pub = new SaddlesPublic();
             pub.is_admin = is_admin;
+            // The fleet summary line: offline/stale/fenced saddles
+            // named at the top of the page (quick win 8 B). The row
+            // loop above collected the views; "fenced" never appears
+            // as a health value today (health is healthy/stale/
+            // offline/unknown) but is tolerated so a future state
+            // reads correctly without touching this.
+            var ailing = new Vector<string>();
+            int total = 0;
+            foreach (var row in rows) {
+                total++;
+                if (row.health == "offline" || row.health == "stale"
+                        || row.health == "fenced") {
+                    ailing.add(row.name);
+                }
+            }
+            if (ailing.length > 0) {
+                var names = new string[ailing.length];
+                for (int at = 0; at < ailing.length; at++) {
+                    names[at] = ailing[at];
+                }
+                pub.fleet_alert = @"$(ailing.length) of $(total) saddles $(ailing.length == 1 ? "is" : "are") not healthy"
+                    + " (" + string.joinv(", ", names) + ") - the dispatcher may be starved";
+            }
             pub.register = action_registry.author<RegisterSaddleAction>();
             pub.create_capability = action_registry.author<CreateCapabilityAction>();
 

+ 19 - 0
src/pages/app.css

@@ -1619,3 +1619,22 @@ td .truncate.repo-url {
    frames itself with the context cluster instead. Keep the strip's
    spacing from leaking into the log panel's top margin. */
 .task-overview-panel > .subnav { margin-top: 0; }
+
+   Goal 41 T8 — saddles head/tail + fleet admin pages
+   (saddles/users/model_settings/stack_detail; appended per the spry
+   conversion contract; every value consumes a --spry-* token).
+
+/* The saddle card's "now" head (ux-notes §6 A): the verdict line is
+   the anchor of the fold — a hairline under the first ~3 lines
+   separates what the saddle IS doing from the keys & admin tail
+   that folds. The .cards grid keeps its page-level gutters; this
+   only draws the fold inside each card. */
+.saddle-fold {
+    border-top: var(--spry-border-width) solid var(--spry-border);
+    margin-top: var(--spry-space-2);
+    padding-top: var(--spry-space-2);
+}
+/* The admin tail's inner stack sits inside the summary, so it gets
+   the card's inset surface to read as one folding block. */
+.saddle-admin { padding-left: var(--spry-space-2); }
+

+ 57 - 39
src/pages/model_settings.html

@@ -5,7 +5,13 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
+    <!-- Goal 41 T8: the model page on the Spry form conventions — the
+         readiness line as an alert, the two endpoint groups as cards
+         with capped-width fields (.form-rows), labelled controls.
+         The form itself is unchanged (same stm-action, same
+         write-only key handling); it was never an expander, so no
+         dialog is needed — the page IS the editor. -->
+    <div class="stack">
         <h2>Model</h2>
         <p class="text-muted">The deployment's model configuration: the OpenAI-compatible endpoint
             every saddle assignment is sent to, and the advisor's optional overrides. Stored
@@ -13,48 +19,60 @@
             deployment's SADDLE_LLM_* environment is imported once, on first read).</p>
         <p stm-if="model == null">Loading…</p>
 
-        <div stm-if="model != null">
-            <p stm-if="!model.dispatch_ready"><span class="badge badge-offline">paused</span>
-                <span class="text-muted">No base URL is configured — task assignment is paused and
-                    the advisor is disabled until one is saved.</span></p>
-            <p stm-if="model.dispatch_ready"><span class="badge badge-done">ready</span>
-                <span class="text-muted">Updated <span stm-text="model.updated_display">when</span>.</span></p>
+        <div stm-if="model != null" class="stack">
+            <p stm-if="!model.dispatch_ready" class="alert alert-warning"><span class="badge badge-offline">paused</span>
+                No base URL is configured — task assignment is paused and
+                the advisor is disabled until one is saved.</p>
+            <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">
-                <h3>Main endpoint</h3>
-                <p class="text-muted">Used by every saddle assignment (the harness's LLM).</p>
-                <div class="field"><label>Base URL</label>
-                    <input type="text" name="base_url" placeholder="http://127.0.0.1:9999/v1"
-                           stm-attribute.value="model.base_url"></div>
-                <div class="field"><label>Model</label>
-                    <input type="text" name="model" placeholder="(the provider's default)"
-                           stm-attribute.value="model.model"></div>
-                <div class="field"><label>API key</label>
-                    <input type="password" name="api_key" autocomplete="off"
-                           placeholder="Leave empty to keep the stored key">
-                    <span class="text-muted" stm-if="model.api_key_set">A key is stored
-                        (<span stm-text="model.api_key_hint">hint</span>)</span>
-                    <label class="choice"><input type="checkbox" name="clear_api_key" value="1"> Clear the stored key</label></div>
+            <form stm-action="model.save" class="stack">
+                <section class="card">
+                    <div class="card-header">
+                        <h3>Main endpoint</h3>
+                        <p class="text-muted">Used by every saddle assignment (the harness's LLM).</p>
+                    </div>
+                    <div class="card-body form-rows">
+                        <div class="field"><label for="model-base-url">Base URL</label>
+                            <input type="text" id="model-base-url" name="base_url" placeholder="http://127.0.0.1:9999/v1"
+                                   stm-attribute.value="model.base_url"></div>
+                        <div class="field"><label for="model-name">Model</label>
+                            <input type="text" id="model-name" name="model" placeholder="(the provider's default)"
+                                   stm-attribute.value="model.model"></div>
+                        <div class="field"><label for="model-api-key">API key</label>
+                            <input type="password" id="model-api-key" name="api_key" autocomplete="off"
+                                   placeholder="Leave empty to keep the stored key">
+                            <span class="text-muted" stm-if="model.api_key_set">A key is stored
+                                (<span stm-text="model.api_key_hint">hint</span>)</span>
+                            <label class="choice"><input type="checkbox" name="clear_api_key" value="1"> Clear the stored key</label></div>
+                    </div>
+                </section>
 
-                <h3>Advisor endpoint (optional)</h3>
-                <p class="text-muted">Overrides for the AI Chat advisor. Leave the base URL empty to
-                    use the main endpoint's fields.</p>
-                <div class="field"><label>Base URL</label>
-                    <input type="text" name="advisor_base_url" placeholder="(use the main endpoint)"
-                           stm-attribute.value="model.advisor_base_url"></div>
-                <div class="field"><label>Model</label>
-                    <input type="text" name="advisor_model" placeholder="(use the main endpoint's model)"
-                           stm-attribute.value="model.advisor_model"></div>
-                <div class="field"><label>API key</label>
-                    <input type="password" name="advisor_api_key" autocomplete="off"
-                           placeholder="Leave empty to keep the stored key">
-                    <span class="text-muted" stm-if="model.advisor_api_key_set">A key is stored
-                        (<span stm-text="model.advisor_api_key_hint">hint</span>)</span>
-                    <label class="choice"><input type="checkbox" name="clear_advisor_api_key" value="1"> Clear the stored key</label></div>
+                <section class="card">
+                    <div class="card-header">
+                        <h3>Advisor endpoint <span class="text-muted">(optional)</span></h3>
+                        <p class="text-muted">Overrides for the AI Chat advisor. Leave the base URL empty to
+                            use the main endpoint's fields.</p>
+                    </div>
+                    <div class="card-body form-rows">
+                        <div class="field"><label for="advisor-base-url">Base URL</label>
+                            <input type="text" id="advisor-base-url" name="advisor_base_url" placeholder="(use the main endpoint)"
+                                   stm-attribute.value="model.advisor_base_url"></div>
+                        <div class="field"><label for="advisor-model">Model</label>
+                            <input type="text" id="advisor-model" name="advisor_model" placeholder="(use the main endpoint's model)"
+                                   stm-attribute.value="model.advisor_model"></div>
+                        <div class="field"><label for="advisor-api-key">API key</label>
+                            <input type="password" id="advisor-api-key" name="advisor_api_key" autocomplete="off"
+                                   placeholder="Leave empty to keep the stored key">
+                            <span class="text-muted" stm-if="model.advisor_api_key_set">A key is stored
+                                (<span stm-text="model.advisor_api_key_hint">hint</span>)</span>
+                            <label class="choice"><input type="checkbox" name="clear_advisor_api_key" value="1"> Clear the stored key</label></div>
+                    </div>
+                </section>
 
-                <button type="submit">Save configuration</button>
+                <div><button type="submit" class="btn-primary">Save configuration</button></div>
             </form>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 187 - 107
src/pages/saddles.html

@@ -5,124 +5,159 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Saddles</h2>
+    <!-- Goal 41 T8 (ux-notes quick win 8): each saddle card is split
+         into a "now" HEAD (name, status badge, the one-line health
+         verdict, current work) and an "admin" TAIL (keys, git
+         identity, prompt note, capabilities, token, refresh/remove)
+         folding under one "Keys &amp; admin" expander. The three
+         health fragments (badge / daemon / up X / contacted Y) are
+         consolidated server-side into one verdict line
+         (saddle.health_verdict). The fleet summary line above the
+         grid surfaces offline/stale/fenced saddles immediately, and
+         the capability catalogue became its own h2 "Routing tags"
+         section (recommendation C). -->
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Saddles</h2>
+            <button type="button" class="btn-primary" stm-if="saddles != null && saddles.is_admin"
+                    onclick="window.openAppDialog('saddle-register-dialog')">+ Register saddle</button>
+        </div>
+        <p class="text-muted">Fleet health and configuration. The dispatcher pairs queued tasks
+            with idle saddles carrying every tag a task requires.</p>
         <p stm-if="saddles == null">Loading…</p>
 
-        <div stm-if="saddles != null">
-            <details stm-if="saddles.is_admin">
-                <summary><strong>Register saddle</strong></summary>
-                <form stm-action="saddles.register">
-                    <div class="field"><label>Name</label>
-                        <input type="text" name="name" required placeholder="saddle-1"></div>
-                    <div class="field"><label>Host</label>
-                        <input type="text" name="host" required placeholder="localhost:7100"></div>
-                    <button type="submit">Register</button>
-                </form>
-            </details>
+        <div stm-if="saddles != null" class="stack">
+
+            <!-- Fleet health summary (recommendation B): one line when
+                 any saddle is offline/stale/fenced, naming the worst
+                 offenders so the dispatcher risk leads the page. -->
+            <p stm-if="saddles.fleet_alert.length > 0" class="alert alert-warning"
+               stm-text="saddles.fleet_alert">fleet alert</p>
 
             <div class="cards">
                 <section class="card" stm-for-saddle-in="saddles.saddles">
-                    <h3 stm-text="saddle.name">name</h3>
-                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
-                        <span class="text-muted" stm-text="saddle.host">host</span></p>
-                    <p stm-if="saddle.current_goal_id > 0">Supervising goal:
-                        <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
-                           stm-text="saddle.current_goal_title">goal</a>
-                        <span class="badge badge-kind-goal">goal</span>
-                        <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
-                    <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Current task:
-                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a>
-                        <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
-                    <p stm-if="saddle.current_task_id == 0" class="text-muted">No current task</p>
-                    <p><span stm-attribute.class="'badge badge-health-' + saddle.health"
-                              stm-text="saddle.health">healthy</span>
-                        <span class="text-muted" stm-if="saddle.daemon_state.length > 0"
-                              stm-text="'daemon ' + saddle.daemon_state">daemon</span>
-                        <span class="text-muted" stm-if="saddle.daemon_state.length > 0 && saddle.uptime_display.length > 0">&middot; up <span stm-text="saddle.uptime_display">uptime</span></span>
-                        <span class="text-muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
-                    <p stm-if="saddles.is_admin && saddle.token.length > 0" class="text-muted">API token:
-                        <code stm-text="saddle.token">token</code></p>
-                    <details stm-if="saddles.is_admin && saddle.ssh_public_key.length > 0">
-                        <summary>SSH public key</summary>
-                        <p class="text-muted">Add this as a <strong>deploy key</strong> on the git server
-                            (with write access) so this saddle can clone and push.</p>
-                        <pre class="ssh-key"><code stm-text="saddle.ssh_public_key">key</code></pre>
-                        <button type="button"
-                                stm-attribute.data-clipboard="saddle.ssh_public_key"
-                                onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
-                    </details>
-                    <p stm-if="saddles.is_admin && saddle.ssh_public_key.length == 0" class="text-muted">No SSH key reported yet —
-                        it appears here once the daemon generates one.</p>
-                    <details stm-if="saddles.is_admin">
-                        <summary>Git identity</summary>
-                        <form stm-action="saddle.update_identity" class="cluster">
-                            <label>Committed as</label>
-                            <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
-                                   placeholder="saddle-1">
-                            <input type="text" name="git_email" stm-attribute.value="saddle.git_email"
-                                   placeholder="saddle-1@example.net">
-                            <button type="submit">Save</button>
-                            <span class="text-muted">Empty fields fall back to the daemon's default identity.</span>
-                        </form>
-                    </details>
-                    <details stm-if="saddles.is_admin">
-                        <summary>Prompt note</summary>
-                        <form stm-action="saddle.update_prompt_note" 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'"
-                                      stm-text="saddle.prompt_note"></textarea>
-                            <button type="submit">Save</button>
-                            <span class="text-muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
-                        </form>
-                    </details>
-                    <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
-                        <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>
-                        </span>
-                    </p>
-                    <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
-                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
-                            <span class="badge"><span stm-text="capability.name">tag</span></span>
-                        </span>
-                    </p>
-                    <p stm-if="saddle.capabilities.length == 0" class="text-muted">No capabilities — matches only tasks with none required.</p>
-                    <form class="cluster" stm-if="saddles.is_admin && saddles.capability_options.length > 0"
-                          stm-action="saddle.attach_capability">
-                        <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
-                        <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
-                            <option stm-for-option-in="saddles.capability_options" stm-attribute.value="option.id"
-                                     stm-text="option.name">capability</option>
-                        </select>
-                        <button type="submit">Add</button>
-                    </form>
-                    <form class="cluster">
-                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.refresh">Refresh status</button>
-                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.unregister"
-                                stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
-                    </form>
+                    <div class="card-header">
+                        <h3 stm-text="saddle.name">name</h3>
+                        <p class="cluster">
+                            <span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
+                            <span class="text-muted" stm-text="saddle.host">host</span>
+                        </p>
+                    </div>
+                    <div class="card-body stack">
+                        <!-- The one-line health verdict consolidates the
+                             old three fragments; the badge keeps the
+                             at-a-glance colour the head is for. -->
+                        <p><span stm-attribute.class="'badge badge-health-' + saddle.health"
+                                  stm-text="saddle.health">healthy</span>
+                            <span class="text-muted" stm-text="saddle.health_verdict">verdict</span></p>
+                        <p stm-if="saddle.current_goal_id > 0">Supervising goal:
+                            <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
+                               stm-text="saddle.current_goal_title">goal</a>
+                            <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
+                        <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Current task:
+                            <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a>
+                            <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
+                        <p stm-if="saddle.current_task_id == 0" class="text-muted">No current task</p>
+
+                        <!-- The admin tail: everything below the fold
+                             folds under ONE expander; the inner
+                             blocks keep their own details so the
+                             disclosure stays gradual. -->
+                        <details stm-if="saddles.is_admin" class="saddle-fold">
+                            <summary>Keys &amp; admin</summary>
+                            <div class="stack saddle-admin">
+                                <p class="text-muted" stm-if="saddle.token.length > 0">API token:
+                                    <code stm-text="saddle.token">token</code></p>
+                                <details stm-if="saddle.ssh_public_key.length > 0">
+                                    <summary>SSH public key</summary>
+                                    <p class="text-muted">Add this as a <strong>deploy key</strong> on the git server
+                                        (with write access) so this saddle can clone and push.</p>
+                                    <pre class="ssh-key"><code stm-text="saddle.ssh_public_key">key</code></pre>
+                                    <button type="button"
+                                            stm-attribute.data-clipboard="saddle.ssh_public_key"
+                                            onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
+                                </details>
+                                <p stm-if="saddle.ssh_public_key.length == 0" class="text-muted">No SSH key reported yet —
+                                    it appears here once the daemon generates one.</p>
+                                <details>
+                                    <summary>Git identity</summary>
+                                    <form stm-action="saddle.update_identity" class="cluster">
+                                        <label>Committed as</label>
+                                        <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
+                                               placeholder="saddle-1">
+                                        <input type="text" name="git_email" stm-attribute.value="saddle.git_email"
+                                               placeholder="saddle-1@example.net">
+                                        <button type="submit" class="btn-sm">Save</button>
+                                        <span class="text-muted">Empty fields fall back to the daemon's default identity.</span>
+                                    </form>
+                                </details>
+                                <details>
+                                    <summary>Prompt note</summary>
+                                    <form stm-action="saddle.update_prompt_note" 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'"
+                                                  stm-text="saddle.prompt_note"></textarea>
+                                        <button type="submit" class="btn-sm">Save</button>
+                                        <span class="text-muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
+                                    </form>
+                                </details>
+                                <div>
+                                    <p stm-if="saddle.capabilities.length > 0">Capabilities:
+                                        <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>
+                                        </span>
+                                    </p>
+                                    <p stm-if="saddle.capabilities.length == 0" class="text-muted">No capabilities — matches only tasks with none required.</p>
+                                    <form class="cluster" stm-if="saddles.capability_options.length > 0"
+                                          stm-action="saddle.attach_capability">
+                                        <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
+                                        <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
+                                            <option stm-for-option-in="saddles.capability_options" stm-attribute.value="option.id"
+                                                     stm-text="option.name">capability</option>
+                                        </select>
+                                        <button type="submit" class="btn-sm">Add</button>
+                                    </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"
+                                            stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
+                                </form>
+                            </div>
+                        </details>
+
+                        <!-- Non-admins see the routing tags without any
+                             admin machinery (the roles decision). -->
+                        <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
+                            <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                                <span class="badge"><span stm-text="capability.name">tag</span></span>
+                            </span>
+                        </p>
+                    </div>
                 </section>
                 <section class="card" stm-if="saddles.saddles.length == 0">
                     <p class="text-muted">No saddles registered. The dispatcher pairs queued tasks with idle saddles automatically.</p>
                 </section>
             </div>
+        </div>
 
-            <h3>Capabilities</h3>
+        <!-- The capability catalogue (recommendation C): its own
+             h2-level section, a different modality than the card
+             grid above, so the page reads as two deliberate
+             sections. -->
+        <div class="stack" stm-if="saddles != null">
+            <h2 id="routing-tags">Routing tags</h2>
             <p class="text-muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
                 tag the task requires. Manage a saddle's tags on its card above; require tags on a task's page.</p>
-            <details stm-if="saddles.is_admin">
-                <summary><strong>New capability</strong></summary>
-                <form stm-action="saddles.create_capability" class="cluster">
-                    <label for="new-capability">Name</label>
-                    <input type="text" id="new-capability" name="name" required placeholder="gpu">
-                    <button type="submit">Create capability</button>
-                </form>
-            </details>
+            <div class="cluster">
+                <button type="button" class="btn-primary" stm-if="saddles.is_admin"
+                        onclick="window.openAppDialog('capability-create-dialog')">+ New tag</button>
+            </div>
             <div class="table-wrap">
-                <table>
+                <table class="table-hover">
 
                 <thead><tr><th>Tag</th><th>Saddles carrying it</th><th>Tasks requiring it</th><th></th></tr></thead>
                 <tbody>
@@ -130,17 +165,62 @@
                         <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" stm-action="capability.delete"
+                        <td stm-if="saddles.is_admin"><button type="button" class="btn-sm btn-danger" stm-action="capability.delete"
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">
                         <td colspan="4" class="text-muted">No capabilities defined yet.</td>
                     </tr>
                 </tbody>
-            
+
                 </table>
             </div>
         </div>
-    </section>
+    </div>
+
+    <!-- Goal 41 T8: the register form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ Register saddle" button -
+         same fields, same stm-action (the shared openAppDialog helper
+         closes it on the action's notify). -->
+    <dialog id="saddle-register-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">Register saddle</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('saddle-register-dialog')">×</button>
+        </div>
+        <form stm-action="saddles.register">
+            <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>
+                <div class="field"><label for="saddle-host">Host</label>
+                    <input type="text" id="saddle-host" name="host" required placeholder="localhost:7100"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('saddle-register-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Register</button>
+            </div>
+        </form>
+    </dialog>
+
+    <!-- The capability create form, same treatment. -->
+    <dialog id="capability-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New routing tag</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('capability-create-dialog')">×</button>
+        </div>
+        <form stm-action="saddles.create_capability">
+            <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>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('capability-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create tag</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 81 - 48
src/pages/stack_detail.html

@@ -3,65 +3,98 @@
 <head>
     <pstm-uri>/stacks/{stack_id}</pstm-uri>
     <pstm-template>main</pstm-template>
+<script>
+    // The Details card's in-place editor (goal 41 T8): the shared
+    // data-long-form engine in app.js toggles the read/edit halves
+    // and prefills the inputs from the stack slot. No page-specific
+    // JS beyond the init call.
+    if (window.statum && window.initLongFormEditors) {
+        window.initLongFormEditors();
+    }
+</script>
 </head>
 <body>
-    <section>
+    <!-- Goal 41 T8 ("history folds, actions surface" for the stack
+         detail page, mirroring T9's project-detail treatment): the
+         Details card becomes the read view with an in-place Edit
+         toggle (the data-long-form pattern) instead of a second
+         side-by-side edit card duplicating it; membership editing
+         stays a table with the add form surfaced next to it; the
+         destructive Delete stack is demoted into a visually
+         secondary danger zone at the bottom. -->
+    <div class="stack">
         <h2 stm-if="stack != null && stack.exists" stm-text="stack.name">Stack</h2>
         <p stm-if="stack != null && !stack.exists" class="text-muted">Stack not found.</p>
 
-        <div stm-if="stack != null && stack.exists">
+        <div stm-if="stack != null && stack.exists" class="stack">
             <p><a href="/stacks">All stacks</a></p>
-            <div class="cards">
-                <section class="card">
-                    <h3>Details</h3>
-                    <p class="text-muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
-                    <p class="text-muted">Created <span stm-text="stack.created_display">when</span></p>
-                </section>
 
-                <section class="card">
-                    <h3>Edit</h3>
-                    <form stm-action="stack.update">
+            <section class="card" data-long-form data-field="stack">
+                <div class="card-header cluster cluster-between">
+                    <h3>Details</h3>
+                    <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                </div>
+                <div class="card-body">
+                    <div data-lf-view>
+                        <p class="text-muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
+                        <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">
                         <input type="hidden" name="stack_id" stm-attribute.value="stack.id">
-                        <div class="field"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="stack.name"></div>
-                        <div class="field"><label>Summary</label>
-                            <input type="text" name="summary" stm-attribute.value="stack.summary"></div>
-                        <button type="submit">Save</button>
+                        <div class="field"><label for="stack-name">Name</label>
+                            <input type="text" id="stack-name" name="name" data-lf-src="stack.name"
+                                   stm-attribute.value="stack.name"></div>
+                        <div class="field"><label for="stack-summary">Summary</label>
+                            <input type="text" id="stack-summary" name="summary" data-lf-src="stack.summary"
+                                   stm-attribute.value="stack.summary"></div>
+                        <div class="cluster">
+                            <button type="submit" class="btn-primary btn-sm">Save</button>
+                            <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Cancel</button>
+                        </div>
                     </form>
-                </section>
-            </div>
+                </div>
+            </section>
 
-            <h3>Projects in this stack</h3>
-            <p class="text-muted">Applying the stack adds each of these to a project's or a goal's
-                linked projects with the relationship shown here.</p>
-            <table>
-                <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-member-in="stack.members">
-                        <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" stm-action="member.remove" 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>
-            <form class="cluster" stm-action="stack.add_member" 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>
-                </select>
-                <select name="relationship">
-                    <option value="reference" selected>reference</option>
-                    <option value="co-developed">co-developed</option>
-                </select>
-                <button type="submit">Add</button>
-            </form>
+            <div class="stack">
+                <h3>Projects in this stack</h3>
+                <p class="text-muted">Applying the stack adds each of these to a project's or a goal's
+                    linked projects with the relationship shown here.</p>
+                <div class="table-wrap">
+                    <table class="table-hover">
+                        <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
+                        <tbody>
+                            <tr stm-for-member-in="stack.members">
+                                <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>
+                            </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">
+                    <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>
+                    </select>
+                    <select name="relationship" aria-label="Relationship">
+                        <option value="reference" selected>reference</option>
+                        <option value="co-developed">co-developed</option>
+                    </select>
+                    <button type="submit">Add</button>
+                </form>
+            </div>
 
-            <form class="cluster">
-                <button type="button" stm-action="stack.delete" stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
-            </form>
+            <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"
+                            stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
+                </div>
+            </section>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 82 - 57
src/pages/users.html

@@ -5,38 +5,25 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Users</h2>
+    <!-- Goal 41 T8: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New user" button (the
+         expander-fatigue fix; ux-notes shorter notes, Users), the
+         edit expander became an in-place editor and the destructive
+         row actions sit visually secondary. Admin-only semantics are
+         untouched (same stm-actions, same server guards). -->
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Users</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('user-create-dialog')">+ New user</button>
+        </div>
         <p class="text-muted">Accounts and their roles. Normal users manage projects, tasks,
             goals and rules; admins additionally manage users, saddles and capabilities.</p>
         <p stm-if="users == null">Loading…</p>
 
         <div stm-if="users != null">
-            <details>
-                <summary><strong>New user</strong></summary>
-                <form stm-action="users.create">
-                    <div class="field"><label>Username</label>
-                        <input type="text" name="username" required placeholder="ada"></div>
-                    <div class="field"><label>Email</label>
-                        <input type="email" name="email" required placeholder="ada@example.com"></div>
-                    <div class="field"><label>Forename</label>
-                        <input type="text" name="forename" required placeholder="Ada"></div>
-                    <div class="field"><label>Surname</label>
-                        <input type="text" name="surname" required placeholder="Lovelace"></div>
-                    <div class="field"><label>Date of birth</label>
-                        <input type="text" name="date_of_birth" required placeholder="1990-01-31"></div>
-                    <div class="field"><label>Password</label>
-                        <input type="password" name="password" required placeholder="At least 6 characters"></div>
-                    <div class="field"><label>Confirm password</label>
-                        <input type="password" name="confirm_password" required></div>
-                    <div class="field"><label>Role</label>
-                        <label class="choice"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
-                    <button type="submit">Create user</button>
-                </form>
-            </details>
-
             <div class="table-wrap">
-                <table>
+                <table class="table-hover">
 
                 <thead><tr>
                     <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
@@ -53,45 +40,83 @@
                                   stm-text="user.enabled ? 'enabled' : 'disabled'">enabled</span></td>
                         <td class="text-muted" stm-text="user.created_display">when</td>
                         <td>
-                            <details>
-                                <summary>Edit</summary>
-                                <form stm-action="user.alter">
-                                    <div class="field"><label>Username</label>
-                                        <input type="text" name="username" required stm-attribute.value="user.username"></div>
-                                    <div class="field"><label>Email</label>
-                                        <input type="email" name="email" required stm-attribute.value="user.email"></div>
-                                    <div class="field"><label>Forename</label>
-                                        <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
-                                    <div class="field"><label>Surname</label>
-                                        <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
-                                    <div class="field"><label>Date of birth</label>
-                                        <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
-                                    <div class="field"><label>Enabled</label>
-                                        <label class="choice"><input type="checkbox" name="enabled" value="1"
-                                               stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
-                                    <div class="field"><label>New password</label>
-                                        <input type="password" name="new_password"
-                                               placeholder="Leave empty to keep the current password"></div>
-                                    <button type="submit">Save</button>
-                                    <span class="text-muted">Your own account is edited on your profile page.</span>
-                                </form>
-                            </details>
-                            <form class="cluster">
-                                <button type="button" stm-action="user.set_enabled"
+                            <div class="cluster">
+                                <details>
+                                    <summary>Edit</summary>
+                                    <form stm-action="user.alter" 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>
+                                            <input type="email" name="email" required stm-attribute.value="user.email"></div>
+                                        <div class="field"><label>Forename</label>
+                                            <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
+                                        <div class="field"><label>Surname</label>
+                                            <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
+                                        <div class="field"><label>Date of birth</label>
+                                            <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
+                                        <div class="field"><label>Enabled</label>
+                                            <label class="choice"><input type="checkbox" name="enabled" value="1"
+                                                   stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
+                                        <div class="field"><label>New password</label>
+                                            <input type="password" name="new_password"
+                                                   placeholder="Leave empty to keep the current password"></div>
+                                        <div class="cluster">
+                                            <button type="submit" class="btn-primary">Save</button>
+                                            <span class="text-muted">Your own account is edited on your profile page.</span>
+                                        </div>
+                                    </form>
+                                </details>
+                                <button type="button" class="btn-sm" stm-action="user.set_enabled"
                                         stm-text="user.enabled ? 'Disable' : 'Enable'">toggle</button>
-                                <button type="button" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
-                                <button type="button" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
-                                <button type="button" stm-action="user.delete"
+                                <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"
                                         stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
-                            </form>
+                            </div>
                         </td>
                     </tr>
                     <tr stm-if="users.users.length == 0"><td colspan="7" class="text-muted">No users.</td></tr>
                 </tbody>
-            
+
                 </table>
             </div>
         </div>
-    </section>
+    </div>
+
+    <!-- The create dialog: same fields, same stm-action as the old
+         expander form; the shared openAppDialog helper closes it on
+         the action's notify. -->
+    <dialog id="user-create-dialog">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New user</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('user-create-dialog')">×</button>
+        </div>
+        <form stm-action="users.create">
+            <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>
+                <div class="field"><label for="user-email">Email</label>
+                    <input type="email" id="user-email" name="email" required placeholder="ada@example.com"></div>
+                <div class="field"><label for="user-forename">Forename</label>
+                    <input type="text" id="user-forename" name="forename" required placeholder="Ada"></div>
+                <div class="field"><label for="user-surname">Surname</label>
+                    <input type="text" id="user-surname" name="surname" required placeholder="Lovelace"></div>
+                <div class="field"><label for="user-dob">Date of birth</label>
+                    <input type="text" id="user-dob" name="date_of_birth" required placeholder="1990-01-31"></div>
+                <div class="field"><label for="user-password">Password</label>
+                    <input type="password" id="user-password" name="password" required placeholder="At least 6 characters"></div>
+                <div class="field"><label for="user-confirm">Confirm password</label>
+                    <input type="password" id="user-confirm" name="confirm_password" required></div>
+                <div class="field"><label>Role</label>
+                    <label class="choice"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('user-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create user</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 173 - 0
test/bundles/t8fleet.py

@@ -0,0 +1,173 @@
+"""The T8 fleet bundle (goal 41 task 8): the fleet + admin pages on the
+Spry conversion — the saddle cards' "now" head / "Keys & admin" tail
+split with the ONE consolidated health verdict line (saddle.health_verdict),
+the fleet health summary line above the grid when any saddle is
+offline/stale (saddles.fleet_alert), the capability catalogue as its own
+h2 "Routing tags" section, the register/capability create forms as
+native <dialog> modals behind buttons (the shared openAppDialog helper),
+the users page's create dialog + dense row actions, the model page's
+Spry form cards and the stack detail's in-place Details editor with the
+demoted delete. Markup-level via http_get_page; the slot-level verdict
+and alert cross-checked against the saddles slot data."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    print("== /saddles: the head/tail split markup ==")
+    page = http_get_page(auth, "/saddles")
+    # The admin tail folds under ONE expander after the verdict/work
+    # head (ux-notes quick win 8 A); the fold carries the hairline.
+    check("Keys &amp; admin</summary>" in page,
+          "the card tail folds under one Keys & admin expander")
+    check(page.index("Keys &amp; admin</summary>")
+          > page.index("stm-text=\"saddle.health_verdict\""),
+          "the admin tail sits after the verdict line (head first)")
+    check("saddle-fold" in page,
+          "the head/tail boundary carries the saddle-fold hairline")
+    check("saddle.health_verdict" in page,
+          "the card head renders the composed health verdict line")
+    # The three old fragments no longer lead the head: the badge keeps
+    # the colour, the verdict carries the words.
+    check("'daemon ' + saddle.daemon_state" not in page
+          and "'contacted ' + saddle.last_contact_display" not in page,
+          "the fragmented health line is gone (one verdict instead)")
+    check("stm-text=\"saddle.current_goal_title\"" in page
+          and "stm-text=\"saddle.current_task_title\"" in page,
+          "current work stays in the head")
+
+    print("== /saddles: the fleet summary line + Routing tags section ==")
+    # fleet_alert is server-composed; the page renders it when
+    # non-empty. Mark a saddle offline to shape it, then restore.
+    conn = db()
+    conn.execute("update saddles set status = 'offline' where id = ?",
+                 (ctx.fake_id,))
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/saddles", "saddles")
+    check(state["fleet_alert"].startswith("1 of ")
+          and "not healthy" in state["fleet_alert"]
+          and "fake-saddle" in state["fleet_alert"],
+          "an offline saddle shapes the fleet alert line: %s"
+          % state["fleet_alert"])
+    check("dispatcher may be starved" in state["fleet_alert"],
+          "the alert names the dispatcher risk")
+    card = saddle_card(auth, "fake-saddle")
+    check(card["health"] == "offline"
+          and "offline" in card["health_verdict"],
+          "the offline row's verdict agrees with its health")
+    conn = db()
+    conn.execute("update saddles set status = 'idle' where id = ?",
+                 (ctx.fake_id,))
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/saddles", "saddles")
+    check(state["fleet_alert"] == "",
+          "a healthy fleet renders no alert (empty string)")
+
+    # The catalogue is its own h2 section (recommendation C), not an
+    # h3 tail after the grid.
+    check("<h2 id=\"routing-tags\">Routing tags</h2>" in page,
+          "the capability catalogue is its own h2 Routing tags section")
+    check("<h3>Capabilities</h3>" not in page,
+          "the old h3 Capabilities tail is gone")
+
+    print("== /saddles: the register + capability dialogs ==")
+    check('id="saddle-register-dialog"' in page
+          and "<dialog" in page,
+          "the register form is a native dialog")
+    check('onclick="window.openAppDialog(\'saddle-register-dialog\')"' in page,
+          "the + Register saddle button opens the dialog via the helper")
+    register_html = page[page.index('id="saddle-register-dialog"'):]
+    check('stm-action="saddles.register"' in register_html
+          and 'name="name"' in register_html
+          and 'name="host"' in register_html,
+          "the dialog carries the unchanged register form")
+    check('class="dialog-titlebar"' in register_html
+          and 'class="dialog-footer"' in register_html,
+          "the register dialog uses Spry's dialog classes")
+    check('id="capability-create-dialog"' in page
+          and 'stm-action="saddles.create_capability"' in page,
+          "the capability create form lives in a dialog too")
+    check("<details>" in page and "<summary><strong>Register saddle</strong></summary>" not in page,
+          "no create expander remains on the saddles page (details are the admin tail)")
+
+    # Semantics: registering through the dialog form's action works,
+    # then clean up (the fake saddle stays the fleet's only daemon).
+    before = len(slot_public(auth, "/saddles", "saddles")["saddles"])
+    act(auth, state["register"], {"name": "t8-probe", "host": "localhost:1"})
+    rows = slot_public(auth, "/saddles", "saddles")["saddles"]
+    check(len(rows) == before + 1, "the register action still creates")
+    probe = [s for s in rows if s["name"] == "t8-probe"][0]
+    # register_saddle creates the row IDLE (the proven-derivation
+    # decision): a never-contacted saddle honestly reads unknown, and
+    # the verdict says so without the alarm of "offline".
+    check(probe["health"] == "unknown"
+          and "no health report yet" in probe["health_verdict"],
+          "a never-contacted saddle reads unknown with the no-report verdict")
+    act(auth, probe["unregister"], {})
+    check(len(slot_public(auth, "/saddles", "saddles")["saddles"]) == before,
+          "the probe saddle unregisters again")
+
+    print("== /users: create dialog + dense row actions ==")
+    users_page = http_get_page(auth, "/users")
+    check('id="user-create-dialog"' in users_page
+          and 'stm-action="users.create"' in users_page
+          and 'onclick="window.openAppDialog(\'user-create-dialog\')"' in users_page,
+          "the user create form lives in a dialog behind the button")
+    check("<summary><strong>New user</strong></summary>" not in users_page,
+          "no create expander remains on the users page")
+    check('name="confirm_password"' in users_page
+          and 'name="date_of_birth"' in users_page,
+          "the dialog carries the unchanged create fields")
+    check("btn-danger" in users_page and "Delete this user?" in users_page,
+          "the destructive delete is the visually secondary danger button")
+
+    print("== /models: Spry form cards ==")
+    models_page = http_get_page(auth, "/models")
+    check('stm-action="model.save"' in models_page
+          and 'id="model-base-url"' in models_page
+          and 'id="advisor-base-url"' in models_page,
+          "the model form keeps its fields with labelled controls")
+    check('class="card-header"' in models_page and "form-rows" in models_page,
+          "the endpoint groups are cards with capped-width fields")
+    check("alert-warning" in models_page or "alert-info" in models_page,
+          "the readiness line renders as an alert")
+    # The saved hint round-trip already covered by modelconfig; the
+    # save action stays the same one.
+
+    print("== /stacks/{id}: in-place Details editor + demoted delete ==")
+    stacks_state = slot_public(auth, "/stacks", "stacks")
+    result = act(auth, stacks_state["create"],
+                 {"name": "T8 probe stack", "summary": "markup probe"})
+    stack_id = None
+    for row in slot_public(auth, "/stacks", "stacks")["stacks"]:
+        if row["name"] == "T8 probe stack":
+            stack_id = row["id"]
+    check(stack_id is not None, "the probe stack exists")
+    stack_page = http_get_page(auth, "/stacks/%s" % stack_id)
+    check("data-long-form" in stack_page and "data-lf-toggle" in stack_page,
+          "the Details card toggles in place (view/edit halves)")
+    check("initLongFormEditors" in stack_page,
+          "the stack page initialises the shared editor engine")
+    check('stm-action="stack.update"' in stack_page
+          and 'stm-action="stack.add_member"' in stack_page,
+          "the update and add-member actions are unchanged")
+    # The old side-by-side Edit card is gone; the delete is a small
+    # danger button inside a flat card at the bottom.
+    check("<h3>Edit</h3>" not in stack_page,
+          "the duplicate side-by-side Edit card is gone")
+    check('class="card card-flat"' in stack_page
+          and "btn-danger" in stack_page,
+          "the delete action sits in a flat danger zone")
+    # Update through the in-place form's action still works.
+    detail = slot_public(auth, "/stacks/%s" % stack_id, "stack")
+    result = act(auth, detail["update"],
+                 {"stack_id": str(stack_id), "name": "T8 probe stack 2",
+                  "summary": "renamed in place"})
+    check("saved" in (action_notify(result) or ""),
+          "the in-place editor's action saves the rename")
+    act(auth, slot_public(auth, "/stacks/%s" % stack_id, "stack")["delete"], {})
+    check(slot_public(auth, "/stacks", "stacks")["stacks"] == [],
+          "the probe stack deletes again")

+ 1 - 1
test/harness.py

@@ -1367,7 +1367,7 @@ BUNDLES_ORDER = ("pages", "t7log", "dispatch", "subtasks", "lineage",
                  "turncappark", "modelconfig",
                  "push",
                  "goals", "replay", "goalui", "forcewake", "users", "theme", "stacks",
-                 "notes", "resurrect", "t4lists", "origins", "parity", "understanding",
+                 "notes", "resurrect", "t4lists", "t8fleet", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",