瀏覽代碼

Library pages on Spry: in-place editors, blast radius, reverse links, crumbs

Goal 41 T9 (phase C wave 2) - the project detail, rules, discoveries and
chats pages, per docs/ux-notes.md sections Projects/Rules/Discoveries and
the expander-fatigue and navigation notes.

Project detail:
- Details + side-by-side Edit cards collapsed into ONE card with an
  in-place Edit toggle (the data-long-form engine; the edit half
  prefills from the slot's current values via data-lf-src, so the
  editor shows what IS instead of a cold empty form).
- Breadcrumbs (Dashboard / Projects / name) via the shared partial; the
  hand-drawn "All projects" anchor is gone. The entrypoint publishes
  the crumbs vector.
- Goals in this project: the missing reverse cross-link - the project's
  own goals plus goals linked as co-developed (reference links stay
  off; they are context, not work happening here). Badged like the
  goals list, branch and relationship columns.
- "+ New task in this project" deep-links to /tasks#new?project=<id>;
  tasks.html gained an additive prefill pass that opens the create
  dialog with the project preselected (the option pick retries until
  statum renders the select), then drops the hash so a reload does not
  reopen the dialog.

Rules:
- The New rule expander became a native dialog behind a "+ New rule"
  button (openAppDialog).
- The per-rule Edit expander became the in-place editor (data-lf-view /
  data-lf-edit halves; the textarea prefills from stm-text because the
  editors live inside a stm-for loop no slot path can address).
- Delete demoted out of the row's face: a ghost button whose confirm
  names the blast radius (the old bare Delete was more prominent than
  edit - inverted, per ux-notes).
- New blast-radius list on each rule: where it is attached (projects
  and tasks, linked). RuleService gained two by-rule attachment
  queries; the rules slot publishes attachments through list_rich
  (arrays cannot ride the GObject snapshot).

Discoveries: the per-row Edit expander became the in-place editor
(same loop-safe prefill); search semantics untouched (client-side
filter + server-side q seed both verified).

Chats + chat detail: breadcrumbs on the detail page (Dashboard / Chats
/ title, crumbs from the chat slot builder); the unconfigured-advisor
notices use the Spry alert instead of the app-local .notice; the list
row's and the detail header's Delete are ghost buttons. The .chat-*
components were already tokenised - structure unchanged (conversation
leads, composer pinned below).

app.css: one appended marker section (no component additions - every
surface adopts Spry primitives and the shared engines).

Tests: new t9library bundle (39 checks - editors, crumbs vectors,
goals reverse link incl. the reference-link exclusion, rule blast
radius, dialog create, prefill hookup, ghost deletes) registered in
the bundle map and order. Full suite: 1976 passed, 0 failed across
all 39 bundles.
saddle-ClankerSaddle 2 天之前
父節點
當前提交
7ff7320f70

+ 24 - 0
src/Services/RuleService.vala

