spry-conversion.md 22 KB

Spry conversion conventions

ClankerSaddle runs on the Spry CSS framework that ships inside libspry. This document is the binding contract for the UI/UX page tasks: what Spry provides, what app.css owns, how classes map from the old stylesheet, the rules for new markup — and the app-side contracts of the Spry services the app adopts (Web Push).

How the framework is loaded

main.html and login.html link, in this order:

  1. spry.css — core + default theme (light palette, dark palette on prefers-color-scheme: dark). This IS the product default; no data-theme is set anywhere by default.
  2. spry-theme-{airy,dwarf,formal,glassy,h2o,living-sky,modern}.css — the seven optional themes, loaded but INERT until <html data-theme="slug"> is set. T2 wires the account-settings picker that sets that attribute; until then they cost a few KB of cache and do nothing.
  3. app.css — the app component layer, always LAST so it wins ties.

Color mode is data-mode="auto" on <html> (spry's default behavior — omitting the attribute is also auto). Never hard-code a mode.

The theme slot contract (T2 — the account-settings picker)

The user's saved appearance lives in TWO places with ONE source of truth:

  • Server side: the user_preferences table (M0011) stores theme ("" = the core default, or one of the seven slugs) and mode (auto|light|dark) per spry_users.id — see Services.UserPreferenceService. This is the durable record the login seeds from.
  • Client side: the DEVICE-scope Statum slot "ui" (public data {theme, mode}) — written by SetThemeAction on save and re-seeded by LoginAction at login. DEVICE scope means the browser caches it in localStorage, so a returning visit paints the chosen theme before any server round-trip (no FOUC beyond the very first visit, which sees the Spry default — itself the default).

app.js's applyUiSlot hook reads the slot on statum init and after every render cycle and does the only client-side work there is: data-theme="slug" on <html> when a theme is set (attribute REMOVED for the core default — no data-theme="core"), and data-mode="light|dark" when forced (always present; auto is the default value). No other JS, stylesheet swaps or reloads — the seven theme stylesheets are always linked and inert until data-theme scopes them.

Per-account on a per-device slot: two humans sharing a browser profile each get their own choice once they log in; a single user on two devices picks per device (the DEVICE slot is written on every save and every login of that user, so the LAST login on a device wins). Deleting the slot client-side (statum.clear('ui')) reverts to the Spry default until the next login.

Where styles live — the layering contract

  • spry.css owns every primitive: typography, links, focus rings, buttons (button, .btn, .btn-primary/danger/outline/ghost/link, .btn-sm/lg/block, .btn-group), forms (input/select/textarea element styles, .field, .label, .hint, .is-invalid/valid, .choice(s)(-inline), .switch), tables (table, .table-wrap, .table-striped/hover/compact/borderless), cards (.card, -flat/-header/-body/-footer/-title/-subtitle), alerts (.alert
    • -danger/success/warning/info), badges (.badge + -danger/success/warning/info/muted), progress/meter/spinners, dialogs (<dialog>, .dialog-sm/lg/titlebar/title/close/body/footer, [popover], .popover-menu, .anchor/.popover-anchor), expanders (details/summary, .expander-group), hero/avatar/otp/ drawer/pagination, topbar/nav/menu/submenu/breadcrumb/tabs, layout utilities (.container(-sm/-lg), .stack, .cluster, .center, .with-sidebar, .block/inline/hidden/visually-hidden, width/ position/overflow utilities), spacing utilities (.{m,p}{t,r,b,l,x,y}-{0..5}, .mx-auto), and text utilities (.text-{xs..2xl}, .text-muted/accent/danger/success/warning/info, .fw-*, .mono, .num, .truncate, .clamp-2/3, .measure, .lead, .small).
  • app.css owns only ClankerSaddle components: status/event badges (the .badge-* family below), the goal identity, comments and footnote treatment, blocks + plan review, the event log, advisor chat, the terminal, toasts, the quote popover, the login shell, .cards grid, .grid-2, and chrome shims (.brand-emblem, .nav-auth, .nav-user, .site-footer).
  • Never restyle a Spry primitive from app.css and never re-declare Spry's reset/typography. If a page needs something Spry has, USE the Spry class; only add to app.css when the component is genuinely app-specific.

app.css append-only discipline

app.css is organised in sections with ==== header ==== comments. Within a section, new rules are APPENDED under the section header with a /* comment */ naming the component and the reason (which page, which plan/goal). Never edit another task's rules in place — merge conflicts in this file are append-per-section and resolved trivially. Sections may be added at the END of the file.

Class mapping (old app.css → Spry)

old class use instead
body 64rem column .container on <main> (72rem, fluid padding)
.form-row .field (label + input inside)
button.primary .btn-primary
button.approve .btn-primary
button.reject .btn-danger
form.inline .cluster (wrap the fields/buttons)
label.inline (checkbox row) .choice
.muted .text-muted
.error (text color) .text-danger
details custom bare details/summary (Spry styles them)
.card .card (Spry) + .card-body etc. as needed
header custom chrome .topbar + .topbar-brand + ul.nav/.has-submenu/ul.submenu
.nav-menu/.nav-dropdown deleted — see topbar row above
tables bare <table> (+ .table-hover/.table-compact where dense) inside .table-wrap when wide

Kept as app components (now tokenised): .comment, .footnote, .block-card, .plan-review, .plan-popover, .review-banner, .goal-*, .log-*, .event-*, .term-*, .terminal, .chat-*, .toast*, .quote-*, .ssh-key, .badge-* status family, .cards, .grid-2, .status-head, .status-corner, .waiting-drafts, .rule-card, .chip-remove, .yeehaw-*.

App tokens: --goal: var(--spry-accent) and --goal-surface: var(--spry-accent-soft) (goal identity derives from the theme accent; goal pages read as themselves through layout and badges, not a competing hue). All other old app tokens (--ink, --line, --surface, --accent, --success, …) are DELETED — use the --spry-* equivalents.

The badge-colour contract

Every status/health/event badge consumes a semantic Spry variable — NO hard-coded hex anywhere. A .badge-<status> rule in app.css maps to exactly one of:

  • muted (--spry-bg-alt bg, --spry-fg-muted fg): draft, paused, cancelled, offline, reference, command, created, shell-reset, mode-implement, and their goal-* twins.
  • queued (--spry-warning-soft/--spry-warning): queued, enqueued.
  • info (--spry-info-soft/--spry-info): assigned, planning, started, resumed, dispatch, query, question, user-comment, co-developed, mode-investigate, chat-awaiting_answer, kind-task, state_change.
  • warning (--spry-warning-soft/--spry-warning): working, blocked, block, suspended, review_requested, review, completion_review, mode-review, busy, chat-running, suggestion-pending, yeehaw, and the block-raised family.
  • success (--spry-success-soft/--spry-success): done, idle, active, complete, completed, progress, mode-test, chat-idle, suggestion-approved, and the answered family.
  • danger (--spry-danger-soft/--spry-danger): failed and every rejected/failed goal transition.
  • accent (--spry-accent-soft/--spry-accent): plan, plan_review, rfc, mode-orchestrate, block-plan, admin, capability, kind-goal, goal-plan-submitted. (The old violet plan token retired: plans and goals now ride the theme accent.)
  • neutral inset (--spry-bg-inset/--spry-fg-muted): comment, tool.
  • Health badges are FILLED (strong var as bg, --spry-accent-fg fg) so they read at a glance on the saddles page.

New statuses in page tasks: add ONE line to the matching group in the badge section of app.css, never a new hex.

Markup rules for page tasks (T3–T10)

  1. Page shell: every page's content sits directly under <main class="container">; page-level stacks use .stack or .stack-like sections with spacing utilities. Detail pages may adopt .with-sidebar from T5/T6 for side navigation.
  2. Spacing comes from Spry utilities (.mt-3, .p-2, .gap-* via .cluster/.stack), not bespoke margins. If you write margin: … in app.css, you need a reason comment.
  3. Every colour via --spry-* (or an app token derived from one). Dark mode must work with zero extra rules — if you find yourself writing @media (prefers-color-scheme), stop: you hard-coded something.
  4. Buttons: the element default is already Spry-styled; add .btn-primary for the one primary action per view, .btn-danger for destructive, .btn-ghost for quiet/inline actions (the old button.inline), .btn-sm inside dense rows/tables.
  5. Forms: <div class="field"><label…>…</label><input…> <p class="hint">…</p></div>. Checkboxes/radios use .choice / .choices(-inline); toggles use .switch. Wide editors (textareas) live in .fields capped by .form-rows .field { max-width: 34rem } — group a page's edit fields in .form-rows.
  6. History folds / actions surfaces: per the goal plan, the collapsed-history expander pattern and "actions surface" reshaping (modals via native <dialog> with .dialog-titlebar/-title/ -close/-body/-footer) arrive WITH the page tasks (T3–T9), not in this foundation. When you add one: <dialog class="…" id="…"> with a .dialog-titlebar carrying <p class="dialog-title"> and a .dialog-close button, body in .dialog-body, actions right- aligned in .dialog-footer.
  7. Expanders: plain <details>/<summary> (Spry styles them; group siblings in .expander-group to fuse borders).
  8. Badges: <span class="badge badge-<status>"> per the contract above; event kinds on the log/narrative use the same semantic groups via .event-kind-* in app.css.
  9. Wide tables sit in .table-wrap; dense tables add .table-compact, row-hover .table-hover.
  10. Login is the reference for form markup conventions (src/pages/login.html).

The breadcrumb partial

src/pages/breadcrumb.html is a pstm-fragment partial (the progress_feed pattern) that renders a Spry .breadcrumb from a crumbs array of {href?, label} items — the last crumb carries aria-current="page". Include it as:

<pstm-fragment name="breadcrumb" crumbs="page.crumbs"/>

Adoption by detail pages happens in T5/T6/T9 (it needs each page's entrypoint to expose a crumbs vector — do not fake it client-side).

The task-log-feed partial (a client-JS component partial)

src/pages/task_log_feed.html is the event log's interactive shell (toolbar, filters, search, feed, load-older) as a pstm-fragment partial, included by BOTH the standalone /tasks/{id}/log page and the task page's "Event log" tab. It binds the including page's task_log PAGE slot (exists / task_id / task_title / task_status / task_url); task_log.js (loaded via pstm-res-src by whichever page embeds it) mounts everything from its [data-log-*] hooks. A page embedding it must (a) load the script resource, (b) have its entrypoint publish the slot — TaskLogEntrypoint.log_slot_for(state_service, db, task_id) builds it — and (c) re-set the slot from any pulse refresh the page owns (see RefreshTaskDataAction). Both surfaces share ONE markup and ONE engine by construction: never hand-copy the shell.

The sub-nav engine (T11 — one implementation, every detail page)

A detail page's anchor sub-nav is ONE shared engine, initSubnav() in app.js. T5 shipped an IntersectionObserver chassis inline on the task page and T6 a closest-to-fold highlighter inline on the goal page; T11 unified them — never hand-copy either again. The engine owns smooth jumps that stop below the sticky chrome (topbar

  • strip, measured at click time), the closest-to-fold section highlight (the section whose top sits closest above the strip's bottom edge — the two-column Act|Consult grid made the naive "last heading that crossed" rule light the wrong card), the .is-stuck strip class, aria-current on the active link, and publishing the measured topbar height as --topbar-bottom (the sticky log toolbar consumes the same property).

Markup contract:

<nav class="subnav" aria-label="Sections" data-subnav>
  <a href="#section-id">…</a>
</nav>
<section id="section-id" data-subnav-section="section-id">…

Both attributes are required: the id is what an anchor jumps to, data-subnav-section is what the highlight matches. A page opts in with one call — if (window.initSubnav) { window.initSubnav(); } — which is idempotent (the engine binds its document-level listeners once, flagged on <html>, and re-runs only the sync pass from statum.afterEvaluate).

The Act|Consult column layout (T11 — one class)

Both detail pages (task, goal) lay out acting surfaces left and consulting surfaces right with ONE class:

<div class="with-sidebar act-consult">
  <div class="stack">…ACT…</div>
  <div class="stack">…CONSULT…</div>
</div>

app.css re-proportions Spry's .with-sidebar (act takes the flexible track, consult a bounded 18–24rem rail) and restores the framework's 55rem stack below the breakpoint so ACT renders fully above CONSULT on narrow screens — DOM order IS the stack order. The per-page .task-columns / .goal-columns variants were unified away; do not reintroduce them.

The work-stream row grammar (T10)

The dashboard's work stream is ONE <ul class="work-stream"> of list rows (badge → when → actor → text), each row's badge mapping to a semantic group per the badge contract: task progress → the success group (.badge-stream-task_progress), goal events → accent (.badge-stream-goal_event), log highlights → the quiet inset (.badge-stream-log_highlight). Rows are list items, never table rows — a stream is read down the left edge and wraps under itself when narrow.

The saved-view hash contract (T10)

/tasks filter state lives in location.hash (client-owned, shareable): the page reads it on load and keeps it current as the chips change, so a saved URL restores the exact view. The keys are the filter names the tasks slot already carries (#status=…, combinable). No server round-trip — the hash IS the view.

The dialog contract (T4/T8/T11)

Every create/answer dialog in the app follows ONE shape:

<dialog id="…" class="dialog-sm">
  <div class="dialog-titlebar">
    <p class="dialog-title">…</p>
    <button type="button" class="dialog-close" aria-label="Close"
            onclick="window.closeAppDialog('…')">×</button>
  </div>
  <form stm-action="…">
    <div class="dialog-body form-rows">…fields…</div>
    <div class="dialog-footer">…buttons right-aligned…</div>
  </form>
</dialog>

Open with window.openAppDialog('…') — it wraps statum.onNotifyHandler and closes the dialog on ANY notify (a success closes the modal; an error closes it too, the toast carries the message). An open dialog KEEPS its open state and typed values across pulse re-renders (statum never rebuilds static markup). The one intentional deviation is the project delete dialog, which keeps its bespoke confirm-by-name danger shape (T9's design).

The CrumbView contract (T11)

Every detail page renders its breadcrumb from ONE class: ClankerSaddle.CrumbView (src/Json.vala) — {href, label}, href null on the leaf. Build the vector in the entrypoint, publish it as crumbs via StateJson.list<CrumbView>(…), include the partial (<pstm-fragment name="breadcrumb" crumbs="…"/>). The four per-page copies (Task/Goal/Project/Chat CrumbView) were unified away; do not add a page-local one.

The mobile containment contract (T12)

The mobile audit (test/mobile_audit.py) renders every route in headless Chromium at 360 / 768 / 1280 and fails on any box whose right edge exits the viewport (page-level overflow is the aggregate). These are the patterns that keep it green — every new page follows them:

  1. Wide tables sit in .table-wrap (spry's containment: overflow-x: auto). The table may scroll inside its wrap on a phone; the page never scrolls sideways. A table outside a wrap is the classic page-overflow bug.
  2. Long unbroken tokens wrap anywhere: prose containers (.md, .card-body, .comment, chat text, .event-pre, .terminal) carry overflow-wrap: anywhere (T12 rule) — SSH keys, URLs, hashes and base64 break at any character instead of spilling the card. code spans use word-break: break-word.
  3. Flex/grid children may shrink: layout utilities (.with-sidebar, .act-consult, .cards, .grid-2) have min-width: 0 children and minmax(min(100%, …), 1fr) tracks, so a wide child cannot stretch its column past the viewport. When you build a new layout, give the children .min-w-0 (or rely on the T12 rules) and put the wide thing inside a containment surface.
  4. Selects cap at the column: a select's intrinsic width is its longest option — goal titles run long. form.cluster > select carries flex: 0 1 auto; min-width: 0; max-width: 100%, so filter rows wrap instead of widening.
  5. Dropdown menus never widen the page: spry's .submenu is absolutely positioned (min-width: 11rem); a closed visibility:hidden menu still occupies the scroll area. The topbar nav carries overflow-x: clip (combines with visible-y, so open dropdowns still drop) and every .submenu is clamped to max-width: calc(100vw - 1.5rem).
  6. Touch targets >= 44px on phones: nav toggles, filter chips, log filters, tab labels, choice rows, dialog buttons and every full-size button meet the 44px floor under @media (any-pointer: coarse) and (max-width: 640px). Bare <button>s count too (spry styles them at zero specificity — include them in any new selector). Compact .btn-sm is the deliberate exception (dense table rows).
  7. Dialogs fit the phone: spry's dialog { width: min(92vw, …) } does the work; the audit opens the first dialog on each page and asserts it sits fully inside the viewport. Never give a dialog a fixed pixel width.
  8. Inputs never force width: cols=/size= floor a control's intrinsic width; the global input, select, textarea { max-width: 100% } (T12) caps them at the column.

The audit is wired into the suite as the t12mobile bundle: the server-side invariants run always; the browser audit runs when chromium is present (CI without chromium skips it gracefully — pass T12MOBILE_BROWSER=1 to require it).

Web Push (the app side of Spry.Push)

Web Push plumbing is Spry's, delivered by SpryModule (application.add_module<SpryModule>() in main.vala registers it):

  • Spry.Push.PushService — the subscription store over the spry_push_subscriptions table (Spry's own migration creates it): subscribe / unsubscribe / subscriptions_for / forget.
  • Spry.Push.PushSender — the singleton delivery engine: Locksmith RFC 8291 payload encryption + RFC 8292 VAPID signing and the libsoup POST, with inline bounded retry (two attempts, 250 ms apart). Endpoints answering 404/410 (gone) or 401/403 (refused) are swept from the store; other failures keep the row. Without keys every send answers SKIPPED — loudly warned, never an error.

Keys are operator-supplied (spry keys writes the statum pairs AND the spry section's vapid_private_key / vapid_public_key; see the README's operations section). The app never generates, stores or rotates key material.

What OUR side owns (src/Services/PushService.vala — the thin app layer; it never touches a key or a socket):

  • Payload builders — block_notification, lifecycle_notification, goal_block_notification, goal_terminal_notification, test_notification: pure functions producing the {title, body, url} wire payload.
  • Audiences — global (every push-subscribed user: block events, goal blocks, goal terminals) vs opted-in (users holding a task_notify_prefs row: a task's first start, failure, completion). Dispatch is fire-and-forget at event time through a fresh container scope — no queue, no dispatcher tick.
  • Notify prefs — the task_notify_prefs CRUD (pure app domain).
  • Fingerprints — endpoint_fingerprint (first 16 hex of SHA-256) powers the per-browser bell without ever exposing an endpoint URL.
  • send_test — the bell's end-to-end probe, mapping Spry's DeliveryOutcome[] to the toast's delivered/failed/pruned counts.

The subscribe/unsubscribe actions (src/actions/PushActions.vala) keep our wording and the navigate-to-/ refresh but call Spry's store underneath; they subclass nothing.

The service worker GET /sw.js (src/Api/PushApi.vala) is deliberately app-owned: Spry's model leaves sw.js to the application because the click-through routing and the payload shape are the app's. It parses our {title, body, url} payload and focuses or opens the task/goal page on click.

The bell UX (initPushBell in src/pages/app.js, rendered by the .push-bell-card on the dashboard rail and /profile) is OUR UI contract. Spry ships a client script (spry-push.js at /_statum/resource/) — it is deliberately NOT used: the bell's per-browser fingerprint matching, the enable/turn-off/test trio and the unkeyed state (no VAPID key in the page state => Enable hidden, helper text points the operator at spry keys) predate and differ from it. Do not "simplify" the bell onto spry-push.js without redesigning that contract.