Browse Source

Tasks/goals list chips and counts; create forms become dialogs

Goal 41 task 4 (phase C wave 1). The four list pages adopt the Spry
vocabulary per docs/spry-conversion.md and the ux-notes list sections:

- /tasks: the status select strip became faceted status chips with
  live counts (the goal-detail strip pattern ported to the queue),
  driving the SAME client-side filter engine (window.taskFilters /
  taskRowVisible / setTaskFilter); the default view is open work
  (queued+assigned+working+blocked+suspended) instead of all, so the
  queue leads over months of terminal rows. Project and goal selects
  stay on the same axis as before.
- /goals: the same chip strip (window.goalFilters, default all), and
  the Tasks column's four-count muted string became a total badge
  plus running/queued/failed badges - the goal's shape at a glance.
- Create forms on /tasks /goals /projects /stacks moved from collapsed
  expanders into native <dialog> modals with Spry dialog classes,
  opened by always-visible + New ... buttons. Fields and stm-action
  are unchanged.
- /projects: the long Git URL column folded into one repo cell
  (truncated mono URL, branch underneath).

Shared plumbing: app.js gains a marked end-section with
openAppDialog/closeAppDialog. Verified against js/statum.js and pinned
in the bundle: a re-render (pulse or set) never rebuilds static
markup, so an open dialog keeps open state and typed values; statum
never closes or resets a form itself, and every create action emits a
notify on both success and error paths, so the helper wraps
statum.onNotifyHandler and closes the dialog on any notify while one
is open (a success closes it; an error closes it with the toast
carrying the message). app.css gains an append-only T4 section
(.cluster-between, the projects repo cell bound).

Tests: pages.py assertions updated to the chip strip and dialog; new
t4lists bundle pins the chips (engine presence, open-work default,
counts vs slot data), the dialog markup with the unchanged forms
inside, the goal badges and the repo cell. Full suite 1804+4 pass via
the builddir binary (canon_deploy needs the repo ./builddir layout,
not ./build).
saddle-ClankerSaddle 1 day ago
parent
commit
7ef73aeab5
10 changed files with 602 additions and 138 deletions
  1. 19 0
      src/pages/app.css
  2. 48 0
      src/pages/app.js
  3. 139 30
      src/pages/goals.html
  4. 51 24
      src/pages/projects.html
  5. 32 15
      src/pages/stacks.html
  6. 162 63
      src/pages/tasks.html
  7. 2 1
      test/bundles/__init__.py
  8. 4 4
      test/bundles/pages.py
  9. 144 0
      test/bundles/t4lists.py
  10. 1 1
      test/harness.py

+ 19 - 0
src/pages/app.css

@@ -1253,3 +1253,22 @@ dialog#quote-comment-dialog blockquote {
     white-space: pre-wrap;
     word-break: break-all;
 }
+
+/* ==========================================================================
+   Goal 41 T4 — tasks/goals/projects/stacks list pages (append-only)
+   The faceted status chips reuse .goal-strip/.goal-filter above; this
+   section only adds what the pages needed that no layer owned.
+   ========================================================================== */
+
+/* A page head with its primary action: the h1/h2 on the left, the
+   "+ New …" button on the right, wrapping under on narrow screens
+   (.cluster gives the wrap + gap; only the space-between is ours). */
+.cluster-between { justify-content: space-between; }
+
+/* The projects list's folded "repo" cell (ux-notes projects list):
+   the clone URL truncated on one mono line with the branch under it.
+   .truncate needs a width bound inside a table cell to bite. */
+td .truncate.repo-url {
+    display: block;
+    max-width: 22rem;
+}

+ 48 - 0
src/pages/app.js

@@ -456,3 +456,51 @@
             statum.afterEvaluate(window.applyUiSlot);
             window.applyUiSlot();
         }