@@ -216,6 +216,30 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
+        /**
+         * Goal 41 T9: the `rule_projects` rows carrying `rule_id`,
+         * oldest first — the project half of a rule's blast radius on
+         * the rules page.
+         */
+        public async ImmutableLot<RuleProject> list_project_attachments_by_rule(int64 rule_id) throws Error {
+            return yield db.query<RuleProject>()
+                .where(expr("rule_id == $0", elem<int64?>(rule_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Goal 41 T9: the `task_rules` rows carrying `rule_id`, oldest
+         * first — the task half of a rule's blast radius on the rules
+         * page.
+         */
+        public async ImmutableLot<TaskRule> list_task_attachments_by_rule(int64 rule_id) throws Error {
+            return yield db.query<TaskRule>()
+                .where(expr("rule_id == $0", elem<int64?>(rule_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
         /** The rules attached to `task_id`, attachment order. */
         public async ImmutableLot<Rule> list_rules_for_task(int64 task_id) throws Error {
             var rows = new Vector<Rule>();

+ 17 - 0
src/entrypoints/ChatDetailEntrypoint.vala

@@ -77,6 +77,15 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? refresh { get; set; }
     }
 
+    /**
+     * One breadcrumb item on the chat detail page (goal 41 T9): the
+     * shared partial renders the trail; the leaf carries no href.
+     */
+    public class ChatCrumbView : Object {
+        public string? href { get; set; default = null; }
+        public string label { get; set; default = ""; }
+    }
+
     /**
      * Builds the `chat` PAGE slot shared by the chat detail entrypoint
      * and the advisor actions ({@link SendAdvisorMessageAction} answers
@@ -165,6 +174,14 @@ namespace ClankerSaddle {
                     messages.to_immutable_buffer());
                 props["suggestions"] = StateJson.list<ChatSuggestionView>(
                     suggestions.to_immutable_buffer());
+                // Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
+                // <title>) rendered by the shared partial.
+                var crumbs = new Vector<ChatCrumbView>();
+                crumbs.add(new ChatCrumbView() { href = "/", label = "Dashboard" });
+                crumbs.add(new ChatCrumbView() { href = "/chats", label = "Chats" });
+                crumbs.add(new ChatCrumbView() { label = ((!)chat).title });
+                props["crumbs"] = StateJson.list<ChatCrumbView>(
+                    crumbs.to_immutable_buffer());
             } else {
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);
                 props["messages"] = StateJson.list<ChatMessageView>(

+ 80 - 0
src/entrypoints/ProjectsEntrypoint.vala

@@ -1,5 +1,6 @@
 using Invercargill;
 using Invercargill.DataStructures;
+using Invercargill.Expressions;
 using Spry;
 using Spry.Authentication;
 using Statum;
@@ -103,6 +104,32 @@ namespace ClankerSaddle {
         public int64 priority { get; set; }
     }
 
+    /**
+     * A goal row on the project detail page (goal 41 T9): the missing
+     * reverse cross-link from ux-notes — the project's own goals plus
+     * the ones linked to it as co-developed, so "what is being built
+     * here" is answerable from the project page itself.
+     */
+    public class ProjectGoalRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        /** The status with underscores replaced (matches goal pages). */
+        public string display { get; set; default = ""; }
+        /** "primary" for the project's own goals, else the link's relation. */
+        public string relationship { get; set; default = ""; }
+        public string branch_name { get; set; default = ""; }
+    }
+
+    /**
+     * One breadcrumb item on the project detail page (goal 41 T9):
+     * the shared partial renders the trail; the leaf carries no href.
+     */
+    public class ProjectCrumbView : Object {
+        public string? href { get; set; default = null; }
+        public string label { get; set; default = ""; }
+    }
+
     /** A rule attached to the project, with its detach action. */
     public class ProjectRuleRowView : Object {
         public int64 link_id { get; set; }
@@ -191,6 +218,8 @@ namespace ClankerSaddle {
         private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private StackService stack_service = Inversion.inject<StackService>();
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -322,6 +351,48 @@ namespace ClankerSaddle {
                 });
             }
 
+            // Goal 41 T9: the goals reverse link (ux-notes navigation).
+            // The project's own goals lead; goals linked as co-developed
+            // follow with their relationship, so the page answers "what
+            // is being built here" without a detour through /goals.
+            // Reference links are deliberately omitted — a reference
+            // repo is context, not work happening in this project.
+            var goal_rows = new Vector<ProjectGoalRowView>();
+            var seen_goals = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var goal in yield goal_service.list_goals()) {
+                if (goal.project_id == project_id) {
+                    seen_goals.add(goal.id.to_string());
+                    goal_rows.add(new ProjectGoalRowView() {
+                        id = goal.id,
+                        title = goal.title,
+                        status = goal.status,
+                        display = goal.status.replace("_", " "),
+                        relationship = "primary",
+                        branch_name = goal.branch_name
+                    });
+                }
+            }
+            foreach (var link in yield db.query<GoalLink>()
+                    .where(expr("project_id == $0", elem<int64?>(project_id)))
+                    .materialise_async()) {
+                if (link.relationship != "co-developed"
+                        || seen_goals.contains(link.goal_id.to_string())) {
+                    continue;
+                }
+                var goal = yield goal_service.get_goal(link.goal_id);
+                if (goal == null) {
+                    continue;
+                }
+                goal_rows.add(new ProjectGoalRowView() {
+                    id = ((!)goal).id,
+                    title = ((!)goal).title,
+                    status = ((!)goal).status,
+                    display = ((!)goal).status.replace("_", " "),
+                    relationship = link.relationship,
+                    branch_name = ((!)goal).branch_name
+                });
+            }
+
             var note_rows = new Vector<ProjectNoteRowView>();
             foreach (var note in yield project_service.list_notes(project_id)) {
                 var note_private = new ProjectNoteActionPrivate() {
@@ -376,6 +447,7 @@ namespace ClankerSaddle {
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
+            props["goals"] = StateJson.list<ProjectGoalRowView>(goal_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["understanding_current"] = StateJson.element(current_understanding_row);
@@ -390,6 +462,14 @@ namespace ClankerSaddle {
             }
             props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
+            // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
+            // <name>) — the shared partial renders it, replacing the
+            // hand-drawn "All projects" anchor.
+            var crumbs = new Vector<ProjectCrumbView>();
+            crumbs.add(new ProjectCrumbView() { href = "/", label = "Dashboard" });
+            crumbs.add(new ProjectCrumbView() { href = "/projects", label = "Projects" });
+            crumbs.add(new ProjectCrumbView() { label = ((!)project).name });
+            props["crumbs"] = StateJson.list<ProjectCrumbView>(crumbs.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",

+ 65 - 2
src/entrypoints/RulesEntrypoint.vala

@@ -8,6 +8,20 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
+    /**
+     * One "where this rule is attached" row on the rules page (goal 41
+     * T9): the blast radius of a delete, per ux-notes — the page says
+     * rules attach on project/task pages but you could not see from
+     * here which ones carry them.
+     */
+    public class RuleAttachmentRowView : Object {
+        /** "project" or "task". */
+        public string kind { get; set; default = ""; }
+        public int64 id { get; set; }
+        /** The project or task's name/title. */
+        public string label { get; set; default = ""; }
+    }
+
     /** A rule row on the rules page, with its row actions. */
     public class RuleRowView : Object {
         public int64 id { get; set; }
@@ -16,6 +30,14 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? delete { get; set; }
+        /**
+         * Goal 41 T9: where the rule is attached (the delete's blast
+         * radius). An array-valued field cannot ride GObjectMapping's
+         * snapshot, so the entrypoint publishes it through
+         * StateJson.list_rich (the goal page's options/capabilities
+         * pattern).
+         */
+        public Vector<RuleAttachmentRowView> attachments { get; set; default = new Vector<RuleAttachmentRowView>(); }
     }
 
     /** Public data of the `rules` slot. */
@@ -32,6 +54,8 @@ namespace ClankerSaddle {
     public class RulesEntrypoint : StatumEntrypoint {
 
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -49,7 +73,8 @@ namespace ClankerSaddle {
                     body = rule.body,
                     created_display = format_stamp(rule.created_at),
                     update = action_registry.author_private<UpdateRuleAction, RuleActionPrivate>(sealed),
-                    delete = action_registry.author_private<DeleteRuleAction, RuleActionPrivate>(sealed)
+                    delete = action_registry.author_private<DeleteRuleAction, RuleActionPrivate>(sealed),
+                    attachments = yield attachments_for(rule.id)
                 });
             }
 
@@ -57,7 +82,12 @@ namespace ClankerSaddle {
             pub.create = action_registry.author<CreateRuleAction>();
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            props["rules"] = StateJson.list<RuleRowView>(rows.to_immutable_buffer());
+            props["rules"] = StateJson.list_rich<RuleRowView>(
+                rows.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["attachments"] = StateJson.list<RuleAttachmentRowView>(
+                        ((RuleRowView) item).attachments.to_immutable_buffer());
+                });
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "rules",
@@ -66,5 +96,38 @@ namespace ClankerSaddle {
             });
             return directives().set(slot);
         }
+
+        /**
+         * Goal 41 T9: where `rule_id` is attached — every project whose
+         * dispatches carry it and every task with its own copy, each as
+         * a linked row. This is the blast radius the delete confirm
+         * should be able to name.
+         */
+        private async Vector<RuleAttachmentRowView> attachments_for(int64 rule_id) throws GLib.Error {
+            var attached = new Vector<RuleAttachmentRowView>();
+            foreach (var link in yield rule_service.list_project_attachments_by_rule(rule_id)) {
+                var project = yield project_service.get_project(link.project_id);
+                if (project == null) {
+                    continue;
+                }
+                attached.add(new RuleAttachmentRowView() {
+                    kind = "project",
+                    id = link.project_id,
+                    label = ((!)project).name
+                });
+            }
+            foreach (var link in yield rule_service.list_task_attachments_by_rule(rule_id)) {
+                var detail = yield task_service.get_task(link.task_id);
+                if (detail == null) {
+                    continue;
+                }
+                attached.add(new RuleAttachmentRowView() {
+                    kind = "task",
+                    id = link.task_id,
+                    label = ((!)detail).task.title
+                });
+            }
+            return attached;
+        }
     }
 }

+ 9 - 0
src/pages/app.css

@@ -1639,3 +1639,12 @@ td .truncate.repo-url {
    the card's inset surface to read as one folding block. */
 .saddle-admin { padding-left: var(--spry-space-2); }
 
+
+/* ==========================================================================
+   Goal 41 T9 — library pages (project_detail / rules / discoveries / chats)
+   No component additions: every surface adopts Spry primitives
+   (.card-header/-body, dialogs, .btn-ghost, .alert-*) and the shared
+   data-long-form engine, per the spry-conversion contract. This
+   marker section exists so parallel goal-41 tasks can merge by
+   keeping both sides in sequence.
+   ========================================================================== */

+ 10 - 3
src/pages/chat_detail.html

@@ -132,15 +132,22 @@
 
         <div stm-if="chat != null && chat.id > 0" class="chat-page">
             <div class="chat-head">
+                <!-- Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
+                     <title>) - the partial is T1's, the crumbs vector the
+                     entrypoint's, replacing the hand-drawn back link. -->
+                <pstm-fragment name="breadcrumb" crumbs="chat.crumbs"/>
                 <h2 stm-text="chat.title">title</h2>
-                <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                <p class="cluster"><span stm-attribute.class="'badge badge-chat-' + chat.status"
                          stm-text="chat.status_display">status</span>
                     <span class="text-muted">advisor chat</span>
                     <form class="cluster">
-                        <button type="button" class="chat-delete" stm-action="chat.delete"
+                        <!-- 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"
                                 stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
                     </form></p>
-                <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
+                <p class="alert alert-warning" stm-if="!chat.advisor_configured">The advisor is not
                     configured - set the model endpoint on the <a href="/models">Admin
                     → Model</a> page to enable it. Your messages wait
                     until then.</p>

+ 5 - 2
src/pages/chats.html

@@ -11,7 +11,7 @@
             <a href="/">dashboard</a> to start a new one - it can read the fleet's
             state, ask you questions and suggest tasks or goals, but never runs
             commands itself.</p>
-        <p class="notice" stm-if="chats != null && !chats.advisor_configured">The
+        <p class="alert alert-warning" stm-if="chats != null && !chats.advisor_configured">The
             advisor is not configured - set the model endpoint on the <a href="/models">Admin
             → Model</a> page to enable it. Chats you start now
             will wait until then.</p>
@@ -33,7 +33,10 @@
                         <td stm-text="chat.updated_display">when</td>
                         <td>
                             <form class="cluster">
-                                <button type="button" stm-action="chat.delete"
+                                <!-- 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"
                                         stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
                             </form>
                         </td>

+ 36 - 16
src/pages/discoveries.html

@@ -54,23 +54,43 @@
 
             <div stm-for-discovery-in="discoveries.discoveries" stm-key="discovery.id"
                  stm-if="window.discoveryRowVisible(discovery)" class="card">
-                <div class="md" stm-markdown="discovery.body">body</div>
-                <p class="text-muted">Created <span stm-text="discovery.created_display">when</span><span
-                        stm-if="discovery.updated_display != ''"> · Updated <span
-                        stm-text="discovery.updated_display">when</span></span></p>
-                <details>
-                    <summary>Edit</summary>
-                    <form stm-action="discovery.update">
-                        <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
-                        <div class="field"><label>Body</label>
-                            <textarea name="body" rows="6" stm-attribute.value="discovery.body"></textarea></div>
-                        <button type="submit">Save</button>
-                    </form>
-                </details>
-                <form class="cluster">
-                    <button type="button" stm-action="discovery.delete"
+                <!-- Goal 41 T9 (ux-notes expander fatigue): the Edit
+                     expander became the in-place editor (the
+                     data-long-form engine in app.js). The editors live
+                     INSIDE a stm-for loop, so no slot path can address
+                     a row: the textarea prefills from stm-text (text
+                     content pre-fills a pristine textarea; the page
+                     re-renders only on its own actions) and the lf
+                     engine's prefill pass finds no data-lf-src controls
+                     and leaves it as rendered. -->
+                <div data-long-form data-field="discovery-row">
+                    <div class="card-header cluster cluster-between">
+                        <p class="text-muted">Discovery <span stm-text="discovery.id">#</span></p>
+                        <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                    </div>
+                    <div class="card-body" data-lf-view>
+                        <div class="md" stm-markdown="discovery.body">body</div>
+                        <p class="text-muted">Created <span stm-text="discovery.created_display">when</span><span
+                                stm-if="discovery.updated_display != ''"> · Updated <span
+                                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">
+                            <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
+                            <div class="field"><label>Body</label>
+                                <textarea name="body" rows="6"
+                                          stm-text="discovery.body">body</textarea></div>
+                            <div class="cluster">
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+                <div class="card-body">
+                    <button type="button" class="btn-ghost btn-sm" stm-action="discovery.delete"
                             stm-confirm="Delete this discovery?">Delete</button>
-                </form>
+                </div>
             </div>
             <p stm-if="discoveries.discoveries.length == 0" class="text-muted">
                 No discoveries yet — the knowledge base starts empty and grows as agents

+ 68 - 20
src/pages/project_detail.html

@@ -10,32 +10,51 @@
         <p stm-if="project != null && !project.exists" class="text-muted">Project not found.</p>
 
         <div stm-if="project != null && project.exists">
-            <p><a href="/projects">All projects</a></p>
-            <div class="cards">
-                <section class="card">
+            <!-- Goal 41 T9: breadcrumbs replace the hand-drawn "All
+                 projects" anchor (the partial is T1's; the crumbs
+                 vector is the entrypoint's). -->
+            <pstm-fragment name="breadcrumb" crumbs="project.crumbs"/>
+
+            <!-- Goal 41 T9 (ux-notes projects): Details and Edit were two
+                 side-by-side cards - a form for exactly what Details
+                 displays. The card is now ONE read view with an in-place
+                 Edit toggle (the data-long-form engine in app.js: the
+                 edit half prefills from the slot's current values via
+                 data-lf-src, so the editor shows what IS, not a cold
+                 empty form). -->
+            <section class="card" data-long-form data-field="project">
+                <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" data-lf-view>
                     <p><strong stm-text="project.git_url">git_url</strong> · branch
                         <em stm-text="project.default_branch">main</em></p>
                     <p class="text-muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</p>
                     <p class="text-muted">Created <span stm-text="project.created_display">when</span></p>
-                </section>
-
-                <section class="card">
-                    <h3>Edit</h3>
-                    <form stm-action="project.update">
+                </div>
+                <div class="card-body" data-lf-edit hidden>
+                    <form stm-action="project.update" class="form-rows">
                         <input type="hidden" name="project_id" stm-attribute.value="project.id">
-                        <div class="field"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="project.name"></div>
-                        <div class="field"><label>Git URL</label>
-                            <input type="text" name="git_url" stm-attribute.value="project.git_url"></div>
-                        <div class="field"><label>Branch</label>
-                            <input type="text" name="default_branch" stm-attribute.value="project.default_branch"></div>
-                        <div class="field"><label>Summary</label>
-                            <input type="text" name="summary" stm-attribute.value="project.summary"></div>
-                        <button type="submit">Save</button>
+                        <div class="field"><label for="project-name">Name</label>
+                            <input type="text" id="project-name" name="name"
+                                   data-lf-src="project.name"></div>
+                        <div class="field"><label for="project-git">Git URL</label>
+                            <input type="text" id="project-git" name="git_url"
+                                   data-lf-src="project.git_url"></div>
+                        <div class="field"><label for="project-branch">Branch</label>
+                            <input type="text" id="project-branch" name="default_branch"
+                                   data-lf-src="project.default_branch"></div>
+                        <div class="field"><label for="project-summary">Summary</label>
+                            <input type="text" id="project-summary" name="summary"
+                                   data-lf-src="project.summary"></div>
+                        <div class="cluster">
+                            <button type="submit" class="btn-primary">Save</button>
+                            <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                        </div>
                     </form>
-                </section>
-            </div>
+                </div>
+            </section>
 
             <h3>Linked projects</h3>
             <table>
@@ -137,6 +156,14 @@
                 record them with <code>saddle note</code>.</p>
 
             <h3>Tasks in this project</h3>
+            <!-- Goal 41 T9: the create affordance deep-links to /tasks
+                 with this project preselected in the New task dialog
+                 (the tasks page owns the form; the hash carries the
+                 prefill, see tasks.html's new-task-prefill). -->
+            <p class="cluster">
+                <a class="btn btn-primary btn-sm"
+                   stm-attribute.href="'/tasks#new?project=' + project.id">+ New task in this project</a>
+            </p>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
                 <tbody>
@@ -147,9 +174,30 @@
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                     </tr>
-                    <tr stm-if="project.tasks.length == 0"><td colspan="4" class="text-muted">No tasks yet — create one from the <a href="/tasks">queue</a>.</td></tr>
+                    <tr stm-if="project.tasks.length == 0"><td colspan="4" class="text-muted">No tasks yet — create one above or from the <a href="/tasks">queue</a>.</td></tr>
+                </tbody>
+            </table>
+
+            <!-- Goal 41 T9 (ux-notes navigation): the missing reverse
+                 cross-link - the project's own goals plus those linked
+                 as co-developed, so "what is being built here" is
+                 answerable from this page. -->
+            <h3>Goals in this project</h3>
+            <table stm-if="project.goals.length > 0">
+                <thead><tr><th>Goal</th><th>Status</th><th>Branch</th><th>Relationship</th></tr></thead>
+                <tbody>
+                    <tr stm-for-goal-in="project.goals">
+                        <td><a stm-attribute.href="'/goals/' + goal.id"
+                               stm-text="goal.title">title</a></td>
+                        <td><span stm-attribute.class="'badge badge-' + goal.status"
+                                  stm-text="goal.display">status</span></td>
+                        <td><code stm-if="goal.branch_name.length > 0" stm-text="goal.branch_name">branch</code>
+                            <span class="text-muted" stm-if="goal.branch_name.length == 0">—</span></td>
+                        <td stm-text="goal.relationship">primary</td>
+                    </tr>
                 </tbody>
             </table>
+            <p stm-if="project.goals.length == 0" class="text-muted">No goals build here yet — start one from the <a href="/goals">goals page</a>.</p>
 
             <!-- Admin-only deletion (ownership cascade). The dialog asks
                  for the project's EXACT name — the server re-checks both

+ 97 - 29
src/pages/rules.html

@@ -6,45 +6,113 @@
 </head>
 <body>
     <section>
-        <h2>Rules</h2>
+        <div class="cluster cluster-between">
+            <h1>Rules</h1>
+            <!-- Goal 41 T9 (ux-notes expander fatigue): "New rule" hid
+                 behind a collapsed <details>. The primary action is now
+                 a persistent button opening a Spry dialog (the
+                 openAppDialog helper closes it on the action's
+                 notify). -->
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('rule-create-dialog')">+ New rule</button>
+        </div>
         <p class="text-muted">Reusable instructions dispatched with every task that carries them —
             attach rules on the project and task pages.</p>
         <p stm-if="rules == null">Loading…</p>
 
         <div stm-if="rules != null">
-            <details>
-                <summary><strong>New rule</strong></summary>
-                <form stm-action="rules.create">
-                    <div class="field"><label>Name</label>
-                        <input type="text" name="name" required placeholder="Use Invercargill"></div>
-                    <div class="field"><label>Body</label>
-                        <textarea name="body" rows="3" required
-                                  placeholder="Never use libgee; use Invercargill enumerables."></textarea></div>
-                    <button type="submit">Create rule</button>
-                </form>
-            </details>
 
             <div stm-for-rule-in="rules.rules" stm-key="rule.id" class="card">
-                <h3 stm-text="rule.name">name</h3>
-                <div class="md" stm-markdown="rule.body">body</div>
-                <p class="text-muted">Created <span stm-text="rule.created_display">when</span></p>
-                <details>
-                    <summary>Edit</summary>
-                    <form stm-action="rule.update">
-                        <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
-                        <div class="field"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="rule.name"></div>
-                        <div class="field"><label>Body</label>
-                            <textarea name="body" rows="4" stm-attribute.value="rule.body"></textarea></div>
-                        <button type="submit">Save</button>
-                    </form>
-                </details>
-                <form class="cluster">
-                    <button type="button" stm-action="rule.delete" stm-confirm="Delete this rule everywhere?">Delete</button>
-                </form>
+                <!-- Goal 41 T9 (ux-notes rules): the Edit expander became
+                     the in-place editor (the data-long-form engine in
+                     app.js) — the edit half prefills from the slot's
+                     current values, so it edits what IS instead of a
+                     cold empty form. Delete left the row's face for a
+                     visually secondary danger control inside the
+                     confirm, inverting the old destructive-more-
+                     prominent-than-edit accident. -->
+                <div data-long-form data-field="rule-row">
+                    <div class="card-header cluster cluster-between">
+                        <h3 stm-text="rule.name">name</h3>
+                        <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                    </div>
+                    <div class="card-body" data-lf-view>
+                        <div class="md" stm-markdown="rule.body">body</div>
+                        <p class="text-muted">Created <span stm-text="rule.created_display">when</span></p>
+                    </div>
+                    <div class="card-body" data-lf-edit hidden>
+                        <!-- No data-lf-src here: these editors live INSIDE a
+                             stm-for loop, so no slot path can address the
+                             row. The textarea instead prefills from
+                             stm-text (textContent pre-fills a pristine
+                             textarea; this page re-renders only on its
+                             own actions, never on a pulse) and the input
+                             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">
+                            <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
+                            <div class="field"><label>Name</label>
+                                <input type="text" name="name"
+                                       stm-attribute.value="rule.name"></div>
+                            <div class="field"><label>Body</label>
+                                <textarea name="body" rows="4"
+                                          stm-text="rule.body">body</textarea></div>
+                            <div class="cluster">
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+
+                <div class="card-body">
+                    <!-- Goal 41 T9 (ux-notes rules): the blast radius —
+                         where this rule is attached. A delete here
+                         detaches it from every listed project and
+                         task; naming them makes the confirm honest. -->
+                    <p stm-if="rule.attachments.length > 0" class="text-muted">Attached to
+                        <span stm-for-attachment-in="rule.attachments">
+                            <a stm-if="attachment.kind == 'project'"
+                               stm-attribute.href="'/projects/' + attachment.id"
+                               stm-text="attachment.label">project</a>
+                            <a stm-if="attachment.kind == 'task'"
+                               stm-attribute.href="'/tasks/' + attachment.id"
+                               stm-text="'#' + attachment.id + ' ' + attachment.label">task</a>
+                        </span>
+                    </p>
+                    <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-confirm="Delete this rule everywhere? It detaches from every project and task listed above.">Delete rule…</button>
+                </div>
             </div>
             <p stm-if="rules.rules.length == 0" class="text-muted">No rules yet.</p>
         </div>
+
+        <dialog id="rule-create-dialog">
+            <div class="dialog-titlebar">
+                <p class="dialog-title">New rule</p>
+                <button type="button" class="dialog-close" aria-label="Close"
+                        onclick="window.closeAppDialog('rule-create-dialog')">×</button>
+            </div>
+            <form stm-action="rules.create">
+                <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
+                               placeholder="Use Invercargill"></div>
+                    <div class="field"><label for="rule-new-body">Body</label>
+                        <textarea name="body" id="rule-new-body" rows="3" required
+                                  placeholder="Never use libgee; use Invercargill enumerables."></textarea></div>
+                </div>
+                <div class="dialog-footer">
+                    <button type="button" class="btn-ghost"
+                            onclick="window.closeAppDialog('rule-create-dialog')">Cancel</button>
+                    <button type="submit" class="btn-primary">Create rule</button>
+                </div>
+            </form>
+        </dialog>
     </section>
 </body>
 </html>

+ 46 - 0
src/pages/tasks.html

@@ -104,6 +104,52 @@
             if (window.taskFilters.status == null) {
                 window.taskFilters.status = 'open';
             }
+
+            // Goal 41 T9: the project page's "+ New task in this
+            // project" deep-link. The hash #new?project=<id> opens the
+            // create dialog with the project preselected (the dialog
+            // is T4's; this prefill pass is additive and runs once on
+            // load, before the first render wires the select options -
+            // statum's attribute binding then keeps the chosen option).
+            // The history.replaceState drops the hash so a reload does
+            // not reopen the dialog behind the user's back.
+            window.applyNewTaskPrefill = function () {
+                var hash = window.location.hash || '';
+                if (hash.indexOf('#new') !== 0) return;
+                var query = hash.indexOf('?') === -1 ? ''
+                    : hash.slice(hash.indexOf('?') + 1);
+                var params = {};
+                query.split('&').forEach(function (part) {
+                    var eq = part.indexOf('=');
+                    if (eq === -1) return;
+                    var key = decodeURIComponent(part.slice(0, eq));
+                    var value = decodeURIComponent(part.slice(eq + 1));
+                    if (key) params[key] = value;
+                });
+                window.history.replaceState(null, '',
+                    window.location.pathname + window.location.search);
+                var select = document.getElementById('task-project');
+                if (params.project && select) {
+                    // The options may not be rendered yet (the entry's
+                    // slot arrives after first paint): retry briefly
+                    // until statum has built them.
+                    var tries = 0;
+                    var pick = function () {
+                        var option = select.querySelector(
+                            'option[value="' + params.project + '"]');
+                        if (option) {
+                            select.value = params.project;
+                            return;
+                        }
+                        if (tries++ < 40) window.setTimeout(pick, 50);
+                    };
+                    pick();
+                }
+                if (window.openAppDialog) {
+                    window.openAppDialog('task-create-dialog');
+                }
+            };
+            window.applyNewTaskPrefill();
         })();
     }
 </script>

+ 3 - 1
test/bundles/__init__.py

@@ -5,7 +5,7 @@ order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
                lifecycle, notes, origins, pages, push, replay, resurrect,
-               stacks, subtasks, t4lists, t7log, users)
+               stacks, subtasks, t4lists, t7log, t8fleet, t9library, users)
 
 BUNDLES = {
     "pages": pages,
@@ -26,4 +26,6 @@ BUNDLES = {
     "notes": notes,
     "resurrect": resurrect,
     "t4lists": t4lists,
+    "t8fleet": t8fleet,
+    "t9library": t9library,
 }

+ 238 - 0
test/bundles/t9library.py

@@ -0,0 +1,238 @@
+"""The T9 library bundle (goal 41 task 9): the project detail, rules,
+discoveries and chats pages on the Spry conversion — the project page's
+Details/Edit twin cards collapsed into ONE in-place editor
+(data-long-form) with the freed structure carrying the Goals reverse
+link and a create-task deep-link into the /tasks dialog, the rules
+page's Edit expander turned in-place with Delete demoted behind a
+ghost button and the new blast-radius attachments list, the discoveries
+rows' edit expander turned in-place, and breadcrumbs on the project and
+chat detail pages. Markup-level via http_get_page; the new slot members
+(goals vector, rule attachments, crumbs vectors) cross-checked against
+seeded database rows."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake = ctx.auth, ctx.fake
+
+    now = "strftime('%s','now')"
+
+    print("== project detail: the in-place Details editor ==")
+    # A dedicated project so the bundle owns every row it asserts on.
+    state = slot_public(auth, "/projects", "projects")
+    act(auth, state["create"], {"name": "t9-library",
+                                "git_url": "https://example.com/t9.git",
+                                "default_branch": "main",
+                                "summary": "the T9 probe project"})
+    rows = slot_public(auth, "/projects", "projects")["projects"]
+    project_id = int([p["id"] for p in rows if p["name"] == "t9-library"][0])
+
+    detail = slot_public(auth, "/projects/%d" % project_id, "project")
+    check(detail["update"] is not None,
+          "the project slot authors the update action")
+
+    page = http_get_page(auth, "/projects/%d" % project_id)
+    check('data-long-form data-field="project"' in page,
+          "the Details card is one data-long-form card")
+    check(page.count('data-lf-edit') >= 1 and page.count('data-lf-view') >= 1,
+          "the card carries the read/edit halves")
+    check('data-lf-src="project.git_url"' in page
+          and 'data-lf-src="project.summary"' in page,
+          "the edit half prefills from the slot's current values")
+    check("<h3>Edit</h3>" not in page,
+          "the side-by-side Edit card is gone")
+    check('stm-action="project.update"' in page,
+          "the editor form posts the project update action")
+
+    print("== project detail: breadcrumbs replace the back anchor ==")
+    check("<p><a href=\"/projects\">All projects</a></p>" not in page,
+          "the hand-drawn All-projects anchor is gone")
+    check('stm-def-crumbs-as="project.crumbs"' in page,
+          "the breadcrumb partial rides the project page")
+    crumbs = detail["crumbs"]
+    check([c["label"] for c in crumbs]
+          == ["Dashboard", "Projects", "t9-library"]
+          and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/projects"
+          and crumbs[2].get("href") is None,
+          "the crumbs vector trails Dashboard / Projects / <name> (%s)"
+          % [c["label"] for c in crumbs])
+
+    print("== project detail: the create-task deep-link ==")
+    check("'#new?project=' + project.id" in page
+          or "'/tasks#new?project=' + project.id" in page,
+          "the create affordance deep-links the /tasks dialog prefill")
+    check("New task in this project" in page,
+          "the affordance names the project flow")
+    tasks_page = http_get_page(auth, "/tasks")
+    check("applyNewTaskPrefill" in tasks_page,
+          "the tasks page ships the prefill pass")
+    check("task-create-dialog" in tasks_page
+          and "openAppDialog" in tasks_page,
+          "the prefill opens T4's create dialog through the helper")
+
+    print("== project detail: the goals reverse link ==")
+    # Seed one primary goal and one co-developed link; a reference link
+    # must NOT appear (context, not work happening here).
+    conn = db()
+    now = "strftime('%s','now')"
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 primary goal',"
+        " 'built here', 'goal/t9-primary', 'active', 3, 'human', %s)"
+        % now, (project_id,))
+    primary_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into projects (name, git_url, default_branch, created_at)"
+        " values ('t9-codev', 'https://example.com/t9c.git', 'main', %s)"
+        % now)
+    codev_project = int(conn.execute("select max(id) from projects")
+                        .fetchone()[0])
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 co-dev goal',"
+        " 'built alongside', 'goal/t9-codedev', 'planning', 3, 'human', %s)"
+        % now, (codev_project,))
+    codev_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into goal_links (goal_id, project_id, relationship)"
+        " values (?, ?, 'co-developed')", (codev_goal, project_id))
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 ref goal',"
+        " 'read only', 'goal/t9-ref', 'draft', 3, 'human', %s)"
+        % now, (codev_project,))
+    ref_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into goal_links (goal_id, project_id, relationship)"
+        " values (?, ?, 'reference')", (ref_goal, project_id))
+    conn.commit()
+    conn.close()
+
+    detail = slot_public(auth, "/projects/%d" % project_id, "project")
+    goals = {str(g["id"]): g for g in detail["goals"]}
+    check(set(goals) == {str(primary_goal), str(codev_goal)},
+          "the goals vector carries the primary and co-developed goals only"
+          " (got %s)" % sorted(goals))
+    check(goals[str(primary_goal)]["relationship"] == "primary"
+          and goals[str(codev_goal)]["relationship"] == "co-developed",
+          "each goal row names its relationship")
+    check(goals[str(primary_goal)]["display"] == "active"
+          and goals[str(codev_goal)]["display"] == "planning",
+          "the rows carry the human display status")
+
+    page = http_get_page(auth, "/projects/%d" % project_id)
+    check("Goals in this project" in page
+          and "'/goals/' + goal.id" in page,
+          "the page renders the goals reverse-link table")
+    check("'badge badge-' + goal.status" in page,
+          "goal rows badge their status like the goals list")
+
+    print("== rules: the in-place editor + the demoted delete ==")
+    rules_state = slot_public(auth, "/rules", "rules")
+    act(auth, rules_state["create"], {
+        "name": "T9 blast rule", "body": "The blast radius probe rule."})
+    conn = db()
+    rule_id = int(conn.execute(
+        "select id from rules where name = 'T9 blast rule'").fetchone()[0])
+    # Attach it to the probe project and to one task.
+    conn.execute(
+        "insert into rule_projects (rule_id, project_id, created_at)"
+        " values (?, ?, %s)" % now, (rule_id, project_id))
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (?, 'T9 blast task', 'queued', 5, %s)" % now, (project_id,))
+    blast_task = int(conn.execute("select max(id) from tasks").fetchone()[0])
+    conn.execute(
+        "insert into task_rules (rule_id, task_id, created_at)"
+        " values (?, ?, %s)" % now, (rule_id, blast_task))
+    conn.commit()
+    conn.close()
+
+    rules_state = slot_public(auth, "/rules", "rules")
+    row = [r for r in rules_state["rules"]
+           if r["name"] == "T9 blast rule"][0]
+    check([(a["kind"], a["label"]) for a in row["attachments"]]
+          == [("project", "t9-library"), ("task", "T9 blast task")],
+          "the rule row lists its blast radius in attachment order (%s)"
+          % [(a["kind"], a["label"]) for a in row["attachments"]])
+    check(all(int(a["id"]) > 0 for a in row["attachments"]),
+          "each attachment carries the linked row's id")
+
+    unattached = [r for r in rules_state["rules"]
+                  if len(r.get("attachments") or []) == 0]
+    check(len(unattached) >= 1
+          or len(rules_state["rules"]) == 1,
+          "rules attached nowhere list no attachments (no crash)")
+
+    page = http_get_page(auth, "/rules")
+    check("<summary><strong>New rule</strong></summary>" not in page
+          and "rule-create-dialog" in page
+          and "openAppDialog('rule-create-dialog')" in page,
+          "the create expander became a dialog behind a button")
+    check("<summary>Edit</summary>" not in page,
+          "the per-rule Edit expander is gone")
+    check('data-long-form data-field="rule-row"' in page
+          and 'data-lf-toggle' in page,
+          "each rule edits through the in-place engine")
+    # The compiled page entity-encodes the body text ("…" -> &hellip;);
+    # assert on the escaped form and on the ghost class itself.
+    check("Delete rule&hellip;</button>" in page
+          and 'class="btn-ghost btn-sm"' in page,
+          "delete is demoted to a ghost button out of the row's face")
+    check('stm-text="attachment.label"' in page,
+          "the blast radius renders as links on the rule card")
+
+    print("== discoveries: the in-place editor ==")
+    page = http_get_page(auth, "/discoveries")
+    check("<summary>Edit</summary>" not in page,
+          "the discoveries Edit expander is gone")
+    check('data-long-form data-field="discovery-row"' in page,
+          "each discovery edits through the in-place engine")
+    check('stm-text="discovery.body"' in page
+          and 'name="body"' in page,
+          "the edit textarea prefills from the rendered row value")
+    check('discoveryRowVisible' in page and 'setDiscoveryQuery' in page,
+          "the client-side search semantics are unchanged")
+    state = slot_public(auth, "/discoveries", "discoveries")
+    check("query" in state and "more" in state,
+          "the slot still seeds the search box and the more count")
+
+    print("== chats: breadcrumbs on the detail page ==")
+    # Seed the chat row directly (the slot builder reads any owned row;
+    # this bundle does not restart the app with a scripted LLM - the
+    # chatui bundle owns that surface).
+    conn = db()
+    conn.execute(
+        "insert into advisor_chats (user_id, title, status, created_at,"
+        " updated_at) values ((select id from spry_users where username ="
+        " 'admin'), 'T9 crumbs probe', 'idle', %s, %s)" % (now, now))
+    chat_id = int(conn.execute("select max(id) from advisor_chats")
+                  .fetchone()[0])
+    conn.commit()
+    conn.close()
+
+    chat_uri = "/chats/%d" % chat_id
+    chat_state = slot_public(auth, chat_uri, "chat")
+    check(chat_state["id"] == chat_id, "the probe chat exists (%s)" % chat_uri)
+    crumbs = chat_state["crumbs"]
+    check([c["label"] for c in crumbs]
+          == ["Dashboard", "Chats", "T9 crumbs probe"]
+          and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/chats"
+          and crumbs[2].get("href") is None,
+          "the chat slot trails Dashboard / Chats / <title> (%s)"
+          % [c["label"] for c in crumbs])
+    page = http_get_page(auth, chat_uri)
+    check('stm-def-crumbs-as="chat.crumbs"' in page,
+          "the breadcrumb partial rides the chat detail page")
+    check("chat-thread" in page and "chat-composer" in page,
+          "the conversation still leads with the composer below")
+    detail = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(detail["delete"] is not None,
+          "the seeded chat still authors its delete action")
+
+    print("== chats list: the demoted delete ==")
+    page = http_get_page(auth, "/chats")
+    check('class="btn-ghost btn-sm" stm-action="chat.delete"' in page,
+          "the list's delete is a quiet ghost button")
+    check("alert alert-warning" in page,
+          "the unconfigured-advisor notice uses the Spry alert")

+ 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", "t8fleet", "origins", "parity", "understanding",
+                 "notes", "resurrect", "t4lists", "t8fleet", "t9library", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",