# 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 `` 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 `` (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 `` 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-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 `
` (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-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-` 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 `
`; 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**: `
…

…

`. Checkboxes/radios use `.choice` / `.choices(-inline)`; toggles use `.switch`. Wide editors (textareas) live in `.field`s 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 `` with `.dialog-titlebar/-title/ -close/-body/-footer`) arrive WITH the page tasks (T3–T9), not in this foundation. When you add one: `` with a `.dialog-titlebar` carrying `

` and a `.dialog-close` button, body in `.dialog-body`, actions right- aligned in `.dialog-footer`. 7. **Expanders**: plain `

`/`` (Spry styles them; group siblings in `.expander-group` to fuse borders). 8. **Badges**: `` 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: 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:
… 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 ``, 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:
…ACT…
…CONSULT…
`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 `
    ` 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:

    …

    …fields…
    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(…)`, include the partial (``). 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 `