# 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 `` 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.