spry-conversion.md 19 KB

Spry conversion conventions (goal 41, binding for T3–T10)

ClankerSaddle runs on the Spry CSS framework that ships inside libspry. This document is the binding contract for every page task in the UI/UX goal: what Spry provides, what app.css owns, how classes map from the old stylesheet, and the rules for new markup.

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).