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