Quellcode durchsuchen

Rebuild the dashboard attention-first on the Spry layout vocabulary

home.html now leads with what the page exists for: the Needs a human
pile (with an open-count badge) and the active-tasks table form the
attention column, while the Queue/Saddles metrics, a compact saddle
status list and the push-bell strip fold into a right-hand rail
(.with-sidebar re-proportioned; stacks under the framework's 55rem
breakpoint). The full-width Enable notifications opener card is gone
- the preference UI moved to /profile as a Notifications card beside
Appearance, reusing the existing push action DTOs (PushService
untouched); the dashboard keeps a compact strip linking to it.

Activity folds under a collapsed expander with a live N new since
your last visit badge and an 8-row cap with a Show all toggle. The
ambient zones (notes, latest discoveries) start collapsed per the
mocks sampler review. The advisor hero keeps its card and composer
contract but gains the samplers' shape: the description sits inside
the composer card above the textarea.

The per-browser bell engine moved from home.html's inline script into
app.js as window.initPushBell so the dashboard strip and the profile
settings card run the same verdict logic. Last-visit choice: the
framework carries no cookies (auth is slot-based) and statum exposes
no client slot write, so the stamp lives in localStorage, written on
unload - a visit updates the baseline, a background pulse does not.

app.css gains only an appended goal-41-task-3 section consuming
--spry-* tokens. The push bundle's wiring assertion follows the
engine to app.js (feature-level, baseline-verified).
saddle-ClankerSaddle vor 1 Tag
Ursprung
Commit
cf1ee329a5

+ 4 - 1
src/actions/ProfileActions.vala