+
+        //
+        // App dialog helper (goal 41 T4). The create forms on the
+        // tasks/goals/projects/stacks lists live in native <dialog>
+        // modals styled with Spry's dialog classes; this helper owns
+        // opening them and closing them when their stm-action form
+        // gets an answer.
+        //
+        // Verified against js/statum.js (asserted in the T4 bundle):
+        // a re-render (pulse or set directive) never rebuilds static
+        // markup — only stm-for loop instances are cloned — so an
+        // open dialog KEEPS its open state and typed values across
+        // re-renders. Nothing in statum closes or resets a form
+        // after an action: the server's `notify` directive is the
+        // success/failure signal (kind success vs error), and
+        // Clanker's create actions ALWAYS notify on both paths. So
+        // the helper wraps statum.onNotifyHandler and closes the top
+        // open dialog on ANY notify while one is open: a success
+        // closes the modal; an error closes it too (the toast
+        // carries the message — a failed create with a stuck-open
+        // modal is worse than a closed one whose field values are
+        // still there when it is reopened). A `navigate` response
+        // tears the whole page down, dialog included.
+        //
+        window.openAppDialog = function (id) {
+            var dialog = document.getElementById(id);
+            if (!dialog) return;
+            var previousNotify = statum.onNotifyHandler;
+            statum.onNotifyHandler = function (kind, message) {
+                // Restore the app toast handler, then close.
+                statum.onNotifyHandler = previousNotify === null
+                    ? null : previousNotify;
+                if (dialog.open) dialog.close();
+                if (statum.onNotifyHandler) {
+                    statum.onNotifyHandler(kind, message);
+                }
+            };
+            if (typeof dialog.showModal === 'function') {
+                dialog.showModal();
+            } else {
+                dialog.setAttribute('open', '');
+            }
+        };
+
+        window.closeAppDialog = function (id) {
+            var dialog = document.getElementById(id);
+            if (dialog && dialog.open) dialog.close();
+        };

+ 139 - 30
src/pages/goals.html

@@ -9,22 +9,149 @@
     // brief is a heredoc-style textarea (markdown, rendered on the
     // goal page). Branch name is required and uniqueness-validated
     // server-side.
+    //
+    // Goal 41 T4: the list gained the SAME faceted status chip strip
+    // as /tasks (client-side, the queue's engine pattern) and the
+    // Tasks column's four-count muted string became readable badges
+    // - total plus the live counts (ux-notes goals list + quick win
+    // 9). The create form moved into a Spry <dialog> so the required
+    // brief textarea is visible the moment the form opens.
     if (window.statum) {
         (function () {
             'use strict';
+
+            window.goalFilters = window.goalFilters || {};
+
+            window.goalStatusCount = function (status) {
+                var goals = statum.read('goals');
+                var rows = (goals && goals.goals) || [];
+                var count = 0;
+                for (var i = 0; i < rows.length; i++) {
+                    if (status === 'all' || rows[i].status === status) {
+                        count += 1;
+                    }
+                }
+                return count;
+            };
+
+            window.goalRowVisible = function (row) {
+                var status = (window.goalFilters || {}).status;
+                return !status || status === 'all' || row.status === status;
+            };
+
+            window.goalChipClass = function (status) {
+                var current = (window.goalFilters || {}).status;
+                if (current == null || current === '') current = 'all';
+                return current === status ? 'goal-filter active' : 'goal-filter';
+            };
+
+            window.setGoalStatusFilter = function (status) {
+                var current = (window.goalFilters || {}).status;
+                if (current == null || current === '') current = 'all';
+                if (current === status) return; // one chip is always on
+                window.goalFilters.status = status;
+                var goals = statum.read('goals');
+                if (goals && goals.refresh) {
+                    statum.act(goals.refresh).catch(function () { });
+                } else {
+                    document.body.setAttribute('data-filter-bump', String(Date.now()));
+                }
+            };
         })();
     }
 </script>
 </head>
 <body>
-    <div class="page">
-        <h1><span class="goal-icon">🎯</span> Goals</h1>
+    <div class="stack">
+
+        <div class="cluster cluster-between">
+            <h1><span class="goal-icon">🎯</span> Goals</h1>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('goal-create-dialog')">+ New goal</button>
+        </div>
         <p class="text-muted">User-created units of agent-orchestrated work — brief, plan,
             task fan-out, one shared goal branch per involved repo.</p>
 
