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.
main.html and login.html link, in this order:
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.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.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 user's saved appearance lives in TWO places with ONE source of truth:
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."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.
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)..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).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.
| 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.
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:
--spry-bg-alt bg, --spry-fg-muted fg): draft, paused,
cancelled, offline, reference, command, created, shell-reset,
mode-implement, and their goal-* twins.--spry-warning-soft/--spry-warning): queued,
enqueued.--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.--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.--spry-success-soft/--spry-success): done, idle,
active, complete, completed, progress, mode-test, chat-idle,
suggestion-approved, and the answered family.--spry-danger-soft/--spry-danger): failed and every
rejected/failed goal transition.--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.)--spry-bg-inset/--spry-fg-muted): comment,
tool.--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.
<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..mt-3, .p-2, .gap-*
via .cluster/.stack), not bespoke margins. If you write
margin: … in app.css, you need a reason comment.--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..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.<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.<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.<details>/<summary> (Spry styles them;
group siblings in .expander-group to fuse borders).<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..table-wrap; dense tables add
.table-compact, row-hover .table-hover.src/pages/login.html).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).
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.
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
.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).
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 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.
/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.
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).
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.