@@ -56,6 +56,8 @@ namespace ClankerSaddle {
         private UserService users = inject<UserService>();
         private UserPreferenceService preferences = inject<UserPreferenceService>();
         private StateService states = inject<StateService>();
+        private PushService push_service = inject<PushService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
 
         /** True when `slug` names a theme the client may apply. */
         public static bool is_valid_theme(string slug) {
@@ -121,7 +123,8 @@ namespace ClankerSaddle {
                 var user = yield users.get_user(me.user_id);
                 if (user != null) {
                     var profile = yield ProfileEntrypoint.build_profile_slot(
-                        states, action_registry, users, preferences, me, (!)user);
+                        states, action_registry, users, preferences, me, (!)user,
+                        push_service, db);
                     builder.set(profile);
                 }
                 return builder

+ 45 - 2
src/entrypoints/ProfileEntrypoint.vala

@@ -20,6 +20,18 @@ namespace ClankerSaddle {
         /** The stored colour mode (auto|light|dark). */
         public string mode { get; set; default = MODE_DEFAULT; }
         public Statum.Model.ActionDto set_theme { get; set; }
+        /**
+         * The VAPID public key the browser subscribes with ("" when
+         * push is unavailable) — goal 41 task 3: the bell preference
+         * UI moved from the dashboard's opener card to this page's
+         * Notifications card, riding the SAME dashboard push actions
+         * (PushService untouched).
+         */
+        public string push_vapid_key { get; set; default = ""; }
+        public Statum.Model.ActionDto? push_subscribe { get; set; }
+        public Statum.Model.ActionDto? push_unsubscribe { get; set; }
+        /** Queues a REAL test push to this user (the bell's probe). */
+        public Statum.Model.ActionDto? push_test { get; set; }
     }
 
     /**
@@ -35,6 +47,8 @@ namespace ClankerSaddle {
 
         private UserService users_service = Inversion.inject<UserService>();
         private UserPreferenceService preferences = Inversion.inject<UserPreferenceService>();
+        private PushService push_service = Inversion.inject<PushService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users_service);
@@ -52,7 +66,7 @@ namespace ClankerSaddle {
             }
 
             var slot = yield build_profile_slot(state_service, action_registry,
-                users_service, preferences, me, (!)user);
+                users_service, preferences, me, (!)user, push_service, db);
             return directives().set(slot);
         }
 
@@ -66,7 +80,8 @@ namespace ClankerSaddle {
          */
         public static async Statum.Slot build_profile_slot(StateService state_service,
                 ActionRegistry action_registry, UserService users_service,
-                UserPreferenceService preferences, CurrentUser me, UserProjection user)
+                UserPreferenceService preferences, CurrentUser me, UserProjection user,
+                PushService? push = null, InvercargillSql.Orm.OrmSession? db = null)
                 throws GLib.Error {
             // The appearance card's current values: the stored
             // preferences (missing rows read as the defaults — the
@@ -93,7 +108,35 @@ namespace ClankerSaddle {
                 set_theme = action_registry.author<SetThemeAction>()
             };
 
+            // The Notifications card (goal 41 task 3): the same facts
+            // the dashboard's bell strip derives its per-browser verdict
+            // from, so /profile owns the preference UI while the
+            // dashboard keeps only the compact strip. Reuses the
+            // EXISTING dashboard push actions — PushService untouched.
+            // The push service and db session arrive as parameters
+            // (SetThemeAction's scope may carry them; a static method
+            // cannot Inversion.inject — the context stack is per-request).
+            view.push_vapid_key = PushService.vapid_public_key;
+            view.push_subscribe = action_registry.author<PushSubscribeAction>();
+            view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
+            view.push_test = action_registry.author<PushTestAction>();
+            string[] fingerprints = new string[0];
+            if (push != null && db != null) {
+                try {
+                    fingerprints = yield ((!)push)
+                        .user_endpoint_fingerprints((!)db, me.user_id);
+                } catch (Error e) {
+                    warning(@"[ClankerSaddle] could not read push endpoints: $(e.message)");
+                }
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(view);
+            // The fingerprint list is array-shaped — GObjectMapping
+            // cannot serialise arrays as properties (the same
+            // constraint as the dashboard's lists), so it rides the
+            // props dictionary under the SAME key the bell engine
+            // reads on the dashboard slot.
+            props["push_endpoints"] = StateJson.strings(fingerprints);
 
             return state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "profile",

+ 105 - 0
src/pages/app.css

@@ -1253,3 +1253,108 @@ dialog#quote-comment-dialog blockquote {
     white-space: pre-wrap;
     word-break: break-all;
 }
+
+/* ==========================================================================
+   Dashboard attention-first layout (goal 41 task 3)
+   Page-specific rules for home.html ONLY — appended per the spry
+   conversion contract; every colour/spacing value consumes a --spry-*
+   token so themes and auto dark mode work with zero extra rules.
+   ========================================================================== */
+
+/* The attention/rail split: Spry's .with-sidebar gives the rail a
+   minmax(11rem, 16rem) first column; the dashboard wants the rail on
+   the RIGHT and WIDER (metrics + saddle list + bell strip), the
+   attention column taking the rest. */
+.dash-board {
+    grid-template-columns: minmax(0, 1fr) minmax(14rem, 19rem);
+}
+
+/* Section headings inside the attention column: quieter than the
+   page's card headings so the pinned cards stay the visual lead. */
+.dash-heading {
+    font-size: 1.15rem;
+    margin-bottom: var(--spry-space-2);
+}
+.dash-heading .badge {
+    margin-left: var(--spry-space-2);
+    vertical-align: middle;
+}
+
+/* The rail's metric lead line: the number reads larger than the
+   card's prose so the one-glance verdict lands first. */
+.dash-metric {
+    font-size: 1.05rem;
+    margin: 0 0 var(--spry-space-1);
+}
+.dash-metric strong {
+    font-size: 1.6rem;
+    font-weight: 650;
+    line-height: 1;
+    margin-right: var(--spry-space-1);
+}
+
+/* The rail's saddle list: a compact status list (ux-notes §1 B), one
+   line per saddle — badge, name, current work — instead of the old
+   full card grid. */
+.dash-saddles {
+    display: flex;
+    flex-direction: column;
+    gap: var(--spry-space-1);
+    list-style: none;
+    margin: 0 0 var(--spry-space-2);
+    max-width: 100%;
+    padding: 0;
+}
+.dash-saddles li {
+    align-items: baseline;
+    display: flex;
+    flex-wrap: wrap;
+    font-size: 0.85rem;
+    gap: 0 var(--spry-space-1);
+}
+.dash-saddles .badge {
+    font-size: 0.7rem;
+    margin-right: var(--spry-space-1);
+}
+
+/* Ambient zones (activity, notes, discoveries): reference material,
+   deliberately flat — the rules below keep Spry's expander chrome
+   from boxing them into card-weight sections that would compete with
+   the attention column above (the mocks/ sampler review: collapsed
+   zones read as ruled rows, not cards). */
+.dash-ambient > summary {
+    color: var(--spry-fg-muted);
+    font-size: 0.95rem;
+    font-weight: 600;
+}
+.dash-ambient > summary .badge {
+    font-size: 0.7rem;
+    font-weight: 400;
+}
+.dash-ambient-body {
+    border-left: 2px solid var(--spry-border);
+    margin: var(--spry-space-1) 0 var(--spry-space-3) var(--spry-space-2);
+    padding: 0 0 0 var(--spry-space-3);
+}
+
+/* Activity cap (ux-notes §1 C): eight rows visible until "Show all"
+   flips [data-activity-all]; the count badge still covers the whole
+   window the slot carries. */
+.dash-activity-table tbody tr:nth-child(n + 9) { display: none; }
+.dash-ambient[data-activity-all="1"] .dash-activity-table tbody
+    tr:nth-child(n + 9) { display: table-row; }
+
+/* The advisor hero's in-card heading copy (the samplers' composer
+   shape): the description sits inside the composer card, above the
+   textarea; the chats link sits with the action, left. */
+.advisor-hero-lede {
+    margin: calc(-1 * var(--spry-space-1)) 0 0;
+}
+.advisor-hero-actions {
+    align-items: baseline;
+    justify-content: space-between;
+}
+.advisor-hero-actions .advisor-hero-link {
+    margin: 0;
+}
+

+ 221 - 0
src/pages/app.js

@@ -351,6 +351,227 @@
                     showToast(message || kind, kind);
                 };
 
+                //
+                // Web Push bell engine (goal 41 task 3; extracted from
+                // the dashboard's inline script so the /profile settings
+                // card and the dashboard's compact rail strip run the
+                // SAME per-browser verdict logic): the page state's
+                // endpoint FINGERPRINTS are matched against this
+                // browser's own pushManager subscription (hashed the
+                // same way), so each browser shows its own truth —
+                // subscribed here ("Turn off") vs subscribed elsewhere
+                // only ("Enable" + a count). The client-side check is
+                // authoritative; the server flag is a convenience for
+                // other consumers.
+                //
+                window.initPushBell = function (config) {
+                    var stateType = (config && config.stateType) || 'dashboard';
+
+                    function state() {
+                        return (window.statum && statum.read(stateType)) || {};
+                    }
+
+                    function fail(error) {
+                        showToast('Notifications: '
+                            + (error && error.message ? error.message : error), 'error');
+                    }
+
+                    function vapidKeyBytes(base64Url) {
+                        var padding = '='.repeat((4 - base64Url.length % 4) % 4);
+                        var base64 = (base64Url + padding).replace(/-/g, '+')
+                            .replace(/_/g, '/');
+                        var raw = atob(base64);
+                        var bytes = new Uint8Array(raw.length);
+                        for (var index = 0; index < raw.length; index++) {
+                            bytes[index] = raw.charCodeAt(index);
+                        }
+                        return bytes;
+                    }
+
+                    function enablePush() {
+                        var page = state();
+                        if (!page.push_vapid_key) {
+                            fail('not available on this server');
+                            return;
+                        }
+                        if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+                            fail('this browser has no Web Push support');
+                            return;
+                        }
+                        Notification.requestPermission().then(function (permission) {
+                            if (permission !== 'granted') {
+                                fail('permission denied in the browser');
+                                return;
+                            }
+                            // The service worker lives at the origin root
+                            // (GET /sw.js, Service-Worker-Allowed: /), so
+                            // its scope covers every page.
+                            return navigator.serviceWorker.register('/sw.js', { scope: '/' })
+                                .then(function () { return navigator.serviceWorker.ready; })
+                                .then(function (registration) {
+                                    return registration.pushManager
+                                        .getSubscription().then(function (existing) {
+                                            if (existing) return existing;
+                                            return registration.pushManager.subscribe({
+                                                userVisibleOnly: true,
+                                                applicationServerKey:
+                                                    vapidKeyBytes(page.push_vapid_key)
+                                            });
+                                        });
+                                })
+                                .then(function (subscription) {
+                                    var keys = subscription.toJSON().keys || {};
+                                    return statum.act(page.push_subscribe, {
+                                        endpoint: subscription.endpoint,
+                                        p256dh: keys.p256dh,
+                                        auth: keys.auth
+                                    });
+                                })
+                                .catch(function (error) { fail(error); });
+                        });
+                    }
+
+                    function disablePush() {
+                        var page = state();
+                        navigator.serviceWorker.getRegistration('/')
+                            .then(function (registration) {
+                                if (!registration) return null;
+                                return registration.pushManager.getSubscription();
+                            }).then(function (subscription) {
+                                var done = subscription
+                                    ? subscription.unsubscribe() : Promise.resolve(true);
+                                return done.then(function () {
+                                    if (subscription && page.push_unsubscribe) {
+                                        return statum.act(page.push_unsubscribe, {
+                                            endpoint: subscription.endpoint
+                                        });
+                                    }
+                                });
+                            }).catch(function (error) { fail(error); });
+                    }
+
+                    function sendTest() {
+                        var page = state();
+                        if (!page.push_test) {
+                            fail('not available on this server');
+                            return;
+                        }
+                        statum.act(page.push_test, {})
+                            .catch(function (error) { fail(error); });
+                    }
+
+                    var browserSubscribed = null; // null = still asking the browser
+                    var hereNow = false;         // the displayed verdict (a click acts on it)
+
+                    function sha256Hex16(text) {
+                        // Mirrors PushService.endpoint_fingerprint: the
+                        // first 8 bytes of the SHA-256, hex.
+                        // crypto.subtle needs a secure context, exactly
+                        // like pushManager itself.
+                        var data = new TextEncoder().encode(text);
+                        return crypto.subtle.digest('SHA-256', data)
+                            .then(function (digest) {
+                                var bytes = new Uint8Array(digest).slice(0, 8);
+                                var hex = '';
+                                for (var index = 0; index < bytes.length; index++) {
+                                    hex += ('0' + bytes[index].toString(16)).slice(-2);
+                                }
+                                return hex;
+                            });
+                    }
+
+                    function myFingerprint() {
+                        if (!('serviceWorker' in navigator)
+                                || !('PushManager' in window)) {
+                            return Promise.resolve(null);
+                        }
+                        return navigator.serviceWorker.getRegistration('/')
+                            .then(function (registration) {
+                                if (!registration) return null;
+                                return registration.pushManager
+                                    .getSubscription().then(function (sub) {
+                                        return sub ? sha256Hex16(sub.endpoint) : null;
+                                    });
+                            }).catch(function () { return null; });
+                    }
+
+                    function otherBrowserCount() {
+                        // The state's fingerprints minus this browser's own.
+                        var mine = browserSubscribed;
+                        var list = state().push_endpoints || [];
+                        if (mine === null || mine === undefined) return list.length;
+                        var others = 0;
+                        for (var index = 0; index < list.length; index++) {
+                            if (list[index] !== mine) others++;
+                        }
+                        return others;
+                    }
+
+                    function refreshBell() {
+                        return myFingerprint().then(function (mine) {
+                            browserSubscribed = mine;
+                            var list = state().push_endpoints || [];
+                            // The page's .push-bell-card is the live one
+                            // (the dashboard's rail strip or /profile's
+                            // settings card — one per page).
+                            var card = document.querySelector('.push-bell-card');
+                            if (!card) return;
+                            var here = mine !== null && list.indexOf(mine) !== -1;
+                            var others = otherBrowserCount();
+                            var onBtn = card.querySelector('[data-bell-on]');
+                            var offBtn = card.querySelector('[data-bell-off]');
+                            var help = card.querySelector('[data-bell-help]');
+                            var testBtn = card.querySelector('[data-push-test]');
+                            hereNow = here;
+                            if (onBtn) onBtn.hidden = here;
+                            if (offBtn) offBtn.hidden = !here;
+                            if (testBtn) testBtn.hidden = !here;
+                            if (help) {
+                                if (here) {
+                                    help.textContent = others > 0
+                                        ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
+                                          + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
+                                        : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
+                                } else if (list.length > 0) {
+                                    help.textContent = '\u2014 you are subscribed on '
+                                        + list.length
+                                        + ' other browser(s); enable here too to be notified in this browser';
+                                } else {
+                                    help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
+                                }
+                            }
+                        });
+                    }
+
+                    // The page state refreshes on every pulse; the
+                    // bell's per-browser verdict must be re-derived each
+                    // time (the browser's own subscription may have
+                    // changed under us).
+                    statum.addStateListener(stateType, refreshBell);
+                    refreshBell();
+
+                    document.addEventListener('click', function (event) {
+                        if (!event.target.closest) return;
+                        var card = event.target.closest('.push-bell-card');
+                        if (!card) return;
+                        var bell = event.target.closest('[data-push-bell]');
+                        var test = event.target.closest('[data-push-test]');
+                        if (bell) {
+                            event.preventDefault();
+                            // Follow the DISPLAYED verdict: a browser
+                            // holding a subscription the server no longer
+                            // knows (a pruned row) shows Enable — and
+                            // enabling re-registers the existing
+                            // subscription, healing it.
+                            if (hereNow) disablePush();
+                            else enablePush();
+                        } else if (test) {
+                            event.preventDefault();
+                            sendTest();
+                        }
+                    });
+                };
+
                 var quoteHandlers = [];
                 var quoteButton = null;
                 var quotePopover = null;