-        <details class="history-expander">
-            <summary><strong>New goal</strong></summary>
-            <form stm-action="goals.create">
+        <!-- The /tasks chip strip, on goals: one chip always active,
+             default "All". -->
+        <p class="goal-strip">
+            <button type="button" stm-attribute.class="window.goalChipClass('all')"
+                    onclick="window.setGoalStatusFilter('all')"
+                    stm-text="'all ' + window.goalStatusCount('all')">all</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('draft')"
+                    onclick="window.setGoalStatusFilter('draft')"
+                    stm-text="'draft ' + window.goalStatusCount('draft')">draft</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('planning')"
+                    onclick="window.setGoalStatusFilter('planning')"
+                    stm-text="'planning ' + window.goalStatusCount('planning')">planning</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('active')"
+                    onclick="window.setGoalStatusFilter('active')"
+                    stm-text="'active ' + window.goalStatusCount('active')">active</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('plan_review')"
+                    onclick="window.setGoalStatusFilter('plan_review')"
+                    stm-text="'plan review ' + window.goalStatusCount('plan_review')">plan review</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('completion_review')"
+                    onclick="window.setGoalStatusFilter('completion_review')"
+                    stm-text="'completion review ' + window.goalStatusCount('completion_review')">completion review</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('paused')"
+                    onclick="window.setGoalStatusFilter('paused')"
+                    stm-text="'paused ' + window.goalStatusCount('paused')">paused</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('done')"
+                    onclick="window.setGoalStatusFilter('done')"
+                    stm-text="'done ' + window.goalStatusCount('done')">done</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('failed')"
+                    onclick="window.setGoalStatusFilter('failed')"
+                    stm-text="'failed ' + window.goalStatusCount('failed')">failed</button>
+        </p>
+
+        <div class="table-wrap">
+            <table class="table-hover">
+
+            <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Branch</th><th>Priority</th>
+                <th>Tasks</th><th>Created</th></tr></thead>
+            <tbody>
+                <tr stm-for-goal-in="goals.goals" stm-if="window.goalRowVisible(goal)">
+                    <td stm-text="goal.id">id</td>
+                    <td><span class="goal-icon">🎯</span><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-text="goal.branch_name">branch</code></td>
+                    <td stm-text="goal.priority">3</td>
+                    <!-- The four-count muted string became a total
+                         badge plus the live states - the shape of the
+                         goal at a glance instead of four numbers in a
+                         row (ux-notes quick win 9). -->
+                    <td>
+                        <span class="badge"
+                              stm-text="(goal.draft_tasks + goal.queued_tasks + goal.running_tasks + goal.done_tasks + goal.failed_tasks) + ' tasks'">0 tasks</span>
+                        <span class="badge badge-warning" stm-if="goal.running_tasks > 0"
+                              stm-text="goal.running_tasks + ' running'">running</span>
+                        <span class="badge badge-warning" stm-if="goal.queued_tasks > 0"
+                              stm-text="goal.queued_tasks + ' queued'">queued</span>
+                        <span class="badge badge-danger" stm-if="goal.failed_tasks > 0"
+                              stm-text="goal.failed_tasks + ' failed'">failed</span>
+                    </td>
+                    <td class="text-muted" stm-text="goal.created_display">when</td>
+                </tr>
+                <tr stm-if="goals.goals.length == 0"><td colspan="7" class="text-muted">No goals yet.</td></tr>
+            </tbody>
+        
+            </table>
+        </div>
+    </div>
+
+    <!-- Goal 41 T4: the create form in a Spry <dialog> — the
+         required brief textarea (the goal's most important field) is
+         visible the moment the dialog opens instead of hiding behind
+         a collapsed summary. Same fields, same stm-action. -->
+    <dialog id="goal-create-dialog">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New goal</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('goal-create-dialog')">×</button>
+        </div>
+        <form stm-action="goals.create">
+            <div class="dialog-body form-rows">
                 <div class="field"><label for="goal-project">Project</label>
                     <select id="goal-project" name="project_id" required>
                         <option stm-for-project-in="goals.projects" stm-attribute.value="project.id"
@@ -54,31 +181,13 @@
                         <option value="8">8</option>
                         <option value="9">9 — lowest</option>
                     </select></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('goal-create-dialog')">Cancel</button>
                 <button type="submit" class="btn-primary">Create goal</button>
-            </form>
-        </details>
-
-        <div class="table-wrap">
-            <table>
-
-            <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Branch</th><th>Priority</th>
-                <th>Tasks</th><th>Created</th></tr></thead>
-            <tbody>
-                <tr stm-for-goal-in="goals.goals">
-                    <td stm-text="goal.id">id</td>
-                    <td><span class="goal-icon">🎯</span><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-text="goal.branch_name">branch</code></td>
-                    <td stm-text="goal.priority">3</td>
-                    <td><span class="text-muted" stm-text="goal.draft_tasks + ' draft / ' + goal.queued_tasks + ' queued / ' + goal.running_tasks + ' running / ' + goal.done_tasks + ' done'">counts</span></td>
-                    <td class="text-muted" stm-text="goal.created_display">when</td>
-                </tr>
-                <tr stm-if="goals.goals.length == 0"><td colspan="7" class="text-muted">No goals yet.</td></tr>
-            </tbody>
-        
-            </table>
-        </div>
-    </div>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 51 - 24
src/pages/projects.html

@@ -5,45 +5,72 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Projects</h2>
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Projects</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('project-create-dialog')">+ New project</button>
+        </div>
         <p stm-if="projects == null">Loading…</p>
 
-        <div stm-if="projects != null">
-            <details>
-                <summary><strong>New project</strong></summary>
-                <form stm-action="projects.create">
-                    <div class="field"><label>Name</label>
-                        <input type="text" name="name" required placeholder="web-stack"></div>
-                    <div class="field"><label>Git URL</label>
-                        <input type="text" name="git_url" required placeholder="https://example.com/repo.git"></div>
-                    <div class="field"><label>Default branch</label>
-                        <input type="text" name="default_branch" placeholder="main"></div>
-                    <div class="field"><label>Summary</label>
-                        <input type="text" name="summary" placeholder="What this project is for"></div>
-                    <button type="submit">Create project</button>
-                </form>
-            </details>
-
+        <div stm-if="projects != null" class="stack">
             <div class="table-wrap">
-                <table>
+                <table class="table-hover">
 
-                <thead><tr><th>Name</th><th>Git URL</th><th>Branch</th><th>Tasks</th></tr></thead>
+                <thead><tr><th>Name</th><th>Repo</th><th>Tasks</th></tr></thead>
                 <tbody>
                     <tr stm-for-project-in="projects.projects">
                         <td><a stm-attribute.href="'/projects/' + project.id" stm-text="project.name">name</a>
                             <div class="text-muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</div></td>
-                        <td stm-text="project.git_url">git_url</td>
-                        <td stm-text="project.default_branch">branch</td>
+                        <!-- Goal 41 T4: the long Git URL column folded
+                             into one "repo" cell - host/path/code>
+                             truncated, branch on the line below - so
+                             the table scans as name → repo → work
+                             instead of drowning in clone URLs
+                             (ux-notes projects list). -->
+                        <td>
+                            <div class="truncate mono repo-url" stm-attribute.title="project.git_url"
+                                 stm-text="project.git_url">git_url</div>
+                            <div class="text-muted"><span stm-if="project.default_branch.length > 0"
+                                     stm-text="'⎇ ' + project.default_branch">branch</span></div>
+                        </td>
                         <td><span stm-text="project.open_tasks">0</span> open ·
                             <span stm-text="project.done_tasks">0</span> closed</td>
                     </tr>
-                    <tr stm-if="projects.projects.length == 0"><td colspan="4" class="text-muted">No projects yet.</td></tr>
+                    <tr stm-if="projects.projects.length == 0"><td colspan="3" class="text-muted">No projects yet.</td></tr>
                 </tbody>
             
                 </table>
             </div>
         </div>
-    </section>
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New project" button -
+         same fields, same stm-action. -->
+    <dialog id="project-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New project</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('project-create-dialog')">×</button>
+        </div>
+        <form stm-action="projects.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="project-name">Name</label>
+                    <input type="text" id="project-name" name="name" required placeholder="web-stack"></div>
+                <div class="field"><label for="project-git-url">Git URL</label>
+                    <input type="text" id="project-git-url" name="git_url" required placeholder="https://example.com/repo.git"></div>
+                <div class="field"><label for="project-default-branch">Default branch</label>
+                    <input type="text" id="project-default-branch" name="default_branch" placeholder="main"></div>
+                <div class="field"><label for="project-summary">Summary</label>
+                    <input type="text" id="project-summary" name="summary" placeholder="What this project is for"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('project-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create project</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 32 - 15
src/pages/stacks.html

@@ -5,27 +5,20 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Stacks</h2>
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Stacks</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('stack-create-dialog')">+ New stack</button>
+        </div>
         <p class="text-muted">Named bundles of reference projects — apply one to a project or a
             goal to attach its whole collection of links in a single click.</p>
         <p stm-if="stacks == null">Loading…</p>
 
         <div stm-if="stacks != null">
-            <details>
-                <summary><strong>New stack</strong></summary>
-                <form stm-action="stacks.create">
-                    <div class="field"><label>Name</label>
-                        <input type="text" name="name" required placeholder="Web stack references"></div>
-                    <div class="field"><label>Summary <span class="text-muted">(optional)</span></label>
-                        <input type="text" name="summary" placeholder="What this collection is for"></div>
-                    <button type="submit">Create stack</button>
-                </form>
-            </details>
-
             <div class="table-wrap">
                 <table stm-if="stacks.stacks.length >
- 0">
+ 0" class="table-hover">
                 <thead><tr><th>#</th><th>Name</th><th>Projects</th><th>Summary</th><th>Created</th></tr></thead>
                 <tbody>
                     <tr stm-for-stack-in="stacks.stacks">
@@ -41,6 +34,30 @@
             </div>
             <p stm-if="stacks.stacks.length == 0" class="text-muted">No stacks yet.</p>
         </div>
-    </section>
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New stack" button - same
+         fields, same stm-action. -->
+    <dialog id="stack-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New stack</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('stack-create-dialog')">×</button>
+        </div>
+        <form stm-action="stacks.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="stack-name">Name</label>
+                    <input type="text" id="stack-name" name="name" required placeholder="Web stack references"></div>
+                <div class="field"><label for="stack-summary">Summary <span class="text-muted">(optional)</span></label>
+                    <input type="text" id="stack-summary" name="summary" placeholder="What this collection is for"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('stack-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create stack</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 162 - 63
src/pages/tasks.html

@@ -6,23 +6,58 @@
 <script>
     // The task queue. Plan 12: the filters (status / project / goal)
     // run CLIENT-SIDE - the Statum entrypoint fetch never carries the
-    // page's own query string to the server, so selects filter the
-    // rendered rows directly (window.taskFilters + taskRowVisible)
-    // and survive re-renders; the create form gains the mode select
-    // (with descriptions) and the auto-enqueue-after dropdown (the
-    // global human-task list; goal tasks are the goal agent's to
-    // sequence).
+    // page's own query string to the server, so the status chips and
+    // the selects filter the rendered rows directly (window.taskFilters
+    // + taskRowVisible) and survive re-renders; the create form gains
+    // the mode select (with descriptions) and the auto-enqueue-after
+    // dropdown (the global human-task list; goal tasks are the goal
+    // agent's to sequence).
+    //
+    // Goal 41 T4: the status select became faceted chips with counts
+    // (the goal-detail strip pattern) and the default view is OPEN
+    // WORK - queued+assigned+working+blocked+suspended - instead of
+    // "all", so months of done rows no longer bury the queue (ux-notes
+    // tasks A+B). taskFilters.status holds the chip state:
+    //   null          -> every status (the "All" chip)
+    //   'open'        -> the default open-work set
+    //   '<status>'    -> one exact status
     if (window.statum) {
         (function () {
             'use strict';
 
+            window.TASK_OPEN_STATUSES = ['queued', 'assigned', 'working',
+                'blocked', 'suspended'];
+
             window.taskFilters = window.taskFilters || {};
 
+            window.taskStatusCount = function (status) {
+                var tasks = statum.read('tasks');
+                var rows = (tasks && tasks.tasks) || [];
+                var count = 0;
+                for (var i = 0; i < rows.length; i++) {
+                    if (status === 'open') {
+                        if (window.TASK_OPEN_STATUSES.indexOf(rows[i].status) !== -1) {
+                            count += 1;
+                        }
+                    } else if (status === 'all') {
+                        count += 1;
+                    } else if (rows[i].status === status) {
+                        count += 1;
+                    }
+                }
+                return count;
+            };
+
             window.taskRowVisible = function (row) {
                 var filters = window.taskFilters || {};
-                if (filters.status && filters.status !== 'all'
-                        && row.status !== filters.status) {
-                    return false;
+                if (filters.status && filters.status !== 'all') {
+                    if (filters.status === 'open') {
+                        if (window.TASK_OPEN_STATUSES.indexOf(row.status) === -1) {
+                            return false;
+                        }
+                    } else if (row.status !== filters.status) {
+                        return false;
+                    }
                 }
                 if (filters.project && filters.project > 0
                         && String(row.project_id) !== String(filters.project)) {
@@ -51,27 +86,77 @@
                     document.body.setAttribute('data-filter-bump', String(Date.now()));
                 }
             };
+
+            window.taskChipClass = function (status) {
+                var current = (window.taskFilters || {}).status;
+                if (current == null || current === '') current = 'open';
+                return current === status ? 'goal-filter active' : 'goal-filter';
+            };
+
+            window.setTaskStatusFilter = function (status) {
+                var current = (window.taskFilters || {}).status;
+                if (current == null || current === '') current = 'open';
+                if (current === status) return; // one chip is always on
+                window.setTaskFilter('status', status);
+            };
+
+            // Default to open work until a chip says otherwise.
+            if (window.taskFilters.status == null) {
+                window.taskFilters.status = 'open';
+            }
         })();
     }
 </script>
 </head>
 <body>
-    <div class="page">
-        <h1>Tasks</h1>
+    <div class="stack">
+
+        <div class="cluster cluster-between">
+            <h1>Tasks</h1>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('task-create-dialog')">+ New task</button>
+        </div>
+
+        <!-- Goal 41 T4: faceted status chips with counts (the
+             goal-strip pattern ported to the queue). One chip is
+             always active; the default is "Open" - queued, assigned,
+             working, blocked, suspended - so the queue leads and
+             done/failed stay one click away with their counts
+             visible. -->
+        <p class="goal-strip">
+            <button type="button" stm-attribute.class="window.taskChipClass('open')"
+                    onclick="window.setTaskStatusFilter('open')"
+                    stm-text="'Open ' + window.taskStatusCount('open')">open</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('draft')"
+                    onclick="window.setTaskStatusFilter('draft')"
+                    stm-text="'draft ' + window.taskStatusCount('draft')">draft</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('queued')"
+                    onclick="window.setTaskStatusFilter('queued')"
+                    stm-text="'queued ' + window.taskStatusCount('queued')">queued</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('assigned')"
+                    onclick="window.setTaskStatusFilter('assigned')"
+                    stm-text="'assigned ' + window.taskStatusCount('assigned')">assigned</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('working')"
+                    onclick="window.setTaskStatusFilter('working')"
+                    stm-text="'working ' + window.taskStatusCount('working')">working</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('blocked')"
+                    onclick="window.setTaskStatusFilter('blocked')"
+                    stm-text="'blocked ' + window.taskStatusCount('blocked')">blocked</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('suspended')"
+                    onclick="window.setTaskStatusFilter('suspended')"
+                    stm-text="'suspended ' + window.taskStatusCount('suspended')">suspended</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('done')"
+                    onclick="window.setTaskStatusFilter('done')"
+                    stm-text="'done ' + window.taskStatusCount('done')">done</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('failed')"
+                    onclick="window.setTaskStatusFilter('failed')"
+                    stm-text="'failed ' + window.taskStatusCount('failed')">failed</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('all')"
+                    onclick="window.setTaskStatusFilter('all')"
+                    stm-text="'all ' + window.taskStatusCount('all')">all</button>
+        </p>
 
         <form class="cluster" onsubmit="return false">
-            <label for="filter-status">Status</label>
-            <select id="filter-status" onchange="window.setTaskFilter('status', this.value)">
-                <option value="all">all</option>
-                <option value="draft">draft</option>
-                <option value="queued">queued</option>
-                <option value="assigned">assigned</option>
-                <option value="working">working</option>
-                <option value="blocked">blocked</option>
-                <option value="suspended">suspended</option>
-                <option value="done">done</option>
-                <option value="failed">failed</option>
-            </select>
             <label for="filter-project">Project</label>
             <select id="filter-project" onchange="window.setTaskFilter('project', this.value)">
                 <option value="0">all</option>
@@ -85,12 +170,55 @@
                 <option stm-for-goal-in="tasks.goal_options" stm-attribute.value="goal.id"
                          stm-text="goal.title">goal</option>
             </select>
-            <span class="text-muted">— filters apply instantly</span>
         </form>
 
-        <details class="history-expander">
-            <summary><strong>New task</strong></summary>
-            <form stm-action="tasks.create">
+        <div class="table-wrap">
+            <table class="table-hover">
+
+            <thead>
+                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
+                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
+            </thead>
+            <tbody>
+                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
+                    <td stm-text="task.id">id</td>
+                    <td>
+                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                    </td>
+                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
+                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
+                    <td stm-if="task.goal_id > 0">
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
+                        <span class="text-muted" stm-if="task.creator == 'goal'">(agent)</span>
+                    </td>
+                    <td stm-if="task.goal_id == 0" class="text-muted">—</td>
+                    <td stm-text="task.priority">5</td>
+                    <td stm-text="task.project_name">project</td>
+                    <td><span stm-if="task.capabilities.length > 0"
+                              stm-text="task.capabilities.join(', ')">caps</span>
+                        <span class="text-muted" stm-if="task.capabilities.length == 0">—</span></td>
+                </tr>
+                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="text-muted">No tasks.</td></tr>
+            </tbody>
+        
+            </table>
+        </div>
+
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry-styled native <dialog> opened by the "+ New
+         task" button (ux-notes quick win 4). The form itself is
+         unchanged - same fields, same stm-action - and the shared
+         openAppDialog helper closes it on the action's notify. -->
+    <dialog id="task-create-dialog">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New task</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('task-create-dialog')">×</button>
+        </div>
+        <form stm-action="tasks.create">
+            <div class="dialog-body form-rows">
                 <div class="field"><label for="task-project">Project</label>
                     <!-- Goal 8 task 4: "No project" posts project_id 0 —
                          a STANDALONE task with its own ephemeral
@@ -149,42 +277,13 @@
                                  stm-attribute.value="capability.id"
                                  stm-text="capability.name">cap</option>
                     </select></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('task-create-dialog')">Cancel</button>
                 <button type="submit" class="btn-primary">Create task</button>
-            </form>
-        </details>
-
-        <div class="table-wrap">
-            <table>
-
-            <thead>
-                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
-                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
-            </thead>
-            <tbody>
-                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
-                    <td stm-text="task.id">id</td>
-                    <td>
-                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                    </td>
-                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
-                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
-                    <td stm-if="task.goal_id > 0">
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
-                        <span class="text-muted" stm-if="task.creator == 'goal'">(agent)</span>
-                    </td>
-                    <td stm-if="task.goal_id == 0" class="text-muted">—</td>
-                    <td stm-text="task.priority">5</td>
-                    <td stm-text="task.project_name">project</td>
-                    <td><span stm-if="task.capabilities.length > 0"
-                              stm-text="task.capabilities.join(', ')">caps</span>
-                        <span class="text-muted" stm-if="task.capabilities.length == 0">—</span></td>
-                </tr>
-                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="text-muted">No tasks.</td></tr>
-            </tbody>
-        
-            </table>
-        </div>
-
-    </div>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 2 - 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, users)
+               stacks, subtasks, t4lists, users)
 
 BUNDLES = {
     "pages": pages,
@@ -24,4 +24,5 @@ BUNDLES = {
     "origins": origins,
     "notes": notes,
     "resurrect": resurrect,
+    "t4lists": t4lists,
 }

+ 4 - 4
test/bundles/pages.py

@@ -623,13 +623,13 @@ def run(ctx):
           "the lifecycle buttons ride the status card inside the grid")
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
-          and queue_page.index("filter-status") < queue_page.index("<table")
+          and queue_page.index("goal-strip") < queue_page.index("<table")
           and "filter-goal" in queue_page,
-          "the filter controls (status + goal) sit above the queue table")
+          "the filter controls (status chips + goal select) sit above the queue table")
     check("New task" in queue_page and "task-mode" in queue_page
           and "task-enqueue-after" in queue_page
           and 'name="plan_auto_approve"' not in queue_page,
-          "the create expander offers the mode + auto-enqueue-after selects"
+          "the create form offers the mode + auto-enqueue-after selects"
           " (no subtask checkboxes)")
 
     print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