+ 329 - 405
src/pages/home.html

@@ -8,8 +8,9 @@
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
     // The notification bell drives the Web Push subscribe flow (plan
-    // 11 task E): permission -> service worker -> pushManager.subscribe
-    // with the page's VAPID key -> the sealed push_subscribe action.
+    // 11 task E) — the shared engine lives in app.js
+    // (window.initPushBell, goal 41 task 3: the dashboard's compact
+    // rail strip and /profile's settings card run the same code).
     if (window.statum) {
         (function () {
             'use strict';
@@ -20,26 +21,6 @@
                 }
             });
 
-            function toBase64Url(buffer) {
-                var bytes = new Uint8Array(buffer);
-                var binary = '';
-                for (var index = 0; index < bytes.length; index++) {
-                    binary += String.fromCharCode(bytes[index]);
-                }
-                return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-            }
-
-            function vapidKeyBytes(base64Url) {
-                var padding = '='.repeat((4 - base64Url.length % 4) % 4);
-                var base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
-                var raw = atob(base64);
-                var bytes = new Uint8Array(raw.length);
-                for (var index = 0; index < raw.length; index++) {
-                    bytes[index] = raw.charCodeAt(index);
-                }
-                return bytes;
-            }
-
             function dashboard() {
                 return statum.read('dashboard') || {};
             }
@@ -59,419 +40,362 @@
                 statum.act(state.start_advisor, { message: text }).catch(function () { });
             };
 
-            function fail(error) {
-                if (window.showToast) {
-                    showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
-                }
-            }
+            // ---- activity: "new since your last visit" + row cap ----
+            // Goal 41 task 3. The framework carries no cookies (auth
+            // is slot-based) and statum exposes no client slot WRITE,
+            // so a true server-side last-visit is not available: the
+            // stamp lives in localStorage, written when the dashboard
+            // tab UNLOADS — a visit updates the baseline, a background
+            // pulse does not (an open tab keeps its count growing
+            // until you come back). First visit: no stamp, no badge
+            // (everything would be "new"). The count covers the full
+            // window the slot carries; the visible rows cap at eight
+            // until "Show all".
+            var VISIT_KEY = 'clanker.lastVisit';
 