@@ -804,7 +804,7 @@ def run(ctx):
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
           and queue_page.index("New task") < queue_page.index("<table"),
-          "the New task expander sits above the tasks table")
+          "the + New task button sits above the tasks table")
     # Comments/blocks threads: newest first since the sweep. The
     # comment list leads with the most recently added one.
     conn = db()

+ 144 - 0
test/bundles/t4lists.py

@@ -0,0 +1,144 @@
+"""The T4 lists bundle (goal 41 task 4): the tasks/goals/projects/stacks
+list pages on the Spry conversion — faceted status chips with counts on
+/tasks (default view = open work) and /goals, the create-form expanders
+replaced by native <dialog> modals (openAppDialog helper in app.js),
+the goals Tasks column as readable badges, and the projects list's
+folded repo cell. Markup-level: http_get_page fetches the compiled
+page; the counts are cross-checked against the slot data the chips
+count over."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    OPEN = ("queued", "assigned", "working", "blocked", "suspended")
+
+    def status_counts():
+        """Per-status task counts from the /tasks slot (the chip
+        numbers count exactly these rows)."""
+        rows = slot_public(auth, "/tasks", "tasks")["tasks"]
+        counts = {}
+        for row in rows:
+            counts[row["status"]] = counts.get(row["status"], 0) + 1
+        counts["all"] = len(rows)
+        counts["open"] = sum(n for s, n in counts.items()
+                             if s in OPEN)
+        return counts
+
+    def seed_tasks():
+        conn = db()
+        for i, status in enumerate(("queued", "working", "blocked", "done",
+                                    "done", "failed", "draft")):
+            conn.execute(
+                "insert into tasks (project_id, title, status, priority,"
+                " created_at) values (1, ?, ?, 5, strftime('%s','now'))",
+                ("T4 chip probe %d" % i, status))
+        conn.commit()
+        conn.close()
+
+    print("== /tasks: faceted status chips with counts ==")
+    seed_tasks()
+    counts = status_counts()
+    check(counts["open"] == 3 and counts["all"] >= 7,
+          "the fixture shapes the counts (open %d / all %d)"
+          % (counts["open"], counts["all"]))
+
+    tasks_page = http_get_page(auth, "/tasks")
+    check("taskChipClass" in tasks_page and "taskStatusCount" in tasks_page
+          and "TASK_OPEN_STATUSES" in tasks_page,
+          "the tasks page ships the chip engine (class/count/open-set)")
+    check(tasks_page.index("goal-strip") < tasks_page.index("<table"),
+          "the chip strip sits above the queue table")
+    check("window.setTaskStatusFilter('open')" in tasks_page
+          and "window.setTaskStatusFilter('done')" in tasks_page,
+          "every chip drives the shared setTaskFilter engine")
+    # The default view is open work: the status filter initialises to
+    # 'open' and taskRowVisible understands the open set.
+    check("taskFilters.status = 'open'" in tasks_page
+          and "TASK_OPEN_STATUSES.indexOf(row.status) === -1" in tasks_page,
+          "the default filter is the open-work set (queued..suspended)")
+    check("filter-project" in tasks_page and "filter-goal" in tasks_page,
+          "the project and goal selects stay (chips drive the status axis)")
+    check('id="filter-status"' not in tasks_page,
+          "the old status select is gone")
+
+    print("== /tasks: the create form lives in a dialog ==")
+    check('id="task-create-dialog"' in tasks_page
+          and "<dialog" in tasks_page,
+          "the create form is a native dialog")
+    check(tasks_page.index("<dialog") > tasks_page.index("</table>"),
+          "the dialog markup sits after the table, opened by the button")
+    check('onclick="window.openAppDialog(\'task-create-dialog\')"' in tasks_page,
+          "the + New task button opens the dialog through the shared helper")
+    # The form inside the dialog is the SAME form: same action, same
+    # fields (the B6b branch fields, the mode select, auto-enqueue).
+    dialog_html = tasks_page[tasks_page.index('id="task-create-dialog"'):]
+    check('stm-action="tasks.create"' in dialog_html
+          and 'name="task_branch"' in dialog_html
+          and 'name="branch_from"' in dialog_html
+          and 'id="task-mode"' in dialog_html
+          and 'id="task-enqueue-after"' in dialog_html
+          and "No project (standalone)" in dialog_html,
+          "the dialog carries the unchanged create form (action + fields)")
+    check('class="dialog-titlebar"' in dialog_html
+          and 'class="dialog-footer"' in dialog_html,
+          "the dialog uses Spry's dialog classes (titlebar/footer)")
+    # Semantics still work end to end: create through the action the
+    # dialog form posts (new tasks land in draft).
+    before = counts["draft"]
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "T4 dialog create task", "priority": "5"})
+    after = status_counts()
+    check(after["draft"] == before + 1,
+          "the dialog form's action still creates (draft %d -> %d)"
+          % (before, after["draft"]))
+
+    print("== app.js: the shared dialog helper ==")
+    status, app_js = http("GET", "/_statum/resource/app.js")
+    check(status == 200 and "window.openAppDialog" in app_js
+          and "window.closeAppDialog" in app_js,
+          "the shared dialog helper ships in app.js")
+    check("onNotifyHandler" in app_js and "dialog.close()" in app_js,
+          "the helper closes the dialog on the action's notify")
+
+    print("== /goals: chips + readable task badges + create dialog ==")
+    goals_page = http_get_page(auth, "/goals")
+    check("goalStatusCount" in goals_page and "goalChipClass" in goals_page
+          and "setGoalStatusFilter" in goals_page,
+          "the goals list ships the same chip engine")
+    check("goalRowVisible" in goals_page,
+          "the goals rows filter client-side like the queue")
+    # The four-count muted string is gone; badges carry the shape.
+    check("draft / " not in goals_page
+          and "running_tasks + " in goals_page,
+          "the Tasks column renders the total badge from the counts")
+    check('badge-warning" stm-if="goal.running_tasks &gt; 0"' in goals_page,
+          "running tasks surface as a warning badge")
+    check('id="goal-create-dialog"' in goals_page
+          and 'stm-action="goals.create"' in goals_page,
+          "the goal create form lives in a dialog")
+    goal_dialog = goals_page[goals_page.index('id="goal-create-dialog"'):]
+    check('id="goal-brief"' in goal_dialog and "textarea" in goal_dialog
+          and "required" in goal_dialog,
+          "the required brief textarea is inside the dialog (visible on open)")
+
+    print("== /projects: the folded repo cell + create dialog ==")
+    projects_page = http_get_page(auth, "/projects")
+    check('id="project-create-dialog"' in projects_page
+          and 'stm-action="projects.create"' in projects_page
+          and 'onclick="window.openAppDialog(\'project-create-dialog\')"' in projects_page,
+          "the project create form lives in a dialog behind the button")
+    check("<th>Repo</th>" in projects_page and "<th>Git URL</th>" not in projects_page,
+          "the Git URL column folded into one Repo cell")
+    check("repo-url" in projects_page and "truncate" in projects_page,
+          "the repo cell truncates the clone URL (branch under it)")
+
+    print("== /stacks: the create dialog ==")
+    stacks_page = http_get_page(auth, "/stacks")
+    check('id="stack-create-dialog"' in stacks_page
+          and 'stm-action="stacks.create"' in stacks_page
+          and 'onclick="window.openAppDialog(\'stack-create-dialog\')"' in stacks_page,
+          "the stack create form lives in a dialog behind the button")
+    check("<details>" not in stacks_page and "<details>" not in projects_page,
+          "no create expander remains on stacks or projects")

+ 1 - 1
test/harness.py

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