-            function enablePush() {
-                var state = dashboard();
-                if (!state.push_vapid_key) {
-                    fail('not available on this server');
-                    return;
+            function readLastVisit() {
+                try {
+                    var raw = window.localStorage.getItem(VISIT_KEY);
+                    if (!raw) return null;
+                    var stamp = parseInt(raw, 10);
+                    return isNaN(stamp) ? null : stamp;
+                } catch (error) {
+                    return null;
                 }
-                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
-                    fail('this browser has no Web Push support');
-                    return;
-                }
-                Notification.requestPermission().then(function (permission) {
-                    if (permission !== 'granted') {
-                        fail('permission denied in the browser');
-                        return;
-                    }
-                    // The service worker lives at the origin root (GET
-                    // /sw.js, Service-Worker-Allowed: /), so its scope
-                    // covers every page.
-                    return navigator.serviceWorker.register('/sw.js', { scope: '/' })
-                        .then(function () { return navigator.serviceWorker.ready; })
-                        .then(function (registration) {
-                            return registration.pushManager.getSubscription().then(function (existing) {
-                                if (existing) return existing;
-                                return registration.pushManager.subscribe({
-                                    userVisibleOnly: true,
-                                    applicationServerKey: vapidKeyBytes(state.push_vapid_key)
-                                });
-                            });
-                        })
-                        .then(function (subscription) {
-                            var keys = subscription.toJSON().keys || {};
-                            return statum.act(state.push_subscribe, {
-                                endpoint: subscription.endpoint,
-                                p256dh: keys.p256dh,
-                                auth: keys.auth
-                            });
-                        })
-                        .catch(function (error) { fail(error); });
-                });
             }
 
-            function disablePush() {
-                var state = dashboard();
-                navigator.serviceWorker.getRegistration('/').then(function (registration) {
-                    if (!registration) return null;
-                    return registration.pushManager.getSubscription();
-                }).then(function (subscription) {
-                    var done = subscription ? subscription.unsubscribe() : Promise.resolve(true);
-                    return done.then(function () {
-                        if (subscription && state.push_unsubscribe) {
-                            return statum.act(state.push_unsubscribe, {
-                                endpoint: subscription.endpoint
-                            });
-                        }
-                    });
-                }).catch(function (error) { fail(error); });
-            }
-
-            // ---- per-browser bell state (the user-global flag lied) ----
-            // The dashboard exposes the user's endpoint FINGERPRINTS;
-            // this browser's own pushManager subscription is hashed
-            // the same way and looked up in the list. Each browser
-            // then shows its own truth: subscribed here ("Turn off")
-            // vs subscribed elsewhere only ("Enable" + a count). The
-            // client-side check is authoritative - the server flag is
-            // just a convenience for other consumers.
-            var browserSubscribed = null; // null = still asking the browser
-            var hereNow = false;          // the displayed verdict (click acts on it)
-
-            function sha256Hex16(text) {
-                // Mirrors PushService.endpoint_fingerprint: first 8
-                // bytes of the SHA-256, hex - crypto.subtle needs a
-                // secure context, exactly like pushManager itself.
-                var data = new TextEncoder().encode(text);
-                return crypto.subtle.digest('SHA-256', data).then(function (digest) {
-                    var bytes = new Uint8Array(digest).slice(0, 8);
-                    var hex = '';
-                    for (var index = 0; index < bytes.length; index++) {
-                        hex += ('0' + bytes[index].toString(16)).slice(-2);
+            function syncActivityCount() {
+                var state = statum.read('dashboard');
+                var badge = document.querySelector('[data-activity-new]');
+                if (!state || !badge || !state.activity) return;
+                var last = readLastVisit();
+                var fresh = 0;
+                if (last !== null) {
+                    for (var index = 0; index < state.activity.length; index++) {
+                        if (state.activity[index].at_epoch > last) fresh++;
                     }
-                    return hex;
-                });
-            }
-
-            function myFingerprint() {
-                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
-                    return Promise.resolve(null);
                 }
-                return navigator.serviceWorker.getRegistration('/').then(function (registration) {
-                    if (!registration) return null;
-                    return registration.pushManager.getSubscription().then(function (sub) {
-                        return sub ? sha256Hex16(sub.endpoint) : null;
-                    });
-                }).catch(function () { return null; });
+                badge.textContent = fresh > 0
+                    ? fresh + ' new since your last visit' : '';
+                badge.hidden = fresh === 0;
             }
 
-            function otherBrowserCount() {
-                // The dashboard's fingerprints minus this browser's own.
-                var mine = browserSubscribed;
-                var list = dashboard().push_endpoints || [];
-                if (mine === null || mine === undefined) return list.length;
-                var others = 0;
-                for (var index = 0; index < list.length; index++) {
-                    if (list[index] !== mine) others++;
-                }
-                return others;
-            }
-
-            function refreshBell() {
-                return myFingerprint().then(function (mine) {
-                    browserSubscribed = mine;
-                    var list = dashboard().push_endpoints || [];
-                    var card = document.querySelector('.push-bell-card');
-                    if (!card) return;
-                    var here = mine !== null && list.indexOf(mine) !== -1;
-                    var others = otherBrowserCount();
-                    var onBtn = card.querySelector('[data-bell-on]');
-                    var offBtn = card.querySelector('[data-bell-off]');
-                    var help = card.querySelector('[data-bell-help]');
-                    var testBtn = card.querySelector('[data-push-test]');
-                    hereNow = here;
-                    if (onBtn) onBtn.hidden = here;
-                    if (offBtn) offBtn.hidden = !here;
-                    if (testBtn) testBtn.hidden = !here;
-                    if (help) {
-                        if (here) {
-                            help.textContent = others > 0
-                                ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
-                                  + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
-                                : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
-                        } else if (list.length > 0) {
-                            help.textContent = '\u2014 you are subscribed on ' + list.length
-                                + ' other browser(s); enable here too to be notified in this browser';
-                        } else {
-                            help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
-                        }
-                    }
-                });
+            function syncActivityToggle() {
+                var state = statum.read('dashboard');
+                var expander = document.querySelector('[data-activity-expander]');
+                if (!expander || !state || !state.activity) return;
+                var button = expander.querySelector('[data-activity-toggle]');
+                if (!button) return;
+                var all = expander.getAttribute('data-activity-all') === '1';
+                button.textContent = all ? 'Show fewer'
+                    : 'Show all ' + state.activity.length;
             }
 
-            function sendTest() {
-                var state = dashboard();
-                if (!state.push_test) {
-                    fail('not available on this server');
-                    return;
-                }
-                statum.act(state.push_test, {}).catch(function (error) { fail(error); });
-            }
-
-            // The dashboard state refreshes on every pulse; the bell's
-            // per-browser verdict must be re-derived each time (the
-            // browser's own subscription may have changed under us).
-            statum.addStateListener('dashboard', refreshBell);
-            refreshBell();
-
             document.addEventListener('click', function (event) {
                 if (!event.target.closest) return;
-                var card = event.target.closest('.push-bell-card');
-                if (!card) return;
-                var bell = event.target.closest('[data-push-bell]');
-                var test = event.target.closest('[data-push-test]');
-                if (bell) {
-                    event.preventDefault();
-                    // Follow the DISPLAYED verdict: a browser holding a
-                    // subscription the server no longer knows (a pruned
-                    // row) shows Enable - and enabling re-registers the
-                    // existing subscription, healing it.
-                    if (hereNow) disablePush();
-                    else enablePush();
-                } else if (test) {
-                    event.preventDefault();
-                    sendTest();
-                }
+                var button = event.target.closest('[data-activity-toggle]');
+                if (!button) return;
+                event.preventDefault();
+                var expander = button.closest('[data-activity-expander]');
+                if (!expander) return;
+                var all = expander.getAttribute('data-activity-all') === '1';
+                expander.setAttribute('data-activity-all', all ? '0' : '1');
+                syncActivityToggle();
+            });
+
+            statum.afterEvaluate(syncActivityCount);
+            statum.afterEvaluate(syncActivityToggle);
+            window.addEventListener('beforeunload', function () {
+                try {
+                    window.localStorage.setItem(VISIT_KEY,
+                        String(Math.floor(Date.now() / 1000)));
+                } catch (error) { /* private mode: no baseline, no badge */ }
             });
+
+            // The per-browser bell engine (fingerprint this browser's
+            // subscription against the slot's endpoint list; Enable vs
+            // Turn off follows the DISPLAYED verdict): shared with the
+            // /profile settings card, defined in app.js.
+            window.initPushBell({ stateType: 'dashboard' });
         })();
     }
 </script>
 </head>
 <body>
-    <section>
-        <!-- The advisor hero (goal 27): the page's FIRST and
-             predominant control. Starting a chat here creates it
-             (titled from the first line) and lands on the chat page;
-             the advisor reads fleet state, asks questions and
-             suggests tasks/goals, but never runs commands. -->
-        <section stm-if="dashboard != null" class="advisor-hero card">
-            <!-- Its own block layout (.advisor-hero-form): NOT
-                 form.inline + .form-row, whose inline-flex and 34rem
-                 column cap squeeze a multi-line textarea into a
-                 cramped sliver. The composer spans the card's full
-                 width with a roomy textarea; the button sits below it,
-                 right-aligned. -->
-            <form class="advisor-hero-form"
-                  onsubmit="window.startAdvisorChat(); return false;">
-                <label for="advisor-composer">Ask the advisor</label>
-                <textarea id="advisor-composer" rows="3"
-                          stm-attribute.disabled="!dashboard.advisor_configured"
-                          placeholder="What should the fleet work on next?"></textarea>
-                <div class="advisor-hero-actions">
-                    <button type="submit" class="btn-primary"
-                            stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
-                </div>
-            </form>
-            <p class="text-muted" stm-if="dashboard.advisor_configured">Reads your projects,
-                tasks, goals, rules and discoveries - asks you questions and suggests
-                work. <a href="/chats">Your chats</a></p>
-            <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
-                configured - set the model endpoint on the <a href="/models">Admin →
-                Model</a> page to enable it.</p>
-            <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
-                no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
-                the <code>share/clanker-saddle/canon</code> package files.</p>
-        </section>
-
-        <h2>Dashboard</h2>
+    <div class="stack">
         <p stm-if="dashboard == null">Loading…</p>
 
-        <div stm-if="dashboard != null">
-            <!-- The notification bell (plan 11 task E): subscribe this
-                 browser (permission + service worker + VAPID key) or
-                 turn it off. Block events notify every subscribed user;
-                 per-task "Notify me" adds start/failure/completion. -->
-            <!-- The notification bell (plan 11 task E; per-browser
-                 since the user-global flag lied to second browsers):
-                 THIS browser's own subscription decides Enable vs Turn
-                 off (the JS fingerprints the browser's endpoint and
-                 matches it against the dashboard's list), the helper
-                 text counts the user's OTHER subscribed browsers, and
-                 "Send test notification" fires the real end-to-end
-                 probe through this user's subscriptions. -->
-            <section class="card push-bell-card">
-                <form class="cluster">
-                    <button type="button" data-push-bell data-bell-on class="btn-primary"
-                            hidden>Enable notifications</button>
-                    <button type="button" data-push-bell data-bell-off
-                            hidden>Turn off notifications</button>
-                    <button type="button" data-push-test
-                            hidden>Send test notification</button>
-                    <span class="text-muted" data-bell-help>— be told when an agent asks a
-                        question, raises an RFC or review</span>
+        <div stm-if="dashboard != null" class="stack">
+            <!-- The advisor hero (goal 27; goal 41 task 3 keeps it the
+                 page's first control but gives it the samplers' shape:
+                 the heading and the description sit INSIDE the composer
+                 card, above the textarea, so the primary surface is
+                 self-describing). Starting a chat here creates it
+                 (titled from the first line) and lands on the chat
+                 page; the advisor reads fleet state, asks questions
+                 and suggests tasks/goals, but never runs commands. -->
+            <section stm-if="dashboard != null" class="advisor-hero card">
+                <!-- Its own block layout (.advisor-hero-form): NOT
+                     form.inline + .form-row, whose inline-flex and 34rem
+                     column cap squeeze a multi-line textarea into a
+                     cramped sliver. The composer spans the card's full
+                     width with a roomy textarea; the button sits below
+                     it, right-aligned. -->
+                <form class="advisor-hero-form"
+                      onsubmit="window.startAdvisorChat(); return false;">
+                    <label for="advisor-composer">Ask the advisor</label>
+                    <p class="hint advisor-hero-lede">Reads your projects, tasks, goals, rules and
+                        discoveries — asks you questions and suggests work. It never runs
+                        commands.</p>
+                    <textarea id="advisor-composer" rows="3"
+                              stm-attribute.disabled="!dashboard.advisor_configured"
+                              placeholder="What should the fleet work on next?"></textarea>
+                    <div class="advisor-hero-actions">
+                        <p class="hint advisor-hero-link"><a href="/chats">Your chats</a></p>
+                        <button type="submit" class="btn-primary"
+                                stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
+                    </div>
                 </form>
+                <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
+                    configured - set the model endpoint on the <a href="/models">Admin →
+                    Model</a> page to enable it.</p>
+                <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
+                    no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
+                    the <code>share/clanker-saddle/canon</code> package files.</p>
             </section>
 
-            <div class="cards">
-                <section class="card">
-                    <h3>Queue</h3>
-                    <p><strong stm-text="dashboard.ready_count">0</strong> ready to dispatch</p>
-                    <p class="text-muted"><span stm-text="dashboard.queued_count">0</span> queued ·
-                        <span stm-text="dashboard.active_count">0</span> active ·
-                        <span stm-text="dashboard.blocked_count">0</span> blocked</p>
-                    <p class="text-muted"><span stm-text="dashboard.done_count">0</span> done ·
-                        <span stm-text="dashboard.failed_count">0</span> failed</p>
-                </section>
-                <section class="card">
-                    <h3>Saddles</h3>
-                    <p><strong stm-text="dashboard.idle_saddle_count">0</strong> idle</p>
-                    <p><a href="/saddles">Manage saddles</a> · <a href="/tasks">Task queue</a></p>
-                </section>
-            </div>
+            <!-- ATTENTION FIRST (goal 41 task 3 / ux-notes §1 A+B): the
+                 left column is why the page exists — pinned needs-a-human
+                 blocks, then active tasks with their latest progress. The
+                 right rail carries the reference material: queue metrics,
+                 the saddle list and the push-bell preference strip (a
+                 settings action, not news — the old full-width opener card
+                 is gone; the preference UI now lives on /profile). Stacks
+                 under the framework's 55rem breakpoint. -->
+            <div class="with-sidebar dash-board">
+                <div class="dash-attention stack">
+                    <section aria-labelledby="needs-a-human-h">
+                        <h2 id="needs-a-human-h" class="dash-heading">Needs a human
+                            <span class="badge badge-warning"
+                                  stm-if="dashboard.pinned.length > 0"
+                                  stm-text="dashboard.pinned.length">0</span></h2>
+                        <p class="text-muted">Open questions, RFCs and reviews across all tasks and
+                            goals, newest first — follow the link to answer on the task or goal
+                            page.</p>
+                        <div class="cards" stm-if="dashboard.pinned.length > 0">
+                            <section stm-for-blockview-in="dashboard.pinned"
+                                     stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
+                                <p>
+                                    <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
+                                    <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
+                                          stm-text="blockview.item_type">task</span>
+                                    <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
+                                    <span class="text-muted" stm-text="blockview.created_display">when</span></p>
+                                <p><strong stm-text="blockview.question">question</strong></p>
+                                <p stm-if="blockview.item_type == 'task'">
+                                    <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
+                                <p stm-if="blockview.item_type == 'goal'">
+                                    <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
+                            </section>
+                        </div>
+                        <p stm-if="dashboard.pinned.length == 0" class="text-muted">Nothing needs a
+                            human — no open questions, RFCs or reviews.</p>
+                    </section>
 
-            <!-- The scratchpad (goal 8 task 3): jot down batches of
-                 changes before they are ready to be full-on goals.
-                 Check a note off, delete it, or promote it — promote
-                 creates a DRAFT goal (title = the first line, brief =
-                 the whole body) and jumps to its page to finish. -->
-            <h3>Notes</h3>
-            <section class="card">
-                <form stm-action="dashboard.add_note">
-                    <div class="field">
-                        <textarea name="body" rows="2" required
-                                  placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
-                    <button type="submit">Add note</button>
-                </form>
-            </section>
+                    <section aria-labelledby="active-tasks-h"
+                             stm-if="dashboard.active_tasks.length > 0">
+                        <h2 id="active-tasks-h" class="dash-heading">Active tasks</h2>
+                        <div class="table-wrap">
+                            <table class="table-hover">
+                                <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
+                                <tbody>
+                                    <tr stm-for-active-in="dashboard.active_tasks">
+                                        <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
+                                        <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
+                                        <td stm-text="active.task_branch">branch</td>
+                                        <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
+                                            <span class="text-muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
+                                            <span class="text-muted" stm-if="active.latest_progress.length == 0">—</span></td>
+                                    </tr>
+                                </tbody>
+                            </table>
+                        </div>
+                    </section>
+                </div>
 
-            <!-- The five newest discoveries (goal 24): the fleet's
-                 knowledge base at a glance; the Discoveries page owns
-                 the searchable listing. -->
-            <div stm-if="dashboard.discoveries.length > 0">
-                <h3>Latest discoveries</h3>
-                <ul>
-                    <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
-                        stm-markdown="discovery.body">body</li>
-                </ul>
-                <p class="text-muted"><a href="/discoveries">All discoveries →</a></p>
-            </div>
-            <table stm-if="dashboard.notes.length > 0">
-                <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-note-in="dashboard.notes">
-                        <td><button type="button" stm-action="note.toggle"
-                                    stm-attribute.class="note.done ? 'approve' : ''"
-                                    stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
-                        <td><span stm-attribute.class="note.done ? 'footnote' : ''"
-                                  stm-text="note.body">body</span></td>
-                        <td class="text-muted" stm-text="note.created_display">when</td>
-                        <td>
-                            <span stm-if="note.goal_id > 0">
-                                <span class="goal-icon">🎯</span>
-                                <a stm-attribute.href="'/goals/' + note.goal_id"
-                                   stm-text="note.goal_title">goal</a>
-                            </span>
-                            <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
-                                  stm-action="note.promote">
-                                <select name="project_id" required>
-                                    <option stm-for-project-in="dashboard.projects"
-                                            stm-attribute.value="project.id"
-                                            stm-text="project.name">project</option>
-                                </select>
-                                <input type="text" name="branch_name" required
-                                       placeholder="goal/branch" size="14">
-                                <button type="submit" class="btn-primary">Create goal</button>
-                            </form>
-                            <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
-                            <form class="cluster" stm-action="note.delete">
-                                <button type="submit">Delete</button>
-                            </form>
-                        </td>
-                    </tr>
-                </tbody>
-            </table>
-            <p stm-if="dashboard.notes.length == 0" class="text-muted">No notes yet — jot down the
-                next batch of changes above.</p>
+                <!-- The right rail: metrics, the saddle verdict list and
+                     the bell strip. Everything here is one-glance state or
+                     a settings action — nothing that outranks the
+                     attention column. -->
+                <aside class="dash-rail stack" aria-label="Fleet status">
+                    <section class="card card-flat">
+                        <h3>Queue</h3>
+                        <p class="dash-metric"><strong class="num" stm-text="dashboard.ready_count">0</strong>
+                            ready to dispatch</p>
+                        <p class="text-muted"><span stm-text="dashboard.queued_count">0</span> queued ·
+                            <span stm-text="dashboard.active_count">0</span> active ·
+                            <span stm-text="dashboard.blocked_count">0</span> blocked</p>
+                        <p class="text-muted"><span stm-text="dashboard.done_count">0</span> done ·
+                            <span stm-text="dashboard.failed_count">0</span> failed</p>
+                        <p><a href="/tasks">Task queue</a></p>
+                    </section>
+
+                    <section class="card card-flat" aria-labelledby="rail-saddles-h">
+                        <h3 id="rail-saddles-h">Saddles</h3>
+                        <p class="dash-metric"><strong class="num" stm-text="dashboard.idle_saddle_count">0</strong>
+                            idle</p>
+                        <ul class="dash-saddles">
+                            <li stm-for-saddle-in="dashboard.saddles">
+                                <span stm-attribute.class="'badge badge-' + saddle.status"
+                                      stm-text="saddle.status">status</span>
+                                <strong stm-text="saddle.name">name</strong>
+                                <span stm-if="saddle.current_goal_id > 0">·
+                                    <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
+                                       stm-text="saddle.current_goal_title">goal</a></span>
+                                <span stm-if="saddle.current_goal_id == 0 && saddle.current_task_id > 0">·
+                                    <a stm-attribute.href="'/tasks/' + saddle.current_task_id"
+                                       stm-text="saddle.current_task_title">task</a></span>
+                            </li>
+                            <li stm-if="dashboard.saddles.length == 0" class="text-muted">No saddles
+                                registered.</li>
+                        </ul>
+                        <p><a href="/saddles">Manage saddles</a></p>
+                    </section>
 
-            <h3 stm-if="dashboard.pinned.length > 0">Needs a human
-                (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
-            <p class="text-muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
-                reviews across all tasks and goals, newest first — follow the link to
-                answer on the task or goal page.</p>
-            <div class="cards" stm-if="dashboard.pinned.length > 0">
-                <section stm-for-blockview-in="dashboard.pinned"
-                         stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
-                    <p>
-                        <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
-                        <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
-                              stm-text="blockview.item_type">task</span>
-                        <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
-                        <span class="text-muted" stm-text="blockview.created_display">when</span></p>
-                    <p><strong stm-text="blockview.question">question</strong></p>
-                    <p stm-if="blockview.item_type == 'task'">
-                        <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
-                    <p stm-if="blockview.item_type == 'goal'">
-                        <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
-                </section>
+                    <!-- The notification bell (plan 11 task E; per-browser
+                         since the user-global flag lied to second
+                         browsers): THIS browser's own subscription decides
+                         Enable vs Turn off (the JS fingerprints the
+                         browser's endpoint and matches it against the
+                         slot's list), the helper text counts the user's
+                         OTHER subscribed browsers, and "Send test
+                         notification" fires the real end-to-end probe
+                         through this user's subscriptions. A compact
+                         strip in the rail (goal 41 task 3): the
+                         preference UI lives on /profile. -->
+                    <section class="card card-flat push-bell-card">
+                        <h3>Notifications</h3>
+                        <form class="stack">
+                            <button type="button" class="btn-sm btn-primary" data-push-bell
+                                    data-bell-on hidden>Enable notifications</button>
+                            <button type="button" class="btn-sm" data-push-bell data-bell-off
+                                    hidden>Turn off notifications</button>
+                            <button type="button" class="btn-sm" data-push-test
+                                    hidden>Send test notification</button>
+                            <p class="hint" data-bell-help>— be told when an agent asks a question,
+                                raises an RFC or review</p>
+                            <p class="hint"><a href="/profile">Notification settings</a></p>
+                        </form>
+                    </section>
+                </aside>
             </div>
-            <p stm-if="dashboard.pinned.length == 0" class="text-muted">Nothing needs a human —
-                no open questions, RFCs or reviews.</p>
 
-            <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
-            <table stm-if="dashboard.active_tasks.length > 0">
-                <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
-                <tbody>
-                    <tr stm-for-active-in="dashboard.active_tasks">
-                        <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
-                        <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
-                        <td stm-text="active.task_branch">branch</td>
-                        <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
-                            <span class="text-muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
-                            <span class="text-muted" stm-if="active.latest_progress.length == 0">—</span></td>
-                    </tr>
-                </tbody>
-            </table>
+            <!-- ACTIVITY (ux-notes §1 C): the page's only pure-read
+                 section folds under a collapsed expander. The summary
+                 carries a live "N new since your last visit" badge (the
+                 localStorage stamp — see the head script for why) and
+                 the rows cap at eight until "Show all" (the count still
+                 covers the whole window the slot carries). -->
+            <details class="dash-ambient" data-activity-expander>
+                <summary>Activity <span class="badge badge-info" data-activity-new hidden>new</span></summary>
+                <div class="dash-ambient-body">
+                    <p class="text-muted">The last <span stm-text="dashboard.activity.length">0</span>
+                        events across all tasks, newest first.</p>
+                    <div class="table-wrap">
+                        <table class="table-compact dash-activity-table">
+                            <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
+                            <tbody>
+                                <tr stm-for-item-in="dashboard.activity">
+                                    <td stm-text="item.when_display">when</td>
+                                    <td stm-if="item.goal_id > 0">
+                                        <span class="badge badge-kind-goal">goal</span>
+                                        <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
+                                        <a stm-if="item.task_id > 0" class="text-muted"
+                                           stm-attribute.href="'/tasks/' + item.task_id"
+                                           >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
+                                    <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
+                                    <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
+                                        <!-- Comment rows read as footnotes (plan 13
+                                             B4): the body leads, muted and smaller. -->
+                                        <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
+                                              stm-text="item.text">activity</span></td>
+                                </tr>
+                                <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="text-muted">Nothing yet.</td></tr>
+                            </tbody>
+                        </table>
+                    </div>
+                    <button type="button" class="btn-ghost btn-sm" data-activity-toggle
+                            stm-attribute.hidden="dashboard.activity.length <= 8">Show all</button>
+                </div>
+            </details>
 
-            <h3>Saddles</h3>
-            <div class="cards">
-                <section class="card" stm-for-saddle-in="dashboard.saddles">
-                    <h3 stm-text="saddle.name">name</h3>
-                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
-                        <span class="text-muted" stm-text="saddle.host">host</span></p>
-                    <p stm-if="saddle.current_goal_id > 0">Supervising goal
-                        <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
-                           stm-text="saddle.current_goal_title">goal</a>
-                        <span class="badge badge-kind-goal">goal</span></p>
-                    <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Working on
-                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a></p>
-                    <p stm-if="saddle.current_task_id == 0" class="text-muted">No current task</p>
-                </section>
-                <section class="card" stm-if="dashboard.saddles.length == 0">
-                    <p class="text-muted">No saddles registered.</p>
-                </section>
-            </div>
+            <!-- AMBIENT ZONES (goal 41 task 3; the mocks/ sampler
+                 review: reference material starts COLLAPSED so a visit
+                 for "answer the question" never scrolls past it). -->
+            <div class="expander-group dash-ambient">
+                <!-- The scratchpad (goal 8 task 3): jot down batches of
+                     changes before they are ready to be full-on goals.
+                     Check a note off, delete it, or promote it — promote
+                     creates a DRAFT goal (title = the first line, brief =
+                     the whole body) and jumps to its page to finish. -->
+                <details stm-if="dashboard.notes.length > 0">
+                    <summary>Notes <span class="badge"
+                            stm-text="dashboard.notes.length">0</span></summary>
+                    <div class="dash-ambient-body">
+                        <table>
+                            <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
+                            <tbody>
+                                <tr stm-for-note-in="dashboard.notes">
+                                    <td><button type="button" stm-action="note.toggle"
+                                                stm-attribute.class="note.done ? 'approve' : ''"
+                                                stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
+                                    <td><span stm-attribute.class="note.done ? 'footnote' : ''"
+                                              stm-text="note.body">body</span></td>
+                                    <td class="text-muted" stm-text="note.created_display">when</td>
+                                    <td>
+                                        <span stm-if="note.goal_id > 0">
+                                            <span class="goal-icon">🎯</span>
+                                            <a stm-attribute.href="'/goals/' + note.goal_id"
+                                               stm-text="note.goal_title">goal</a>
+                                        </span>
+                                        <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
+                                              stm-action="note.promote">
+                                            <select name="project_id" required>
+                                                <option stm-for-project-in="dashboard.projects"
+                                                        stm-attribute.value="project.id"
+                                                        stm-text="project.name">project</option>
+                                            </select>
+                                            <input type="text" name="branch_name" required
+                                                   placeholder="goal/branch" size="14">
+                                            <button type="submit" class="btn-primary">Create goal</button>
+                                        </form>
+                                        <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
+                                        <form class="cluster" stm-action="note.delete">
+                                            <button type="submit">Delete</button>
+                                        </form>
+                                    </td>
+                                </tr>
+                            </tbody>
+                        </table>
+                    </div>
+                </details>
 
-            <h3>Activity</h3>
-            <p class="text-muted">The last <span stm-text="dashboard.activity.length">0</span> events
-                across all tasks, newest first.</p>
-            <table>
-                <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
-                <tbody>
-                    <tr stm-for-item-in="dashboard.activity">
-                        <td stm-text="item.when_display">when</td>
-                        <td stm-if="item.goal_id > 0">
-                            <span class="badge badge-kind-goal">goal</span>
-                            <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
-                            <a stm-if="item.task_id > 0" class="text-muted"
-                               stm-attribute.href="'/tasks/' + item.task_id"
-                               >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
-                        <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
-                        <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
-                            <!-- Comment rows read as footnotes (plan 13
-                                 B4): the body leads, muted and smaller. -->
-                            <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
-                                  stm-text="item.text">activity</span></td>
-                    </tr>
-                    <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="text-muted">Nothing yet.</td></tr>
-                </tbody>
-            </table>
+                <!-- The five newest discoveries (goal 24): the fleet's
+                     knowledge base at a glance; the Discoveries page owns
+                     the searchable listing. -->
+                <details stm-if="dashboard.discoveries.length > 0">
+                    <summary>Latest discoveries <span class="badge"
+                            stm-text="dashboard.discoveries.length">0</span></summary>
+                    <div class="dash-ambient-body">
+                        <ul>
+                            <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
+                                stm-markdown="discovery.body">body</li>
+                        </ul>
+                        <p class="text-muted"><a href="/discoveries">All discoveries →</a></p>
+                    </div>
+                </details>
+            </div>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 30 - 0
src/pages/profile.html

@@ -3,6 +3,18 @@
 <head>
     <pstm-uri>/profile</pstm-uri>
     <pstm-template>main</pstm-template>
+<script>
+    // The Notifications card (goal 41 task 3): the push-bell
+    // preference UI moved here from the dashboard's full-width opener
+    // card. The per-browser engine is shared with the dashboard's
+    // compact rail strip (window.initPushBell in app.js); it reads
+    // THIS page's slot (stateType: 'profile'), which carries the same
+    // VAPID key, endpoint fingerprints and push actions the dashboard
+    // exposes — the server actions are the existing DTOs, untouched.
+    if (window.statum && window.initPushBell) {
+        window.initPushBell({ stateType: 'profile' });
+    }
+</script>
 </head>
 <body>
     <section class="stack">
@@ -72,6 +84,24 @@
                 </form>
             </div>
 
+            <div class="card push-bell-card">
+                <h3>Notifications</h3>
+                <p class="text-muted">Be told when an agent asks a question, raises an RFC or
+                    review. The preference is per browser: each one you enable receives the
+                    alerts; per-task "Notify me" adds that task's start, failure and
+                    completion.</p>
+                <form class="stack">
+                    <button type="button" data-push-bell data-bell-on
+                            hidden>Enable notifications</button>
+                    <button type="button" data-push-bell data-bell-off
+                            hidden>Turn off notifications</button>
+                    <button type="button" data-push-test
+                            hidden>Send test notification</button>
+                    <p class="hint" data-bell-help>— be told when an agent asks a question,
+                        raises an RFC or review</p>
+                </form>
+            </div>
+
             <div class="card">
                 <h3>Change password</h3>
                 <form stm-action="profile.change_password" class="stack">

+ 16 - 2
test/bundles/push.py

@@ -527,8 +527,22 @@ def run(ctx):
     check(status == 200 and "data-push-test" in home_page
           and "data-bell-on" in home_page and "data-bell-off" in home_page,
           "the bell card ships the per-browser buttons + test button")
-    check("push_endpoints" in home_page and "crypto.subtle.digest" in home_page,
-          "the client fingerprints its own endpoint and matches the list")
+    # Goal 41 task 3: the fingerprint engine moved from home.html's
+    # inline script into app.js (window.initPushBell) so the dashboard's
+    # rail strip and /profile's settings card share it; the home page
+    # wires it and still reads the dashboard slot's endpoint list.
+    app_js = ""
+    try:
+        with open(os.path.join(REPO, "src", "pages", "app.js"), "r",
+                  errors="replace") as handle:
+            app_js = handle.read()
+    except OSError:
+        pass
+    check("initPushBell" in home_page and "stateType" in home_page,
+          "the home page wires the shared bell engine on the dashboard slot")
+    check("crypto.subtle.digest" in app_js
+          and "endpoint_fingerprint" in app_js,
+          "the shared engine fingerprints this browser's own endpoint")
 
     print("== cleanup: unsubscribe the probe fixtures ==")
     act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_a})