3 Commits 47c2efa790 ... d2b815b265

Auteur SHA1 Bericht Datum
  Billy Barrow d2b815b265 Merge 1 dag geleden
  Billy Barrow 8486a0c6a2 Add themes 1 dag geleden
  USM LCC af5f33f8a2 Version 0.3.1 2 weken geleden

+ 2 - 1
MANIFEST.usm

@@ -1,6 +1,6 @@
 {
   "name" : "spry",
-  "version" : "0.3",
+  "version" : "0.3.1",
   "summary" : "Reusable authentication and user-management services for Statum, plus the spry CLI",
   "licences" : [],
   "flags" : [
@@ -36,6 +36,7 @@
       "bin:gcc",
       "bin:g-ir-compiler",
       "bin:python3",
+      "bin:statum-mkres",
       "pc:glib-2.0.pc",
       "pc:gobject-2.0.pc",
       "pc:gio-2.0.pc",

+ 92 - 0
README.md

@@ -32,6 +32,98 @@ SpryAuth.register_actions(statum);             // or hand-pick with statum.actio
 (`spry_users`, `spry_user_permissions`), the `UserEntity`/`UserPermissionEntity`
 mappings, the `UserProjection` projection and the scoped `UserService`.
 
+`SpryModule` also serves the [Spry CSS](../Spry-CSS) framework as built-in
+Statum resources (embedded in `libspry` at build time, precompressed):
+`/_statum/resource/spry.css` — the core stylesheet with the default light +
+dark theme — plus every optional theme as
+`/_statum/resource/spry-theme-<slug>.css` (`airy`, `dwarf`, `formal`,
+`glassy`, `h2o`, `living-sky`, `modern`). Scaffolds reference the core with
+
+```html
+<link rel="stylesheet" pstm-res-href="spry.css">
+```
+
+and activate a theme by loading it after the core and setting the slug on
+the root element:
+
+```html
+<link rel="stylesheet" pstm-res-href="spry-theme-dwarf.css">
+<html lang="en" data-theme="dwarf" data-mode="auto">
+```
+
+### Available classes
+
+Beyond sane defaults for bare tags (headings, forms, tables, `dialog`,
+`details`, `progress`), the framework provides utilities and components.
+The spacing/gap naming scale is `0`–`5` = `0` `0.25rem` `0.5rem` `1rem`
+`1.5rem` `2.5rem` (remappable via `--spry-space-*`). Open
+`../Spry-CSS/index.html` in a browser for a live tour of everything.
+
+Layout:
+
+| Class | Effect |
+|---|---|
+| `.container` `.container-sm` `.container-lg` | centered page shells (72/52/96rem) |
+| `.stack` | column with a gap — vertical rhythm |
+| `.cluster` | wrapping row with a gap — chips, toolbars |
+| `.center` | flex-centered both axes |
+| `.with-sidebar` | `16rem 1fr` two-column grid, collapses under 55rem |
+| `.block .inline-block .inline .hidden .visually-hidden` | display |
+| `.relative .absolute .fixed .sticky` + `.top-0 .right-0 .bottom-0 .left-0 .inset-0` | position and offsets |
+| `.overflow-auto .overflow-hidden` | overflow |
+| `.w-25 .w-50 .w-75 .w-100 .w-auto .h-100 .mw-100 .min-w-0` | size |
+| `.mx-auto .ml-auto .mt-auto` | auto margins |
+
+Spacing, flex and grid:
+
+- `.{m|p}{t|r|b|l|x|y}-{0..5}` — margins and padding, e.g. `.m-0 .mt-3 .px-2 .py-4`
+- `.flex .inline-flex .flex-row .flex-col .flex-wrap .flex-1 .flex-auto .flex-none`
+  `.items-{start,center,end,stretch,baseline}` `.justify-{start,center,end,between,around,evenly}`
+  `.self-{start,center,end}` `.order-first .order-last`
+- `.grid` — responsive auto-fit grid (min column `--spry-grid-min`, 14rem);
+  `.grid-sm`/`.grid-lg` retune it (9rem / 20rem)
+- `.grid-cols-1`…`.grid-cols-12` — fixed columns; 3+ collapse to two under 45rem
+- `.col-span-1`…`.col-span-12`, `.col-span-full`, `.gap-{0..5}` `.gap-x-*` `.gap-y-*`
+
+Typography and color:
+
+`.h1`–`.h6`, `.text-{xs,sm,base,lg,xl,2xl}`, `.text-{left,center,right}`,
+`.text-muted .text-accent .text-danger .text-success .text-warning .text-info`,
+`.fw-{light,normal,medium,semibold,bold}`, `.fs-italic .fs-normal`,
+`.uppercase .lowercase .capitalize`, `.lead .small .mono .num`,
+`.truncate .clamp-2 .clamp-3 .measure .measure-sm .text-nowrap`,
+`.bg-surface .bg-alt .bg-inset .bg-accent`, `.border .border-0 .rounded
+.rounded-sm .rounded-lg .rounded-pill .shadow .shadow-lg .shadow-none`.
+
+Components:
+
+| Component | Classes |
+|---|---|
+| Bars | `.topbar` `.topbar-brand` |
+| Nav | `.nav` (+ `a.active`), `.menu`, `.has-submenu` > `.submenu` (CSS-only dropdown), `.breadcrumb` |
+| Tabs (no JS) | `.tabs` > radios + `.tab-list` labels + `.tab-panels` sections |
+| Buttons | `.btn` + `.btn-primary .btn-outline .btn-ghost .btn-link .btn-danger`, `.btn-sm .btn-lg .btn-block`, `.btn-group` |
+| Forms | `.field` `.label` `.hint` `.input`, `.is-valid .is-invalid`, `fieldset`/`legend` defaults |
+| Checks | `.choice`, `.choices` / `.choices-inline`, `.switch` with `<span class="track">` |
+| Cards | `.card` (+ `.card-flat`), `.card-header .card-body .card-footer .card-title .card-subtitle` |
+| Alerts / badges | `.alert` + `.alert-{danger,success,warning,info}`, `.badge` + `.badge-*` |
+| Hero | `.hero` (+ `.hero-compact`), `.hero-eyebrow .hero-title .hero-sub .hero-actions` |
+| Avatars | `.avatar` (+ `-sm/-lg`), `.avatar-group` |
+| OTP input | `.otp` with one `maxlength=1` input per digit, `.otp-separator` |
+| Drawers (no JS) | `[popover]` + `.drawer-left` / `.drawer-right` |
+| Pagination | `.pagination` list, `aria-current="page"`, `aria-disabled="true"` |
+| Tables | bare `table` + `.table-striped .table-hover .table-compact .table-borderless`, `.table-wrap`, `.num` |
+| Progress | bare `<progress>` (determinate + indeterminate pulse), `.progress-thin`, `<meter>` |
+| Spinners | `.spinner` `.spinner-sm` `.spinner-lg` |
+| Dialogs | `<dialog>` + `.dialog-sm .dialog-lg`, `.dialog-titlebar .dialog-title .dialog-close .dialog-body .dialog-footer` |
+| Popovers | any `[popover]` element + `popovertarget` buttons; `.anchor` on the trigger + `.popover-anchor` on the popover + a shared `--anchor-name`; `.popover-title .popover-menu .popover-footer` |
+| Expanders | `<details>`/`<summary>` defaults, `.expander-group` |
+
+Every visual decision is a `--spry-*` custom property; color mode follows
+`data-mode="light|dark"` on `<html>` (default: auto, following the OS
+preference). See the [Spry CSS README](../Spry-CSS) for the full variable
+catalog and theme-writing guide.
+
 Seed an admin at startup (idempotent — safe on every boot):
 
 ```vala

+ 2233 - 0
css/spry.css

@@ -0,0 +1,2233 @@
+/*!
+ * Spry CSS — a light, no-JavaScript CSS framework of utilities and components.
+ *
+ * Optional companion to the Spry web application framework.
+ * Core + default theme in one file. Themes live in themes/*.css and are
+ * activated with <html data-theme="name"> (or the #spry-theme-* radio hooks
+ * used by the demo page's CSS-only switcher).
+ *
+ * Color mode: <html data-mode="light|dark"> (omit for auto).
+ */
+
+/* ==========================================================================
+   1. Default theme — light palette
+   --------------------------------------------------------------------------
+   Palette blocks use :where() so their specificity is (0,0,0): any active
+   theme scope (attribute or radio hook) overrides them, including the dark
+   blocks below. Themes can therefore be "fully light" and ignore dark mode.
+   ========================================================================== */
+
+:where(:root) {
+  color-scheme: light;
+
+  /* Palette */
+  --spry-bg: #f5f7f9;
+  --spry-bg-alt: #edf1f5;
+  --spry-bg-inset: #eef2f6;
+  --spry-surface: #ffffff;
+  --spry-surface-hover: #f6f9fc;
+  --spry-fg: #1c2733;
+  --spry-fg-muted: #5b6b7c;
+  --spry-border: #d9e1e9;
+  --spry-border-strong: #b9c6d2;
+  --spry-accent: #346894;
+  --spry-accent-hover: #2a5578;
+  --spry-accent-fg: #ffffff;
+  --spry-danger: #b3261e;
+  --spry-success: #2e7d43;
+  --spry-warning: #946200;
+  --spry-info: #0b68a3;
+  --spry-link: var(--spry-accent);
+  --spry-link-hover: var(--spry-accent-hover);
+  --spry-backdrop: rgb(15 23 36 / 0.45);
+
+  /* Soft tints are derived — themes only set the base colors above */
+  --spry-accent-soft: color-mix(in srgb, var(--spry-accent) 12%, transparent);
+  --spry-danger-soft: color-mix(in srgb, var(--spry-danger) 10%, transparent);
+  --spry-success-soft: color-mix(in srgb, var(--spry-success) 10%, transparent);
+  --spry-warning-soft: color-mix(in srgb, var(--spry-warning) 14%, transparent);
+  --spry-info-soft: color-mix(in srgb, var(--spry-info) 10%, transparent);
+  --spry-focus-ring: 0 0 0 3px color-mix(in srgb, var(--spry-accent) 25%, transparent);
+
+  /* Typography */
+  --spry-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-mono: ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
+  --spry-font-size: 1rem;
+  --spry-line-height: 1.6;
+  --spry-heading-weight: 650;
+  --spry-heading-color: var(--spry-fg);
+
+  /* Shape, depth, motion */
+  --spry-radius: 0.5rem;
+  --spry-radius-sm: 0.3rem;
+  --spry-radius-lg: 0.75rem;
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: 0 1px 2px rgb(16 24 40 / 0.05), 0 1px 3px rgb(16 24 40 / 0.08);
+  --spry-shadow-lg: 0 16px 40px -12px rgb(16 24 40 / 0.25);
+  --spry-glass: none;
+  --spry-transition: 140ms ease;
+  --spry-dialog-duration: 180ms;
+  --spry-dialog-ease: cubic-bezier(0.2, 0.9, 0.3, 1);
+  --spry-dialog-close-duration: 140ms;
+  --spry-dialog-close-ease: ease;
+  --spry-dialog-in: translateY(0.6rem) scale(0.98);
+
+  /* Spacing scale (0..5) */
+  --spry-space-0: 0;
+  --spry-space-1: 0.25rem;
+  --spry-space-2: 0.5rem;
+  --spry-space-3: 1rem;
+  --spry-space-4: 1.5rem;
+  --spry-space-5: 2.5rem;
+
+  /* Controls (inputs, selects) */
+  --spry-control-bg: var(--spry-surface);
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: var(--spry-border);
+  --spry-control-radius: var(--spry-radius-sm);
+  --spry-control-padding-x: 0.65rem;
+  --spry-control-padding-y: 0.45rem;
+
+  /* Buttons */
+  --spry-btn-bg: var(--spry-surface);
+  --spry-btn-fg: var(--spry-fg);
+  --spry-btn-border: var(--spry-border);
+  --spry-btn-bg-hover: var(--spry-bg-alt);
+  --spry-btn-bg-active: var(--spry-bg-inset);
+
+  /* Cards */
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: var(--spry-border);
+  --spry-card-padding: 1.25rem;
+  --spry-card-shadow: var(--spry-shadow);
+
+  /* Tables */
+  --spry-table-header-bg: var(--spry-bg-alt);
+  --spry-table-hover: var(--spry-bg-alt);
+
+  /* Progress */
+  --spry-progress-bg: var(--spry-bg-inset);
+  --spry-progress-fg: var(--spry-accent);
+  --spry-pulse-cap: 0.4em; /* half the default bar height: stadium ends */
+
+  /* Popovers & dialogs */
+  --spry-popover-bg: var(--spry-surface);
+  --spry-popover-border: var(--spry-border);
+  --spry-popover-shadow: var(--spry-shadow-lg);
+
+  accent-color: var(--spry-accent);
+}
+
+/* --------------------------------------------------------------------------
+   Default theme — dark palette.
+   Duplicated on purpose: once for forced dark (data-mode="dark" or the
+   #spry-mode-dark switcher hook), once for auto dark (system preference,
+   unless disabled with data-mode="light" or #spry-mode-light).
+   -------------------------------------------------------------------------- */
+
+:where(:root[data-mode="dark"], html:has(#spry-mode-dark:checked)) {
+  color-scheme: dark;
+
+  --spry-bg: #0f141b;
+  --spry-bg-alt: #151d27;
+  --spry-bg-inset: #1a2330;
+  --spry-surface: #161f2b;
+  --spry-surface-hover: #1c2734;
+  --spry-fg: #dee7f1;
+  --spry-fg-muted: #8fa1b5;
+  --spry-border: #2a3646;
+  --spry-border-strong: #3d4c61;
+  --spry-accent: #6ba3d8;
+  --spry-accent-hover: #8cbcea;
+  --spry-accent-fg: #0b141d;
+  --spry-danger: #e5736c;
+  --spry-success: #72c08c;
+  --spry-warning: #d8a850;
+  --spry-info: #6cb6e8;
+  --spry-backdrop: rgb(2 6 12 / 0.6);
+  --spry-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 1px 3px rgb(0 0 0 / 0.4);
+  --spry-shadow-lg: 0 16px 40px -12px rgb(0 0 0 / 0.6);
+}
+
+@media (prefers-color-scheme: dark) {
+  :where(:root:not([data-mode="light"]):not(:has(#spry-mode-light:checked)),
+    html:has(#spry-mode-auto:checked):not([data-mode="light"])) {
+    color-scheme: dark;
+
+    --spry-bg: #0f141b;
+    --spry-bg-alt: #151d27;
+    --spry-bg-inset: #1a2330;
+    --spry-surface: #161f2b;
+    --spry-surface-hover: #1c2734;
+    --spry-fg: #dee7f1;
+    --spry-fg-muted: #8fa1b5;
+    --spry-border: #2a3646;
+    --spry-border-strong: #3d4c61;
+    --spry-accent: #6ba3d8;
+    --spry-accent-hover: #8cbcea;
+    --spry-accent-fg: #0b141d;
+    --spry-danger: #e5736c;
+    --spry-success: #72c08c;
+    --spry-warning: #d8a850;
+    --spry-info: #6cb6e8;
+    --spry-backdrop: rgb(2 6 12 / 0.6);
+    --spry-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 1px 3px rgb(0 0 0 / 0.4);
+    --spry-shadow-lg: 0 16px 40px -12px rgb(0 0 0 / 0.6);
+  }
+}
+
+/* ==========================================================================
+   2. Reset & sane defaults
+   ========================================================================== */
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+* {
+  margin: 0;
+}
+
+html {
+  scroll-behavior: smooth;
+  scroll-padding-top: 5rem;
+  -webkit-text-size-adjust: 100%;
+  text-size-adjust: 100%;
+}
+
+body {
+  font-family: var(--spry-font);
+  font-size: var(--spry-font-size);
+  line-height: var(--spry-line-height);
+  color: var(--spry-fg);
+  background: var(--spry-bg);
+  -webkit-font-smoothing: antialiased;
+}
+
+img,
+video,
+svg {
+  display: block;
+  max-width: 100%;
+  height: auto;
+}
+
+svg:not([width]) {
+  width: 1em;
+}
+
+input,
+button,
+select,
+textarea {
+  font: inherit;
+  color: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+:where(a) {
+  color: var(--spry-link);
+  text-underline-offset: 0.2em;
+}
+
+:where(a:hover) {
+  color: var(--spry-link-hover);
+}
+
+:focus-visible {
+  outline: 2px solid var(--spry-accent);
+  outline-offset: 2px;
+}
+
+::selection {
+  background: color-mix(in srgb, var(--spry-accent) 30%, transparent);
+}
+
+::placeholder {
+  color: var(--spry-fg-muted);
+  opacity: 0.8;
+}
+
+[hidden] {
+  display: none !important;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  html {
+    scroll-behavior: auto;
+  }
+
+  *,
+  *::before,
+  *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+  }
+}
+
+/* ==========================================================================
+   3. Base typography
+   ========================================================================== */
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-family: var(--spry-font-heading);
+  font-weight: var(--spry-heading-weight);
+  color: var(--spry-heading-color);
+  line-height: 1.2;
+  text-wrap: balance;
+  margin-block: 1.6em 0.6em;
+}
+
+h1,
+.h1 {
+  font-size: clamp(1.9rem, 1.4rem + 1.8vw, 2.6rem);
+}
+
+h2,
+.h2 {
+  font-size: clamp(1.5rem, 1.25rem + 1vw, 2rem);
+}
+
+h3,
+.h3 {
+  font-size: 1.35rem;
+}
+
+h4,
+.h4 {
+  font-size: 1.15rem;
+}
+
+h5,
+.h5 {
+  font-size: 1rem;
+}
+
+h6,
+.h6 {
+  font-size: 0.85rem;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--spry-fg-muted);
+}
+
+h1:first-child,
+h2:first-child,
+h3:first-child,
+h4:first-child,
+h5:first-child,
+h6:first-child {
+  margin-block-start: 0;
+}
+
+p,
+ul,
+ol,
+dl,
+blockquote,
+figure,
+pre {
+  margin-block: 0 1em;
+}
+
+:where(li) > ul,
+:where(li) > ol {
+  margin-block: 0.25em;
+}
+
+:where(li + li) {
+  margin-block-start: 0.25em;
+}
+
+:where(dt) {
+  font-weight: 600;
+}
+
+:where(dd) {
+  margin-inline-start: 1.5em;
+}
+
+small {
+  font-size: 0.85em;
+}
+
+b,
+strong {
+  font-weight: 700;
+}
+
+hr {
+  border: 0;
+  border-top: var(--spry-border-width) solid var(--spry-border);
+  margin-block: 2rem;
+}
+
+blockquote {
+  border-inline-start: 3px solid var(--spry-accent);
+  padding: 0.25rem 1rem;
+  color: var(--spry-fg-muted);
+}
+
+blockquote > p:last-child {
+  margin-bottom: 0;
+}
+
+:where(code, kbd, samp) {
+  font-family: var(--spry-font-mono);
+  font-size: 0.9em;
+}
+
+:where(:not(pre) > code) {
+  background: var(--spry-bg-inset);
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius-sm);
+  padding: 0.1em 0.35em;
+}
+
+pre {
+  background: var(--spry-bg-inset);
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius);
+  padding: 1rem 1.25rem;
+  overflow-x: auto;
+  line-height: 1.5;
+}
+
+pre code {
+  background: none;
+  border: 0;
+  padding: 0;
+  font-size: 0.875rem;
+}
+
+kbd {
+  background: var(--spry-bg-alt);
+  border: var(--spry-border-width) solid var(--spry-border-strong);
+  border-bottom-width: 2px;
+  border-radius: var(--spry-radius-sm);
+  padding: 0.1em 0.4em;
+  font-size: 0.8em;
+}
+
+mark {
+  background: var(--spry-warning-soft);
+  color: inherit;
+  border-radius: 0.2em;
+  padding: 0 0.15em;
+}
+
+abbr[title] {
+  text-decoration: underline dotted;
+  cursor: help;
+}
+
+:where(figure) figcaption {
+  color: var(--spry-fg-muted);
+  font-size: 0.875em;
+  margin-top: 0.5rem;
+}
+
+/* ==========================================================================
+   4. Utilities — layout
+   ========================================================================== */
+
+.container {
+  max-width: 72rem;
+  margin-inline: auto;
+  padding-inline: clamp(1rem, 3vw, 2rem);
+}
+
+.container-sm {
+  max-width: 52rem;
+}
+
+.container-lg {
+  max-width: 96rem;
+}
+
+.stack {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spry-space-3);
+}
+
+.cluster {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--spry-space-2);
+}
+
+.center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.with-sidebar {
+  display: grid;
+  grid-template-columns: minmax(11rem, 16rem) minmax(0, 1fr);
+  gap: var(--spry-space-5);
+}
+
+@media (max-width: 55rem) {
+  .with-sidebar {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+
+.block { display: block; }
+.inline-block { display: inline-block; }
+.inline { display: inline; }
+.hidden { display: none; }
+
+.visually-hidden {
+  position: absolute !important;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+  border: 0;
+}
+
+.relative { position: relative; }
+.absolute { position: absolute; }
+.fixed { position: fixed; }
+.sticky { position: sticky; }
+
+.top-0 { top: 0; }
+.right-0 { right: 0; }
+.bottom-0 { bottom: 0; }
+.left-0 { left: 0; }
+.inset-0 { inset: 0; }
+
+.overflow-auto { overflow: auto; }
+.overflow-hidden { overflow: hidden; }
+
+.w-25 { width: 25%; }
+.w-50 { width: 50%; }
+.w-75 { width: 75%; }
+.w-100 { width: 100%; }
+.w-auto { width: auto; }
+.h-100 { height: 100%; }
+.mw-100 { max-width: 100%; }
+.min-w-0 { min-width: 0; }
+
+/* ==========================================================================
+   5. Utilities — spacing
+   ========================================================================== */
+
+.m-0 { margin: var(--spry-space-0); }
+.m-1 { margin: var(--spry-space-1); }
+.m-2 { margin: var(--spry-space-2); }
+.m-3 { margin: var(--spry-space-3); }
+.m-4 { margin: var(--spry-space-4); }
+.m-5 { margin: var(--spry-space-5); }
+.mt-0 { margin-top: var(--spry-space-0); }
+.mt-1 { margin-top: var(--spry-space-1); }
+.mt-2 { margin-top: var(--spry-space-2); }
+.mt-3 { margin-top: var(--spry-space-3); }
+.mt-4 { margin-top: var(--spry-space-4); }
+.mt-5 { margin-top: var(--spry-space-5); }
+.mr-0 { margin-right: var(--spry-space-0); }
+.mr-1 { margin-right: var(--spry-space-1); }
+.mr-2 { margin-right: var(--spry-space-2); }
+.mr-3 { margin-right: var(--spry-space-3); }
+.mr-4 { margin-right: var(--spry-space-4); }
+.mr-5 { margin-right: var(--spry-space-5); }
+.mb-0 { margin-bottom: var(--spry-space-0); }
+.mb-1 { margin-bottom: var(--spry-space-1); }
+.mb-2 { margin-bottom: var(--spry-space-2); }
+.mb-3 { margin-bottom: var(--spry-space-3); }
+.mb-4 { margin-bottom: var(--spry-space-4); }
+.mb-5 { margin-bottom: var(--spry-space-5); }
+.ml-0 { margin-left: var(--spry-space-0); }
+.ml-1 { margin-left: var(--spry-space-1); }
+.ml-2 { margin-left: var(--spry-space-2); }
+.ml-3 { margin-left: var(--spry-space-3); }
+.ml-4 { margin-left: var(--spry-space-4); }
+.ml-5 { margin-left: var(--spry-space-5); }
+.mx-0 { margin-inline: var(--spry-space-0); }
+.mx-1 { margin-inline: var(--spry-space-1); }
+.mx-2 { margin-inline: var(--spry-space-2); }
+.mx-3 { margin-inline: var(--spry-space-3); }
+.mx-4 { margin-inline: var(--spry-space-4); }
+.mx-5 { margin-inline: var(--spry-space-5); }
+.my-0 { margin-block: var(--spry-space-0); }
+.my-1 { margin-block: var(--spry-space-1); }
+.my-2 { margin-block: var(--spry-space-2); }
+.my-3 { margin-block: var(--spry-space-3); }
+.my-4 { margin-block: var(--spry-space-4); }
+.my-5 { margin-block: var(--spry-space-5); }
+.mx-auto { margin-inline: auto; }
+.ml-auto { margin-left: auto; }
+.mt-auto { margin-top: auto; }
+
+.p-0 { padding: var(--spry-space-0); }
+.p-1 { padding: var(--spry-space-1); }
+.p-2 { padding: var(--spry-space-2); }
+.p-3 { padding: var(--spry-space-3); }
+.p-4 { padding: var(--spry-space-4); }
+.p-5 { padding: var(--spry-space-5); }
+.pt-0 { padding-top: var(--spry-space-0); }
+.pt-1 { padding-top: var(--spry-space-1); }
+.pt-2 { padding-top: var(--spry-space-2); }
+.pt-3 { padding-top: var(--spry-space-3); }
+.pt-4 { padding-top: var(--spry-space-4); }
+.pt-5 { padding-top: var(--spry-space-5); }
+.pr-0 { padding-right: var(--spry-space-0); }
+.pr-1 { padding-right: var(--spry-space-1); }
+.pr-2 { padding-right: var(--spry-space-2); }
+.pr-3 { padding-right: var(--spry-space-3); }
+.pr-4 { padding-right: var(--spry-space-4); }
+.pr-5 { padding-right: var(--spry-space-5); }
+.pb-0 { padding-bottom: var(--spry-space-0); }
+.pb-1 { padding-bottom: var(--spry-space-1); }
+.pb-2 { padding-bottom: var(--spry-space-2); }
+.pb-3 { padding-bottom: var(--spry-space-3); }
+.pb-4 { padding-bottom: var(--spry-space-4); }
+.pb-5 { padding-bottom: var(--spry-space-5); }
+.pl-0 { padding-left: var(--spry-space-0); }
+.pl-1 { padding-left: var(--spry-space-1); }
+.pl-2 { padding-left: var(--spry-space-2); }
+.pl-3 { padding-left: var(--spry-space-3); }
+.pl-4 { padding-left: var(--spry-space-4); }
+.pl-5 { padding-left: var(--spry-space-5); }
+.px-0 { padding-inline: var(--spry-space-0); }
+.px-1 { padding-inline: var(--spry-space-1); }
+.px-2 { padding-inline: var(--spry-space-2); }
+.px-3 { padding-inline: var(--spry-space-3); }
+.px-4 { padding-inline: var(--spry-space-4); }
+.px-5 { padding-inline: var(--spry-space-5); }
+.py-0 { padding-block: var(--spry-space-0); }
+.py-1 { padding-block: var(--spry-space-1); }
+.py-2 { padding-block: var(--spry-space-2); }
+.py-3 { padding-block: var(--spry-space-3); }
+.py-4 { padding-block: var(--spry-space-4); }
+.py-5 { padding-block: var(--spry-space-5); }
+
+/* ==========================================================================
+   6. Utilities — flex
+   ========================================================================== */
+
+.flex { display: flex; }
+.inline-flex { display: inline-flex; }
+.flex-row { flex-direction: row; }
+.flex-col { flex-direction: column; }
+.flex-col-reverse { flex-direction: column-reverse; }
+.flex-wrap { flex-wrap: wrap; }
+.flex-nowrap { flex-wrap: nowrap; }
+.flex-1 { flex: 1 1 0%; }
+.flex-auto { flex: 1 1 auto; }
+.flex-none { flex: none; }
+.items-start { align-items: flex-start; }
+.items-center { align-items: center; }
+.items-end { align-items: flex-end; }
+.items-stretch { align-items: stretch; }
+.items-baseline { align-items: baseline; }
+.justify-start { justify-content: flex-start; }
+.justify-center { justify-content: center; }
+.justify-end { justify-content: flex-end; }
+.justify-between { justify-content: space-between; }
+.justify-around { justify-content: space-around; }
+.justify-evenly { justify-content: space-evenly; }
+.self-start { align-self: flex-start; }
+.self-center { align-self: center; }
+.self-end { align-self: flex-end; }
+.order-first { order: -1; }
+.order-last { order: 999; }
+
+/* ==========================================================================
+   7. Utilities — grid
+   ========================================================================== */
+
+.grid {
+  display: grid;
+  gap: var(--spry-space-3);
+  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--spry-grid-min, 14rem)), 1fr));
+}
+
+.grid-sm { --spry-grid-min: 9rem; }
+.grid-lg { --spry-grid-min: 20rem; }
+
+.grid-cols-1 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(1, minmax(0, 1fr)); }
+.grid-cols-2 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.grid-cols-3 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.grid-cols-4 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.grid-cols-5 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.grid-cols-6 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(6, minmax(0, 1fr)); }
+.grid-cols-7 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(7, minmax(0, 1fr)); }
+.grid-cols-8 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(8, minmax(0, 1fr)); }
+.grid-cols-9 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(9, minmax(0, 1fr)); }
+.grid-cols-10 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(10, minmax(0, 1fr)); }
+.grid-cols-11 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(11, minmax(0, 1fr)); }
+.grid-cols-12 { display: grid; gap: var(--spry-space-3); grid-template-columns: repeat(12, minmax(0, 1fr)); }
+
+@media (max-width: 45rem) {
+  .grid-cols-3,
+  .grid-cols-4,
+  .grid-cols-5,
+  .grid-cols-6,
+  .grid-cols-7,
+  .grid-cols-8,
+  .grid-cols-9,
+  .grid-cols-10,
+  .grid-cols-11,
+  .grid-cols-12 {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+.col-span-1 { grid-column: span 1; }
+.col-span-2 { grid-column: span 2; }
+.col-span-3 { grid-column: span 3; }
+.col-span-4 { grid-column: span 4; }
+.col-span-5 { grid-column: span 5; }
+.col-span-6 { grid-column: span 6; }
+.col-span-7 { grid-column: span 7; }
+.col-span-8 { grid-column: span 8; }
+.col-span-9 { grid-column: span 9; }
+.col-span-10 { grid-column: span 10; }
+.col-span-11 { grid-column: span 11; }
+.col-span-12 { grid-column: span 12; }
+.col-span-full { grid-column: 1 / -1; }
+
+.gap-0 { gap: var(--spry-space-0); }
+.gap-1 { gap: var(--spry-space-1); }
+.gap-2 { gap: var(--spry-space-2); }
+.gap-3 { gap: var(--spry-space-3); }
+.gap-4 { gap: var(--spry-space-4); }
+.gap-5 { gap: var(--spry-space-5); }
+.gap-x-0 { column-gap: var(--spry-space-0); }
+.gap-x-1 { column-gap: var(--spry-space-1); }
+.gap-x-2 { column-gap: var(--spry-space-2); }
+.gap-x-3 { column-gap: var(--spry-space-3); }
+.gap-x-4 { column-gap: var(--spry-space-4); }
+.gap-x-5 { column-gap: var(--spry-space-5); }
+.gap-y-0 { row-gap: var(--spry-space-0); }
+.gap-y-1 { row-gap: var(--spry-space-1); }
+.gap-y-2 { row-gap: var(--spry-space-2); }
+.gap-y-3 { row-gap: var(--spry-space-3); }
+.gap-y-4 { row-gap: var(--spry-space-4); }
+.gap-y-5 { row-gap: var(--spry-space-5); }
+
+/* ==========================================================================
+   8. Utilities — text & color
+   ========================================================================== */
+
+.text-xs { font-size: 0.75rem; }
+.text-sm { font-size: 0.875rem; }
+.text-base { font-size: 1rem; }
+.text-lg { font-size: 1.125rem; }
+.text-xl { font-size: 1.25rem; }
+.text-2xl { font-size: 1.5rem; }
+
+.text-left { text-align: left; }
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+.text-nowrap { white-space: nowrap; }
+
+.text-muted { color: var(--spry-fg-muted); }
+.text-accent { color: var(--spry-accent); }
+.text-danger { color: var(--spry-danger); }
+.text-success { color: var(--spry-success); }
+.text-warning { color: var(--spry-warning); }
+.text-info { color: var(--spry-info); }
+
+.fw-light { font-weight: 300; }
+.fw-normal { font-weight: 400; }
+.fw-medium { font-weight: 500; }
+.fw-semibold { font-weight: 600; }
+.fw-bold { font-weight: 700; }
+.fs-italic { font-style: italic; }
+.fs-normal { font-style: normal; }
+.uppercase { text-transform: uppercase; }
+.lowercase { text-transform: lowercase; }
+.capitalize { text-transform: capitalize; }
+
+.lead {
+  font-size: 1.15em;
+  line-height: 1.55;
+  color: var(--spry-fg-muted);
+}
+
+.small { font-size: 0.85em; }
+.mono { font-family: var(--spry-font-mono); font-size: 0.92em; }
+.num { font-variant-numeric: tabular-nums; text-align: right; }
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.clamp-2,
+.clamp-3 {
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.clamp-2 { -webkit-line-clamp: 2; line-clamp: 2; }
+.clamp-3 { -webkit-line-clamp: 3; line-clamp: 3; }
+
+.measure { max-width: 65ch; }
+.measure-sm { max-width: 45ch; }
+
+.bg-surface { background: var(--spry-surface); }
+.bg-alt { background: var(--spry-bg-alt); }
+.bg-inset { background: var(--spry-bg-inset); }
+.bg-accent { background: var(--spry-accent); color: var(--spry-accent-fg); }
+
+.border { border: var(--spry-border-width) solid var(--spry-border); }
+.border-0 { border: 0; }
+.rounded { border-radius: var(--spry-radius); }
+.rounded-sm { border-radius: var(--spry-radius-sm); }
+.rounded-lg { border-radius: var(--spry-radius-lg); }
+.rounded-pill { border-radius: var(--spry-radius-pill); }
+.shadow { box-shadow: var(--spry-shadow); }
+.shadow-lg { box-shadow: var(--spry-shadow-lg); }
+.shadow-none { box-shadow: none; }
+
+/* ==========================================================================
+   9. Components — topbar & navigation
+   ========================================================================== */
+
+.topbar {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: var(--spry-space-2);
+  padding: 0.65rem clamp(1rem, 3vw, 2rem);
+  background: var(--spry-surface);
+  border-bottom: var(--spry-border-width) solid var(--spry-border);
+  backdrop-filter: var(--spry-glass);
+}
+
+.topbar-brand {
+  font-weight: 700;
+  font-size: 1.05rem;
+  color: var(--spry-fg);
+  text-decoration: none;
+  letter-spacing: 0.01em;
+}
+
+.topbar-brand:hover {
+  color: var(--spry-accent);
+}
+
+.nav {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--spry-space-1);
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.nav a {
+  display: inline-block;
+  padding: 0.35rem 0.75rem;
+  border-radius: var(--spry-radius-sm);
+  color: var(--spry-fg);
+  text-decoration: none;
+  font-weight: 500;
+  transition: background-color var(--spry-transition), color var(--spry-transition);
+}
+
+.nav a:hover {
+  background: var(--spry-bg-alt);
+  color: var(--spry-accent);
+}
+
+.nav a.active {
+  background: var(--spry-accent-soft);
+  color: var(--spry-accent);
+  font-weight: 600;
+}
+
+/* Menus with CSS-only submenus */
+.menu,
+.menu ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.menu {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.menu a {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.5rem;
+  padding: 0.4rem 0.7rem;
+  border-radius: var(--spry-radius-sm);
+  color: var(--spry-fg);
+  text-decoration: none;
+}
+
+.menu a:hover,
+.menu a.active {
+  background: var(--spry-bg-alt);
+  color: var(--spry-accent);
+}
+
+.has-submenu {
+  position: relative;
+}
+
+.has-submenu > a::after {
+  content: "▾";
+  font-size: 0.75em;
+  color: var(--spry-fg-muted);
+}
+
+.submenu {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  z-index: 30;
+  min-width: 11rem;
+  padding: 0.35rem;
+  background: var(--spry-popover-bg);
+  border: var(--spry-border-width) solid var(--spry-popover-border);
+  border-radius: var(--spry-radius);
+  box-shadow: var(--spry-popover-shadow);
+  backdrop-filter: var(--spry-glass);
+  opacity: 0;
+  visibility: hidden;
+  transform: translateY(0.3rem);
+  transition: opacity var(--spry-transition), transform var(--spry-transition), visibility var(--spry-transition);
+}
+
+.submenu a,
+.menu a {
+  width: 100%;
+}
+
+.submenu a {
+  display: block;
+  padding: 0.4rem 0.75rem;
+  color: var(--spry-fg);
+  text-decoration: none;
+  border-radius: var(--spry-radius-sm);
+}
+
+.submenu a:hover {
+  background: var(--spry-accent-soft);
+  color: var(--spry-accent);
+}
+
+.has-submenu:hover > .submenu,
+.has-submenu:focus-within > .submenu {
+  opacity: 1;
+  visibility: visible;
+  transform: none;
+}
+
+.menu .has-submenu .submenu {
+  position: static;
+  opacity: 1;
+  visibility: visible;
+  transform: none;
+  border: 0;
+  box-shadow: none;
+  padding: 0 0 0 1rem;
+  margin: 2px 0;
+}
+
+.menu .has-submenu > a::after {
+  content: "▸";
+}
+
+.menu .has-submenu .submenu a {
+  font-size: 0.95em;
+}
+
+.breadcrumb {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.4rem;
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  font-size: 0.9rem;
+}
+
+.breadcrumb li + li::before {
+  content: "/";
+  color: var(--spry-fg-muted);
+  margin-right: 0.4rem;
+}
+
+.breadcrumb a {
+  color: var(--spry-link);
+  text-decoration: none;
+}
+
+.breadcrumb a:hover {
+  text-decoration: underline;
+}
+
+.breadcrumb [aria-current] {
+  color: var(--spry-fg-muted);
+}
+
+/* ==========================================================================
+   10. Components — tabs (CSS-only, radio based)
+   --------------------------------------------------------------------------
+   Markup: all radio inputs first (direct children), then .tab-list with one
+   label per input (same order), then .tab-panels with one panel per input.
+   ========================================================================== */
+
+.tabs > input[type="radio"] {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+}
+
+.tab-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.25rem;
+  border-bottom: var(--spry-border-width) solid var(--spry-border);
+}
+
+.tab-list label {
+  padding: 0.5rem 0.9rem;
+  border-radius: var(--spry-radius-sm) var(--spry-radius-sm) 0 0;
+  cursor: pointer;
+  font-weight: 500;
+  color: var(--spry-fg-muted);
+  transition: background-color var(--spry-transition), color var(--spry-transition), box-shadow var(--spry-transition);
+}
+
+.tab-list label:hover {
+  color: var(--spry-accent);
+  background: var(--spry-bg-alt);
+}
+
+.tab-panels > * {
+  display: none;
+  padding-top: var(--spry-space-3);
+}
+
+.tab-panels > :first-child {
+  display: block;
+}
+
+.tabs > input:nth-of-type(1):checked ~ .tab-list label:nth-of-type(1),
+.tabs > input:nth-of-type(2):checked ~ .tab-list label:nth-of-type(2),
+.tabs > input:nth-of-type(3):checked ~ .tab-list label:nth-of-type(3),
+.tabs > input:nth-of-type(4):checked ~ .tab-list label:nth-of-type(4),
+.tabs > input:nth-of-type(5):checked ~ .tab-list label:nth-of-type(5),
+.tabs > input:nth-of-type(6):checked ~ .tab-list label:nth-of-type(6) {
+  color: var(--spry-accent);
+  font-weight: 600;
+  box-shadow: inset 0 -3px 0 var(--spry-accent);
+}
+
+.tabs > input:nth-of-type(1):checked ~ .tab-panels > :nth-child(1),
+.tabs > input:nth-of-type(2):checked ~ .tab-panels > :nth-child(2),
+.tabs > input:nth-of-type(3):checked ~ .tab-panels > :nth-child(3),
+.tabs > input:nth-of-type(4):checked ~ .tab-panels > :nth-child(4),
+.tabs > input:nth-of-type(5):checked ~ .tab-panels > :nth-child(5),
+.tabs > input:nth-of-type(6):checked ~ .tab-panels > :nth-child(6) {
+  display: block;
+}
+
+.tabs > input:focus-visible ~ .tab-list label {
+  outline: 2px solid var(--spry-accent);
+  outline-offset: -2px;
+}
+
+/* ==========================================================================
+   11. Components — buttons
+   ========================================================================== */
+
+/* Bare buttons and submit inputs get the same base look at zero specificity
+   (:where), so .btn-* variant classes always win. */
+.btn,
+:where(button, input[type="submit"], input[type="reset"], input[type="button"]) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.45em;
+  padding: var(--spry-control-padding-y) calc(var(--spry-control-padding-x) + 0.15rem);
+  border: var(--spry-border-width) solid var(--spry-btn-border);
+  border-radius: var(--spry-control-radius);
+  background: var(--spry-btn-bg);
+  color: var(--spry-btn-fg);
+  font-weight: 550;
+  font-size: inherit;
+  line-height: 1.2;
+  text-decoration: none;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: background-color var(--spry-transition), border-color var(--spry-transition),
+    color var(--spry-transition), box-shadow var(--spry-transition);
+}
+
+.btn:hover,
+:where(button, input[type="submit"], input[type="reset"], input[type="button"]):hover {
+  background: var(--spry-btn-bg-hover);
+  border-color: var(--spry-border-strong);
+}
+
+.btn:active,
+:where(button, input[type="submit"], input[type="reset"], input[type="button"]):active {
+  background: var(--spry-btn-bg-active);
+}
+
+.btn:focus-visible,
+:where(button, input[type="submit"], input[type="reset"], input[type="button"]):focus-visible {
+  outline: 2px solid var(--spry-accent);
+  outline-offset: 2px;
+}
+
+.btn:disabled,
+.btn[aria-disabled="true"],
+:where(button, input[type="submit"], input[type="reset"], input[type="button"]):disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+  pointer-events: none;
+}
+
+.btn-primary {
+  background: var(--spry-accent);
+  border-color: var(--spry-accent);
+  color: var(--spry-accent-fg);
+}
+
+.btn-primary:hover {
+  background: var(--spry-accent-hover);
+  border-color: var(--spry-accent-hover);
+}
+
+.btn-danger {
+  background: var(--spry-danger);
+  border-color: var(--spry-danger);
+  color: #fff;
+}
+
+.btn-danger:hover {
+  background: color-mix(in srgb, var(--spry-danger) 85%, black);
+  border-color: color-mix(in srgb, var(--spry-danger) 85%, black);
+}
+
+.btn-outline {
+  background: transparent;
+  border-color: var(--spry-accent);
+  color: var(--spry-accent);
+}
+
+.btn-outline:hover {
+  background: var(--spry-accent-soft);
+  border-color: var(--spry-accent-hover);
+  color: var(--spry-accent-hover);
+}
+
+.btn-ghost {
+  background: transparent;
+  border-color: transparent;
+  color: var(--spry-fg);
+}
+
+.btn-ghost:hover {
+  background: var(--spry-bg-alt);
+  border-color: transparent;
+}
+
+.btn-link {
+  background: none;
+  border: 0;
+  color: var(--spry-link);
+  text-decoration: underline;
+  text-underline-offset: 0.2em;
+  padding-inline: 0.25rem;
+}
+
+.btn-link:hover {
+  color: var(--spry-link-hover);
+}
+
+/* Size modifiers use :is() for (0,2,0) specificity so theme base restyles
+   (scoped, (0,1,0)-ish) cannot clobber the small/large padding. */
+:is(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]).btn-sm {
+  padding: 0.25rem 0.6rem;
+  font-size: 0.85em;
+  border-radius: var(--spry-radius-sm);
+}
+
+:is(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]).btn-lg {
+  padding: 0.6rem 1.2rem;
+  font-size: 1.1em;
+  border-radius: var(--spry-radius);
+}
+
+.btn-block {
+  display: flex;
+  width: 100%;
+}
+
+.btn-group {
+  display: inline-flex;
+}
+
+.btn-group > .btn,
+.btn-group > button {
+  border-radius: 0;
+  margin-left: calc(-1 * var(--spry-border-width));
+}
+
+.btn-group > .btn:first-child,
+.btn-group > button:first-child {
+  margin-left: 0;
+  border-start-start-radius: var(--spry-control-radius);
+  border-end-start-radius: var(--spry-control-radius);
+}
+
+.btn-group > .btn:last-child,
+.btn-group > button:last-child {
+  border-start-end-radius: var(--spry-control-radius);
+  border-end-end-radius: var(--spry-control-radius);
+}
+
+/* ==========================================================================
+   12. Components — forms
+   ========================================================================== */
+
+input[type="text"],
+input[type="email"],
+input[type="password"],
+input[type="number"],
+input[type="search"],
+input[type="tel"],
+input[type="url"],
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"],
+input[type="month"],
+input[type="week"],
+input[type="color"],
+select,
+textarea,
+.input {
+  width: 100%;
+  padding: var(--spry-control-padding-y) var(--spry-control-padding-x);
+  background: var(--spry-control-bg);
+  color: var(--spry-control-fg);
+  border: var(--spry-border-width) solid var(--spry-control-border);
+  border-radius: var(--spry-control-radius);
+  box-shadow: var(--spry-glass);
+  transition: border-color var(--spry-transition), box-shadow var(--spry-transition);
+}
+
+textarea {
+  min-height: 6lh;
+  resize: vertical;
+}
+
+select:not([size]):not([multiple]) {
+  width: auto;
+}
+
+select[multiple] {
+  height: auto;
+}
+
+input[type="color"] {
+  width: 3.5rem;
+  padding: 0.2rem;
+  height: 2.4rem;
+  cursor: pointer;
+}
+
+input[type="search"] {
+  border-radius: var(--spry-radius-pill);
+}
+
+input:hover:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
+select:hover,
+textarea:hover {
+  border-color: var(--spry-border-strong);
+}
+
+input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]),
+select:focus,
+textarea:focus,
+.input:focus {
+  outline: none;
+  border-color: var(--spry-accent);
+  box-shadow: var(--spry-focus-ring);
+}
+
+input:disabled,
+select:disabled,
+textarea:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+  background: var(--spry-bg-inset);
+}
+
+input[readonly],
+textarea[readonly] {
+  background: var(--spry-bg-inset);
+}
+
+.is-invalid {
+  border-color: var(--spry-danger) !important;
+}
+
+.is-invalid:focus {
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--spry-danger) 25%, transparent) !important;
+}
+
+.is-valid {
+  border-color: var(--spry-success) !important;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.field .label,
+.label {
+  font-weight: 600;
+  font-size: 0.92em;
+}
+
+.hint {
+  font-size: 0.85em;
+  color: var(--spry-fg-muted);
+}
+
+.field .is-invalid ~ .hint {
+  color: var(--spry-danger);
+}
+
+fieldset {
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius);
+  padding: 1rem 1.25rem;
+  margin: 0 0 1rem;
+  min-width: 0;
+}
+
+legend {
+  font-weight: 600;
+  padding-inline: 0.4rem;
+}
+
+/* Checkboxes & radios */
+input[type="checkbox"],
+input[type="radio"] {
+  width: 1.05em;
+  height: 1.05em;
+  flex: none;
+  accent-color: var(--spry-accent);
+  cursor: pointer;
+}
+
+.choice {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  cursor: pointer;
+}
+
+.choices {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 0.4rem;
+}
+
+.choices-inline {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.4rem 1.2rem;
+}
+
+/* Switch (toggle) */
+.switch {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.6rem;
+  cursor: pointer;
+}
+
+.switch input {
+  position: absolute;
+  opacity: 0;
+  width: 1px;
+  height: 1px;
+}
+
+.switch .track {
+  position: relative;
+  flex: none;
+  width: 2.4rem;
+  height: 1.35rem;
+  border-radius: var(--spry-radius-pill);
+  background: var(--spry-border-strong);
+  transition: background-color var(--spry-transition);
+}
+
+.switch .track::after {
+  content: "";
+  position: absolute;
+  top: 0.175rem;
+  left: 0.175rem;
+  width: 1rem;
+  height: 1rem;
+  border-radius: 50%;
+  background: var(--spry-surface);
+  box-shadow: var(--spry-shadow);
+  transition: translate var(--spry-transition);
+}
+
+.switch input:checked + .track {
+  background: var(--spry-accent);
+}
+
+.switch input:checked + .track::after {
+  translate: 1.05rem 0;
+}
+
+.switch input:focus-visible + .track {
+  outline: 2px solid var(--spry-accent);
+  outline-offset: 2px;
+}
+
+.switch input:disabled ~ * {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+
+/* ==========================================================================
+   13. Components — cards
+   ========================================================================== */
+
+.card {
+  background: var(--spry-card-bg);
+  border: var(--spry-border-width) solid var(--spry-card-border);
+  border-radius: var(--spry-radius);
+  box-shadow: var(--spry-card-shadow);
+  backdrop-filter: var(--spry-glass);
+}
+
+.card-flat {
+  box-shadow: none;
+}
+
+.card-header,
+.card-body,
+.card-footer {
+  padding: var(--spry-card-padding);
+}
+
+.card-header {
+  border-bottom: var(--spry-border-width) solid var(--spry-card-border);
+  background: var(--spry-bg-alt);
+  border-radius: var(--spry-radius) var(--spry-radius) 0 0;
+}
+
+.card-footer {
+  border-top: var(--spry-border-width) solid var(--spry-card-border);
+  background: var(--spry-bg-alt);
+  border-radius: 0 0 var(--spry-radius) var(--spry-radius);
+}
+
+.card-header + .card-body,
+.card-body + .card-footer,
+.card-header:first-child:last-child {
+  margin: 0;
+}
+
+.card-header + .card-body {
+  border-radius: 0;
+}
+
+.card-body + .card-footer {
+  border-radius: 0 0 var(--spry-radius) var(--spry-radius);
+}
+
+.card-title {
+  font-size: 1.1rem;
+  font-weight: 650;
+  margin: 0;
+  font-family: var(--spry-font-heading);
+}
+
+.card-subtitle {
+  font-size: 0.875em;
+  color: var(--spry-fg-muted);
+  margin: 0.15rem 0 0;
+}
+
+/* ==========================================================================
+   14. Components — alerts & badges
+   ========================================================================== */
+
+.alert {
+  display: flex;
+  align-items: baseline;
+  gap: 0.6rem;
+  padding: 0.8rem 1rem;
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius);
+  background: var(--spry-surface);
+}
+
+.alert p {
+  margin: 0;
+}
+
+.alert-danger { background: var(--spry-danger-soft); border-color: color-mix(in srgb, var(--spry-danger) 35%, transparent); color: var(--spry-danger); }
+.alert-success { background: var(--spry-success-soft); border-color: color-mix(in srgb, var(--spry-success) 35%, transparent); color: var(--spry-success); }
+.alert-warning { background: var(--spry-warning-soft); border-color: color-mix(in srgb, var(--spry-warning) 35%, transparent); color: var(--spry-warning); }
+.alert-info { background: var(--spry-info-soft); border-color: color-mix(in srgb, var(--spry-info) 35%, transparent); color: var(--spry-info); }
+
+.badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.3em;
+  padding: 0.1em 0.65em;
+  border-radius: var(--spry-radius-pill);
+  font-size: 0.78em;
+  font-weight: 600;
+  line-height: 1.6;
+  background: var(--spry-accent-soft);
+  color: var(--spry-accent);
+  white-space: nowrap;
+}
+
+.badge-danger { background: var(--spry-danger-soft); color: var(--spry-danger); }
+.badge-success { background: var(--spry-success-soft); color: var(--spry-success); }
+.badge-warning { background: var(--spry-warning-soft); color: var(--spry-warning); }
+.badge-info { background: var(--spry-info-soft); color: var(--spry-info); }
+.badge-muted { background: var(--spry-bg-alt); color: var(--spry-fg-muted); border: var(--spry-border-width) solid var(--spry-border); }
+
+/* ==========================================================================
+   15. Components — tables
+   ========================================================================== */
+
+.table-wrap {
+  overflow-x: auto;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 0.95em;
+  background: var(--spry-surface);
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius);
+}
+
+caption {
+  caption-side: bottom;
+  text-align: left;
+  color: var(--spry-fg-muted);
+  font-size: 0.85em;
+  padding-top: 0.5rem;
+}
+
+th,
+td {
+  text-align: left;
+  padding: 0.5rem 0.75rem;
+  border-bottom: var(--spry-border-width) solid var(--spry-border);
+  vertical-align: top;
+}
+
+thead th {
+  background: var(--spry-table-header-bg);
+  font-size: 0.75em;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--spry-fg-muted);
+  border-bottom-width: 2px;
+}
+
+tbody tr:last-child td,
+tbody tr:last-child th {
+  border-bottom: 0;
+}
+
+.table-striped tbody tr:nth-child(even) {
+  background: color-mix(in srgb, var(--spry-bg-alt) 55%, transparent);
+}
+
+.table-hover tbody tr:hover {
+  background: var(--spry-table-hover);
+}
+
+.table-compact th,
+.table-compact td {
+  padding: 0.3rem 0.55rem;
+}
+
+.table-borderless,
+.table-borderless th,
+.table-borderless td {
+  border: 0;
+  background: transparent;
+}
+
+/* ==========================================================================
+   16. Components — progress, meter, spinners
+   ========================================================================== */
+
+progress {
+  appearance: none;
+  -webkit-appearance: none;
+  border: 0;
+  width: 100%;
+  height: 0.8rem;
+  border-radius: var(--spry-radius-pill);
+  overflow: hidden;
+  background: var(--spry-progress-bg);
+}
+
+progress::-webkit-progress-bar {
+  background: var(--spry-progress-bg);
+  border-radius: var(--spry-radius-pill);
+}
+
+progress::-webkit-progress-value {
+  background: var(--spry-progress-fg);
+  border-radius: var(--spry-radius-pill);
+  transition: width 200ms ease;
+}
+
+progress::-moz-progress-bar {
+  background: var(--spry-progress-fg);
+  border-radius: var(--spry-radius-pill);
+}
+/* Indeterminate: a GTK-style "pulse" block bouncing back and forth.
+   Chromium ignores author styles on the indeterminate value pseudo
+   entirely, so the slug lives on the ELEMENT's background (both engines
+   animate element backgrounds reliably) and the track pseudo is made
+   transparent so it doesn't paint over it. */
+/* html-prefixed so themed plain `progress { background }` rules (equal or
+   lower specificity, loaded later) can never override the indeterminate
+   state. The slug is a true stadium — rectangle + two half-disc caps in
+   three equally-sized layers (so percentage positions move them in
+   lockstep); a single radial would be a lens, which reads pointy. */
+html progress:indeterminate {
+  background:
+    radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at var(--spry-pulse-cap) 50%, var(--spry-progress-fg) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+    radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at calc(100% - var(--spry-pulse-cap)) 50%, var(--spry-progress-fg) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+    linear-gradient(90deg, transparent 0 var(--spry-pulse-cap), var(--spry-progress-fg) var(--spry-pulse-cap) calc(100% - var(--spry-pulse-cap)), transparent calc(100% - var(--spry-pulse-cap))) 0% 50% / 25% 100% no-repeat,
+    var(--spry-progress-bg);
+  animation: spry-pulse 1.4s linear infinite alternate;
+}
+
+html progress:indeterminate::-webkit-progress-bar,
+progress:indeterminate::-webkit-progress-bar {
+  background: transparent !important;
+}
+
+html progress:indeterminate::-moz-progress-bar,
+progress:indeterminate::-moz-progress-bar {
+  background: transparent !important;
+}
+
+.progress-thin {
+  height: 0.4rem;
+}
+
+meter {
+  width: 100%;
+  height: 1.1rem;
+}
+
+.spinner {
+  display: inline-block;
+  width: 1.5rem;
+  height: 1.5rem;
+  border-radius: 50%;
+  border: 0.18em solid var(--spry-border);
+  border-top-color: var(--spry-accent);
+  animation: spry-spin 0.9s linear infinite;
+  vertical-align: -0.25em;
+}
+
+.spinner-sm { width: 1rem; height: 1rem; border-width: 0.14em; }
+.spinner-lg { width: 2.5rem; height: 2.5rem; border-width: 0.12em; }
+
+@keyframes spry-spin {
+  to { transform: rotate(1turn); }
+}
+
+@keyframes spry-stripes {
+  to { background-position: 1.7rem 0; }
+}
+
+@keyframes spry-pulse {
+  /* three slug layers + the track — identical percentage positions keep
+     the stadium parts glued together */
+  from { background-position: 0% 50%, 0% 50%, 0% 50%, 0 0; }
+  to { background-position: 100% 50%, 100% 50%, 100% 50%, 0 0; }
+}
+
+/* ==========================================================================
+   17. Components — dialogs
+   --------------------------------------------------------------------------
+   Native <dialog> (open non-modally with the open attribute, or modally with
+   dialog.showModal()). Dialog-like windows without any JavaScript: use
+   <div popover> with a popovertarget button (see the popover section).
+   ========================================================================== */
+
+dialog {
+  width: min(92vw, 36rem);
+  max-height: min(85vh, 100%);
+  margin: auto; /* re-assert UA centering — the reset above zeroes margins */
+  padding: 0;
+  border: var(--spry-border-width) solid var(--spry-popover-border);
+  border-radius: var(--spry-radius-lg);
+  background: var(--spry-popover-bg);
+  color: var(--spry-fg);
+  box-shadow: var(--spry-popover-shadow);
+  backdrop-filter: var(--spry-glass);
+}
+
+dialog.sm,
+.dialog-sm { width: min(92vw, 24rem); }
+dialog.lg,
+.dialog-lg { width: min(92vw, 56rem); }
+
+dialog::backdrop {
+  background: var(--spry-backdrop);
+}
+
+.dialog-titlebar {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.55rem 0.65rem 0.55rem 1rem;
+  background: var(--spry-bg-alt);
+  border-bottom: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius-lg) var(--spry-radius-lg) 0 0;
+  user-select: none;
+}
+
+.dialog-title {
+  font-weight: 650;
+  font-size: 0.95rem;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.dialog-close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 1.7rem;
+  height: 1.7rem;
+  flex: none;
+  padding: 0;
+  border: 0;
+  border-radius: var(--spry-radius-sm);
+  background: transparent;
+  color: var(--spry-fg-muted);
+  font-size: 1.1rem;
+  line-height: 1;
+  cursor: pointer;
+  transition: background-color var(--spry-transition), color var(--spry-transition);
+}
+
+.dialog-close:hover {
+  background: var(--spry-danger-soft);
+  color: var(--spry-danger);
+}
+
+.dialog-close:focus-visible {
+  outline: 2px solid var(--spry-accent);
+  outline-offset: 1px;
+}
+
+.dialog-body {
+  padding: 1.25rem;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  padding: 0.8rem 1.25rem;
+  border-top: var(--spry-border-width) solid var(--spry-border);
+  background: var(--spry-bg-alt);
+  border-radius: 0 0 var(--spry-radius-lg) var(--spry-radius-lg);
+}
+
+/* Open/close animation. Two-state pattern: the closed state carries the
+   "hidden" values and the EXIT timing; the open state carries the ENTRY
+   timing — each direction reads the transition of the state it lands in.
+   @starting-style makes even a dialog with a static open attribute fade in.
+   Browsers without support simply don't animate. */
+dialog,
+[popover] {
+  opacity: 0;
+  transform: var(--spry-dialog-in);
+  transition:
+    opacity var(--spry-dialog-close-duration) var(--spry-dialog-close-ease),
+    transform var(--spry-dialog-close-duration) var(--spry-dialog-close-ease),
+    display var(--spry-dialog-close-duration) allow-discrete,
+    overlay var(--spry-dialog-close-duration) allow-discrete;
+}
+
+dialog[open],
+:popover-open {
+  opacity: 1;
+  transform: none;
+  transition:
+    opacity var(--spry-dialog-duration) var(--spry-dialog-ease),
+    transform var(--spry-dialog-duration) var(--spry-dialog-ease),
+    display var(--spry-dialog-duration) allow-discrete,
+    overlay var(--spry-dialog-duration) allow-discrete;
+
+  @starting-style {
+    opacity: 0;
+    transform: var(--spry-dialog-in);
+  }
+}
+
+dialog::backdrop,
+[popover]::backdrop {
+  opacity: 0;
+  transition:
+    opacity var(--spry-dialog-close-duration) var(--spry-dialog-close-ease),
+    display var(--spry-dialog-close-duration) allow-discrete,
+    overlay var(--spry-dialog-close-duration) allow-discrete;
+}
+
+dialog[open]::backdrop,
+:popover-open::backdrop {
+  opacity: 1;
+  transition:
+    opacity var(--spry-dialog-duration) var(--spry-dialog-ease),
+    display var(--spry-dialog-duration) allow-discrete,
+    overlay var(--spry-dialog-duration) allow-discrete;
+
+  @starting-style {
+    opacity: 0;
+  }
+}
+
+/* ==========================================================================
+   18. Components — popovers & menus (no JavaScript)
+   --------------------------------------------------------------------------
+   Any element with the popover attribute opens/closes via a button with
+   popovertarget="id" (or the light-dismiss "auto" behavior by default).
+   ========================================================================== */
+
+[popover] {
+  /* re-assert the UA popover centering model (inset 0 + margin auto);
+     the reset above zeroes margins, which parks popovers in the top-left */
+  margin: auto;
+  width: min(92vw, 26rem);
+  padding: 1.1rem 1.25rem;
+  border: var(--spry-border-width) solid var(--spry-popover-border);
+  border-radius: var(--spry-radius-lg);
+  background: var(--spry-popover-bg);
+  color: var(--spry-fg);
+  box-shadow: var(--spry-popover-shadow);
+  backdrop-filter: var(--spry-glass);
+}
+
+[popover]::backdrop {
+  background: var(--spry-backdrop);
+}
+
+/* A popover built from dialog layout parts is a "window": drop the generic
+   popover padding so the titlebar/body/footer run flush to the frame. */
+[popover]:has(> .dialog-titlebar, > .dialog-body, > .dialog-footer) {
+  padding: 0;
+}
+
+/* Adwaita-style anchored popovers: give the trigger `.anchor` and the popover
+   `.popover-anchor`, and set the same --anchor-name custom property on both.
+   The popover drops below the trigger and flips at the viewport edges; its
+   backdrop is transparent (menus light-dismiss without a modal scrim).
+   Without anchor positioning support, popovers stay centered. */
+.anchor {
+  anchor-name: var(--anchor-name, --spry-anchor);
+}
+
+@supports (position-area: bottom) {
+  .popover-anchor {
+    position-anchor: var(--anchor-name, --spry-anchor);
+    position-area: bottom;
+    position-try: flip-block, flip-inline;
+    margin: 0.4rem 0 0;
+    width: auto;
+    max-width: min(92vw, 26rem);
+  }
+
+  .popover-anchor::backdrop {
+    background: transparent;
+  }
+}
+
+.popover-title {
+  font-weight: 650;
+  margin-bottom: 0.4rem;
+}
+
+.popover-menu {
+  padding: 0.4rem;
+  min-width: 12rem;
+}
+
+/* Only set display in the open state: an author `display` on a closed
+   [popover] overrides the UA's display:none and the menu never hides. */
+.popover-menu:popover-open {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.popover-menu a,
+.popover-menu button {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  width: 100%;
+  padding: 0.45rem 0.7rem;
+  border: 0;
+  border-radius: var(--spry-radius-sm);
+  background: none;
+  color: var(--spry-fg);
+  text-align: left;
+  text-decoration: none;
+  font-weight: 450;
+}
+
+.popover-menu a:hover,
+.popover-menu button:hover {
+  background: var(--spry-accent-soft);
+  color: var(--spry-accent);
+}
+
+.popover-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 0.5rem;
+  margin-top: 0.9rem;
+}
+
+/* ==========================================================================
+   19. Components — expanders (details/summary)
+   ========================================================================== */
+
+details {
+  border: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius);
+  background: var(--spry-surface);
+}
+
+details + details {
+  margin-top: 0.6rem;
+}
+
+summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.7rem 1rem;
+  border-radius: var(--spry-radius);
+  font-weight: 600;
+  cursor: pointer;
+  list-style: none;
+  user-select: none;
+  transition: background-color var(--spry-transition);
+}
+
+summary:hover {
+  background: var(--spry-bg-alt);
+}
+
+summary::-webkit-details-marker {
+  display: none;
+}
+
+summary::before {
+  content: "";
+  width: 0.45rem;
+  height: 0.45rem;
+  flex: none;
+  margin-right: 0.15rem;
+  border-right: 2px solid var(--spry-fg-muted);
+  border-bottom: 2px solid var(--spry-fg-muted);
+  rotate: -45deg;
+  transition: rotate var(--spry-transition);
+}
+
+details[open] > summary::before {
+  rotate: 45deg;
+}
+
+details[open] > summary {
+  border-bottom: var(--spry-border-width) solid var(--spry-border);
+  border-radius: var(--spry-radius) var(--spry-radius) 0 0;
+}
+
+details > :not(summary):not(:last-child) {
+  padding: 1rem 1rem 0;
+}
+
+details > :not(summary):last-child {
+  padding: 1rem;
+}
+
+details > *:not(summary) {
+  margin: 0;
+}
+
+.expander-group > details + details {
+  margin-top: calc(-1 * var(--spry-border-width));
+  border-radius: 0;
+}
+
+.expander-group > details:first-child {
+  border-radius: var(--spry-radius) var(--spry-radius) 0 0;
+}
+
+.expander-group > details:last-child {
+  border-radius: 0 0 var(--spry-radius) var(--spry-radius);
+}
+
+.expander-group > details:only-child {
+  border-radius: var(--spry-radius);
+}
+
+.expander-group > details[open]:not(:last-child) > summary {
+  border-bottom-color: transparent;
+}
+
+/* ==========================================================================
+   20. Components — hero, avatars, OTP, drawers, pagination
+   ========================================================================== */
+
+.hero {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  gap: var(--spry-space-3);
+  padding: calc(var(--spry-space-5) * 1.4) var(--spry-space-3);
+  background:
+    radial-gradient(60rem 24rem at 50% -8rem, var(--spry-accent-soft), transparent 70%),
+    var(--spry-bg-alt);
+  border-block: var(--spry-border-width) solid var(--spry-border);
+}
+
+.hero-compact {
+  padding-block: var(--spry-space-4);
+}
+
+.hero-eyebrow {
+  font-size: 0.78rem;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: var(--spry-accent);
+}
+
+.hero-title {
+  font-size: clamp(2rem, 1.2rem + 3vw, 3.25rem);
+  max-width: 24ch;
+  margin: 0;
+}
+
+.hero-sub {
+  max-width: 52ch;
+  color: var(--spry-fg-muted);
+  margin: 0;
+}
+
+.hero-actions {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: var(--spry-space-2);
+}
+
+.avatar {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  flex: none;
+  border-radius: 50%;
+  background: var(--spry-accent-soft);
+  color: var(--spry-accent);
+  font-weight: 650;
+  font-size: 0.9rem;
+  letter-spacing: 0.02em;
+  overflow: hidden;
+  user-select: none;
+}
+
+.avatar img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-sm { width: 1.75rem; height: 1.75rem; font-size: 0.68rem; }
+.avatar-lg { width: 3.5rem; height: 3.5rem; font-size: 1.2rem; }
+
+.avatar-group {
+  display: inline-flex;
+  align-items: center;
+}
+
+.avatar-group .avatar {
+  border: 2px solid var(--spry-bg);
+  margin-left: -0.65rem;
+  box-shadow: none;
+}
+
+.avatar-group .avatar:first-child {
+  margin-left: 0;
+}
+
+/* OTP / verification-code input. One character per field; moving focus
+   automatically takes one line of JS in apps that want it. */
+.otp {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spry-space-2);
+}
+
+.otp input {
+  width: 2.9rem;
+  height: 3.4rem;
+  padding: 0;
+  text-align: center;
+  font-family: var(--spry-font-mono);
+  font-size: 1.35rem;
+  line-height: 1;
+}
+
+.otp-separator {
+  color: var(--spry-fg-muted);
+  font-weight: 600;
+}
+
+/* Drawers: popover-based side panels, no JavaScript. The slide direction
+   comes from the same --spry-dialog-in variable the dialog animation reads. */
+.drawer-left,
+.drawer-right {
+  width: min(22rem, 90vw);
+  height: 100dvh;
+  padding: 0;
+  margin: 0;
+  border-radius: 0;
+  border-block: 0;
+  box-shadow: var(--spry-shadow-lg);
+}
+
+.drawer-left {
+  inset: 0 auto 0 0;
+  border-right-width: var(--spry-border-width);
+  --spry-dialog-in: translateX(-100%);
+}
+
+.drawer-right {
+  inset: 0 0 0 auto;
+  border-left-width: var(--spry-border-width);
+  --spry-dialog-in: translateX(100%);
+}
+
+.drawer-left .dialog-titlebar,
+.drawer-right .dialog-titlebar {
+  border-radius: 0;
+}
+
+.drawer-left .dialog-footer,
+.drawer-right .dialog-footer {
+  border-radius: 0;
+}
+
+/* Pagination */
+.pagination {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.3rem;
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.pagination a,
+.pagination span {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 2.2rem;
+  height: 2.2rem;
+  padding: 0 0.55rem;
+  border: var(--spry-border-width) solid transparent;
+  border-radius: var(--spry-radius-sm);
+  color: var(--spry-fg);
+  text-decoration: none;
+  font-weight: 500;
+  transition: background-color var(--spry-transition), color var(--spry-transition);
+}
+
+.pagination a:hover {
+  background: var(--spry-bg-alt);
+  color: var(--spry-accent);
+}
+
+.pagination [aria-current="page"] {
+  background: var(--spry-accent);
+  color: var(--spry-accent-fg);
+  font-weight: 650;
+}
+
+.pagination [aria-disabled="true"] {
+  opacity: 0.45;
+  pointer-events: none;
+}

+ 552 - 0
css/themes/spry-theme-airy.css

@@ -0,0 +1,552 @@
+/*
+ * Spry CSS theme "airy" — Frutiger Aero / Windows 7 Aero glass
+ * ----------------------------------------------------------------
+ * Light-only. A port of the arc-themes "aero" reference into Spry's variable
+ * system: one fixed sky gradient page (no wallpaper), Windows 7 push buttons,
+ * gel command buttons, Explorer selection states, compact Win7 menus with a
+ * glossy blue hover, glossy green progress with a sweeping shine, and dialogs
+ * drawn as ONE Aero glass window frame. Activated with <html data-theme="airy">
+ * or the demo radio #spry-theme-airy.
+ */
+
+/* The progress shine sweep: layer 2 of the value background is a diagonal
+   light band, sized 300% and swept left-to-right by background-position. */
+@keyframes airy-progress-shine {
+  /* five layers: the 300%-wide shine sweeps; the rest hold */
+  from { background-position: 100% 0, 0 0, 0 0, 0 0, 0 0; }
+  to { background-position: 0% 0, 0 0, 0 0, 0 0, 0 0; }
+}
+
+/* The 7.css marquee: a 40%-wide shine fully enters, crosses, and exits by
+   60% of the cycle, then holds (the reference's faked delay). Position p%
+   for a 40% layer maps to p x 60% of the track: out-left = -167%, out-right = +167% */
+@keyframes airy-marquee {
+  /* 35%-wide green sweep: p% maps to p x 65% — out-left = -154%, out-right = +154% */
+  0% { background-position: -154% 50%, 0 0, 0 0, 0 0, 0 0; }
+  60% { background-position: 154% 50%, 0 0, 0 0, 0 0, 0 0; }
+  100% { background-position: 154% 50%, 0 0, 0 0, 0 0, 0 0; }
+}
+
+[data-theme="airy"],
+html:has(#spry-theme-airy:checked) {
+  color-scheme: light; /* light-only: dark mode is ignored */
+
+  /* ---- Palette (aero.scss / _tokens.scss, verbatim) ---- */
+  --spry-bg: #dff3f8;              /* sky tone: avatar rings, wells */
+  --spry-bg-alt: rgb(255 255 255 / 0.55);
+  --spry-bg-inset: #eff6fb;
+  --spry-surface: rgb(255 255 255 / 0.82); /* glass fill: cards, popovers */
+  --spry-surface-hover: rgb(255 255 255 / 0.9);
+  --spry-fg: #1e1e1e;
+  --spry-fg-muted: #4f5e6e;        /* the reference's $secondary */
+  --spry-border: rgb(154 194 224 / 0.7);
+  --spry-border-strong: #5794bf;   /* the reference's textbox hover edge */
+  --spry-accent: #1477c9;          /* the Windows 7 command blue */
+  --spry-accent-hover: #1165ab;
+  --spry-accent-fg: #ffffff;
+  --spry-danger: #cc3b2e;
+  --spry-success: #2c9c38;
+  --spry-warning: #f2a71b;
+  --spry-info: #0e8fb0;
+  --spry-link: #0066cc;            /* Windows 7 link blues */
+  --spry-link-hover: #3399ff;
+  --spry-heading-color: #003399;
+  --spry-heading-weight: 400;      /* Win7 headings are regular-weight blue */
+  --spry-backdrop: rgb(0 26 51 / 0.45); /* Vista secure-desktop scrim */
+
+  /* ---- Typography: Segoe UI at Win7's compact 12px; radii 3/2/6 ---- */
+  --spry-font: "Segoe UI", Selawik, Frutiger, "Frutiger Linotype", "Myriad Pro",
+    "Open Sans", system-ui, sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-mono: ui-monospace, Consolas, "Lucida Console", Menlo, monospace;
+  --spry-font-size: 0.85rem;
+  --spry-line-height: 1.5;
+  --spry-radius: 3px;
+  --spry-radius-sm: 2px;
+  --spry-radius-lg: 6px;
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6), 0 1px 3px rgb(20 60 110 / 0.18);
+  --spry-shadow-lg: inset 0 0 0 1px rgb(255 255 255 / 0.7), 0 12px 32px rgb(0 30 80 / 0.35);
+  --spry-glass: blur(12px) saturate(1.7); /* topbar, cards, popovers, dialogs */
+  --spry-transition: 120ms ease;
+
+  /* Windows open the DWM way: a scale-up gliding to a soft stop; closing mirrors
+     it faster on an ease-in. Motion vars only — the core's two-state rules are
+     never touched; drawers keep the core's own translateX --spry-dialog-in. */
+  --spry-dialog-duration: 220ms;
+  --spry-dialog-ease: cubic-bezier(0.16, 1, 0.3, 1);
+  --spry-dialog-close-duration: 150ms;
+  --spry-dialog-close-ease: cubic-bezier(0.32, 0, 0.67, 0);
+  --spry-dialog-in: scale(0.96);
+
+  /* ---- Controls: white wells, #abadb3 edge, blue focus halo ---- */
+  --spry-control-bg: #ffffff;
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: #abadb3;
+  --spry-control-radius: 3px;
+  --spry-control-padding-x: 0.65rem;
+  --spry-control-padding-y: 0.35rem;
+  --spry-focus-ring: 0 0 4px rgb(61 123 173 / 0.55);
+  --spry-btn-bg: #e5e5e5;
+  --spry-btn-fg: var(--spry-fg);
+  --spry-btn-border: #707070;
+  --spry-btn-bg-hover: #bee6fd;
+  --spry-btn-bg-active: #98d1ef;
+
+  /* ---- Aero recipes (aero/_tokens.scss, verbatim stops) ---- */
+  --airy-btn: linear-gradient(180deg, #f2f2f2 0%, #ebebeb 49%, #dddddd 50%, #cfcfcf 100%);
+  --airy-btn-hover: linear-gradient(180deg, #eaf6fd 0%, #d9f0fc 49%, #bee6fd 50%, #a7d9f5 100%);
+  --airy-btn-pressed: linear-gradient(180deg, #e5f4fc 0%, #c4e5f6 49%, #98d1ef 50%, #68b3db 100%);
+  --airy-gloss:
+    radial-gradient(ellipse 90% 55% at 50% 100%, rgb(255 255 255 / 0.35), rgb(255 255 255 / 0) 70%),
+    linear-gradient(180deg, rgb(255 255 255 / 0.6) 0%, rgb(255 255 255 / 0.22) 49%, rgb(255 255 255 / 0) 50%, rgb(255 255 255 / 0.12) 100%);
+  --airy-gloss-pressed: linear-gradient(180deg, rgb(0 0 0 / 0.14) 0%, rgb(0 0 0 / 0.05) 49%, rgb(255 255 255 / 0) 50%, rgb(255 255 255 / 0.14) 100%);
+  --airy-gel-shadow: inset 0 1px 0 rgb(255 255 255 / 0.65), inset 0 0 0 1px rgb(255 255 255 / 0.2), 0 1px 2px rgb(0 0 0 / 0.25);
+  --airy-hot: linear-gradient(180deg, #fafbfd 0%, #ebf3fd 100%);
+  --airy-selected: linear-gradient(180deg, #dcebfc 0%, #c1dbfc 100%);
+  /* Checkbox/radio marks (data: URIs from _controls.scss) */
+  --airy-tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'%3E%3Cpath d='M2.9 6.3l2.4 2.6 4.9-5.6' fill='none' stroke='%23233c5c' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+  --airy-mixed: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'%3E%3Crect x='3' y='3' width='7' height='7' fill='%231f9a35' stroke='%2317702a' stroke-width='.6'/%3E%3C/svg%3E");
+  --airy-dot: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'%3E%3Cdefs%3E%3CradialGradient id='r' cx='.38' cy='.32' r='.75'%3E%3Cstop offset='0' stop-color='%23dff7ff'/%3E%3Cstop offset='.35' stop-color='%234fb6ea'/%3E%3Cstop offset='.8' stop-color='%2311528f'/%3E%3Cstop offset='1' stop-color='%230a3a6b'/%3E%3C/radialGradient%3E%3C/defs%3E%3Ccircle cx='6.5' cy='6.5' r='3.3' fill='url(%23r)'/%3E%3C/svg%3E");
+
+  /* ---- Cards / tables / progress / popovers ---- */
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: rgb(40 80 130 / 0.45);
+  --spry-card-padding: 1.1rem;
+  --spry-card-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.8), 0 2px 8px rgb(0 40 90 / 0.18);
+  --spry-table-header-bg: linear-gradient(180deg, #ffffff 0%, #ffffff 40%, #f3f4f6 100%);
+  --spry-table-hover: var(--airy-hot);
+  --spry-progress-bg: linear-gradient(180deg, #e6e6e6 0%, #f3f3f3 100%);
+  --spry-progress-fg:
+    linear-gradient(180deg, rgb(255 255 255 / 0.55) 0%, rgb(255 255 255 / 0.12) 49%, rgb(255 255 255 / 0) 50%, rgb(255 255 255 / 0.25) 100%),
+    linear-gradient(115deg, rgb(255 255 255 / 0) 35%, rgb(255 255 255 / 0.6) 50%, rgb(255 255 255 / 0) 65%),
+    linear-gradient(180deg, #2ec539 0%, #10b32c 49%, #06b025 50%, #0b9a25 100%);
+  --spry-popover-bg: rgb(255 255 255 / 0.88);
+  --spry-popover-border: rgb(60 100 140 / 0.55);
+  /* $aero-window-frame verbatim: plain popovers get the frame as shadows only */
+  --spry-popover-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.8), 0 0 0 5px rgb(170 210 245 / 0.55),
+    0 0 0 6px rgb(20 60 110 / 0.5), 0 12px 32px rgb(0 30 80 / 0.4);
+
+  /* ---- Page: one simple fixed sky gradient + one soft white radial ---- */
+  & body {
+    background: radial-gradient(ellipse 120% 70% at 50% -10%, rgb(255 255 255 / 0.45), rgb(255 255 255 / 0) 60%),
+      linear-gradient(180deg, #7ec8f5 0%, #a5d8f5 55%, #dff3e8 100%);
+    background-color: #7ec8f5;
+    background-attachment: fixed;
+  }
+
+  & ::selection { background: #3399ff; color: #fff; }
+  & hr { border-top-color: #d5d5d5; box-shadow: 0 1px 0 rgb(255 255 255 / 0.6); }
+  & :where(.btn, button):focus-visible { outline: 1px dotted #000; outline-offset: 2px; }
+
+  /* ---- Win7 push buttons. :where keeps (0,1,0) so core .btn-* variants and
+     .btn-sm/.btn-lg (0,2,0) still win; variants are redefined after these. ---- */
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]) {
+    background: var(--airy-btn);
+    border: 1px solid #707070;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.75);
+    font-weight: 400;
+  }
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):hover {
+    background: var(--airy-btn-hover);
+    border-color: #3c7fb1;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.8);
+  }
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):active {
+    background: var(--airy-btn-pressed);
+    border-color: #2c628b;
+    box-shadow: inset 1px 1px 2px rgb(0 0 0 / 0.35);
+  }
+  /* Disabled buttons go flat grey, as Windows 7 draws them */
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):disabled {
+    background: #f4f4f4;
+    border-color: #adb2b5;
+    box-shadow: none;
+  }
+
+  /* Primary: gel over the Win7 blue — colour under a gloss, glowing on hover */
+  & .btn-primary { background: linear-gradient(180deg, rgb(255 255 255 / 0.15) 0%, rgb(255 255 255 / 0) 50%), #10689f; border-color: #0c4779; color: #fff; text-shadow: 0 1px 1px rgb(0 0 0 / 0.5); box-shadow: var(--airy-gel-shadow); }
+  & .btn-primary:hover { background: var(--airy-gloss), #3087d0; border-color: #0a416c; box-shadow: var(--airy-gel-shadow), 0 0 10px rgb(20 119 201 / 0.8); }
+  & .btn-primary:active { background: var(--airy-gloss-pressed), #1477c9; border-color: #0a416c; box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.35); }
+
+  /* Danger: glossy red gel */
+  & .btn-danger { background: var(--airy-gloss), #cc3b2e; border-color: #7a231c; color: #fff; text-shadow: 0 1px 1px rgb(0 0 0 / 0.5); box-shadow: var(--airy-gel-shadow); }
+  & .btn-danger:hover { background: var(--airy-gloss), #d25347; border-color: #702019; box-shadow: var(--airy-gel-shadow), 0 0 10px rgb(204 59 46 / 0.8); }
+  & .btn-danger:active { background: var(--airy-gloss-pressed), #cc3b2e; border-color: #702019; box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.35); }
+
+  /* Outline: clear glass with a coloured edge, filling with gel when hovered */
+  & .btn-outline {
+    background: linear-gradient(180deg, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.7) 49%, rgb(225 235 245 / 0.55) 50%, rgb(240 245 250 / 0.8) 100%);
+    border-color: rgb(11 71 121 / 0.65);
+    color: #0b4779;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.7);
+  }
+  & .btn-outline:hover { background: var(--airy-gloss), #bee6fd; border-color: #2c628b; color: #0a3f6e; box-shadow: var(--airy-gel-shadow), 0 0 10px rgb(20 119 201 / 0.7); }
+
+  /* Ghost and link stay chrome-less (after the base rules, so they win) */
+  & .btn-ghost, & .btn-link { background: none; border-color: transparent; box-shadow: none; text-shadow: none; }
+  & .btn-link { border: 0; }
+  & .btn-ghost:hover { background: linear-gradient(180deg, rgb(255 255 255 / 0.85) 0%, rgb(230 242 252 / 0.6) 100%); border-color: transparent; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.8); }
+
+  /* ---- Text fields: white wells; the reference's darker top edge. A local
+     --spry-glass (the core's input box-shadow) adds the inner shadow. ---- */
+  & :where(
+    input[type="text"], input[type="email"], input[type="password"], input[type="number"],
+    input[type="search"], input[type="tel"], input[type="url"], input[type="date"],
+    input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"],
+    select, textarea, .input
+  ) {
+    border-color: #abadb3 #dbdfe6 #e3e9ef #e2e3ea;
+    border-radius: 2px;
+    --spry-glass: inset 0 1px 2px rgb(120 165 205 / 0.35), inset 0 -1px 0 rgb(255 255 255 / 0.7);
+  }
+  & :where(input, textarea, select)::placeholder { color: #6d6d6d; font-style: italic; }
+
+  /* Closed drop-down lists look like push buttons, as in the reference */
+  & :where(select:not([size]):not([multiple])) { background-color: #fff; background-image: var(--airy-btn); }
+  & :where(select:not([size]):not([multiple]):hover) { background-image: var(--airy-btn-hover); }
+
+  /* ---- Checkboxes & radios: the Win7 13px well, shaded from the top left,
+     lit blue on hover; a dark tick, a glossy radio dot, a green mixed square ---- */
+  & input[type="checkbox"], & input[type="radio"] {
+    appearance: none;
+    -webkit-appearance: none;
+    width: 13px;
+    height: 13px;
+    margin: 0;
+    vertical-align: -2px;
+    border: 1px solid #8e8f8f;
+    border-radius: 2px;
+    background-color: #f4f4f4;
+    background-image: var(--airy-mark, none), linear-gradient(135deg, #c3c8ce 0%, #e4e6e9 55%, #f8f8f8 100%);
+    box-shadow: inset 0 0 0 1px #f4f4f4, inset 0 0 0 2px rgb(255 255 255 / 0.5);
+  }
+  & input[type="checkbox"]:hover, & input[type="radio"]:hover {
+    border-color: #3c7fb1;
+    background-image: var(--airy-mark, none), linear-gradient(135deg, #a9d9f8 0%, #d3eefd 55%, #f2fafe 100%);
+    box-shadow: inset 0 0 0 1px #def3fd, inset 0 0 0 2px rgb(255 255 255 / 0.5);
+  }
+  & input[type="checkbox"]:active, & input[type="radio"]:active {
+    border-color: #2c628b;
+    background-image: var(--airy-mark, none), linear-gradient(135deg, #6fbdf0 0%, #b4e0fb 55%, #e2f4fe 100%);
+  }
+  & input[type="radio"] {
+    border-radius: 50%;
+    background-image: var(--airy-mark, none), radial-gradient(circle at 35% 30%, #f8f8f8 0%, #e1e3e6 50%, #c3c8ce 100%);
+  }
+  & input[type="radio"]:hover {
+    background-image: var(--airy-mark, none), radial-gradient(circle at 35% 30%, #f2fafe 0%, #d3eefd 50%, #a9d9f8 100%);
+  }
+  & input[type="checkbox"]:checked { --airy-mark: var(--airy-tick); }
+  & input[type="checkbox"]:indeterminate { --airy-mark: var(--airy-mixed); }
+  & input[type="radio"]:checked { --airy-mark: var(--airy-dot); }
+
+  /* ---- Switch: glossy pill track, pearl knob, blue gel when on ---- */
+  & .switch .track { background: linear-gradient(180deg, #f4f6f8 0%, #dfe6ec 49%, #cfd8e0 50%, #e3e9ef 100%); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.18), inset 0 0 0 1px rgb(255 255 255 / 0.4); }
+  & .switch input:checked + .track { background: var(--airy-gloss), #1477c9; box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.5), 0 0 8px rgb(20 119 201 / 0.55); }
+  & .switch .track::after { background: radial-gradient(circle at 35% 30%, #ffffff 0%, #f0f5fa 60%, #d8e2ec 100%); box-shadow: 0 1px 3px rgb(0 30 70 / 0.5), inset 0 1px 0 #ffffff; }
+
+  /* ---- Topbar: the Win7 taskbar — #1d6fb8 glass, blur, a glowing brand in
+     the start-orb pill, an accent #4fc8ff bottom edge ---- */
+  & .topbar {
+    background: linear-gradient(180deg, rgb(255 255 255 / 0.18) 0%, rgb(255 255 255 / 0.05) 49%, rgb(0 25 50 / 0.22) 50%, rgb(0 25 50 / 0) 100%), rgb(12 60 112 / 0.95);
+    border-bottom-color: rgb(6 36 74 / 0.65);
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.45), inset 0 -1px 0 rgb(79 200 255 / 0.55), 0 6px 18px rgb(0 30 70 / 0.35);
+  }
+  & .topbar .topbar-brand {
+    padding: 0.15rem 0.85rem;
+    border: 1px solid rgb(0 20 50 / 0.7);
+    border-radius: var(--spry-radius-pill);
+    background: linear-gradient(180deg, rgb(255 255 255 / 0.25) 0%, rgb(255 255 255 / 0.05) 48%, rgb(255 255 255 / 0) 52%),
+      radial-gradient(ellipse 80% 70% at 50% 100%, #5fc8f0 0%, #1173c4 45%, #0a3f80 100%);
+    color: #ffffff;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.45), 0 0 10px rgb(110 215 255 / 0.75);
+    text-shadow: 0 1px 2px rgb(0 20 50 / 0.85), 0 0 10px rgb(79 200 255 / 0.85);
+  }
+  & .topbar .topbar-brand:hover { color: #fff; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.6), 0 0 18px 2px rgb(140 235 255 / 0.9); }
+
+  /* Taskbar buttons: glass on hover, lit from below when active */
+  & .topbar .nav a { color: #eaf6ff; border: 1px solid transparent; border-radius: 3px; text-shadow: 0 0 6px rgb(0 20 50 / 0.9); }
+  & .topbar .nav a:hover {
+    background: linear-gradient(180deg, rgb(255 255 255 / 0.35) 0%, rgb(255 255 255 / 0.1) 49%, rgb(255 255 255 / 0.04) 50%, rgb(255 255 255 / 0.2) 100%);
+    border-color: rgb(0 0 0 / 0.45);
+    color: #fff;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
+  }
+  & .topbar .nav a.active {
+    background: radial-gradient(ellipse 75% 65% at 50% 100%, rgb(150 230 255 / 0.3), rgb(150 230 255 / 0) 75%),
+      linear-gradient(180deg, rgb(255 255 255 / 0.2) 0%, rgb(255 255 255 / 0.06) 49%, rgb(255 255 255 / 0.03) 50%, rgb(255 255 255 / 0.14) 100%),
+      rgb(10 52 98 / 0.95);
+    border-color: rgb(0 0 0 / 0.5);
+    color: #fff;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.5), 0 0 8px rgb(120 220 255 / 0.6);
+  }
+  & .topbar .breadcrumb, & .topbar .breadcrumb li + li::before { color: #d9efff; }
+  & .topbar .breadcrumb a { color: #bfe9ff; }
+  & .topbar .breadcrumb a:hover, & .topbar .breadcrumb [aria-current] { color: #fff; }
+
+  /* ---- Nav (page level): chips with Explorer hot/selected states ---- */
+  & .nav a { color: #1e3287; border: 1px solid transparent; border-radius: 3px; }
+  & .nav a:hover { background: var(--airy-hot); border-color: #b8d6fb; color: #0a3f6e; }
+  & .nav a.active { background: var(--airy-selected); border-color: #7da2ce; color: #1e3287; }
+
+  /* ---- Menus: COMPACT Win7 menus — white face, 1px #7f9db9 border, 3px
+     radius, 2px container padding, an icon gutter behind 24px-indented rows
+     22px tall; hover is the glossy blue gradient with white text ---- */
+  & .submenu, & .popover-menu {
+    padding: 2px;
+    background: linear-gradient(90deg, transparent 24px, #e2e3e3 24px 25px, #ffffff 25px 26px, transparent 26px), #ffffff;
+    border: 1px solid #7f9db9;
+    border-radius: 3px;
+    box-shadow: 3px 3px 5px rgb(20 60 110 / 0.3);
+  }
+  & .submenu a, & .popover-menu a, & .popover-menu button, & .menu a {
+    min-height: 22px;
+    padding: 4px 10px 4px 24px;
+    border-radius: 2px;
+    color: #1e1e1e;
+  }
+  & .submenu a:hover, & .popover-menu a:hover, & .popover-menu button:hover {
+    background: var(--airy-gloss), linear-gradient(180deg, #71b4ee 0%, #2f80cf 100%);
+    color: #ffffff;
+    text-shadow: 0 1px 1px rgb(0 20 50 / 0.5);
+  }
+  & .submenu a.active, & .popover-menu a.active, & .menu a.active { background: var(--airy-selected); color: #1e3287; }
+  & .menu a { padding-left: 8px; }
+  & .menu a:hover { background: linear-gradient(180deg, #fafbfd 0%, #ebf3fd 100%); color: #0a3f6e; }
+  /* The core nests .submenu statically inside .menu: keep it unboxed there */
+  & .menu .has-submenu .submenu { background: none; border: 0; box-shadow: none; }
+
+  /* ---- Cards: white glass panes with a gloss across the top ---- */
+  & .card { background-color: var(--spry-card-bg); background-image: linear-gradient(180deg, rgb(255 255 255 / 0.9) 0, rgb(235 245 255 / 0.45) 2.2rem, rgb(255 255 255 / 0) 2.3rem); }
+  & .card-header { background: linear-gradient(180deg, #f9fcfe 0%, #e9f1fb 49%, #dce7f5 50%, #e3edf9 100%); border-bottom-color: #a0afc3; color: #1e3287; }
+  & .card-footer { background: linear-gradient(180deg, rgb(240 246 252 / 0.6), rgb(228 238 247 / 0.85)); }
+
+  /* ---- Dialogs & window popovers: ONE Aero glass frame — a translucent
+     glass border (the border zone shows the blurred backdrop because the
+     background is clipped to the padding box) plus the frame stack's white
+     inner edge, dark outline and drop shadow. No transform/opacity/transition
+     is touched: the core's two-state animation runs on the motion vars. ---- */
+  & dialog, & [popover]:has(> .dialog-titlebar, > .dialog-body, > .dialog-footer) {
+    border: 1px solid rgb(0 0 0 / 0.7);
+    border-radius: 6px;
+    background: #f0f4f8;
+    box-shadow: 2px 2px 10px 1px rgb(0 0 0 / 0.7), inset 0 0 0 1px rgb(255 255 255 / 0.67);
+  }
+  & .dialog-titlebar {
+    border: 0;
+    border-radius: 5px 5px 0 0;
+    background: linear-gradient(to right, rgb(255 255 255 / 0.4), rgb(0 0 0 / 0.1) 50%, rgb(255 255 255 / 0.2)), #4580c4;
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 1), inset 1px 0 0 rgb(255 255 255 / 1), inset -1px 0 0 rgb(255 255 255 / 1);
+    padding-top: 0.4rem;
+  }
+  & .dialog-title {
+    color: #000;
+    font-weight: 400;
+    text-shadow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 4px #fff;
+  }
+
+  /* The caption close button: a 7.css window control on glass */
+  & .dialog-close {
+    background: linear-gradient(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.3) 45%, rgb(0 0 0 / 0.1) 50%, rgb(0 0 0 / 0.1) 75%, rgb(255 255 255 / 0.5));
+    border: 1px solid rgb(0 0 0 / 0.3);
+    border-radius: 4px;
+    color: #000;
+    text-shadow: 0 0 6px #fff, 0 0 6px #fff;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.67);
+  }
+  & .dialog-close:hover {
+    background: radial-gradient(circle at bottom, #2aceda, transparent 65%), linear-gradient(#b6d9ee 50%, #1a6ca1 50%);
+    color: #fff;
+    text-shadow: 0 1px 1px rgb(0 0 0 / 0.5);
+  }
+  & .dialog-close:active {
+    background: radial-gradient(circle at bottom, #0bfdfa, transparent 65%), linear-gradient(#86a7bc 50%, #092747 50%);
+    color: #fff;
+  }
+
+  & .dialog-body {
+    background: #ffffff;
+    border: 1px solid rgb(0 0 0 / 0.7);
+    box-shadow: 0 0 0 1px rgb(255 255 255 / 0.6);
+    margin: 6px;
+    margin-top: 0;
+  }
+  & .dialog-footer {
+    border: 1px solid rgb(0 0 0 / 0.7);
+    border-top: 0;
+    border-radius: 0 0 5px 5px;
+    background: #f0f0f0;
+    box-shadow: inset 0 1px 0 rgb(0 0 0 / 0.3), inset 0 2px 0 rgb(255 255 255 / 0.6);
+    margin: 0 6px 6px;
+  }
+
+  /* Drawers are docked glass bars, not framed windows: reset the frame (this
+     follows the frame rule on purpose) and light the docked edge instead */
+  & [popover].drawer-left, & [popover].drawer-right { border: 0; border-radius: 0; background-clip: border-box; }
+  & [popover].drawer-left { border-right: 1px solid rgb(60 100 140 / 0.55); box-shadow: -6px 0 0 rgb(170 210 245 / 0.55), -7px 0 0 rgb(20 60 110 / 0.5), -12px 0 32px rgb(0 30 80 / 0.4); }
+  & [popover].drawer-right { border-left: 1px solid rgb(60 100 140 / 0.55); box-shadow: 6px 0 0 rgb(170 210 245 / 0.55), 7px 0 0 rgb(20 60 110 / 0.5), 12px 0 32px rgb(0 30 80 / 0.4); }
+  & .drawer-left .dialog-titlebar, & .drawer-right .dialog-titlebar,
+  & .drawer-left .dialog-footer, & .drawer-right .dialog-footer { border-radius: 0; }
+
+  /* ---- Tabs: Vista/Win7 — glossy gradient labels, 3px 3px 0 0 corners, a
+     bright border-top; the active label is white and connected to the panel
+     (the core's nth-of-type chains are mirrored to restyle it) ---- */
+  & .tab-list { border-bottom-color: #898c95; }
+  & .tab-list label {
+    padding: 0.45rem 0.9rem;
+    border: 1px solid #898c95;
+    border-top-color: #aeb4bc;
+    border-bottom: 0;
+    border-radius: 3px 3px 0 0;
+    background: linear-gradient(180deg, #f3f3f3 0%, #e1e1e1 100%);
+    color: #1e1e1e;
+  }
+  & .tab-list label:hover { background: var(--airy-btn-hover); color: #0a3f6e; }
+  & .tabs > input:nth-of-type(1):checked ~ .tab-list label:nth-of-type(1),
+  & .tabs > input:nth-of-type(2):checked ~ .tab-list label:nth-of-type(2),
+  & .tabs > input:nth-of-type(3):checked ~ .tab-list label:nth-of-type(3),
+  & .tabs > input:nth-of-type(4):checked ~ .tab-list label:nth-of-type(4),
+  & .tabs > input:nth-of-type(5):checked ~ .tab-list label:nth-of-type(5),
+  & .tabs > input:nth-of-type(6):checked ~ .tab-list label:nth-of-type(6) {
+    margin-bottom: -1px;
+    padding-bottom: calc(0.45rem + 1px);
+    border-color: #898c95;
+    border-bottom: 1px solid #ffffff;
+    background: linear-gradient(180deg, #ffffff 0%, #ffffff 75%, #e8f1fb 100%);
+    color: #1e3287;
+    box-shadow: inset 0 1px 0 #ffffff;
+  }
+
+  /* ---- Tables: Explorer's details view — pale gradient header band with
+     column dividers, hot rows on hover, selected rows ---- */
+  & thead th {
+    background: var(--spry-table-header-bg);
+    color: #4c607a;
+    font-weight: 400;
+    text-transform: none;
+    letter-spacing: normal;
+    border-bottom: 1px solid #d5d5d5;
+    box-shadow: inset -1px 0 0 #e5e5e5;
+  }
+  & .table-hover tbody tr:hover { background: var(--airy-hot); box-shadow: inset 0 1px 0 #b8d6fb, inset 0 -1px 0 #b8d6fb; }
+  & tbody tr[aria-selected="true"] { background: var(--airy-selected); box-shadow: inset 0 1px 0 #7da2ce, inset 0 -1px 0 #7da2ce; }
+
+  /* ---- Progress: the Win7 bar — green #06b025 under a gloss with a shine
+     sweeping along it (aero-progress-shine ported to native <progress>; a
+     ::after dies inside progress pseudos, so the shine is background layer 2,
+     300% wide, slid by keyframes; engine rules stay separate) ---- */
+  /* Progress, ported from the 7.css reference: a beveled groove with
+     end-dip shading, glossy green fill, and a marquee shine that travels
+     then pauses (60% move / 40% hold, exactly like the reference) */
+  & progress {
+    height: 0.9rem;
+    border: 1px solid #7f9db9;
+    border-radius: 3px;
+    box-shadow: inset 0 0 0 1px rgb(243 243 243 / 0.53), 0 0 0 1px rgb(234 234 234 / 0.53);
+    overflow: hidden;
+    background:
+      radial-gradient(circle at 0 50%, rgb(0 0 0 / 0.12) 10px, transparent 30px),
+      radial-gradient(circle at 100% 50%, rgb(0 0 0 / 0.12) 10px, transparent 30px),
+      linear-gradient(to bottom, rgb(243 243 243 / 0.68), rgb(252 252 252 / 0.68) 3px, rgb(219 219 219 / 0.68) 6px, rgb(202 202 202 / 0.68) 6px, rgb(213 213 213 / 0.68)),
+      #ddd;
+  }
+
+  & progress::-webkit-progress-bar { background: transparent; }
+
+  /* Determinate: glossy green with the 7.css traveling shine (layer 1,
+     300% wide, swept by airy-progress-shine). The shine stops once the bar
+     is complete: add .is-complete (value = max). */
+  & progress:not(.is-complete)::-webkit-progress-value,
+  & progress:not(.is-complete)::-moz-progress-bar {
+    border-radius: 2px;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
+    background-color: #0bd82c;
+    background-image:
+      linear-gradient(100deg, transparent 25%, rgb(255 255 255 / 0.4) 50%, transparent 75%),
+      linear-gradient(to bottom, rgb(243 243 243 / 0.68), rgb(252 252 252 / 0.68) 3px, rgb(219 219 219 / 0.68) 6px, transparent 6px),
+      radial-gradient(circle at 0% 50%, rgb(0 0 0 / 0.18) 10px, transparent 30px),
+      radial-gradient(circle at 100% 50%, rgb(0 0 0 / 0.18) 10px, transparent 30px),
+      linear-gradient(to bottom, transparent 65%, rgb(255 255 255 / 0.33));
+    background-size: 300% 100%, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
+    animation: airy-progress-shine 2.4s ease-in-out infinite;
+  }
+
+  & progress.is-complete::-webkit-progress-value,
+  & progress.is-complete::-moz-progress-bar {
+    border-radius: 2px;
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
+    background-color: #0bd82c;
+    background-image:
+      linear-gradient(to bottom, rgb(243 243 243 / 0.68), rgb(252 252 252 / 0.68) 3px, rgb(219 219 219 / 0.68) 6px, transparent 6px),
+      linear-gradient(to bottom, transparent 65%, rgb(255 255 255 / 0.33));
+  }
+
+  /* Indeterminate: the 7.css marquee — the EMPTY groove with a soft green
+     sweep crossing it (enter, cross by 60% of the cycle, then hold) */
+  & progress:indeterminate {
+    background:
+      linear-gradient(to right, transparent, rgb(11 216 44 / 0.55) 50%, transparent 100%) 0 50% / 35% 100% no-repeat,
+      radial-gradient(circle at 0% 50%, rgb(0 0 0 / 0.12) 10px, transparent 30px),
+      radial-gradient(circle at 100% 50%, rgb(0 0 0 / 0.12) 10px, transparent 30px),
+      linear-gradient(to bottom, rgb(243 243 243 / 0.68), rgb(252 252 252 / 0.68) 3px, rgb(219 219 219 / 0.68) 6px, rgb(202 202 202 / 0.68) 6px, rgb(213 213 213 / 0.68)),
+      #ddd;
+    animation: airy-marquee 3s linear infinite;
+  }
+
+  /* The busy ring glows */
+  & .spinner { border-color: rgb(90 180 240 / 0.25); border-top-color: #1e90e8; border-right-color: #7fdcff; filter: drop-shadow(0 0 3px rgb(80 190 255 / 0.8)); }
+
+  /* ---- Alerts & badges: a white fade over the tint; little gel pills ---- */
+  & .alert { background-image: linear-gradient(180deg, rgb(255 255 255 / 0.65) 0%, rgb(255 255 255 / 0) 100%); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.8); }
+  & .alert-danger { color: #a8281c; }
+  & .alert-success { color: #1e6b28; }
+  & .alert-warning { color: #7a5410; }
+  & .alert-info { color: #0c5f78; }
+  & .hero-eyebrow { color: #0a587f; }
+  & .badge { color: #0f5f9e; }
+  & .badge { padding-inline: 0.6em; background-image: linear-gradient(180deg, rgb(255 255 255 / 0.5) 0%, rgb(255 255 255 / 0.12) 49%, rgb(0 0 0 / 0.06) 50%, rgb(255 255 255 / 0.15) 100%); border: 1px solid rgb(0 0 0 / 0.2); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.5); }
+
+  /* ---- Hero: a frosted band over the sky with an aqua glow ---- */
+  & .hero {
+    background: radial-gradient(60rem 24rem at 50% -8rem, rgb(79 200 255 / 0.28), rgb(255 255 255 / 0) 70%), rgb(255 255 255 / 0.55);
+    border-block-color: rgb(154 194 224 / 0.75);
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.85), inset 0 -1px 0 rgb(255 255 255 / 0.5);
+    backdrop-filter: var(--spry-glass);
+  }
+
+  /* ---- Avatars: glass portrait chips ---- */
+  & .avatar { background: linear-gradient(180deg, rgb(255 255 255 / 0.95) 0%, rgb(214 236 251 / 0.85) 100%); border: 1px solid rgb(120 170 210 / 0.7); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.95), 0 1px 3px rgb(20 90 160 / 0.25); }
+  & .avatar-group .avatar { border: 2px solid #dff3f8; }
+
+  /* ---- OTP: bright frosted wells for single characters ---- */
+  & .otp input { background: linear-gradient(180deg, rgb(255 255 255 / 0.95) 0%, rgb(238 247 253 / 0.95) 100%); border-color: #9bb7cc; }
+
+  /* ---- Pagination: pages are push buttons; the current page is a blue gel ---- */
+  & .pagination a { background: var(--airy-btn); border-color: #707070; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.75); }
+  & .pagination a:hover { background: var(--airy-btn-hover); border-color: #3c7fb1; color: #0a3f6e; }
+  & .pagination [aria-current="page"] { background: linear-gradient(180deg, rgb(255 255 255 / 0.15) 0%, rgb(255 255 255 / 0) 50%), #10689f; border-color: #0c4779; color: #fff; text-shadow: 0 1px 1px rgb(0 0 0 / 0.5); box-shadow: var(--airy-gel-shadow); }
+
+  /* ---- Breadcrumb: Explorer address-bar links, hot on hover ---- */
+  & .breadcrumb a { padding: 1px 5px; border-radius: 2px; }
+  & .breadcrumb a:hover { background: var(--airy-hot); color: #0066cc; text-decoration: none; }
+
+  /* ---- Expanders: Explorer nav-pane group headers ---- */
+  & details { background: #ffffff; border-color: #b6c6d8; }
+  & summary:hover { background: linear-gradient(180deg, #fafbfd 0%, #ebf3fd 100%); }
+  & details[open] > summary { background: linear-gradient(180deg, #fdfeff 0%, #eaf1fa 100%); color: #1e3287; }
+
+  /* ---- Fieldset: a Win7 groupbox; kbd: a little push button ---- */
+  & fieldset { border-color: #b6c6d8; border-radius: 3px; }
+  & legend { color: #003399; font-weight: 400; }
+  & kbd { background: var(--airy-btn); border: 1px solid #707070; border-radius: 2px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.75), 0 1px 0 rgb(0 30 80 / 0.2); }
+  & :not(pre) > code { background: #eff6fb; border-color: rgb(154 194 224 / 0.7); }
+
+  /* ---- Win7 scrollbars: a guttered track, a push-button thumb (Chromium
+     pseudos; Firefox keeps its native bars) ---- */
+  & ::-webkit-scrollbar { width: 16px; height: 16px; }
+  & ::-webkit-scrollbar-track { background: linear-gradient(90deg, #e3e3e3 0%, #f6f6f6 30%, #f0f0f0 100%); }
+  & ::-webkit-scrollbar-thumb { background: linear-gradient(90deg, #f3f3f3 0%, #e8e8e8 49%, #d8d8d8 50%, #cacaca 100%); border: 1px solid #959595; border-radius: 2px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.8); }
+  & ::-webkit-scrollbar-thumb:hover { background: linear-gradient(90deg, #e3f4fc 0%, #d6eef9 49%, #b9e0f5 50%, #a7d9f5 100%); border-color: #3c7fb1; }
+}

+ 565 - 0
css/themes/spry-theme-dwarf.css

@@ -0,0 +1,565 @@
+/*
+ * Spry CSS theme: "dwarf" — a GNOME libadwaita / Adwaita clone
+ * ---------------------------------------------------------------------
+ * Ported from the ARC Adwaita reference (libadwaita's own stylesheet):
+ * flat 9px buttons with 17px side padding, bold labels and 7%/16%
+ * currentColor hovers; opaque accent primaries that brighten 10% on
+ * hover; filled borderless entries (#e6e6e7 / #38383c) with a 2px
+ * accent focus edge; boxed-row cards (12px radius, shade hairline, no
+ * shadow); a hairline-outlined progress trough; 46x26 switches; dim
+ * caption column headers; GTK tab bars (the checked tab is a
+ * view-surface card bridging the strip's hairline into the panel);
+ * flat headerbar dialogs with a centred bold title; a link/accent
+ * split (#0461be / #81d0ff text over the same #3584e4 accent_bg in
+ * both modes). Shadows appear only on popovers and dialogs. Light +
+ * dark: the Adwaita dark palette repeats verbatim in the forced-dark
+ * and auto-dark blocks below.
+ */
+
+/* Light scope (Adwaita light). -------------------------------------------- */
+
+[data-theme="dwarf"],
+html:has(#spry-theme-dwarf:checked) {
+  color-scheme: light;
+
+  /* Palette — libadwaita named colours (adwaita.scss) */
+  --spry-bg: #fafafb;            /* window_bg_color */
+  --spry-bg-alt: #ebebed;        /* sidebar_bg_color band */
+  --spry-bg-inset: #e6e6e7;      /* entry_bg fill: wells, code, disabled */
+  --spry-surface: #ffffff;       /* card / boxed rows */
+  --spry-surface-hover: #f4f4f5;
+  --spry-headerbar-bg: #ffffff;  /* headerbar_bg_color */
+  --spry-view-bg: #ffffff;       /* view_bg_color: active tab card */
+  --spry-dialog-bg: #fafafb;     /* dialog_bg_color (AdwDialog, not popover white) */
+  --spry-popover-bg: #ffffff;    /* popover_bg_color */
+  --spry-popover-border: transparent;
+  --spry-fg: #323237;            /* window_fg (opaque equivalent) */
+  --spry-fg-muted: #5e5c64;      /* secondary */
+  --spry-border: rgb(0 0 6 / 0.12);         /* headerbar-shade hairline: visible on light */
+  --spry-border-strong: transparent;        /* filled entries have no edge */
+  --spry-headerbar-shade: rgb(0 0 6 / 0.12);
+  --spry-switch-knob: #ffffff;   /* slider_color */
+  --spry-accent: #3584e4;        /* accent_bg_color */
+  --spry-accent-hover: color-mix(in srgb, var(--spry-accent) 90%, #fff);
+  --spry-accent-fg: #ffffff;     /* accent_fg_color */
+  --spry-accent-soft: color-mix(in srgb, var(--spry-accent) 25%, transparent);
+  --spry-danger: #c30000;        /* destructive_color (text) */
+  --spry-danger-bg: #e01b24;     /* destructive_bg_color */
+  --spry-danger-fg: #ffffff;     /* destructive_fg_color */
+  --spry-success: #1b8553;       /* success_color */
+  --spry-warning: #9c6e03;       /* warning_color */
+  --spry-info: #2190a4;
+  --spry-link: #0461be;          /* accent_color (text) */
+  --spry-link-hover: color-mix(in srgb, var(--spry-link) 85%, #000);
+  --spry-backdrop: rgb(0 0 6 / 0.14);       /* dimming 14% */
+
+  /* Overlay scrollbars, as GNOME draws them */
+  scrollbar-width: thin;
+  scrollbar-color: color-mix(in srgb, var(--spry-fg) 20%, transparent) transparent;
+
+  /* Typography — Cantarell / Adwaita Sans, compact GTK metrics */
+  --spry-font: Cantarell, "Adwaita Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Liberation Sans", Arial, sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-mono: "Adwaita Mono", "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  --spry-font-size: 0.95rem;
+  --spry-line-height: 1.55;
+  --spry-heading-weight: 700;
+
+  /* Shape, depth, motion — flat controls; radii 9/12/15 like libadwaita */
+  --spry-radius: 12px;
+  --spry-radius-sm: 9px;
+  --spry-radius-lg: 15px;
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: none;
+  --spry-shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.05), 0 1px 5px 1px rgb(0 0 0 / 0.09),
+    0 2px 14px 3px rgb(0 0 0 / 0.05);
+  --spry-popover-shadow: var(--spry-shadow-lg);
+  --spry-glass: none;
+  --spry-transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
+  /* Dialog motion: the AdwFloatingSheet spring (scale 0.8, damping 0.62 /
+     stiffness 500, overshoot via linear()) for WINDOWS. Drawers and the
+     modal backdrop opt out below — they settle without the bounce. */
+  --spry-dialog-duration: 333ms;
+  --spry-dialog-ease: linear(0, 0.0195, 0.0714, 0.1464, 0.2365, 0.3348, 0.4359, 0.5353, 0.6296, 0.7165, 0.7944, 0.8625, 0.9203, 0.968, 1.0062, 1.0355, 1.0568, 1.0712, 1.0797, 1.0833, 1.0829, 1.0795, 1.0739, 1.0668, 1.0588, 1.0504, 1.042, 1.0339, 1.0264, 1.0196, 1.0136, 1.0084, 1.0041, 1.0006, 0.9979, 0.9959, 1);
+  --spry-dialog-close-duration: 128ms;
+  --spry-dialog-close-ease: linear(0, 0.0148, 0.0549, 0.1141, 0.1868, 0.2684, 0.3547, 0.4422, 0.5284, 0.6108, 0.6881, 0.7588, 0.8225, 0.8785, 0.9268, 0.9677, 1);
+  --spry-dialog-in: scale(0.8);
+
+  /* Drawers slide home smoothly, no bounce */
+  & :is(.drawer-left, .drawer-right) {
+    --spry-dialog-duration: 200ms;
+    --spry-dialog-ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
+    --spry-dialog-close-duration: 150ms;
+    --spry-dialog-close-ease: ease;
+  }
+
+  /* The modal backdrop fades on a plain ease, never the spring */
+  & dialog::backdrop,
+  & [popover]::backdrop {
+    transition-duration: 200ms;
+    transition-timing-function: ease;
+  }
+
+  & dialog[open]::backdrop,
+  & :popover-open::backdrop {
+    transition-duration: 200ms;
+    transition-timing-function: ease;
+  }
+
+  /* Controls — filled, borderless entries (34px metrics) */
+  --spry-control-bg: #e6e6e7;
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: transparent;
+  --spry-control-radius: var(--spry-radius-sm);
+  --spry-control-padding-x: 0.55rem;
+  --spry-control-padding-y: 0.4rem;
+  --spry-focus-ring: inset 0 0 0 2px var(--spry-accent);
+
+  /* Buttons — flat; hover/active are 7%/16% fills of the label colour */
+  --spry-btn-bg: transparent;
+  --spry-btn-fg: var(--spry-fg);
+  --spry-btn-border: transparent;
+  --spry-btn-bg-hover: color-mix(in srgb, var(--spry-fg) 7%, transparent);
+  --spry-btn-bg-active: color-mix(in srgb, var(--spry-fg) 16%, transparent);
+
+  /* Cards / tables / progress */
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: rgb(0 0 6 / 0.07);
+  --spry-card-padding: 0.75rem;
+  --spry-card-shadow: none;
+  --spry-table-header-bg: transparent;
+  --spry-table-hover: color-mix(in srgb, var(--spry-fg) 4%, transparent);
+  --spry-progress-bg: color-mix(in srgb, var(--spry-fg) 15%, transparent);
+  --spry-progress-fg: var(--spry-accent);
+
+  /* Text selection is a 25% accent wash, as in _colors.scss */
+  & ::selection { background-color: color-mix(in srgb, var(--spry-accent) 25%, transparent); }
+
+  /* Headings modest (title-1..4 scale); labels bold and compact */
+  & :where(h1, .h1) { font-size: 1.5rem; font-weight: 800; }
+  & :where(h2, .h2) { font-size: 1.3rem; font-weight: 800; }
+  & :where(h3, .h3) { font-size: 1.15rem; }
+  & :where(h4, .h4) { font-size: 1.05rem; }
+  & :where(h5, .h5) { font-size: 0.95rem; }
+  & :where(h6, .h6) { font-size: 0.82rem; text-transform: none; letter-spacing: normal; }
+  & :is(.label, .field .label) { font-weight: 700; font-size: 0.9rem; }
+
+  /* Buttons: 9px radius (core's --spry-control-radius) with libadwaita's
+     17px side padding and bold labels; :where keeps .btn-sm/.btn-lg (0,2,0)
+     and the variant classes winning. .btn-lg alone is a pill, as in Adwaita. */
+  & :where(.btn:not(.btn-link), input[type="submit"], input[type="reset"], input[type="button"]) {
+    padding-inline: 1.06rem;
+    font-weight: 700;
+  }
+  & :is(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]).btn-lg {
+    border-radius: var(--spry-radius-pill);
+  }
+
+  /* Opaque primaries darken 20% on press, as GTK's active overlay does */
+  & .btn-primary:active {
+    background: color-mix(in srgb, var(--spry-accent) 80%, #000);
+    border-color: transparent;
+  }
+
+  /* Destructive is a flat destructive-bg fill (10% white hover, 20% black press);
+     the outline action is a flat accent-coloured button */
+  & .btn-danger {
+    background: var(--spry-danger-bg);
+    border-color: transparent;
+    color: var(--spry-danger-fg);
+  }
+  & .btn-danger:hover {
+    background: color-mix(in srgb, var(--spry-danger-bg) 90%, #fff);
+    border-color: transparent;
+    color: var(--spry-danger-fg);
+  }
+  & .btn-danger:active {
+    background: color-mix(in srgb, var(--spry-danger-bg) 80%, #000);
+    border-color: transparent;
+    color: var(--spry-danger-fg);
+  }
+  & .btn-outline { background: transparent; border-color: transparent; color: var(--spry-link); }
+  & .btn-outline:hover {
+    background: color-mix(in srgb, var(--spry-accent) 10%, transparent);
+    border-color: transparent;
+    color: var(--spry-link);
+  }
+  & .btn-ghost:hover {
+    background: color-mix(in srgb, var(--spry-fg) 7%, transparent);
+    border-color: transparent;
+  }
+
+  /* Topbar as a headerbar: 47px banded bar, shade edge under it, flat bold
+     nav; the brand reads as a window title (no accent on hover) */
+  & .topbar {
+    min-height: 2.938rem;
+    background: var(--spry-headerbar-bg);
+    border-bottom-color: var(--spry-headerbar-shade);
+  }
+  & .topbar-brand:hover { color: var(--spry-fg); }
+  & .nav a { border-radius: var(--spry-radius-sm); font-weight: 700; color: var(--spry-fg); }
+  & .nav a:hover { background: color-mix(in srgb, var(--spry-fg) 7%, transparent); color: var(--spry-fg); }
+  & .nav a.active { background: color-mix(in srgb, var(--spry-fg) 10%, transparent); color: var(--spry-fg); }
+
+  /* GtkDropDown selects are buttons: bold, with a raised hover fill */
+  & select { font-weight: 700; }
+  & select:hover { background: color-mix(in srgb, var(--spry-fg) 15%, var(--spry-control-bg)); }
+
+  /* Sidebar rows: 36px, rounded, neutral 7% hover; the selected row is a
+     neutral 10% fill (navigation-sidebars select with a shade, not accent) */
+  & .menu a { min-height: 2.25rem; padding: 0.4rem 0.75rem; }
+  & .menu a:hover { background: color-mix(in srgb, var(--spry-fg) 7%, transparent); color: var(--spry-fg); }
+  & .menu a.active {
+    background: color-mix(in srgb, var(--spry-fg) 10%, transparent);
+    color: var(--spry-fg);
+    border-radius: var(--spry-radius-sm);
+  }
+
+  /* Submenu and popover-menu items keep the neutral 10% menu hover; rows are
+     6px and the sheets carry the reference's 6px popover inset */
+  & :where(.submenu, .popover-menu) { padding: 0.375rem; }
+  & .submenu a:hover { background: color-mix(in srgb, var(--spry-fg) 10%, transparent); color: var(--spry-fg); }
+  & .popover-menu a, & .popover-menu button { border-radius: 6px; }
+  & .popover-menu a:hover, & .popover-menu button:hover {
+    background: color-mix(in srgb, var(--spry-fg) 10%, transparent);
+    color: var(--spry-fg);
+  }
+
+  /* kbd keeps an edge even though filled entries have none */
+  & :where(kbd) {
+    background: var(--spry-bg-inset);
+    border-color: color-mix(in srgb, var(--spry-fg) 35%, transparent);
+  }
+
+  /* Switch: 46x26 track, 20px knob with the slider's drop shadow, 15% trough */
+  & .switch .track {
+    width: 2.875rem;
+    height: 1.625rem;
+    background: color-mix(in srgb, var(--spry-fg) 15%, transparent);
+  }
+  & .switch input:not(:checked):hover + .track {
+    background: color-mix(in srgb, var(--spry-fg) 20%, transparent);
+  }
+  & .switch .track::after {
+    top: 0.1875rem;
+    width: 1.25rem;
+    height: 1.25rem;
+    background: var(--spry-switch-knob);
+    box-shadow: 0 1px 3px rgb(0 0 6 / 0.2);
+  }
+  & .switch input:checked + .track::after { translate: 1.25rem 0; }
+
+  /* Spinner: AdwSpinner — libadwaita's faint ring plus a round-capped arc whose
+     ends ease apart and back together while it turns (the paintable's own
+     constants and easing), drawn as an animated SVG mask over currentColor at
+     the core's sizes; dark needs no extra colours */
+  --spry-spinner-mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cstyle%3ec\
+ircle%7bfill:none;stroke:%23000;stroke-width:2;stroke-linecap:round%7dg%7btransform-origin:8px 8px;a\
+nimation:t 1.2s linear infinite%7d.a%7banimation:d 1.59s linear infinite%7d@keyframes t%7bto%7btrans\
+form:rotate(1turn)%7d%7d@keyframes d%7b0%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-17.5%7d\
+1.25%25%7bstroke-dasharray:0.849 99.151;stroke-dashoffset:-16.938%7d2.5%25%7bstroke-dasharray:1.145 \
+98.855;stroke-dashoffset:-16.375%7d3.75%25%7bstroke-dasharray:1.635 98.365;stroke-dashoffset:-15.812\
+%7d5%25%7bstroke-dasharray:2.315 97.685;stroke-dashoffset:-15.25%7d6.25%25%7bstroke-dasharray:3.179 \
+96.821;stroke-dashoffset:-14.688%7d7.5%25%7bstroke-dasharray:4.22 95.78;stroke-dashoffset:-14.125%7d\
+8.75%25%7bstroke-dasharray:5.427 94.57300000000001;stroke-dashoffset:-13.563%7d10%25%7bstroke-dashar\
+ray:6.79 93.21;stroke-dashoffset:-13%7d11.25%25%7bstroke-dasharray:8.297 91.703;stroke-dashoffset:-1\
+2.438%7d12.5%25%7bstroke-dasharray:9.934 90.066;stroke-dashoffset:-11.875%7d13.75%25%7bstroke-dashar\
+ray:11.687 88.313;stroke-dashoffset:-11.313%7d15%25%7bstroke-dasharray:13.54 86.46000000000001;strok\
+e-dashoffset:-10.75%7d16.25%25%7bstroke-dasharray:15.421 84.57900000000001;stroke-dashoffset:-10.244\
+%7d17.5%25%7bstroke-dasharray:17.237 82.763;stroke-dashoffset:-9.868%7d18.75%25%7bstroke-dasharray:1\
+8.971 81.029;stroke-dashoffset:-9.622%7d20%25%7bstroke-dasharray:20.607 79.393;stroke-dashoffset:-9.\
+505%7d21.25%25%7bstroke-dasharray:22.129 77.871;stroke-dashoffset:-9.513%7d22.5%25%7bstroke-dasharra\
+y:23.522 76.47800000000001;stroke-dashoffset:-9.642%7d23.75%25%7bstroke-dasharray:24.77 75.23;stroke\
+-dashoffset:-9.89%7d25%25%7bstroke-dasharray:25.861 74.139;stroke-dashoffset:-10.251%7d26.25%25%7bst\
+roke-dasharray:26.782 73.218;stroke-dashoffset:-10.72%7d27.5%25%7bstroke-dasharray:27.521 72.479;str\
+oke-dashoffset:-11.29%7d28.75%25%7bstroke-dasharray:28.069 71.931;stroke-dashoffset:-11.955%7d30%25%\
+7bstroke-dasharray:28.416 71.584;stroke-dashoffset:-12.707%7d31.25%25%7bstroke-dasharray:28.557 71.4\
+43;stroke-dashoffset:-13.539%7d32.5%25%7bstroke-dasharray:28.485 71.515;stroke-dashoffset:-14.443%7d\
+33.75%25%7bstroke-dasharray:28.197 71.803;stroke-dashoffset:-15.409%7d35%25%7bstroke-dasharray:27.69\
+ 72.31;stroke-dashoffset:-16.429%7d36.25%25%7bstroke-dasharray:26.965 73.035;stroke-dashoffset:-17.4\
+93%7d37.5%25%7bstroke-dasharray:26.023 73.977;stroke-dashoffset:-18.592%7d38.75%25%7bstroke-dasharra\
+y:24.867 75.133;stroke-dashoffset:-19.715%7d40%25%7bstroke-dasharray:23.503 76.497;stroke-dashoffset\
+:-20.853%7d41.25%25%7bstroke-dasharray:21.938 78.062;stroke-dashoffset:-21.995%7d42.5%25%7bstroke-da\
+sharray:20.243 79.757;stroke-dashoffset:-23.132%7d43.75%25%7bstroke-dasharray:18.559 81.441;stroke-d\
+ashoffset:-24.254%7d45%25%7bstroke-dasharray:16.9 83.1;stroke-dashoffset:-25.35%7d46.25%25%7bstroke-\
+dasharray:15.278 84.722;stroke-dashoffset:-26.41%7d47.5%25%7bstroke-dasharray:13.7 86.3;stroke-dasho\
+ffset:-27.425%7d48.75%25%7bstroke-dasharray:12.177 87.82300000000001;stroke-dashoffset:-28.386%7d50%\
+25%7bstroke-dasharray:10.717 89.283;stroke-dashoffset:-29.283%7d51.25%25%7bstroke-dasharray:9.33 90.\
+67;stroke-dashoffset:-30.108%7d52.5%25%7bstroke-dasharray:8.023 91.977;stroke-dashoffset:-30.852%7d5\
+3.75%25%7bstroke-dasharray:6.804 93.196;stroke-dashoffset:-31.509%7d55%25%7bstroke-dasharray:5.681 9\
+4.319;stroke-dashoffset:-32.069%7d56.25%25%7bstroke-dasharray:4.659 95.341;stroke-dashoffset:-32.528\
+%7d57.5%25%7bstroke-dasharray:3.746 96.254;stroke-dashoffset:-32.879%7d58.75%25%7bstroke-dasharray:2\
+.947 97.053;stroke-dashoffset:-33.115%7d60%25%7bstroke-dasharray:2.266 97.734;stroke-dashoffset:-33.\
+234%7d61.25%25%7bstroke-dasharray:1.707 98.293;stroke-dashoffset:-33.23%7d62.5%25%7bstroke-dasharray\
+:1.274 98.726;stroke-dashoffset:-33.101%7d63.75%25%7bstroke-dasharray:0.97 99.03;stroke-dashoffset:-\
+32.843%7d65%25%7bstroke-dasharray:0.795 99.205;stroke-dashoffset:-32.455%7d66.25%25%7bstroke-dasharr\
+ay:0.75 99.25;stroke-dashoffset:-31.937%7d67.5%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-3\
+1.375%7d68.75%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-30.812%7d70%25%7bstroke-dasharray:\
+0.75 99.25;stroke-dashoffset:-30.25%7d71.25%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-29.6\
+87%7d72.5%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-29.125%7d73.75%25%7bstroke-dasharray:0\
+.75 99.25;stroke-dashoffset:-28.562%7d75%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-28%7d76\
+.25%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-27.437%7d77.5%25%7bstroke-dasharray:0.75 99.\
+25;stroke-dashoffset:-26.875%7d78.75%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-26.313%7d80\
+%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-25.75%7d81.25%25%7bstroke-dasharray:0.75 99.25;\
+stroke-dashoffset:-25.187%7d82.5%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-24.625%7d83.75%\
+25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-24.062%7d85%25%7bstroke-dasharray:0.75 99.25;str\
+oke-dashoffset:-23.5%7d86.25%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-22.938%7d87.5%25%7b\
+stroke-dasharray:0.75 99.25;stroke-dashoffset:-22.375%7d88.75%25%7bstroke-dasharray:0.75 99.25;strok\
+e-dashoffset:-21.813%7d90%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-21.25%7d91.25%25%7bstr\
+oke-dasharray:0.75 99.25;stroke-dashoffset:-20.687%7d92.5%25%7bstroke-dasharray:0.75 99.25;stroke-da\
+shoffset:-20.125%7d93.75%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-19.563%7d95%25%7bstroke\
+-dasharray:0.75 99.25;stroke-dashoffset:-19%7d96.25%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffs\
+et:-18.437%7d97.5%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset:-17.875%7d98.75%25%7bstroke-das\
+harray:0.75 99.25;stroke-dashoffset:-17.313%7d100%25%7bstroke-dasharray:0.75 99.25;stroke-dashoffset\
+:-16.75%7d%7d%3c/style%3e%3ccircle cx='8' cy='8' r='7' stroke-opacity='.15'/%3e%3cg%3e%3ccircle clas\
+s='a' cx='8' cy='8' r='7' pathLength='100'/%3e%3c/g%3e%3c/svg%3e") center / contain no-repeat;
+
+  & .spinner {
+    border: 0;
+    background-color: currentColor;
+    -webkit-mask: var(--spry-spinner-mask);
+    mask: var(--spry-spinner-mask);
+    animation: none;
+  }
+
+  /* Progress: the trough carries a hairline outline for definition; the
+     fill stays the plain accent, and the indeterminate pulse keeps it */
+  /* Trough hairline as a REAL border: it participates in layout, so the
+     value fill is sized inside it and can never paint taller than the
+     track (element-level inset shadows and pseudo rings both leaked) */
+  & progress {
+    border: 1px solid var(--spry-border);
+    border-radius: var(--spry-radius-sm);
+  }
+
+  /* Indeterminate: GTK's pulse is a square-ish block, not the rounded
+     stadium the core default uses */
+  & progress:indeterminate {
+    /* GTK pulse: rounded rectangle, softer than the stadium */
+    --spry-pulse-cap: 0.25em;
+    background:
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at var(--spry-pulse-cap) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 22% 100% no-repeat,
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at calc(100% - var(--spry-pulse-cap)) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 22% 100% no-repeat,
+      linear-gradient(90deg, transparent 0 var(--spry-pulse-cap), var(--spry-accent) var(--spry-pulse-cap) calc(100% - var(--spry-pulse-cap)), transparent calc(100% - var(--spry-pulse-cap))) 0% 50% / 22% 100% no-repeat,
+      color-mix(in srgb, var(--spry-fg) 15%, transparent);
+    animation: spry-pulse 1.4s linear infinite alternate;
+  }
+
+  /* Boxed rows: flat headers/footers on the hairline card; expander rows
+     hover at 3%, like AdwExpanderRow */
+  & .card-header, & .card-footer { background: transparent; }
+  & summary:hover { background: color-mix(in srgb, var(--spry-fg) 3%, transparent); }
+
+  /* Alerts are flat tinted boxes: no border, 12px via --spry-radius */
+  & :where(.alert-danger, .alert-success, .alert-warning, .alert-info) { border-color: transparent; }
+  & .alert-info { color: #155e70; }
+  & .alert-success { color: #14683f; }
+  & .alert-warning { color: #7d5a06; }
+  & .badge { color: #034f9c; }
+  & .hero-eyebrow { color: #0461be; }
+
+  /* Invalid entries take the destructive focus edge, not a glow */
+  & .is-invalid:focus { box-shadow: inset 0 0 0 2px var(--spry-danger) !important; }
+
+  /* Column headers: dim caption headings, no band; row separators are
+     the reference's 15% currentColor borders */
+  & :where(th, td) { border-color: color-mix(in srgb, var(--spry-fg) 15%, transparent); }
+  & :where(thead th) {
+    background: transparent;
+    text-transform: none;
+    letter-spacing: normal;
+    font-size: 0.82em;
+    font-weight: 700;
+    color: var(--spry-fg-muted);
+    border-bottom: 1px solid var(--spry-border);
+  }
+
+  /* Tabs: a GTK tab bar — flat sidebar-coloured strip under top-rounded
+     labels with neutral hovers; the checked label (core's nth-of-type
+     mechanism, re-scoped so it wins the cascade over the core's
+     underline) is a view-surface card, bold window-fg text, whose -1px
+     margin bridges the strip's hairline so it connects to the panel. */
+  & .tab-list { background: var(--spry-bg-alt); }
+  & .tab-list label:hover {
+    background: color-mix(in srgb, var(--spry-fg) 7%, transparent);
+    color: var(--spry-fg);
+  }
+  & .tabs > input:nth-of-type(1):checked ~ .tab-list label:nth-of-type(1),
+  & .tabs > input:nth-of-type(2):checked ~ .tab-list label:nth-of-type(2),
+  & .tabs > input:nth-of-type(3):checked ~ .tab-list label:nth-of-type(3),
+  & .tabs > input:nth-of-type(4):checked ~ .tab-list label:nth-of-type(4),
+  & .tabs > input:nth-of-type(5):checked ~ .tab-list label:nth-of-type(5),
+  & .tabs > input:nth-of-type(6):checked ~ .tab-list label:nth-of-type(6) {
+    color: var(--spry-fg);
+    font-weight: 700;
+    background: var(--spry-view-bg);
+    box-shadow: none;
+    margin-bottom: -1px;
+  }
+
+  /* Dialogs as AdwDialogs: dialog-bg surface, soft dialog shadow, flat 47px
+     headerbar with a centred bold title, 24px circular flat close button,
+     borderless response area whose buttons carry AdwAlertDialog's 12px
+     radius and 60px min-width */
+  & :where(dialog) {
+    background: var(--spry-dialog-bg);
+    box-shadow:
+      0 0 14px 2px rgb(0 0 6 / 0.03),
+      0 0 5px 2px rgb(0 0 6 / 0.1),
+      0 0 0 1px rgb(0 0 0 / 0.05);
+  }
+  & .dialog-titlebar {
+    min-height: 2.938rem;
+    background: transparent;
+    border-bottom-color: transparent;
+  }
+  & .dialog-title { font-weight: 700; text-align: center; }
+  & .dialog-close {
+    width: 1.5rem;
+    height: 1.5rem;
+    padding: 0;
+    border-radius: 50%;
+    background: color-mix(in srgb, var(--spry-fg) 10%, transparent);
+    color: var(--spry-fg);
+  }
+  & .dialog-close:hover {
+    background: color-mix(in srgb, var(--spry-fg) 15%, transparent);
+    color: var(--spry-fg);
+  }
+  & .dialog-footer {
+    gap: 0.75rem;
+    padding: 0.75rem;
+    background: transparent;
+    border-top-color: transparent;
+  }
+  & .dialog-footer :is(.btn, button) { min-width: 3.75rem; border-radius: var(--spry-radius); }
+
+  /* Drawers are sidebar panes: the sidebar band, not the popover white */
+  & :is(.drawer-left, .drawer-right) { background: var(--spry-bg-alt); }
+
+  /* Hero: a flat welcome page — window bg with a soft accent glow */
+  & .hero {
+    background:
+      radial-gradient(60rem 24rem at 50% -8rem, var(--spry-accent-soft), transparent 70%),
+      var(--spry-bg);
+    border-block-color: transparent;
+  }
+  & .hero-title { font-weight: 800; }
+
+  /* Avatars: the GNOME user-menu blue gradient with its pale-blue label */
+  & .avatar { background: linear-gradient(#83b6ec, #337fdc); color: #cfe1f5; font-weight: 700; }
+
+  /* OTP fields are large entries: the 12px large-entry radius */
+  & .otp input { border-radius: var(--spry-radius); }
+
+  /* Pagination: flat page buttons, neutral hovers, the current page a
+     10% fill with a bold label (no accent block) */
+  & .pagination a:hover {
+    background: color-mix(in srgb, var(--spry-fg) 7%, transparent);
+    color: var(--spry-fg);
+  }
+  & .pagination [aria-current="page"] {
+    background: color-mix(in srgb, var(--spry-fg) 10%, transparent);
+    color: var(--spry-fg);
+    font-weight: 700;
+  }
+}
+
+/* Forced dark (Adwaita dark). --------------------------------------------- */
+
+[data-theme="dwarf"][data-mode="dark"],
+html:has(#spry-theme-dwarf:checked):has(#spry-mode-dark:checked),
+[data-theme="dwarf"]:has(#spry-mode-dark:checked) {
+  color-scheme: dark;
+
+  /* Light-on-dark counterparts of the light scope's text fixes */
+  & .badge { color: #81d0ff; }
+  & .hero-eyebrow { color: #81d0ff; }
+  & .alert-info { color: #8fd8ea; }
+
+  /* Palette — libadwaita named colours (adwaita-dark.scss) */
+  --spry-bg: #222226;            /* window_bg_color */
+  --spry-bg-alt: #2e2e32;        /* sidebar / headerbar band */
+  --spry-bg-inset: #38383c;      /* entry_bg fill */
+  --spry-surface: #343437;       /* card (opaque equivalent) */
+  --spry-surface-hover: #3a3a3f;
+  --spry-headerbar-bg: #2e2e32;
+  --spry-view-bg: #1d1d20;       /* view_bg_color */
+  --spry-dialog-bg: #36363a;     /* dialog_bg_color */
+  --spry-popover-bg: #36363a;    /* popover_bg_color */
+  --spry-popover-border: rgb(255 255 255 / 0.07);
+  --spry-fg: #ffffff;
+  --spry-fg-muted: rgb(255 255 255 / 0.55);  /* dim labels: fg at 55% */
+  --spry-border: rgb(0 0 6 / 0.36);
+  --spry-headerbar-shade: rgb(0 0 6 / 0.36);
+  --spry-switch-knob: #d2d2d3;   /* white 80% over the view bg */
+  --spry-accent: #3584e4;
+  --spry-accent-fg: #ffffff;
+  --spry-danger: #ff938c;        /* destructive_color */
+  --spry-danger-bg: #c01c28;     /* destructive_bg_color */
+  --spry-danger-fg: #ffffff;
+  --spry-success: #78e9ab;       /* success_color */
+  --spry-warning: #ffc252;       /* warning_color */
+  --spry-info: #2190a4;
+  --spry-link: #81d0ff;          /* accent_color */
+  --spry-link-hover: color-mix(in srgb, var(--spry-link) 85%, #fff);
+  --spry-backdrop: rgb(0 0 6 / 0.5);        /* dimming 50% */
+
+  --spry-shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.4), 0 1px 5px 1px rgb(0 0 0 / 0.35),
+    0 2px 14px 3px rgb(0 0 0 / 0.25);
+
+  --spry-control-bg: #38383c;
+
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: rgb(0 0 6 / 0.36);
+}
+
+/* Auto dark (system preference unless overridden). ------------------------ */
+
+@media (prefers-color-scheme: dark) {
+  [data-theme="dwarf"]:not([data-mode="light"]):not(:has(#spry-mode-light:checked)),
+  html:has(#spry-theme-dwarf:checked):has(#spry-mode-auto:checked) {
+    color-scheme: dark;
+
+    & .badge { color: #81d0ff; }
+    & .hero-eyebrow { color: #81d0ff; }
+    & .alert-info { color: #8fd8ea; }
+
+    /* Palette — libadwaita named colours (adwaita-dark.scss) */
+    --spry-bg: #222226;            /* window_bg_color */
+    --spry-bg-alt: #2e2e32;        /* sidebar / headerbar band */
+    --spry-bg-inset: #38383c;      /* entry_bg fill */
+    --spry-surface: #343437;       /* card (opaque equivalent) */
+    --spry-surface-hover: #3a3a3f;
+    --spry-headerbar-bg: #2e2e32;
+    --spry-view-bg: #1d1d20;       /* view_bg_color */
+    --spry-dialog-bg: #36363a;     /* dialog_bg_color */
+    --spry-popover-bg: #36363a;    /* popover_bg_color */
+    --spry-popover-border: rgb(255 255 255 / 0.07);
+    --spry-fg: #ffffff;
+    --spry-fg-muted: rgb(255 255 255 / 0.55);  /* dim labels: fg at 55% */
+    --spry-border: rgb(0 0 6 / 0.36);
+    --spry-headerbar-shade: rgb(0 0 6 / 0.36);
+    --spry-switch-knob: #d2d2d3;   /* white 80% over the view bg */
+    --spry-accent: #3584e4;
+    --spry-accent-fg: #ffffff;
+    --spry-danger: #ff938c;        /* destructive_color */
+    --spry-danger-bg: #c01c28;     /* destructive_bg_color */
+    --spry-danger-fg: #ffffff;
+    --spry-success: #78e9ab;       /* success_color */
+    --spry-warning: #ffc252;       /* warning_color */
+    --spry-info: #2190a4;
+    --spry-link: #81d0ff;          /* accent_color */
+    --spry-link-hover: color-mix(in srgb, var(--spry-link) 85%, #fff);
+    --spry-backdrop: rgb(0 0 6 / 0.5);        /* dimming 50% */
+
+    --spry-shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.4), 0 1px 5px 1px rgb(0 0 0 / 0.35),
+      0 2px 14px 3px rgb(0 0 0 / 0.25);
+
+    --spry-control-bg: #38383c;
+
+    --spry-card-bg: var(--spry-surface);
+    --spry-card-border: rgb(0 0 6 / 0.36);
+  }
+}

+ 448 - 0
css/themes/spry-theme-formal.css

@@ -0,0 +1,448 @@
+/*!
+ * Spry CSS theme — "Formal" — wedding-stationery suite, second fidelity
+ * pass against the real wedding site: plum header/footer bands, mauve nav
+ * band and mauve expanders (never white), berry buttons in thick pale-berry
+ * mats, frosted panels with the double pressed shadow, script form labels,
+ * ivory paper, serif ink. Light-only. Activated with <html data-theme=
+ * "formal"> or the demo radio #spry-theme-formal. Load after spry.css.
+ * No JavaScript or imports; stacks fall back to Georgia / cursive.
+ */
+
+[data-theme="formal"],
+html:has(#spry-theme-formal:checked) {
+  color-scheme: light;
+
+  /* ---- Palette (the core derives soft tints automatically) ---- */
+  --spry-bg: #fdfbf7;            /* ivory paper */
+  --spry-bg-alt: #f7f2ec;        /* stationery bands, wells, titlebars */
+  --spry-bg-inset: #f3ece2;      /* disabled fills, code blocks */
+  --spry-surface: #ffffff;       /* popovers, menus, tables: kept opaque */
+  --spry-surface-hover: #faf6ef;
+  --spry-fg: #3f3a3c;            /* ink */
+  --spry-fg-muted: #7d7379;
+  --spry-border: #e6dcd2;        /* hairlines */
+  --spry-border-strong: #b47e9a; /* berry mat hsl(329 26% 60%) */
+  --spry-accent: #844d6a;        /* berry — hsl(329 26% 41%), site buttons */
+  --spry-accent-hover: #915574;  /* site hover lightens to hsl(329 26% 45%) */
+  --spry-accent-fg: #ffffff;
+  --spry-danger: #a6304c;        /* deep rose */
+  --spry-success: #5e713d;       /* sage green — hsl(82 30% 34%) */
+  --spry-warning: #8f6824;       /* antique gold */
+  --spry-info: #6f5a7c;          /* plum */
+  --spry-link: var(--spry-success);   /* sage links, as on the site */
+  --spry-link-hover: #49582f;
+  --spry-backdrop: rgb(63 58 60 / 0.5);
+  --formal-gold: #b08d3e;        /* gilded hairlines, fleurons, frames */
+  --formal-plum: #854e6a;        /* site header / footer plum */
+  --formal-mauve: #80628f;       /* hsl(280 19% 47%): nav band, expander edge */
+  --formal-mauve-bar: #c3b3cb;   /* hsl(280 19% 75%): expander summary bar */
+  --formal-mauve-pale: #e7e1ea;  /* hsl(280 19% 90%): expander body */
+  --formal-berry-pale: #ecdfe6;  /* hsl(329 26% 90%): secondary fill */
+  --formal-berry-mat: #d0afc0;   /* hsl(329 26% 75%): secondary mat */
+
+  /* ---- Typography ---- */
+  --spry-font: "Cormorant Garamond", "EB Garamond", Georgia, serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-display: "Snell Roundhand", "Apple Chancery", "Segoe Script",
+    "Brush Script MT", cursive; /* script: h1, hero, brand, form labels */
+  --spry-font-mono: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
+  --spry-font-size: 1.125rem;    /* generous: the site set p/form text to 1.2em */
+  --spry-line-height: 1.65;
+  --spry-heading-weight: 600;
+
+  /* ---- Shape, depth, motion ---- */
+  --spry-radius: 0.75rem;        /* 12px — the site's only radius */
+  --spry-radius-sm: 0.75rem;     /* 12px here too: chips, menu items */
+  --spry-radius-lg: 0.75rem;
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: 0 1px 2px rgb(63 58 60 / 0.05), 0 6px 18px -6px rgb(63 58 60 / 0.07);
+  --spry-shadow-lg: 0 24px 48px -16px rgb(63 58 60 / 0.24), 0 6px 16px rgb(63 58 60 / 0.08);
+  --spry-glass: none;
+  --spry-transition: 150ms ease;
+  --spry-dialog-duration: 200ms;
+  --spry-dialog-ease: cubic-bezier(0.2, 0.9, 0.3, 1);
+  --spry-dialog-in: translateY(0.6rem) scale(0.98);
+  --spry-focus-ring: 0 0 0 3px var(--spry-surface), 0 0 0 4px var(--spry-border),
+    0 0 0 6px color-mix(in srgb, var(--spry-accent) 30%, transparent);
+
+  /* ---- Controls: transparent wells, berry hairline (site inputs) ---- */
+  --spry-control-bg: transparent;
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: var(--spry-border-strong);
+  --spry-control-radius: 0.75rem;    /* 12px */
+  --spry-control-padding-x: 1.35rem; /* buttons: core adds .15rem = 24px */
+  --spry-control-padding-y: 0.5rem;  /* 8px, the site's control padding */
+
+  /* ---- Buttons: the site's berry seals (color rides the vars) ---- */
+  --spry-btn-bg: var(--spry-accent);
+  --spry-btn-fg: #ffffff;
+  --spry-btn-border: var(--spry-border-strong);
+  --spry-btn-bg-hover: var(--spry-accent-hover);
+  --spry-btn-bg-active: #74445d;     /* hsl(329 26% 36%), pressed darker */
+
+  /* ---- Cards: frosted panel on a 6px white mat (site form/#response) ---- */
+  --spry-card-bg: rgb(255 255 255 / 0.5);
+  --spry-card-border: #ffffff;
+  --spry-card-padding: 2em;      /* the site's 2em panel padding */
+  --spry-card-shadow: 0 0 5px rgb(0 0 0 / 0.2), inset 0 0 5px rgb(0 0 0 / 0.2);
+  --spry-table-header-bg: var(--formal-berry-pale);
+  --spry-table-hover: color-mix(in srgb, var(--spry-accent) 5%, var(--spry-surface));
+  --spry-progress-bg: var(--spry-bg-alt);
+  --spry-progress-fg: var(--spry-accent);
+  --spry-popover-bg: var(--spry-surface);
+  --spry-popover-border: var(--spry-border);
+  --spry-popover-shadow: var(--spry-shadow-lg);
+
+  /* ---- Structural polish (looks the variables can't reach) ---- */
+
+  /* Letterpress headings: small-caps, letter-spaced, debossed ink.
+     h1 is the script display moment — swashes in berry. */
+  & :is(h2, h3, h4, h5, .card-title) {
+    font-variant-caps: all-small-caps; letter-spacing: 0.08em;
+    text-shadow: 0 1px 0 #fff;
+  }
+  & h2 { font-size: 2em; }       /* the site's h2 scale */
+  & h1 {
+    font-family: var(--spry-font-display); font-weight: 400;
+    font-size: clamp(2.3rem, 1.7rem + 2.4vw, 3.3rem); line-height: 1.25;
+    color: var(--spry-accent); text-shadow: 0 1px 0 #fff;
+  }
+  & h6 { letter-spacing: 0.12em; text-shadow: 0 1px 0 #fff; }
+
+  /* A card that opens on its title is a hero moment — script it */
+  & .card > .card-title:first-child {
+    font-family: var(--spry-font-display); font-variant-caps: normal;
+    font-weight: 400; font-size: 1.5rem; color: var(--spry-accent); letter-spacing: 0;
+  }
+  & .card-subtitle { font-style: italic; }
+
+  /* Form labels in script, as the site lettered its form text */
+  & :is(.label, legend) {
+    font-family: var(--spry-font-display); font-weight: 600;
+    font-size: 1.15em; letter-spacing: 0;
+  }
+
+  /* Dividers as menu-card ornaments: hairline with a gold fleuron */
+  & hr {
+    position: relative; overflow: visible; border: 0;
+    border-top: 1px solid color-mix(in srgb, var(--spry-border-strong) 55%, transparent);
+    margin-block: 2.4rem;
+    &::after {
+      content: "❧"; position: absolute; left: 50%; top: 0;
+      translate: -50% -55%; color: var(--formal-gold);
+      font-size: 1.15rem; line-height: 1;
+    }
+  }
+
+  /* Panels & generous spacing: thick stationery mats, 15px+ gutters */
+  & .card { border-width: 6px; }
+  & fieldset { padding: 1.25rem 1.5rem; }
+  & :is(.popover-menu, .submenu) { padding: 0.6rem; }
+
+  /* Topbar as the site header: plum band, white text, generous padding,
+     pressed-in shade along the bottom edge */
+  & .topbar {
+    background: var(--formal-plum); color: #fff; border-bottom: 0;
+    padding-block: clamp(1.1rem, 2.5vw, 2.5rem); /* up to the site's 40px */
+    box-shadow: inset 0 -10px 6px rgb(0 0 0 / 0.2);
+  }
+  & footer.topbar { box-shadow: inset 0 10px 6px rgb(0 0 0 / 0.2); }
+
+  /* Brand: script names in white on flanking gilt rules */
+  & .topbar-brand {
+    display: inline-flex; align-items: center; gap: 0.65rem;
+    font-family: var(--spry-font-display); font-weight: 400;
+    font-size: 1.6rem; letter-spacing: 0; color: #fff;
+    &::before,
+    &::after {
+      content: ""; width: 1.4rem; height: 1px;
+      background: color-mix(in srgb, var(--formal-gold) 70%, transparent);
+      transition: width var(--spry-transition);
+    }
+    &:hover { color: #fff; }
+    &:hover::before,
+    &:hover::after { width: 2.2rem; background: var(--formal-gold); }
+  }
+
+  /* Nav links on paper: engraved small-caps, berry underline on hover */
+  & .nav a {
+    font-variant-caps: all-small-caps; font-weight: 500;
+    letter-spacing: 0.14em; border-radius: 0;
+  }
+  & .nav a:hover,
+  & .nav a.active {
+    background: transparent; color: var(--spry-accent);
+    text-decoration: underline; text-decoration-thickness: 1.5px;
+    text-underline-offset: 0.4em;
+  }
+  & .nav a:hover { text-decoration-color: var(--spry-accent); }
+  & .nav a.active { text-decoration-color: var(--formal-gold); }
+  /* Nav in the topbar = the site's mauve band with white links */
+  & .topbar .nav {
+    background: var(--formal-mauve); border-radius: var(--spry-radius);
+    padding: 0.35rem 0.65rem; box-shadow: inset 0 -4px 3px rgb(0 0 0 / 0.2);
+  }
+  & .topbar .nav a,
+  & .topbar .nav a:hover,
+  & .topbar .nav a.active {
+    background: transparent; color: #fff; text-decoration-color: #fff;
+  }
+
+  /* Menus as menu cards: flat items, gold course marker on hover */
+  & :is(.menu, .submenu) a { letter-spacing: 0.05em; border-radius: 0; }
+  & .menu a:hover,
+  & .menu a.active {
+    background: color-mix(in srgb, var(--spry-accent) 7%, var(--spry-surface));
+    box-shadow: inset 2px 0 0 var(--formal-gold);
+  }
+  & .submenu a:hover {
+    background: color-mix(in srgb, var(--spry-accent) 10%, var(--spry-surface));
+    color: var(--spry-accent);
+  }
+
+  /* Breadcrumbs: legible gold diamond separators */
+  & .breadcrumb li + li::before {
+    content: "◆"; font-size: 0.65em; vertical-align: 0.22em; color: var(--formal-gold);
+  }
+
+  /* Buttons: the site's berry seals — thick pale-berry mat border and the
+     double (outer + inset) pressed shadow. Fill, text, hover and the
+     8px 24px padding ride the vars; :where keeps this light enough that
+     core .btn-sm/.btn-lg sizing at (0,2,0) still wins. */
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):not(.btn-link):not(.btn-ghost):not(.dialog-close):not(.popover-menu *) {
+    border-width: 6px; font-weight: 700;
+    box-shadow: 0 0 5px rgb(0 0 0 / 0.2), inset 0 0 5px rgb(0 0 0 / 0.2);
+  }
+  & :is(.btn-primary, .btn-danger) { border-color: var(--spry-border-strong); }
+  /* Outline button = the site's secondary: pale berry, deeper mat */
+  & .btn-outline {
+    background: var(--formal-berry-pale); border-color: var(--formal-berry-mat);
+    color: #444444;
+    &:hover {
+      background: #f4ecf0; border-color: var(--spry-border-strong); color: #444444;
+    }
+  }
+
+  /* Inputs/selects/textareas: transparent wells, 2px berry hairline,
+     8px padding — exactly the site's recipe */
+  & :where(input[type="text"], input[type="email"], input[type="password"],
+      input[type="number"], input[type="search"], input[type="tel"],
+      input[type="url"], input[type="date"], input[type="time"],
+      input[type="datetime-local"], input[type="month"], input[type="week"],
+      input[type="color"], select, textarea, .input) {
+    border-width: 2px;
+    padding: 8px;
+  }
+
+  /* Checkboxes & radios: 20px marks, dark-berry accent (site) */
+  & :where(input[type="checkbox"], input[type="radio"]) {
+    width: 1.25em; height: 1.25em; accent-color: #60394d;
+  }
+  & :is(input[type="checkbox"], input[type="radio"]):focus-visible {
+    outline: 2px solid var(--spry-accent); outline-offset: 2px;
+  }
+  /* Choice labels as the site's check-labels: berry frame, 12px radius,
+     8px padding; hover deepens the frame */
+  & .choice {
+    border: 2px solid var(--spry-border-strong); border-radius: var(--spry-radius);
+    padding: 8px;
+    &:hover { border-color: var(--spry-accent); }
+  }
+  & .choices { gap: 12px; }
+
+  /* Selects: swap the native arrow for a small berry serif caret */
+  & select:not([size]):not([multiple]) {
+    appearance: none; padding-right: 2.1rem;
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1.5 1.5 6 6 10.5 1.5' fill='none' stroke='%23844d6a' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+    background-repeat: no-repeat; background-position: right 0.75rem center;
+  }
+
+  /* Expanders as the site's details/summary — MAUVE, not white: pale-mauve
+     body, deeper mauve summary bar, plum rule under the open summary */
+  & details {
+    background: var(--formal-mauve-pale); border: 2px solid var(--formal-mauve);
+    border-radius: var(--spry-radius); margin-bottom: 18px; overflow: hidden;
+  }
+  & details + details { margin-top: 0; }
+  & details > summary {
+    background: var(--formal-mauve-bar); color: var(--spry-fg);
+    font-family: var(--spry-font); font-size: 1.2em; font-weight: 700;
+    padding: 8px 18px; border-radius: 0;
+    &:hover { background: #bba9c4; }
+  }
+  & details > summary::before { border-color: #5d4766; }
+  & details[open] > summary { border-bottom: 2px solid var(--formal-mauve); }
+  & details > :not(summary):not(:last-child) { padding: 18px 18px 0; }
+  & details > :not(summary):last-child { padding: 18px; }
+  & .expander-group > details { margin-bottom: 0; }
+  & .expander-group > details + details { margin-top: -2px; }
+
+  /* Progress: an envelope window — ivory track in a thin gilt frame,
+     berry fill with a paper highlight (engine rules kept separate) */
+  & progress {
+    background: var(--spry-bg-alt);
+    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--formal-gold) 55%, transparent);
+  }
+  & progress::-webkit-progress-bar {
+    background: var(--spry-bg-alt);
+    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--formal-gold) 55%, transparent);
+  }
+  & progress::-webkit-progress-value {
+    background: var(--spry-progress-fg);
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.3);
+  }
+  & progress::-moz-progress-bar {
+    background: var(--spry-progress-fg);
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.3);
+  }
+
+  /* Indeterminate: theme-explicit so it can never be shadowed */
+  & progress:indeterminate {
+    --spry-pulse-cap: 0.4em;
+    background:
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at var(--spry-pulse-cap) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at calc(100% - var(--spry-pulse-cap)) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+      linear-gradient(90deg, transparent 0 var(--spry-pulse-cap), var(--spry-accent) var(--spry-pulse-cap) calc(100% - var(--spry-pulse-cap)), transparent calc(100% - var(--spry-pulse-cap))) 0% 50% / 25% 100% no-repeat,
+      var(--spry-bg-alt);
+    animation: spry-pulse 1.4s linear infinite alternate;
+  }
+
+  /* Menu-style tables: no grid box, pale-berry header band, hairline courses */
+  & table { border: 0; }
+  & thead th {
+    color: var(--spry-accent); font-size: 0.85em; text-transform: none;
+    font-variant-caps: all-small-caps; letter-spacing: 0.14em;
+    border-bottom: 2px solid var(--spry-border-strong);
+  }
+  & caption { font-style: italic; color: #717171; }
+
+  /* Alerts as enclosure notes; danger = the site's solid .form-errors */
+  & .alert {
+    border-inline-start: 3px solid var(--spry-accent);
+    & p:first-child { font-style: italic; }
+    &.alert-success { border-inline-start-color: var(--spry-success); }
+    &.alert-warning { border-inline-start-color: var(--spry-warning); color: #7a5719; }
+    &.alert-info { border-inline-start-color: var(--spry-info); }
+  }
+  & .alert.alert-danger {
+    background: #b21f1f; border: 0; color: #fff; font-weight: 700;
+    & p { font-style: normal; }
+  }
+
+  /* Badges as place-card chips: ivory, berry text, berry pill edge */
+  & .badge:not(.badge-danger):not(.badge-success):not(.badge-warning):not(.badge-info):not(.badge-muted) {
+    background: var(--spry-bg-alt); color: var(--spry-accent);
+    border: var(--spry-border-width) solid var(--spry-accent); letter-spacing: 0.03em;
+  }
+
+  /* Dialogs, as the suite's invite modal: borderless white card, 16px radius,
+     one large soft shadow, roomy padding — and a blurred scrim behind it.
+     Anchored popovers keep a clear backdrop (no blur on menus). */
+  & dialog,
+  & [popover]:has(> .dialog-titlebar, > .dialog-body, > .dialog-footer) {
+    border-radius: 1rem;
+    box-shadow: 0 15px 50px rgb(63 58 60 / 0.35);
+  }
+  & dialog { width: min(95vw, 42rem); }
+  & dialog::backdrop,
+  & [popover]:not(.popover-anchor):not(.drawer-left):not(.drawer-right)::backdrop {
+    backdrop-filter: blur(2px);
+  }
+  & .dialog-body { padding: 1.5rem 2rem; }
+
+  /* Titlebar: the suite's plum-tinted gradient header with a berry rule */
+  & .dialog-titlebar {
+    background: linear-gradient(135deg, rgb(133 78 106 / 0.16) 0%, rgb(133 78 106 / 0.07) 100%);
+    border-bottom: 2px solid color-mix(in srgb, var(--spry-accent) 55%, transparent);
+    padding: 1.1rem 1.75rem;
+    & .dialog-title {
+      font-family: var(--spry-font-heading); font-weight: 600;
+      font-size: 1.35rem; color: #854e6a; letter-spacing: 0.01em;
+    }
+    & .dialog-close {
+      border: 0; border-radius: var(--spry-radius-sm);
+      background: rgb(133 78 106 / 0.12); color: #6d3e55; font-size: 1.3rem;
+      &:hover { background: rgb(133 78 106 / 0.24); color: #53303f; }
+    }
+  }
+
+  /* Switch: slim gold track, ivory knob */
+  & .switch .track {
+    width: 2.3rem; height: 1.15rem;
+    background: color-mix(in srgb, var(--formal-gold) 28%, var(--spry-surface));
+    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--formal-gold) 50%, transparent);
+    &::after {
+      top: 0.15rem; left: 0.15rem; width: 0.85rem; height: 0.85rem;
+      background: var(--spry-bg); box-shadow: 0 1px 2px rgb(63 58 60 / 0.3);
+    }
+  }
+  & .switch input:checked + .track {
+    background: var(--formal-gold);
+    &::after { translate: 1.15rem 0; }
+  }
+
+  /* ---- Invitation-suite components (section 20) ---- */
+
+  /* Hero as the site header band: plum, white centered text, 40px padding,
+     pressed-in shade along the bottom */
+  & .hero {
+    background: var(--formal-plum); color: #fff; border-block: 0;
+    padding-block: 2.5rem;
+    box-shadow: inset 0 -10px 6px rgb(0 0 0 / 0.2);
+  }
+  & .hero-eyebrow {
+    display: inline-flex; align-items: center; gap: 0.6rem;
+    font-weight: 600; letter-spacing: 0.28em; text-transform: none;
+    font-variant-caps: all-small-caps; color: #e4eed3;
+    &::before,
+    &::after { content: "◆"; color: var(--formal-gold); font-size: 0.45em; }
+  }
+  & .hero-title {
+    font-family: var(--spry-font-display); font-weight: 400;
+    color: #fff; letter-spacing: 0; text-shadow: 0 1px 0 rgb(0 0 0 / 0.25);
+  }
+  & .hero-sub { color: #e4eed3; font-style: italic; }
+  /* Avatars: ivory cameos — berry initials in a paper mat, gilt on hover */
+  & .avatar {
+    background: var(--spry-bg-alt); color: var(--spry-accent);
+    border: 1px solid color-mix(in srgb, var(--spry-accent) 30%, var(--spry-border));
+    box-shadow: 0 0 0 2px var(--spry-surface), 0 0 0 3px var(--spry-border);
+  }
+  & .avatar:hover {
+    box-shadow: 0 0 0 2px var(--spry-surface), 0 0 0 3px var(--formal-gold),
+      0 3px 8px rgb(63 58 60 / 0.15);
+  }
+
+  /* OTP: stationery wells, serif figures, gilded dash */
+  & .otp input { background: var(--spry-bg-alt); font-family: var(--spry-font); font-size: 1.4rem; }
+  & .otp-separator { color: var(--formal-gold); }
+  /* Drawers: ivory panel, plum titlebar like the site's header band */
+  & :is(.drawer-left, .drawer-right) {
+    background: var(--spry-bg);
+    border-color: color-mix(in srgb, var(--spry-accent) 30%, var(--spry-border));
+    & .dialog-titlebar {
+      background: var(--spry-info);
+      border-bottom-color: color-mix(in srgb, var(--formal-gold) 70%, transparent);
+      & .dialog-title { color: #fff; }
+      & .dialog-close {
+        color: #fff; border-color: rgb(255 255 255 / 0.55);
+        &:hover { background: #fff; border-color: #fff; color: var(--spry-info); }
+      }
+    }
+  }
+
+  /* Pagination: hairline chips; the current page is a berry wax seal */
+  & .pagination :is(a, span) {
+    border-color: var(--spry-border); border-radius: var(--spry-radius-pill);
+    font-variant-caps: all-small-caps; letter-spacing: 0.06em;
+  }
+  & .pagination a:hover {
+    background: color-mix(in srgb, var(--spry-accent) 8%, var(--spry-surface));
+    border-color: color-mix(in srgb, var(--spry-accent) 45%, var(--spry-border));
+  }
+  & .pagination [aria-current="page"] {
+    background: var(--spry-accent); border-color: var(--spry-border-strong);
+    color: #fff; box-shadow: 0 0 5px rgb(0 0 0 / 0.2);
+  }
+}

+ 266 - 0
css/themes/spry-theme-glassy.css

@@ -0,0 +1,266 @@
+/*
+ * Spry CSS theme — "Glassy" (ZL4GQ-faithful)
+ *
+ * Solid dark cards (#242424) floating on a deep blue-violet→cyan gradient
+ * field; glass (blur) reserved for chrome bars only (topbar, hero band,
+ * drawer shells, dialog/popover titlebars, card strips). Big Cantarell type,
+ * azure accent, signature wide glow shadow (0 0 80px). Load after spry.css.
+ * No JavaScript, no external resources, no mode blocks.
+ */
+
+[data-theme="glassy"],
+html:has(#spry-theme-glassy:checked) {
+  color-scheme: dark;
+  accent-color: var(--spry-accent);
+
+  /* ---- Palette ---- */
+  --spry-bg: hsl(230 40% 8%);
+  --spry-bg-alt: #2d2d2d;
+  --spry-bg-inset: #2d2d2d;
+  --spry-surface: #242424;          /* SOLID card fill — never glass */
+  --spry-surface-hover: #2d2d2d;
+  --spry-fg: #fff;
+  --spry-fg-muted: #949cad;
+  --spry-border: #2d2d2d;
+  --spry-border-strong: rgb(120 130 150 / 0.5);
+  --spry-accent: hsl(207 100% 70%);
+  --spry-accent-hover: hsl(207 100% 50%);
+  --spry-accent-fg: #0b1626;
+  /* deep blue "primary" hsl(200 100% 30%) + pale blue "secondary"
+   * hsl(200 70% 80%) are used literally below (core has no vars for them) */
+  --spry-danger: #800000;           /* .btn-danger: maroon fill, white text */
+  --spry-success: hsl(140 60% 60%);
+  --spry-info: hsl(207 90% 74%);
+  --spry-warning: hsl(42 90% 62%);
+  --spry-link: hsl(207 100% 70%);
+  --spry-link-hover: hsl(207 100% 50%);
+  --spry-backdrop: rgb(0 0 0 / 0.6);
+  --spry-focus-ring: 0 0 0 3px hsl(200 100% 30% / 0.45);
+
+  /* ---- Typography ---- */
+  --spry-font: Cantarell, system-ui, -apple-system, "Segoe UI", sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-size: 1.2rem;
+  --spry-line-height: 1.5;
+  --spry-heading-weight: 700;
+
+  /* ---- Shape & depth ---- */
+  --spry-radius: 0.5rem;
+  --spry-radius-sm: 0.5rem;
+  --spry-radius-lg: 0.5rem;
+  --spry-shadow: 0 6px 20px 0 rgb(0 0 0 / 0.38);
+  --spry-shadow-lg:                                 /* THE signature glow */
+    0 0 80px 0 rgb(0 0 0 / 0.4), 0 6px 20px 0 rgb(0 0 0 / 0.38);
+  --spry-glass: blur(10px);          /* chrome bars ONLY, never surfaces */
+
+  /* ---- Controls ---- */
+  --spry-control-bg: #242424;
+  --spry-control-fg: #fff;
+  --spry-control-border: #858585;
+
+  /* ---- Buttons ---- */
+  --spry-btn-bg: #242424;
+  --spry-btn-fg: #fff;
+  --spry-btn-border: #858585;
+  --spry-btn-bg-hover: #242424;      /* border swap handled below */
+  --spry-btn-bg-active: hsl(200 70% 80%);
+
+  /* ---- Progress / tables / popovers / cards ---- */
+  --spry-progress-bg: #2d2d2d;
+  --spry-progress-fg: linear-gradient(90deg, hsl(200 100% 35%), hsl(200 70% 65%));
+  --spry-table-header-bg: hsl(200 70% 80%);
+  --spry-table-hover: rgb(255 255 255 / 0.06);
+  --spry-popover-bg: #242424;
+  --spry-popover-border: #2d2d2d;
+  --spry-popover-shadow: var(--spry-shadow-lg);
+  --spry-card-bg: #242424;
+  --spry-card-border: #2d2d2d;
+  --spry-card-padding: 1rem 1.125rem 1.125rem;
+  --spry-card-shadow: var(--spry-shadow-lg);
+
+  /* ---- Structural rules ---- */
+
+  /* Two hsla overlays over a still image, approximated as one fixed gradient */
+  & body {
+    background: linear-gradient(170deg, hsl(258 60% 13% / 0.55), hsl(195 80% 25% / 0.55))
+      fixed hsl(230 40% 8%);
+  }
+
+  /* Site subtitle pattern: h2 riding up under h1, in muted gray */
+  & h1 + h2 {
+    margin-top: -1.2em;
+    color: var(--spry-fg-muted);
+  }
+
+  & blockquote {
+    background: hsl(200 70% 80%);
+    border-left: 10px solid hsl(200 100% 30%);
+    color: #1c2733;
+    font-family: Georgia, serif;
+    border-radius: 0.5rem;
+  }
+
+  & blockquote p { color: inherit; }
+
+  & hr {
+    background: #fff;
+    opacity: 0.85;
+    margin-block: 4rem;
+  }
+
+  & figure {
+    border: 1px solid hsl(200 100% 30%);
+    padding: 1rem;
+  }
+
+  /* Tables: pale-blue header, zebra rows, defined grid lines.
+   * :where() keeps these at (0,1,0)-ish so .table-* modifiers still win. */
+  & :where(table) :where(th, td) { border: 1px solid rgb(120 130 150 / 0.35); }
+  & :where(table thead th) {
+    background: hsl(200 70% 80%);
+    color: #16222e;
+    border: 1px solid rgb(120 130 150 / 0.35);
+  }
+  & :where(table) tbody tr:nth-child(even) { background: #2d2d2d; }
+
+  /* Topbar: the glass banner */
+  & .topbar {
+    background: rgb(50 50 50 / 0.5);
+    backdrop-filter: var(--spry-glass);
+    border-bottom: 1px solid rgb(100 100 100 / 0.7);
+    padding-block: 0.9rem;
+  }
+
+  & .topbar-brand {
+    font-weight: 800;
+    letter-spacing: 0.04em;
+  }
+
+  /* Card strips: subtle glass over the solid card (#2d2d2d edges via
+   * --spry-border) */
+  & :is(.card-header, .card-footer) {
+    background: rgb(50 50 50 / 0.35);
+    backdrop-filter: var(--spry-glass);
+  }
+
+  /* Buttons: hover/active border + active text the vars can't carry
+   * (:where() keeps .btn-* variants winning) */
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):hover {
+    border-color: hsl(200 100% 30%);
+  }
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):active {
+    color: #2d2d2d;
+    border-color: hsl(200 100% 30%);
+  }
+
+  /* Primary: deep blue fill, white text, slightly lighter on hover.
+   * (.btn-danger needs nothing: --spry-danger: #800000 + core white text) */
+  & .btn-primary {
+    background: hsl(200 100% 30%);
+    border-color: hsl(200 100% 30%);
+    color: #fff;
+  }
+  & .btn-primary:hover {
+    background: hsl(200 100% 38%);
+    border-color: hsl(200 100% 38%);
+  }
+
+  /* Hero: glass band with a primary→secondary gradient edge */
+  & .hero {
+    border: 1px solid transparent;
+    background:
+      linear-gradient(rgb(6 14 30 / 0.72), rgb(6 14 30 / 0.45)) padding-box,
+      linear-gradient(90deg, hsl(200 100% 30%), hsl(200 70% 80%)) border-box;
+    backdrop-filter: var(--spry-glass);
+  }
+
+  & .hero-eyebrow {
+    color: #bfe0ff;
+  }
+
+  & .hero-sub {
+    color: #c9d1e0;
+  }
+
+  & .avatar {
+    background: #242424;
+    border: 1px solid hsl(200 100% 30%);
+    color: #fff;
+  }
+
+  /* Drawers: glass bars; inner body stays solid */
+  & :is(.drawer-left, .drawer-right) {
+    background: rgb(50 50 50 / 0.6);
+    backdrop-filter: var(--spry-glass);
+  }
+  & :is(.drawer-left, .drawer-right) .dialog-body { background: #242424; }
+
+  & .pagination [aria-current="page"] {
+    background: hsl(200 70% 80%);
+    color: #16222e;
+  }
+
+  /* Dialog chrome: glass titlebar strip; dialog/popover bodies stay solid
+   * #242424 with the big glow via --spry-popover-bg/shadow */
+  & .dialog-titlebar {
+    background: rgb(50 50 50 / 0.5);
+    backdrop-filter: var(--spry-glass);
+    border-bottom: 1px solid rgb(100 100 100 / 0.7);
+  }
+
+  /* Alerts: solid, not translucent tints. Danger = maroon + white. */
+  & .alert-danger {
+    background: #800000;
+    border-color: rgb(255 255 255 / 0.25);
+    color: #fff;
+  }
+  & .alert-success { background: hsl(140 35% 12%); }
+  & .alert-info { background: hsl(207 40% 14%); }
+  & .alert-warning { background: hsl(42 35% 12%); }
+
+  /* Maroon stays for fills; text usages of danger (validation hints,
+     .text-danger) need the bright red or they vanish on #242424 */
+  & :is(.text-danger, .field .is-invalid ~ .hint) {
+    color: #ff948b;
+  }
+
+  & .is-invalid {
+    border-color: #e05252 !important;
+  }
+
+  & .badge-danger { background: #800000; color: #fff; }
+  & .dialog-close:hover { background: #800000; color: #fff; }
+
+  /* Inputs on focus: darker well + primary border (core keeps the ring) */
+  & input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]),
+  & select:focus,
+  & textarea:focus,
+  & .input:focus {
+    background: #2d2d2d;
+    border-color: hsl(200 100% 30%);
+  }
+
+  /* Progress: gradient fill; engine pseudo rules kept SEPARATE */
+  & progress::-webkit-progress-value { background: var(--spry-progress-fg); }
+  & progress::-moz-progress-bar { background: var(--spry-progress-fg); }
+  & progress:indeterminate {
+    background:
+      repeating-linear-gradient(90deg,
+        rgb(255 255 255 / 0) 0 28px,
+        rgb(255 255 255 / 0.3) 36px 64px,
+        rgb(255 255 255 / 0) 72px 128px) 0 0 / 128px 100%,
+      linear-gradient(90deg, hsl(200 100% 35% / 0.4), hsl(200 70% 65% / 0.4));
+    animation: glassy-march 1.3s linear infinite;
+  }
+
+  /* OTP inputs, spinner, menus, tabs, expanders, switch: all inherit the
+   * control/accent vars above — nothing needed here. */
+}
+
+/* Keyframes stay TOP-LEVEL: nested keyframes are dropped by browsers.
+   The pattern tile is 128px wide and advances exactly one tile per loop:
+   a seamless conveyor — no gap, nothing flying past the edges. */
+@keyframes glassy-march {
+  from { background-position: 0 0, 0 0; }
+  to { background-position: 128px 0, 0 0; }
+}

+ 520 - 0
css/themes/spry-theme-h2o.css

@@ -0,0 +1,520 @@
+/*
+ * Spry CSS theme: "H2O" — Mac OS X Aqua, Mountain Lion (2012)
+ * Ported from the ARC Aqua reference: a brushed menu bar along the top, grey
+ * windows on pinstriped linen, white push buttons that turn blue when pressed,
+ * a pulsing blue default button, pop-up buttons with a blue end, Finder's
+ * striped lists and blue selection. Light-only.
+ * Activate with <html data-theme="h2o"> or the demo radio #spry-theme-h2o.
+ * Load after spry.css.
+ */
+
+[data-theme="h2o"],
+html:has(#spry-theme-h2o:checked) {
+  color-scheme: light;
+
+  /* ---- Aqua surfaces (verbatim from the reference tokens) ---- */
+  --h2o-push: linear-gradient(180deg, #ffffff 0%, #f7f7f7 49%, #ededed 50%, #f3f3f3 100%);
+  --h2o-push-hover: linear-gradient(180deg, #ffffff 0%, #fdfdfd 49%, #f3f3f3 50%, #ffffff 100%);
+  --h2o-blue: linear-gradient(180deg, #b9dcfd 0%, #85c1fb 49%, #4fa0f6 50%, #79bdfa 100%);
+  --h2o-gloss:
+    radial-gradient(ellipse 90% 60% at 50% 100%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0) 70%),
+    linear-gradient(180deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.35) 49%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.1) 100%);
+  --h2o-gel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 1px 1px rgba(0, 0, 0, 0.2);
+  --h2o-highlight: linear-gradient(180deg, #3b6fe2 0%, #1f50cc 100%);
+  --h2o-selection: linear-gradient(180deg, #5f92e2 0%, #2f66cc 100%);
+  --h2o-default-btn: linear-gradient(180deg, #5590e8 0%, #2a6cd4 45%, #1c53b4 55%, #2f6ac8 100%);
+  --h2o-edge: #9a9a9a #959595 #868686;
+  --h2o-blue-edge: #3f6fb6 #3a68ad #2f5996;
+
+  /* ---- Palette ---- */
+  --spry-bg: #ececec;
+  --spry-bg-alt: #e3e3e3;
+  --spry-bg-inset: #f2f2f2;
+  --spry-surface: #ffffff;
+  --spry-surface-hover: #f6f6f8;
+  --spry-fg: #1a1a1a;
+  --spry-fg-muted: #5f5f63;
+  --spry-border: #c8c8c8;
+  --spry-border-strong: #959595;
+  --spry-accent: #3a8ef5;
+  --spry-accent-hover: #2a6ed8;
+  --spry-accent-fg: #ffffff;
+  --spry-danger: #e0443e;
+  --spry-success: #4cae3c;
+  --spry-warning: #f5a623;
+  --spry-info: #33a3d6;
+  --spry-link: #2a6ed8;
+  --spry-link-hover: #1b53b0;
+  --spry-backdrop: rgb(0 0 0 / 0.22); /* Mac dialogs barely dim */
+  --spry-focus-ring: 0 0 0 3px rgba(80, 150, 240, 0.55);
+
+  /* ---- Typography: Aqua is small and compact ---- */
+  --spry-font: 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Geneva, Verdana, sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-mono: 'Menlo', 'Monaco', ui-monospace, 'Courier New', monospace;
+  --spry-font-size: 0.8rem;
+  --spry-line-height: 1.5;
+  --spry-heading-weight: 700;
+  --spry-heading-color: #1a1a1a;
+
+  /* ---- Shape & depth: 4px base, 3px sm, 5px lg ---- */
+  --spry-radius: 4px;
+  --spry-radius-sm: 3px;
+  --spry-radius-lg: 5px;
+  --spry-radius-pill: 999px;
+  --spry-shadow: 0 1px 1px rgb(0 0 0 / 0.12);
+  --spry-shadow-lg: 0 0 0 1px rgb(0 0 0 / 0.22), 0 10px 28px rgb(0 0 0 / 0.4);
+
+  /* ---- Controls: ~22px at 12.8px text ---- */
+  --spry-control-bg: #ffffff;
+  --spry-control-fg: #1a1a1a;
+  --spry-control-border: #a5a5a5;
+  --spry-control-radius: 3px;
+  --spry-control-padding-x: 0.5rem;
+  --spry-control-padding-y: 0.2rem;
+
+  /* Buttons: the white push; held down it turns Aqua blue (the active state) */
+  --spry-btn-bg: var(--h2o-push);
+  --spry-btn-fg: #1a1a1a;
+  --spry-btn-border: #959595;
+  --spry-btn-bg-hover: var(--h2o-push-hover);
+  --spry-btn-bg-active: var(--h2o-blue);
+
+  --spry-card-bg: #ffffff;
+  --spry-card-border: rgba(0, 0, 0, 0.16);
+  --spry-card-padding: 0.9rem;
+  --spry-card-shadow: 0 1px 0 rgba(255, 255, 255, 0.7), 0 1px 4px rgba(0, 0, 0, 0.1);
+
+  --spry-table-header-bg: linear-gradient(180deg, #ffffff 0%, #ececec 100%);
+  --spry-table-hover: #dde7f6;
+
+  --spry-progress-bg: linear-gradient(180deg, #d6d6d6 0%, #f2f2f2 100%);
+  --spry-progress-fg: #3d8ef5;
+
+  --spry-popover-bg: rgba(255, 255, 255, 0.97);
+  --spry-popover-border: #9a9a9a;
+  --spry-popover-shadow: 0 5px 15px rgb(0 0 0 / 0.35);
+
+  /* The desktop: pinstriped linen, light enough to stay readable */
+  & body {
+    background-color: #ececec;
+    background-image:
+      radial-gradient(ellipse at 50% 40%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.18) 100%),
+      repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0 1px, rgba(0, 0, 0, 0.04) 1px 3px);
+    background-attachment: fixed;
+    -webkit-font-smoothing: antialiased;
+  }
+
+  & ::selection { background-color: #b4d5fe; }
+
+  /* ---- The menu bar along the top ---- */
+  & .topbar {
+    padding: 0.25rem clamp(0.75rem, 2vw, 1.5rem);
+    background: linear-gradient(180deg, #f8f8f8 0%, #e3e3e3 55%, #d4d4d4 100%);
+    border-bottom: 1px solid #8e8e8e;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 4px rgba(0, 0, 0, 0.3);
+    font-size: 13px;
+  }
+
+  & .topbar-brand { font-size: 13px; font-weight: 700; color: #1a1a1a; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); }
+
+  /* Menu titles, highlighted blue like an open menu */
+  & .nav a { padding: 0.2rem 0.65rem; border-radius: 3px; color: #000; font-weight: 400; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); }
+
+  & .nav a:hover,
+  & .nav a.active { background-image: var(--h2o-highlight); color: #fff; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); }
+
+  & .nav a.active { font-weight: 700; }
+
+  /* ---- Menus: compact, nearly opaque white, blue highlight across the row ---- */
+  & .menu a { padding: 3px 10px; border-radius: 3px; }
+
+  & .submenu { padding: 4px; background: rgba(255, 255, 255, 0.97); border: 1px solid #9a9a9a; border-radius: 5px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.35); }
+
+  & .submenu a { padding: 3px 10px; border-radius: 3px; }
+
+  & .menu a:hover,
+  & .menu a.active,
+  & .submenu a:hover,
+  & .submenu a.active { background-image: var(--h2o-highlight); color: #fff; }
+
+  & .popover-menu {
+    padding: 4px;
+    min-width: 11rem;
+    background: rgba(255, 255, 255, 0.97);
+    border: 1px solid #9a9a9a;
+    border-radius: 5px;
+    box-shadow: 0 5px 15px rgba(0, 0, 0, 0.35);
+  }
+
+  & .popover-menu :is(a, button) { padding: 3px 10px; border-radius: 3px; box-shadow: none; }
+
+  & .popover-menu :is(a, button):hover { background-image: var(--h2o-highlight); color: #fff; }
+
+  /* ---- Tabs as Aqua segmented controls ---- */
+  & .tab-list { gap: 0; border-bottom: 0; }
+
+  & .tab-list label {
+    padding: 0.25rem 0.9rem;
+    border: 1px solid var(--h2o-edge);
+    border-radius: 0;
+    margin-left: -1px;
+    background-image: var(--h2o-push);
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12);
+    color: #000;
+    font-weight: 500;
+  }
+
+  & .tab-list label:first-of-type { margin-left: 0; border-radius: 4px 0 0 4px; }
+
+  & .tab-list label:last-of-type { border-radius: 0 4px 4px 0; }
+
+  & .tab-list label:hover { background-image: var(--h2o-push-hover); color: #000; }
+
+  & .tabs > input:nth-of-type(1):checked ~ .tab-list label:nth-of-type(1),
+  & .tabs > input:nth-of-type(2):checked ~ .tab-list label:nth-of-type(2),
+  & .tabs > input:nth-of-type(3):checked ~ .tab-list label:nth-of-type(3),
+  & .tabs > input:nth-of-type(4):checked ~ .tab-list label:nth-of-type(4),
+  & .tabs > input:nth-of-type(5):checked ~ .tab-list label:nth-of-type(5),
+  & .tabs > input:nth-of-type(6):checked ~ .tab-list label:nth-of-type(6) {
+    position: relative;
+    z-index: 1;
+    background-color: #4a99f5;
+    background-image: var(--h2o-gloss);
+    border-color: var(--h2o-blue-edge);
+    color: #000;
+    font-weight: 600;
+    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
+    box-shadow: var(--h2o-gel-shadow);
+  }
+
+  /* ---- Buttons: the white push button, for .btn and bare buttons alike ---- */
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]) { border-color: var(--h2o-edge); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12); }
+
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):active { border-color: var(--h2o-blue-edge); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); }
+
+  & :where(.btn, button, input[type="submit"], input[type="reset"], input[type="button"]):is(:disabled, [aria-disabled="true"]) { box-shadow: none; animation: none; filter: none; }
+
+  /* The default button: Aqua blue gel, white writing, pulsing softly.
+     Deep stops keep white text at AA across the band where the label sits. */
+  & .btn-primary {
+    background-color: transparent;
+    background-image: linear-gradient(180deg, rgb(255 255 255 / 0.18) 0%, rgb(255 255 255 / 0) 50%), var(--h2o-default-btn);
+    border-color: var(--h2o-blue-edge);
+    color: #fff;
+    font-weight: 600;
+    text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+    box-shadow: var(--h2o-gel-shadow);
+    animation: h2o-pulse 1s ease-in-out infinite alternate;
+  }
+
+  & .btn-primary:hover { border-color: #2f5996; }
+
+  & .btn-primary:active { background-image: linear-gradient(180deg, rgb(0 0 0 / 0.12) 0%, rgb(0 0 0 / 0) 50%), linear-gradient(180deg, #2f6ac8 0%, #1c53b4 100%); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35); }
+
+  & .btn-primary:is(:disabled, [aria-disabled="true"]) { animation: none; box-shadow: none; }
+
+  /* Danger: a red gel */
+  & .btn-danger {
+    background-color: #e0443e;
+    background-image: var(--h2o-gloss);
+    border-color: #922c28;
+    color: #fff;
+    font-weight: 600;
+    text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3);
+    box-shadow: var(--h2o-gel-shadow);
+  }
+
+  & .btn-danger:hover { background-color: #d63c36; border-color: #7d2420; }
+
+  & .btn-danger:active { filter: brightness(0.88); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35); }
+
+  /* Outline: a white push button that fills with Aqua blue while held */
+  & .btn-outline {
+    background-image: var(--h2o-push);
+    border-color: var(--h2o-edge);
+    color: #2f5996;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12);
+  }
+
+  & .btn-outline:hover { background-image: var(--h2o-push-hover); border-color: var(--h2o-blue-edge); color: #2f5996; }
+
+  & .btn-outline:active { background-image: var(--h2o-blue); border-color: var(--h2o-blue-edge); color: #1a1a1a; }
+
+  /* Ghost and link stay flat: re-assert so the push base cannot leak in */
+  & .btn-ghost { background-image: none; border-color: transparent; box-shadow: none; }
+
+  & .btn-ghost:active { box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35); }
+
+  & .btn-link { background-image: none; border-color: transparent; box-shadow: none; }
+
+  /* ---- Text fields: white sunken wells ---- */
+  & :where(input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="search"], input[type="tel"], input[type="url"], input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"], input[type="color"], select, textarea, .input) { box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.12); }
+
+  & :where(input, textarea)::placeholder { color: #a0a0a0; }
+
+  /* Pop-up buttons: a push button with an Aqua blue end holding the arrows */
+  & select:not([size]):not([multiple]) {
+    appearance: none;
+    padding-right: 1.9rem;
+    border-color: var(--h2o-edge);
+    border-radius: 4px;
+    background-color: #f2f2f2;
+    background-image:
+      conic-gradient(from 135deg at 50% 62%, #fff 0 90deg, rgba(0, 0, 0, 0) 90deg 360deg),
+      conic-gradient(from -45deg at 50% 38%, #fff 0 90deg, rgba(0, 0, 0, 0) 90deg 360deg),
+      linear-gradient(90deg, rgba(47, 89, 150, 0.6) 0 1px, rgba(47, 89, 150, 0) 1px),
+      var(--h2o-blue),
+      var(--h2o-push);
+    background-position:
+      right 5px calc(50% - 5px),
+      right 5px calc(50% + 3px),
+      right 18px top,
+      right top,
+      0 0;
+    background-size: 9px 6px, 9px 6px, 19px 100%, 19px 100%, 100% 100%;
+    background-repeat: no-repeat;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12);
+  }
+
+  & select:not([size]):not([multiple]):focus { border-color: var(--h2o-blue-edge); box-shadow: var(--spry-focus-ring), inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12); }
+
+  /* Checkboxes and radios take the Aqua blue via accent-color */
+
+  /* ---- Switch: a sunken pill, striped Aqua blue when on ---- */
+  & .switch .track {
+    border: 1px solid #9a9a9a;
+    background-color: #f4f4f4;
+    background-image: linear-gradient(180deg, #ffffff 0%, #f3f3f3 49%, #e8e8e8 50%, #f4f4f4 100%);
+    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.8);
+  }
+
+  & .switch input:checked + .track {
+    border-color: var(--h2o-blue-edge);
+    background-color: #4a99f5;
+    background-image:
+      repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.35) 0 6px, rgba(255, 255, 255, 0) 6px 12px),
+      var(--h2o-blue);
+  }
+
+  & .switch .track::after { border: 1px solid #9a9a9a; box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.35); }
+
+  /* ---- Cards: group boxes, white on the grey window ---- */
+  & .card { border-radius: 5px; }
+
+  & .card-header { background: linear-gradient(180deg, #ffffff 0%, #ececec 100%); border-bottom-color: rgba(0, 0, 0, 0.12); font-weight: 700; }
+
+  & .card-footer { background: linear-gradient(180deg, #f2f2f2 0%, #ececec 100%); border-top-color: rgba(0, 0, 0, 0.12); }
+
+  /* ---- Alerts & badges: gel chips ---- */
+  & .alert { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); }
+  & .alert-danger { color: #b3241e; }
+  & .alert-success { color: #1e6a12; }
+  & .alert-warning { color: #8a5a08; }
+  & .alert-info { color: #186a8f; }
+  & .hero-eyebrow { color: #17479e; }
+  & .field .is-invalid ~ .hint { color: #b3241e; }
+
+  & .badge { color: #1b53b0; background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0) 100%); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2); }
+
+  /* ---- Tables: Finder's striped list view ---- */
+  & thead th {
+    background-image: linear-gradient(180deg, #ffffff 0%, #ececec 100%);
+    color: #222;
+    font-weight: 400;
+    text-transform: none;
+    letter-spacing: normal;
+    border-bottom: 1px solid #b3b3b3;
+    box-shadow: inset -1px 0 0 #d1d1d1;
+  }
+
+  & .table-striped tbody tr:nth-child(even) { background: #f1f5fb; }
+
+  & .table-hover tbody tr:hover { background: #dde7f6; }
+
+  & tbody tr[aria-selected="true"] { background-image: var(--h2o-selection); box-shadow: inset 0 1px 0 #5a8ddf; color: #fff; }
+
+  & tbody tr[aria-selected="true"] a { color: #fff; }
+
+  /* ---- Progress: a sunken well with a blue gel fill, stripes marching ---- */
+  & progress { border: 1px solid #9f9f9f; border-radius: 4px; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); }
+
+  & progress::-webkit-progress-bar { background: linear-gradient(180deg, #d6d6d6 0%, #f2f2f2 100%); }
+
+  & progress::-webkit-progress-value {
+    background-color: #3d8ef5;
+    background-image:
+      repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.3) 0 8px, rgba(255, 255, 255, 0) 8px 16px),
+      var(--h2o-gloss);
+    background-size: 22.63px 22.63px, 100% 100%, 100% 100%;
+    border-radius: 3px;
+    animation: h2o-barber 0.9s linear infinite;
+  }
+
+  /* Indeterminate: the classic full-width Aqua barber pole marching across
+     the trough. Element-background layers so it works in every engine. */
+  & progress:indeterminate {
+    background:
+      repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.55) 0 8px, #3d8ef5 8px 16px) 0 0 / 22.63px 22.63px,
+      linear-gradient(180deg, #d6d6d6 0%, #f2f2f2 100%);
+    animation: h2o-barber 0.9s linear infinite;
+  }
+
+  & progress::-moz-progress-bar {
+    background-color: #3d8ef5;
+    background-image:
+      repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.3) 0 8px, rgba(255, 255, 255, 0) 8px 16px),
+      var(--h2o-gloss);
+    background-size: 22.63px 22.63px, 100% 100%, 100% 100%;
+    border-radius: 3px;
+    animation: h2o-barber 0.9s linear infinite;
+  }
+
+
+  /* ---- Spinner: twelve grey spokes stepping round ---- */
+  & .spinner {
+    border: 0;
+    background: conic-gradient(from 15deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.7));
+    -webkit-mask:
+      repeating-conic-gradient(#000 0 12deg, transparent 12deg 30deg),
+      radial-gradient(farthest-side, transparent 42%, #000 44%);
+    -webkit-mask-composite: source-in;
+    mask:
+      repeating-conic-gradient(#000 0 12deg, transparent 12deg 30deg),
+      radial-gradient(farthest-side, transparent 42%, #000 44%);
+    mask-composite: intersect;
+    animation: h2o-spinner 0.9s steps(12) infinite;
+  }
+
+  /* ---- Windows: grey frames with a brushed titlebar ---- */
+  & dialog {
+    background: #ececec;
+    border: 1px solid rgba(0, 0, 0, 0.22);
+    border-radius: 5px;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 0 0 1px rgba(0, 0, 0, 0.22), 0 22px 70px 4px rgba(0, 0, 0, 0.56);
+  }
+
+  & .dialog-titlebar {
+    padding: 0.35rem 0.75rem;
+    background: linear-gradient(180deg, #ebebeb 0%, #d3d3d3 100%);
+    border-bottom: 1px solid #a3a3a3;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
+  }
+
+  & .dialog-title { color: #4d4d4d; font-weight: 700; font-size: 0.85rem; text-align: center; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); }
+
+  /* The close button: a red traffic light, 12px across */
+  & .dialog-close {
+    order: -1;
+    width: 12px;
+    height: 12px;
+    padding: 0;
+    border: 1px solid #7e1d18;
+    border-radius: 50%;
+    background-color: #c1302a;
+    background-image: var(--h2o-gloss), linear-gradient(180deg, #f46b60 0%, #e05648 49%, #c1302a 50%, #d6453a 100%);
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 1px 1px rgba(0, 0, 0, 0.25);
+    color: transparent;
+    font-size: 0;
+  }
+
+  & .dialog-close:hover { filter: brightness(1.12); }
+
+  & .dialog-close:active { filter: brightness(0.88); }
+
+  & .dialog-body { padding: 0.9rem; }
+
+  /* Dialog buttons sit on the window's own grey, with no separator above */
+  & .dialog-footer { padding: 0 0.9rem 0.9rem; border-top: 0; background: transparent; }
+
+  /* ---- Popovers: grey windows; menus above restyle themselves white ---- */
+  & [popover] { background: #ececec; border-color: rgba(0, 0, 0, 0.22); border-radius: 5px; box-shadow: 0 5px 20px rgba(0, 0, 0, 0.35); }
+
+  /* ---- Expanders: boxed rows with a push-button summary ---- */
+  & details { border-color: rgba(0, 0, 0, 0.16); background: #ffffff; }
+
+  & summary { background: linear-gradient(180deg, #ffffff 0%, #ececec 100%); }
+
+  & summary:hover { background: linear-gradient(180deg, #ffffff 0%, #e3e3e3 100%); }
+
+  & details[open] > summary { background: #e3e3e3; }
+
+  /* ---- Fieldsets: group boxes ---- */
+  & fieldset { border: 1px solid #b8b8b8; border-radius: 4px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.5); }
+
+  & legend { font-weight: 700; }
+
+  /* ---- kbd & code: little white wells ---- */
+  & kbd {
+    font-family: var(--spry-font-mono);
+    font-size: 0.9em;
+    padding: 0.05em 0.4em;
+    border: 1px solid #a5a5a5;
+    border-bottom-width: 2px;
+    border-radius: 3px;
+    background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
+    box-shadow: inset 0 1px 0 #ffffff, 0 1px 0 rgba(0, 0, 0, 0.08);
+  }
+
+  & code { font-family: var(--spry-font-mono); font-size: 0.92em; background: rgba(0, 0, 0, 0.045); border-radius: 3px; padding: 0.08em 0.35em; }
+
+  /* ---- Hero: a band of the pinstriped linen ---- */
+  & .hero {
+    background:
+      radial-gradient(ellipse at 50% 40%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 55%),
+      repeating-linear-gradient(180deg, #ececec 0 2px, #f0f0f0 2px 4px);
+    border-block: 1px solid #c9c9c9;
+  }
+
+  /* ---- Avatars: gel circles ---- */
+  & .avatar {
+    background-color: #4a99f5;
+    background-image: var(--h2o-gloss), var(--h2o-blue);
+    border: 1px solid #3a68ad;
+    color: #fff;
+    text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+  }
+
+  /* ---- Drawers: grey windows sliding out over the linen ---- */
+  & .drawer-left,
+  & .drawer-right { background: #ececec; border-color: #8e8e8e; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), -8px 0 24px rgba(0, 0, 0, 0.35); }
+
+  /* ---- Pagination: white gels, the current page in Aqua blue ---- */
+  & .pagination :is(a, span) { border-color: #959595; background-image: var(--h2o-push); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 1px rgba(0, 0, 0, 0.12); }
+
+  & .pagination a:hover { background-image: var(--h2o-push-hover); }
+
+  & .pagination [aria-current="page"] {
+    background-color: #4a99f5;
+    background-image: var(--h2o-gloss), var(--h2o-blue);
+    border-color: var(--h2o-blue-edge);
+    color: #1a1a1a;
+    font-weight: 700;
+    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    & .btn-primary,
+    & .spinner { animation: none; }
+
+    & progress::-webkit-progress-value { animation: none; }
+
+    & progress::-moz-progress-bar { animation: none; }
+  }
+}
+
+@keyframes h2o-barber {
+  from { background-position: 0 0, 0 0, 0 0; }
+
+  to { background-position: 22.63px 0, 0 0, 0 0; }
+}
+
+@keyframes h2o-pulse {
+  from { filter: brightness(0.94) saturate(0.9); }
+
+  to { filter: brightness(1.1) saturate(1.15); }
+}
+
+@keyframes h2o-spinner {
+  to { transform: rotate(1turn); }
+}

+ 441 - 0
css/themes/spry-theme-living-sky.css

@@ -0,0 +1,441 @@
+/*
+ * Spry CSS theme — "Living Sky"
+ * --------------------------------
+ * A medieval almanac of the heavens: parchment pages, deep navy ink,
+ * gold-leaf highlights, and a faint dusting of stars. Light-only.
+ * Pressed toward the illuminated manuscript: drop caps, fleuron rules,
+ * marginalia, night-sky panels, gilded controls, a frontispiece hero.
+ *
+ * Activation:
+ *   - real sites:   <html data-theme="living-sky">
+ *   - demo page:    hidden radio  <input id="spry-theme-living-sky">
+ *   - load AFTER spry.css
+ *
+ * Self-contained: local-if-available font stacks and inline data: URIs
+ * only — no JavaScript, no @import, no external files.
+ */
+
+/* Light-only theme. ------------------------------------------------------ */
+
+[data-theme="living-sky"],
+html:has(#spry-theme-living-sky:checked) {
+  color-scheme: light; /* parchment by day; dark mode is ignored */
+
+  /* ---- Palette ---- */
+  --spry-bg: #f2ebda;            /* parchment */
+  --spry-bg-alt: #efe6cd;        /* deeper parchment bands, card headers */
+  --spry-bg-inset: #eae0c6;      /* wells: code blocks, disabled fills */
+  --spry-surface: #f9f4e6;       /* cards, popovers, inputs */
+  --spry-surface-hover: #f4eddb;
+  --spry-fg: #1f2533;            /* ink — 12:1 on parchment, the bar */
+  --spry-fg-muted: #5c6172;      /* ink-soft — ≥4.5:1 on every parchment tone */
+  --spry-border: #d9cdaa;        /* gold-tan hairlines */
+  --spry-border-strong: #b9a97c; /* hover borders, kbd */
+  --spry-accent: #003078;        /* deep navy: focus, primaries */
+  --spry-accent-hover: #002254;
+  --spry-accent-fg: #f4efe2;     /* parchment text on navy (10.7:1) */
+  --spry-danger: #8a2f2f;        /* rubrication red */
+  --spry-success: #44623a;       /* manuscript green */
+  --spry-warning: #6b510e;       /* gold-ink darkened for AA on soft tints */
+  --spry-info: #2c4a9e;          /* lapis */
+  --spry-link: var(--spry-info); /* lapis links, gold-ink hover, as the site */
+  --spry-link-hover: var(--spry-gold-ink);
+  --spry-backdrop: rgb(6 12 30 / 0.6); /* night-sky scrim */
+  --spry-focus-ring: 0 0 0 3px color-mix(in srgb, #a8801f 45%, transparent);
+
+  /* Theme-internal gold leaf (decorative; --spry-gold-ink for gold text) */
+  --spry-gold: #a8801f;
+  --spry-gold-ink: #7a5d12;      /* gold text on parchment — 5.2:1 */
+  --spry-gold-soft: #cdb377;
+  --spry-night: #16224e;         /* night-sky panels: dialogs, submenus */
+  --spry-night-fg: #ece7d8;      /* parchment ink on night — 12.4:1 */
+  --spry-night-muted: #b9c2df;   /* muted ink on night — 8.7:1 */
+  --spry-ink-line: #4a5a80;      /* navy ruling line for control borders */
+
+  /* ---- Typography ---- */
+  --spry-font: "EB Garamond", Georgia, "Times New Roman", serif;
+  --spry-font-heading: "Cinzel", "EB Garamond", Georgia, serif;
+  --spry-font-mono: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
+  --spry-font-size: 1.125rem;
+  --spry-line-height: 1.65;
+  --spry-heading-weight: 600;
+
+  /* ---- Shape, depth, motion ---- */
+  --spry-radius: 0.25rem;        /* near-square, like ruled folio plates */
+  --spry-radius-sm: 0.125rem;
+  --spry-radius-lg: 0.375rem;
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: 0 1px 2px rgb(31 37 51 / 0.06), 0 2px 6px rgb(31 37 51 / 0.07);
+  --spry-shadow-lg: 0 24px 48px -16px rgb(16 22 46 / 0.32);
+  --spry-glass: none;
+  --spry-transition: 160ms ease;
+  --spry-dialog-duration: 200ms;
+  --spry-dialog-ease: cubic-bezier(0.2, 0.9, 0.3, 1);
+  --spry-dialog-in: translateY(0.6rem) scale(0.98);
+
+  /* ---- Controls: parchment fields ruled in navy, gold focus ring ---- */
+  --spry-control-bg: var(--spry-surface);
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: var(--spry-ink-line);
+  --spry-control-radius: var(--spry-radius-sm);
+  --spry-control-padding-x: 0.65rem;
+  --spry-control-padding-y: 0.45rem;
+
+  /* ---- Buttons (default: parchment ruled in navy; primary below) ---- */
+  --spry-btn-bg: var(--spry-surface);
+  --spry-btn-fg: var(--spry-fg);
+  --spry-btn-border: var(--spry-ink-line);
+  --spry-btn-bg-hover: var(--spry-surface-hover);
+  --spry-btn-bg-active: var(--spry-bg-inset);
+
+  /* ---- Cards / tables / progress / popovers ---- */
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: #d9cdaa;
+  --spry-card-padding: 1.25rem;
+  --spry-card-shadow: var(--spry-shadow);
+  --spry-table-header-bg: var(--spry-bg-alt);
+  --spry-table-hover: #f0e7cf;
+  --spry-progress-bg: #e6d9b8;   /* defined tan trough, bordered below */
+  --spry-progress-fg: var(--spry-gold); /* gilded measure */
+  --spry-popover-bg: var(--spry-night); /* night panels below */
+  --spry-popover-border: var(--spry-gold);
+  --spry-popover-shadow: var(--spry-shadow-lg);
+
+  /* ---- Inline imagery (tiny, self-typed) ---- */
+  /* faint gold star-speck over the parchment (kept below cramp threshold) */
+  --spry-stars: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cg fill='%23a8801f' opacity='.08'%3E%3Cpath d='M24 22l1.2 3.2 3.2 1.2-3.2 1.2-1.2 3.2-1.2-3.2-3.2-1.2 3.2-1.2z'/%3E%3Cpath d='M118 44l1 2.6 2.6 1-2.6 1-1 2.6-1-2.6-2.6-1 2.6-1z'/%3E%3Cpath d='M66 116l1.1 2.8 2.8 1.1-2.8 1.1-1.1 2.8-1.1-2.8-2.8-1.1 2.8-1.1z'/%3E%3Cpath d='M138 126l.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9z'/%3E%3Ccircle cx='98' cy='74' r='1.1'/%3E%3Ccircle cx='36' cy='144' r='.9'/%3E%3Ccircle cx='148' cy='16' r='.8'/%3E%3C/g%3E%3C/svg%3E");
+  /* parchment grain */
+  --spry-parchment-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
+  /* brighter gold speckle for night-sky panels (dimmed to protect text) */
+  --spry-stars-night: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cg fill='%23d3ac52' opacity='.22'%3E%3Cpath d='M28 24l1.1 3 3 1.1-3 1.1-1.1 3-1.1-3-3-1.1 3-1.1z'/%3E%3Cpath d='M96 58l.9 2.5 2.5.9-2.5.9-.9 2.5-.9-2.5-2.5-.9 2.5-.9z'/%3E%3Cpath d='M52 104l1 2.7 2.7 1-2.7 1-1 2.7-1-2.7-2.7-1 2.7-1z'/%3E%3Ccircle cx='118' cy='96' r='1'/%3E%3Ccircle cx='70' cy='12' r='.9'/%3E%3Ccircle cx='12' cy='78' r='.9'/%3E%3C/g%3E%3C/svg%3E");
+  /* gold diamond-fleuron cluster for ornamental rules */
+  --spry-fleuron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='136' height='12'%3E%3Cg fill='%23a8801f'%3E%3Cpath d='M68 1.2L73.4 6 68 10.8 62.6 6z'/%3E%3Cpath d='M34 3.4L37.2 6 34 8.6 30.8 6z'/%3E%3Cpath d='M102 3.4L105.2 6 102 8.6 98.8 6z'/%3E%3Ccircle cx='20' cy='6' r='1.2'/%3E%3Ccircle cx='116' cy='6' r='1.2'/%3E%3C/g%3E%3C/svg%3E");
+  /* the full ornamental rule: hairlines flanking the fleuron */
+  --spry-divider-bg:
+    linear-gradient(var(--spry-gold-soft), var(--spry-gold-soft)) left center / calc(50% - 4.25rem) 1px no-repeat,
+    linear-gradient(var(--spry-gold-soft), var(--spry-gold-soft)) right center / calc(50% - 4.25rem) 1px no-repeat,
+    var(--spry-fleuron) center / 8.5rem 0.75rem no-repeat;
+
+  /* ---- Structural signatures (nested, scoped to this theme only) ---- */
+
+  /* Gold-leaf rule across the top of the page + star-speckled parchment */
+  & body {
+    border-top: 3px solid var(--spry-gold);
+    background-image: var(--spry-stars), var(--spry-parchment-noise);
+  }
+
+  /* Gilded text selection; placeholder kept at full ink-soft for AA */
+  & ::selection { background: color-mix(in srgb, var(--spry-gold) 28%, transparent); }
+  & ::placeholder { color: var(--spry-fg-muted); opacity: 1; }
+
+  /* Illuminated initial: navy letter on a faint gold halo, gold hairline box */
+  & p.lead::first-letter,
+  & article > p:first-of-type::first-letter {
+    float: left;
+    font-family: var(--spry-font-heading);
+    font-size: 3.3em; line-height: 0.82;
+    padding: 0.08em 0.14em 0.12em 0.1em; margin: 0.05em 0.14em 0 0;
+    color: var(--spry-accent);
+    background: radial-gradient(circle at 32% 28%, rgb(168 128 31 / 0.3), rgb(168 128 31 / 0.08) 62%, transparent 78%);
+    border: 1px solid color-mix(in srgb, var(--spry-gold) 60%, transparent);
+    border-radius: var(--spry-radius-sm);
+  }
+
+  /* Headings: Cinzel small caps below h2; h1 gets a short gold flourish */
+  & :is(h3, h4, h5, .card-title) { font-variant: small-caps; letter-spacing: 0.04em; }
+
+  & h1::after {
+    content: ""; display: block; width: 3rem; height: 2px;
+    margin-top: 0.45rem;
+    background: linear-gradient(90deg, var(--spry-gold), transparent);
+  }
+
+  /* Ornamental rule: fleuron divider with flanking gold hairlines */
+  & hr {
+    border: 0; height: 0.75rem; margin-block: 2.4rem;
+    background: var(--spry-divider-bg);
+  }
+
+  /* Blockquote as marginalia: gold rule, faint gilding, small-caps scribe */
+  & blockquote {
+    border-inline-start: 3px solid var(--spry-gold);
+    background: color-mix(in srgb, var(--spry-gold) 5%, transparent);
+    padding: 0.35rem 1.1rem;
+    font-style: italic;
+  }
+
+  & blockquote :is(footer, cite) {
+    font-style: normal; font-variant: small-caps;
+    letter-spacing: 0.08em; font-size: 0.92em;
+    color: var(--spry-gold-ink);
+  }
+
+  /* Engraved controls: small-caps, tracked out (base look via :where) */
+  & :where(button, input[type="submit"], input[type="reset"], input[type="button"], .btn) {
+    font-variant: small-caps;
+    letter-spacing: 0.05em;
+  }
+
+  /* Primary buttons: navy field with a gold-leaf rim */
+  & .btn-primary { border-color: var(--spry-gold); }
+
+  /* Cards: hairline frame with a gold-soft top edge, like the site's
+     plates; the footer carries the fleuron rule as its divider */
+  & .card {
+    box-shadow: inset 0 2px 0 var(--spry-gold-soft), var(--spry-card-shadow);
+  }
+
+  & .card-footer {
+    position: relative;
+    border-top: 1px solid transparent;
+
+    &::before {
+      content: ""; position: absolute; inset: -1px 0 auto 0;
+      height: 0.85rem; pointer-events: none;
+      background: var(--spry-divider-bg);
+    }
+  }
+
+  /* Tables ruled like astronomical tables: navy header band in small caps
+     with a gold rule, gold-tan zebra, mono numerals */
+  & thead th {
+    background: var(--spry-accent); color: var(--spry-accent-fg);
+    text-transform: none; font-variant: small-caps;
+    letter-spacing: 0.12em;
+    border-bottom: 2px solid var(--spry-gold);
+  }
+  & tbody tr:nth-child(even),
+  & .table-striped tbody tr:nth-child(even) {
+    background: color-mix(in srgb, var(--spry-gold) 9%, transparent);
+  }
+
+  & .num { font-family: var(--spry-font-mono); font-size: 0.88em; }
+
+  /* Dialogs, drawers & popovers: night-sky panels — gold-rimmed,
+     star-speckled, parchment-light text. Like the site's sky band, the
+     whole ink & control system flips with the field, so every child —
+     buttons, inputs, links, badges, menus — inherits readable night
+     pairs; the parchment .dialog-body inset un-flips them again. */
+  & dialog,
+  & [popover],
+  & :is(.drawer-left, .drawer-right) {
+    background-color: var(--spry-night);
+    background-image: var(--spry-stars-night);
+    border-color: var(--spry-gold);
+    color: var(--spry-night-fg);
+    --spry-fg: var(--spry-night-fg);
+    --spry-heading-color: var(--spry-night-fg);
+    --spry-fg-muted: var(--spry-night-muted);
+    --spry-bg-alt: rgb(244 239 226 / 0.08);
+    --spry-bg-inset: rgb(10 16 36 / 0.65);
+    --spry-btn-bg: rgb(244 239 226 / 0.12); --spry-btn-fg: var(--spry-night-fg);
+    --spry-btn-border: var(--spry-gold-soft);
+    --spry-btn-bg-hover: rgb(244 239 226 / 0.2); --spry-btn-bg-active: rgb(244 239 226 / 0.26);
+    --spry-control-bg: #0e1731; --spry-control-fg: var(--spry-night-fg);
+    --spry-control-border: var(--spry-gold-soft);
+    --spry-accent: #d3ac52; --spry-accent-hover: #c19b3f;
+    --spry-accent-fg: #211a0e; --spry-accent-soft: rgb(211 172 82 / 0.16);
+    --spry-link: var(--spry-gold-soft); --spry-link-hover: #ecd9a0;
+    --spry-danger: #e5a4a4; --spry-danger-soft: rgb(229 164 164 / 0.16);
+    --spry-success: #b3cda6; --spry-success-soft: rgb(179 205 166 / 0.16);
+    --spry-warning: #e6cd93; --spry-warning-soft: rgb(230 205 147 / 0.18);
+    --spry-info: #b3c4ef; --spry-info-soft: rgb(179 196 239 / 0.16);
+
+    & :focus-visible { outline-color: var(--spry-gold); }
+
+    & .dialog-titlebar {
+      background: none;
+      border-bottom: 1px solid color-mix(in srgb, var(--spry-gold) 45%, transparent);
+
+      & .dialog-title { color: #e7cf8e; font-variant: small-caps; letter-spacing: 0.06em; }
+
+      & .dialog-close { color: var(--spry-gold-soft); }
+      & .dialog-close:hover { background: rgb(168 128 31 / 0.22); color: #f6ecc8; }
+    }
+
+    /* the parchment folio inset: un-flip the system for this subtree */
+    & .dialog-body {
+      margin: 0.6rem;
+      background: var(--spry-surface);
+      border: 1px solid color-mix(in srgb, var(--spry-gold) 35%, transparent);
+      border-radius: var(--spry-radius);
+      color: #1f2533;
+      --spry-fg: #1f2533; --spry-fg-muted: #5c6172;
+      --spry-bg-alt: #efe6cd; --spry-bg-inset: #eae0c6;
+      --spry-btn-bg: var(--spry-surface); --spry-btn-fg: #1f2533;
+      --spry-btn-border: var(--spry-ink-line);
+      --spry-btn-bg-hover: var(--spry-surface-hover);
+      --spry-btn-bg-active: var(--spry-bg-inset);
+      --spry-control-bg: var(--spry-surface); --spry-control-fg: #1f2533;
+      --spry-control-border: var(--spry-ink-line);
+      --spry-accent: #003078; --spry-accent-hover: #002254;
+      --spry-accent-fg: #f4efe2; --spry-accent-soft: rgb(0 48 120 / 0.12);
+      --spry-link: #2c4a9e; --spry-link-hover: var(--spry-gold-ink);
+      --spry-danger: #8a2f2f; --spry-danger-soft: rgb(138 47 47 / 0.1);
+      --spry-success: #44623a; --spry-success-soft: rgb(68 98 58 / 0.1);
+      --spry-warning: #6b510e; --spry-warning-soft: rgb(107 81 14 / 0.14);
+      --spry-info: #2c4a9e; --spry-info-soft: rgb(44 74 158 / 0.1);
+
+      & :focus-visible { outline-color: var(--spry-accent); }
+    }
+
+    & .dialog-footer {
+      background: none;
+      border-top-color: color-mix(in srgb, var(--spry-gold) 30%, transparent);
+    }
+
+    /* alerts riding the night field sit in ivory glass with bright ink */
+    & .alert:not(.dialog-body *) {
+      background: rgb(244 239 226 / 0.07);
+      border-color: color-mix(in srgb, var(--spry-gold) 30%, transparent);
+    }
+  }
+
+  /* Dropdown submenus are night panels too: parchment links, gilded
+     hover. The static submenus inside .menu stay on parchment with ink. */
+  & .submenu {
+    background-color: var(--spry-night);
+    background-image: var(--spry-stars-night);
+    border-color: var(--spry-gold);
+  }
+
+  & .submenu a { color: var(--spry-night-fg); }
+  & .submenu a:hover,
+  & .submenu a.active { background: rgb(168 128 31 / 0.24); color: #f6ecc8; }
+
+  & .menu .has-submenu .submenu { background: none; border: 0; }
+  & .menu .has-submenu .submenu a { color: var(--spry-fg); }
+  & .menu .has-submenu .submenu a:hover,
+  & .menu .has-submenu .submenu a.active { background: var(--spry-accent-soft); color: var(--spry-accent); }
+
+  /* Switch as sun-and-moon dial: navy firmament, gilded sun knob; the
+     checked track deepens to navy speckled with stars */
+  & .switch .track {
+    background: var(--spry-night);
+    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--spry-gold) 45%, transparent);
+  }
+
+  & .switch .track::after {
+    background: radial-gradient(circle at 32% 32%, #f4e2ac, var(--spry-gold) 72%);
+    box-shadow: 0 0 0 2px rgb(249 244 230 / 0.22), 0 0 0.45rem rgb(168 128 31 / 0.45);
+  }
+
+  & .switch input:checked + .track { background-color: var(--spry-accent); background-image: var(--spry-stars-night); }
+
+  /* Progress: a gilded measure in a defined tan trough with an ink
+     hairline border, and subtle diagonal hatching */
+  /* Progress: a gilded measure — solid gold leaf with a lit top edge,
+     no stripes. Indeterminate is a shaft of light travelling the trough,
+     like sun moving across parchment. */
+  & progress { border: 1px solid var(--spry-ink-line); }
+
+  & progress::-webkit-progress-value {
+    background: linear-gradient(180deg, #e8c56a 0%, #d3ac52 45%, #a8801f 100%);
+    box-shadow: inset 0 1px 0 rgb(255 244 214 / 0.55);
+  }
+
+  & progress::-moz-progress-bar {
+    background: linear-gradient(180deg, #e8c56a 0%, #d3ac52 45%, #a8801f 100%);
+    box-shadow: inset 0 1px 0 rgb(255 244 214 / 0.55);
+  }
+
+  & progress:indeterminate {
+    background:
+      linear-gradient(90deg,
+        rgb(211 172 82 / 0) 0%,
+        rgb(211 172 82 / 0.4) 18%,
+        #ecc46a 38% 62%,
+        rgb(211 172 82 / 0.4) 82%,
+        rgb(211 172 82 / 0) 100%) 0 50% / 38% 100% no-repeat,
+      #e6d9b8;
+    animation: sky-glide 2.4s linear infinite;
+  }
+
+  /* Tabs: gold-ink accent swaps through core's own active rule, so the
+     underline gilds itself without fighting the nth-of-type chain */
+  & .tab-list {
+    --spry-accent: var(--spry-gold-ink);
+
+    & label { font-variant: small-caps; letter-spacing: 0.04em; }
+  }
+
+  /* Spinner: a turning gilded star */
+  & .spinner {
+    border-color: var(--spry-gold-soft);
+    border-top-color: var(--spry-gold);
+  }
+
+  /* Brand: Cinzel small-caps wordmark with a gold sigil mark */
+  & .topbar-brand {
+    font-family: var(--spry-font-heading); font-variant: small-caps;
+    letter-spacing: 0.06em;
+  }
+
+  & .topbar-brand::before { content: "✦"; color: var(--spry-gold); margin-right: 0.4rem; }
+
+  /* Nav links: gold underline grows in on hover; the word gilds too */
+  & .nav a {
+    font-variant: small-caps; letter-spacing: 0.06em;
+    background-image: linear-gradient(var(--spry-gold), var(--spry-gold));
+    background-repeat: no-repeat;
+    background-position: 0 100%;
+    background-size: 0% 1px;
+    transition: background-size var(--spry-transition), color var(--spry-transition);
+  }
+
+  & .nav a:hover { background-size: 100% 1px; color: var(--spry-gold-ink); }
+  & .nav a.active {
+    background: var(--spry-accent);
+    color: #f4efe2;
+  }
+
+  /* Breadcrumb: small-caps wayfinding; the current crumb is full ink */
+  & .breadcrumb a { font-variant: small-caps; letter-spacing: 0.05em; }
+  & .breadcrumb [aria-current] { color: var(--spry-fg); }
+
+  /* Hero: an illuminated frontispiece — parchment band under a gold
+     top rule, gilded Cinzel kicker, drop-cap-ready via p.lead */
+  & .hero {
+    background:
+      radial-gradient(60rem 24rem at 50% -8rem, color-mix(in srgb, var(--spry-gold) 9%, transparent), transparent 70%),
+      var(--spry-bg-alt);
+    border-block: 1px solid var(--spry-border-strong);
+    border-top: 3px solid var(--spry-gold);
+  }
+
+  & .hero-eyebrow {
+    display: inline-flex; align-items: center; gap: 0.9rem;
+    font-family: var(--spry-font-heading); letter-spacing: 0.28em;
+    color: var(--spry-gold-ink);
+  }
+
+  & .hero-eyebrow::before,
+  & .hero-eyebrow::after { content: ""; width: 2.5rem; height: 1px; background: var(--spry-gold-soft); }
+
+  & .hero-title { font-family: var(--spry-font-heading); }
+
+  /* Avatars: night-sky medallions with gold initials (10:1) */
+  & .avatar {
+    background: var(--spry-night); color: #e7cf8e;
+    font-family: var(--spry-font-heading);
+    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--spry-gold) 55%, transparent);
+  }
+
+  /* OTP: parchment wells with navy ink; gold focus ring via control vars */
+  & .otp input { background: var(--spry-bg-inset); color: var(--spry-accent); }
+
+  /* Pagination: small-caps folios; the current leaf is navy with
+     parchment text (core) and a gold rim */
+  & .pagination a,
+  & .pagination span { font-variant: small-caps; letter-spacing: 0.04em; }
+  & .pagination [aria-current="page"] { border-color: var(--spry-gold); }
+}
+
+/* The shaft of light: a 38%-wide glow crossing the trough once per loop */
+@keyframes sky-glide {
+  /* 38%-wide layer: p% maps to p x 62% of the track — slide in from fully
+     off-left (-161%) and exit fully right (+161%) */
+  from { background-position: -161% 50%, 0 0; }
+  to { background-position: 161% 50%, 0 0; }
+}

+ 275 - 0
css/themes/spry-theme-modern.css

@@ -0,0 +1,275 @@
+/*
+ * Spry CSS theme: "modern"
+ * ------------------------
+ * Contemporary premium design system — Linear/Vercel/Stripe register.
+ * Signatures: mono micro-labels (h6/badges/table heads/hints), filled
+ * gray input wells that bloom white on focus, flat hairline cards that
+ * lift on hover, borderless tables with row dividers, left-accent-bar
+ * alerts, and a sheen + soft glow primary button.
+ * Light-only: color-scheme stays light and the user's dark preference is ignored.
+ */
+
+[data-theme="modern"],
+html:has(#spry-theme-modern:checked) {
+  color-scheme: light;
+
+  /* ---- Palette (soft tints & alert washes derive automatically) ---- */
+  --spry-bg: #fafafa;             /* near-white page */
+  --spry-bg-alt: #f4f4f5;         /* alternate bands, hovers */
+  --spry-bg-inset: #efeff2;       /* wells: code blocks, disabled fills, tracks */
+  --spry-surface: #ffffff;        /* cards, popovers, inputs on focus */
+  --spry-surface-hover: #fafafa;
+  --spry-fg: #18181b;             /* near-black text */
+  --spry-fg-muted: #52525b;       /* secondary text (AA on bg) */
+  --spry-border: #e4e4e7;         /* hairlines */
+  --spry-border-strong: #d4d4d8;  /* hover borders, kbd, switch track */
+  --spry-accent: #5b5bd6;         /* vivid indigo */
+  --spry-accent-hover: #4949c3;
+  --spry-accent-fg: #ffffff;      /* 5.4:1 on accent */
+  --spry-danger: #d92d20;
+  --spry-success: #15803d;
+  --spry-warning: #a16207;
+  --spry-info: #0369a1;
+  --spry-backdrop: rgb(24 24 27 / 0.35);
+  --spry-link: var(--spry-accent);
+  --spry-link-hover: var(--spry-accent-hover);
+
+  /* Soft halo rings — solid-accent borders/underlines carry the 3:1 contrast */
+  --spry-focus-ring: 0 0 0 2px color-mix(in srgb, var(--spry-accent) 40%, transparent);
+
+  /* ---- Typography ---- */
+  --spry-font: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  --spry-font-heading: var(--spry-font);
+  --spry-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  --spry-font-size: 1rem;
+  --spry-line-height: 1.6;
+  --spry-heading-weight: 650;
+
+  /* ---- Shape, depth, motion: 8px controls / 12px cards / 16px overlays ---- */
+  --spry-radius: 0.75rem;        /* cards, btn-lg */
+  --spry-radius-sm: 0.5rem;      /* buttons, inputs, alerts */
+  --spry-radius-lg: 1rem;        /* dialogs, popovers */
+  --spry-radius-pill: 999px;
+  --spry-border-width: 1px;
+  --spry-shadow: 0 2px 4px rgb(24 24 27 / 0.04), 0 8px 24px -8px rgb(24 24 27 / 0.08);
+  --spry-shadow-lg: 0 8px 20px -6px rgb(24 24 27 / 0.12), 0 20px 48px -12px rgb(24 24 27 / 0.16);
+  --spry-glass: none;
+  --spry-transition: 150ms cubic-bezier(0.25, 0.6, 0.35, 1);
+  --spry-dialog-duration: 180ms;
+  --spry-dialog-ease: cubic-bezier(0.2, 0.9, 0.3, 1);
+  --spry-dialog-in: translateY(0.5rem) scale(0.98);
+
+  /* ---- Controls: filled gray wells, no border until hover/focus ---- */
+  --spry-control-bg: #f4f4f5;
+  --spry-control-fg: var(--spry-fg);
+  --spry-control-border: transparent;
+  --spry-control-radius: var(--spry-radius-sm);
+  --spry-control-padding-x: 0.7rem;
+  --spry-control-padding-y: 0.5rem;
+
+  /* ---- Buttons ---- */
+  --spry-btn-bg: var(--spry-surface);
+  --spry-btn-fg: var(--spry-fg);
+  --spry-btn-border: var(--spry-border);
+  --spry-btn-bg-hover: var(--spry-bg-alt);
+  --spry-btn-bg-active: var(--spry-bg-inset);
+
+  /* ---- Cards / tables / progress / popovers ---- */
+  --spry-card-bg: var(--spry-surface);
+  --spry-card-border: var(--spry-border);
+  --spry-card-padding: 1.5rem;
+  --spry-card-shadow: none;                    /* flat at rest, lift on hover */
+  --spry-table-header-bg: transparent;         /* airy headerless-column look */
+  --spry-table-hover: var(--spry-bg-alt);
+  --spry-progress-bg: var(--spry-bg-inset);
+  --spry-progress-fg: var(--spry-accent);
+  --spry-popover-bg: var(--spry-surface);
+  --spry-popover-border: var(--spry-border);
+  --spry-popover-shadow: var(--spry-shadow-lg);
+
+  /* ---- Structural overrides (nested, active only under this scope) ---- */
+
+  /* Barely-there dot-grid texture — the "modern dev tool" page floor */
+  & body {
+    background:
+      radial-gradient(rgb(24 24 27 / 0.04) 1px, transparent 1px) 0 0 / 24px 24px,
+      var(--spry-bg);
+  }
+
+  /* Tight display-type tracking for the marketing-page look */
+  & :where(h1, h2, h3, .h1, .h2, .h3) {
+    letter-spacing: -0.022em;
+  }
+
+  /* Signature: mono micro-labels */
+  & :where(h6, .h6) {
+    font-family: var(--spry-font-mono);
+    letter-spacing: 0.09em;
+  }
+
+  & .badge {
+    font-family: var(--spry-font-mono);
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    font-weight: 550;
+    padding: 0.15em 0.7em;
+  }
+
+  & .hint {
+    font-family: var(--spry-font-mono);
+    font-size: 0.8em;
+  }
+
+  /* Global soft focus halo (inputs keep their own ring via the core) */
+  & :where(:focus-visible) {
+    outline: 3px solid color-mix(in srgb, var(--spry-accent) 35%, transparent);
+    outline-offset: 2px;
+  }
+
+  /* Buttons: shared motion incl. transform */
+  & .btn {
+    transition: background-color var(--spry-transition), border-color var(--spry-transition),
+      color var(--spry-transition), box-shadow var(--spry-transition),
+      transform var(--spry-transition);
+  }
+
+  & .btn:not(.btn-ghost):not(.btn-link):hover {
+    transform: translateY(-1px);
+  }
+
+  & .btn:not(.btn-ghost):not(.btn-link):active {
+    transform: translateY(0);
+  }
+
+  /* Primary: hairline top sheen + accent glow that deepens on hover */
+  & .btn-primary {
+    box-shadow:
+      inset 0 1px 0 rgb(255 255 255 / 0.2),
+      0 1px 2px color-mix(in srgb, var(--spry-accent) 30%, transparent);
+  }
+
+  & .btn-primary:hover {
+    box-shadow:
+      inset 0 1px 0 rgb(255 255 255 / 0.22),
+      0 2px 4px color-mix(in srgb, var(--spry-accent) 32%, transparent),
+      0 8px 20px -6px color-mix(in srgb, var(--spry-accent) 45%, transparent);
+  }
+
+  & .btn-primary:active {
+    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.12);
+  }
+
+  /* Ghost buttons gain a hairline frame on hover */
+  & .btn-ghost:hover {
+    border-color: var(--spry-border);
+  }
+
+  /* Inputs: filled wells turn white + ring on focus (ring/border via core) */
+  & :where(input, select, textarea) {
+    transition: background-color var(--spry-transition),
+      border-color var(--spry-transition), box-shadow var(--spry-transition);
+  }
+
+  & :where(input[type="text"], input[type="email"], input[type="password"],
+    input[type="number"], input[type="search"], input[type="tel"], input[type="url"],
+    input[type="date"], input[type="time"], input[type="datetime-local"],
+    input[type="month"], input[type="week"], select, textarea, .input):focus:not([readonly]) {
+    background: var(--spry-surface);
+  }
+
+  /* Cards: flat hairline at rest, soft lift on hover */
+  & .card {
+    transition: transform var(--spry-transition), box-shadow var(--spry-transition),
+      border-color var(--spry-transition);
+  }
+
+  & .card:not(.card-flat):hover {
+    transform: translateY(-2px);
+    border-color: var(--spry-border-strong);
+    box-shadow: 0 4px 12px -2px rgb(24 24 27 / 0.06), 0 12px 32px -12px rgb(24 24 27 / 0.12);
+  }
+
+  & :where(.card-header, .card-footer) {
+    background: transparent;
+  }
+
+  /* Tables: no frame, hairline row dividers, mono column heads */
+  & :where(table) {
+    border: 0;
+  }
+
+  & :where(thead th) {
+    font-family: var(--spry-font-mono);
+    font-size: 0.72em;
+    letter-spacing: 0.08em;
+    border-bottom: var(--spry-border-width) solid var(--spry-border-strong);
+  }
+
+  /* Alerts: soft wash + colored inset edge instead of a full frame */
+  & :where(.alert) {
+    border: 0;
+    border-radius: var(--spry-radius-sm);
+    box-shadow: inset 2px 0 0 var(--spry-border-strong);
+  }
+
+  & .alert-danger { box-shadow: inset 2px 0 0 var(--spry-danger); color: #b3180e; }
+  & .alert-success { box-shadow: inset 2px 0 0 var(--spry-success); color: #0f7a34; }
+  & .alert-warning { box-shadow: inset 2px 0 0 var(--spry-warning); color: #855600; }
+  & .alert-info { box-shadow: inset 2px 0 0 var(--spry-info); color: #075e94; }
+  & .nav a.active { color: #4343c5; }
+  & .badge { color: #4343c5; }
+  & .hero-eyebrow { color: #4343c5; }
+
+  /* Dialogs: minimal titlebar (bold text + hairline), circular close */
+  & .dialog-titlebar {
+    background: transparent;
+    border-bottom: var(--spry-border-width) solid var(--spry-border);
+  }
+
+  & .dialog-title {
+    font-size: 1rem;
+  }
+
+  & .dialog-close {
+    border-radius: var(--spry-radius-pill);
+  }
+
+  & .dialog-footer {
+    background: transparent;
+  }
+
+  /* Switch: slightly larger, floating white knob */
+  & .switch .track {
+    width: 2.75rem;
+    height: 1.5rem;
+  }
+
+  & .switch .track::after {
+    top: 0.2rem;
+    left: 0.2rem;
+    width: 1.1rem;
+    height: 1.1rem;
+    box-shadow: 0 1px 3px rgb(24 24 27 / 0.3), 0 0 0 1px rgb(24 24 27 / 0.05);
+  }
+
+  & .switch input:checked + .track::after {
+    translate: 1.25rem 0;
+  }
+
+  /* Slim progress bars */
+  & progress {
+    height: 0.45rem;
+  }
+
+  /* Indeterminate: theme-explicit so it can never be shadowed */
+  & progress:indeterminate {
+    /* cap = half the slim 0.45rem bar */
+    --spry-pulse-cap: 0.23em;
+    background:
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at var(--spry-pulse-cap) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+      radial-gradient(var(--spry-pulse-cap) var(--spry-pulse-cap) at calc(100% - var(--spry-pulse-cap)) 50%, var(--spry-accent) 97%, transparent 100%) 0% 50% / 25% 100% no-repeat,
+      linear-gradient(90deg, transparent 0 var(--spry-pulse-cap), var(--spry-accent) var(--spry-pulse-cap) calc(100% - var(--spry-pulse-cap)), transparent calc(100% - var(--spry-pulse-cap))) 0% 50% / 25% 100% no-repeat,
+      var(--spry-bg-inset);
+    animation: spry-pulse 1.4s linear infinite alternate;
+  }
+}

+ 18 - 1
src/SpryModule.vala

@@ -10,7 +10,10 @@ namespace Spry {
 	 * mappings, and the scoped {@link Spry.Authentication.UserService}.
 	 *
 	 * Register this module with the application container alongside
-	 * `StatumModule`; it registers no endpoints. Ready-made actions are bound
+	 * `StatumModule`; it registers no endpoints. The Spry CSS framework and
+	 * its themes are embedded as default Statum resources (see
+	 * {@link Spry.Resources}), served from `/_statum/resource/spry.css` and
+	 * `/_statum/resource/spry-theme-*.css`. Ready-made actions are bound
 	 * separately via {@link SpryAuth.register_actions} or
 	 * `StatumConfigurator.action`.
 	 */
@@ -27,6 +30,20 @@ namespace Spry {
 
 			// Service setup
 			container.register_scoped<Authentication.UserService>();
+
+			// Embed the Spry CSS framework as default resources (generated at
+			// build time by statum-mkres, precompressed). Apps get the core
+			// stylesheet and every theme for free and may activate a theme
+			// with a second <link pstm-res-src="spry-theme-*.css"> plus
+			// data-theme on <html>.
+			container.register_startup<Resources.SpryCss>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeAiry>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeDwarf>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeFormal>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeGlassy>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeH2o>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeLivingSky>().as<Statum.StatumResource>();
+			container.register_startup<Resources.SpryThemeModern>().as<Statum.StatumResource>();
 		}
 
 	}

+ 44 - 1
src/meson.build

@@ -1,3 +1,46 @@
+# Embed the Spry CSS framework (css/spry.css + css/themes/spry-theme-*.css)
+# into the library as precompressed ConstantStatumResource classes, served by
+# default at /_statum/resource/spry.css and /_statum/resource/spry-theme-*.css
+# (registered by SpryModule, like Statum's own client scripts).
+statum_mkres = find_program('statum-mkres')
+
+themes = {
+    'airy': 'Airy',
+    'dwarf': 'Dwarf',
+    'formal': 'Formal',
+    'glassy': 'Glassy',
+    'h2o': 'H2o',
+    'living-sky': 'LivingSky',
+    'modern': 'Modern',
+}
+
+spry_css = custom_target('spry-css',
+    input: files('../css/spry.css'),
+    output: 'SpryCss.vala',
+    command: [statum_mkres,
+              '-o', '@OUTPUT@',
+              '-n', 'spry.css',
+              '--class-name', 'SpryCss',
+              '--ns', 'Spry.Resources',
+              '-c', 'text/css',
+              '@INPUT@']
+)
+
+theme_resources = []
+foreach slug, suffix : themes
+    theme_resources += custom_target('spry-theme-@0@'.format(slug),
+        input: files('../css/themes/spry-theme-@0@.css'.format(slug)),
+        output: 'SpryTheme@0@.vala'.format(suffix),
+        command: [statum_mkres,
+                  '-o', '@OUTPUT@',
+                  '-n', 'spry-theme-@0@.css'.format(slug),
+                  '--class-name', 'SpryTheme@0@'.format(suffix),
+                  '--ns', 'Spry.Resources',
+                  '-c', 'text/css',
+                  '@INPUT@']
+    )
+endforeach
+
 sources = files(
     'SpryModule.vala',
     'Auth.vala',
@@ -27,7 +70,7 @@ sources = files(
 api_version = '0.2'
 release_version = meson.project_version()
 libspry = shared_library('spry-@0@'.format(api_version),
-    sources,
+    sources, spry_css, theme_resources,
     dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, inversion_dep, astralis_dep, libxml_dep, sodium_deps, invercargill_sql_dep, sqlite_dep, invercargill_sql_inversion_dep, statum_dep],
     install: true,
     vala_gir: 'spry-@0@.gir'.format(api_version),

+ 28 - 1
tools/spry/spry.vala

@@ -348,6 +348,7 @@ namespace Spry.Cli {
         private static int cmd_add_login(string[] args) throws Error, OptionError {
             parse(args, "- add the login page", null);
             var app = require_app();
+            ensure_spry_css(app);
             add_page(app, "login", "Login", "/login",
                 Generator.fill(Templates.LOGIN, app.vars),
                 Generator.fill(Templates.LOGIN_ENTRYPOINT, app.vars));
@@ -357,7 +358,7 @@ namespace Spry.Cli {
             edit_main(app, "statum.action<Spry.Actions.LoginAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
             edit_main(app, "statum.action<Spry.Actions.LogoutAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
             edit_layout(app, "<a href=\"/login\" stm-if=\"!auth\">Login</a>", NAV_BEGIN, NAV_END);
-            edit_layout(app, "<button type=\"button\" stm-action=\"auth.logout\">Log out</button>", NAV_AUTH_BEGIN, NAV_AUTH_END);
+            edit_layout(app, "<button class=\"btn btn-sm btn-outline\" type=\"button\" stm-action=\"auth.logout\">Log out</button>", NAV_AUTH_BEGIN, NAV_AUTH_END);
             Generator.append_section(Path.build_filename(app.dir, "README.md"),
                 "## Guarding pages", Generator.fill(Templates.LOGIN_README, app.vars));
             stdout.printf("Added login page at /login\n");
@@ -367,6 +368,7 @@ namespace Spry.Cli {
         private static int cmd_add_register(string[] args) throws Error, OptionError {
             parse(args, "- add the registration page", null);
             var app = require_app();
+            ensure_spry_css(app);
             add_page(app, "register", "Register", "/register",
                 Generator.fill(Templates.REGISTER, app.vars),
                 Generator.fill(Templates.REGISTER_ENTRYPOINT, app.vars));
@@ -379,6 +381,7 @@ namespace Spry.Cli {
         private static int cmd_add_user_management(string[] args) throws Error, OptionError {
             parse(args, "- add the admin user-management page", null);
             var app = require_app();
+            ensure_spry_css(app);
             add_page(app, "user-management", "UserManagement", "/users",
                 Generator.fill(Templates.USER_MANAGEMENT, app.vars),
                 Generator.fill(Templates.USER_MANAGEMENT_ENTRYPOINT, app.vars));
@@ -552,6 +555,30 @@ namespace Spry.Cli {
             Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
         }
 
+        /**
+         * Splices the Spry CSS stylesheet link into a layout scaffolded
+         * before SpryModule served `spry.css` as a built-in resource (the
+         * auth page markup assumes the framework's classes). Layouts
+         * already carrying the link — and heavily customised ones without
+         * the statum.js script line to anchor on — are left untouched.
+         */
+        private static void ensure_spry_css(AppContext app) throws Error {
+            var path = Path.build_filename(app.dir, "src", "pages", "main.html");
+            if (!FileUtils.test(path, FileTest.EXISTS)) {
+                return;
+            }
+            var contents = Generator.read_file(path);
+            var link = "<link rel=\"stylesheet\" pstm-res-href=\"spry.css\">";
+            if (contents.contains(link)) {
+                return;
+            }
+            var anchor = "<script pstm-res-src=\"statum.js\"></script>";
+            if (!contents.contains(anchor)) {
+                return;
+            }
+            Generator.write_file(path, contents.replace(anchor, anchor + "\n    " + link));
+        }
+
         private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error {
             Generator.write_file(Path.build_filename(app_dir, relative), contents, mode);
         }

+ 18 - 0
tools/spry/templates/README.md

@@ -70,6 +70,24 @@ markers in `src/pages/main.html` (the layout's nav, plus the logged-in-only
 `<!-- spry:nav-auth-begin/end -->` block); the `spry add` commands edit only
 those blocks and are idempotent.
 
+## Styling
+
+`src/pages/main.html` (the layout) loads the Spry CSS framework, served by
+SpryModule as a built-in resource — no file to ship:
+
+```html
+<link rel="stylesheet" pstm-res-href="spry.css">
+```
+
+Optional themes (`spry-theme-airy|dwarf|formal|glassy|h2o|living-sky|modern`)
+are served the same way; load one *after* the core and set its slug on the
+root element:
+
+```html
+<link rel="stylesheet" pstm-res-href="spry-theme-dwarf.css">
+<html lang="en" data-theme="dwarf" data-mode="auto">
+```
+
 ## Growing the app
 
 ```

+ 1 - 1
tools/spry/templates/home.html

@@ -5,7 +5,7 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
+    <section class="stack">
         <h2>Welcome</h2>
         <p stm-text="page.message">Loading…</p>
         <p stm-if="auth != null">Signed in as <strong stm-text="auth.display_name">user</strong>.</p>

+ 6 - 23
tools/spry/templates/layout.html

@@ -6,29 +6,12 @@
     <pstm-name>main</pstm-name>
     <pstm-title>{{APP_NAME}}</pstm-title>
     <script pstm-res-src="statum.js"></script>
-    <style>
-        body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 46rem; padding: 0 1rem; }
-        header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 1px solid #8884; padding: 1rem 0; }
-        header h1 { font-size: 1.2rem; margin: 0; }
-        header nav { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.75rem; }
-        main { padding: 1rem 0 3rem; }
-        footer { border-top: 1px solid #8884; color: #888; font-size: 0.85rem; padding: 1rem 0; }
-        input, button { font: inherit; }
-        input[type="text"], input[type="password"], input[type="email"], input[type="date"] { padding: 0.3rem 0.4rem; }
-        .form-row { display: flex; align-items: center; gap: 0.6rem; margin: 0.6rem 0; }
-        .form-row label { flex: none; width: 16ch; margin: 0; }
-        .form-row input { flex: 1 1 12ch; max-width: 28ch; }
-        form.inline { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0.2rem 0; }
-        table { border-collapse: collapse; }
-        th, td { border: 1px solid #8884; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
-        .error { color: #b00020; }
-        [hidden] { display: none; }
-    </style>
+    <link rel="stylesheet" pstm-res-href="spry.css">
 </head>
 <body>
-    <header>
-        <h1>{{APP_NAME}}</h1>
-        <nav>
+    <header class="topbar">
+        <a class="topbar-brand" href="/">{{APP_NAME}}</a>
+        <nav class="nav">
             <a href="/">Home</a>
             <!-- spry:nav-begin -->
             <!-- spry:nav-end -->
@@ -38,12 +21,12 @@
             </span>
         </nav>
     </header>
-    <main>
+    <main class="container py-4">
         <div stm-preloader>Loading…</div>
         <div stm-content hidden>
             <pstm-content></pstm-content>
         </div>
     </main>
-    <footer><p>Served by Statum + Spry.</p></footer>
+    <footer class="container py-4 text-muted small">Served by Statum + Spry.</footer>
 </body>
 </html>

+ 12 - 12
tools/spry/templates/login.html

@@ -19,19 +19,19 @@
     </script>
 </head>
 <body>
-    <section>
+    <section class="stack" style="max-width: 26rem;">
         <h2>Log in</h2>
-        <p id="login-error" class="error" hidden></p>
-        <form stm-action="page.login">
-            <div class="form-row">
-                <label for="login-username">Username</label>
-                <input id="login-username" name="username" required autocomplete="username" autofocus>
-            </div>
-            <div class="form-row">
-                <label for="login-password">Password</label>
-                <input id="login-password" type="password" name="password" required autocomplete="current-password">
-            </div>
-            <button type="submit">Log in</button>
+        <p id="login-error" class="alert alert-danger" hidden></p>
+        <form stm-action="page.login" class="stack">
+            <label class="field">
+                <span class="label">Username</span>
+                <input class="input" name="username" required autocomplete="username" autofocus>
+            </label>
+            <label class="field">
+                <span class="label">Password</span>
+                <input class="input" type="password" name="password" required autocomplete="current-password">
+            </label>
+            <div><button class="btn btn-primary" type="submit">Log in</button></div>
         </form>
         <p>No account yet? <a href="/register">Register</a>.</p>
     </section>

+ 32 - 32
tools/spry/templates/register.html

@@ -19,39 +19,39 @@
     </script>
 </head>
 <body>
-    <section>
+    <section class="stack" style="max-width: 26rem;">
         <h2>Create an account</h2>
-        <p id="register-error" class="error" hidden></p>
-        <form stm-action="page.register">
-            <div class="form-row">
-                <label for="register-username">Username</label>
-                <input id="register-username" name="username" required autocomplete="username">
-            </div>
-            <div class="form-row">
-                <label for="register-email">Email</label>
-                <input id="register-email" type="email" name="email" required autocomplete="email">
-            </div>
-            <div class="form-row">
-                <label for="register-forename">First name</label>
-                <input id="register-forename" name="forename" required autocomplete="given-name">
-            </div>
-            <div class="form-row">
-                <label for="register-surname">Last name</label>
-                <input id="register-surname" name="surname" required autocomplete="family-name">
-            </div>
-            <div class="form-row">
-                <label for="register-date-of-birth">Date of birth</label>
-                <input id="register-date-of-birth" type="date" name="date_of_birth" required autocomplete="bday">
-            </div>
-            <div class="form-row">
-                <label for="register-password">Password</label>
-                <input id="register-password" type="password" name="password" required autocomplete="new-password">
-            </div>
-            <div class="form-row">
-                <label for="register-confirm-password">Confirm password</label>
-                <input id="register-confirm-password" type="password" name="confirm_password" required autocomplete="new-password">
-            </div>
-            <button type="submit">Create account</button>
+        <p id="register-error" class="alert alert-danger" hidden></p>
+        <form stm-action="page.register" class="stack">
+            <label class="field">
+                <span class="label">Username</span>
+                <input class="input" name="username" required autocomplete="username">
+            </label>
+            <label class="field">
+                <span class="label">Email</span>
+                <input class="input" type="email" name="email" required autocomplete="email">
+            </label>
+            <label class="field">
+                <span class="label">First name</span>
+                <input class="input" name="forename" required autocomplete="given-name">
+            </label>
+            <label class="field">
+                <span class="label">Last name</span>
+                <input class="input" name="surname" required autocomplete="family-name">
+            </label>
+            <label class="field">
+                <span class="label">Date of birth</span>
+                <input class="input" type="date" name="date_of_birth" required autocomplete="bday">
+            </label>
+            <label class="field">
+                <span class="label">Password</span>
+                <input class="input" type="password" name="password" required autocomplete="new-password">
+            </label>
+            <label class="field">
+                <span class="label">Confirm password</span>
+                <input class="input" type="password" name="confirm_password" required autocomplete="new-password">
+            </label>
+            <div><button class="btn btn-primary" type="submit">Create account</button></div>
         </form>
         <p>Already registered? <a href="/login">Log in</a>.</p>
     </section>

+ 43 - 39
tools/spry/templates/user-management.html

@@ -6,46 +6,50 @@
     <link rel="stm-guard" require="auth" href="/login" pre-entrypoint>
 </head>
 <body>
-    <section>
+    <section class="stack">
         <h2>User management</h2>
-        <table stm-if="users != null">
-            <thead>
-                <tr>
-                    <th>Username</th>
-                    <th>Email</th>
-                    <th>Enabled</th>
-                    <th>Permissions</th>
-                    <th>Actions</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr stm-for-user-in="users.users">
-                    <td stm-text="user.username">user</td>
-                    <td stm-text="user.email">email</td>
-                    <td stm-text="user.enabled ? 'Yes' : 'No'">Yes</td>
-                    <td>
-                        <code stm-for-permission-in="user.permissions"><span stm-text="permission">permission</span> </code>
-                    </td>
-                    <td>
-                        <form class="form-row inline" stm-action="user.set_enabled">
-                            <input type="checkbox" name="enabled" stm-attribute.checked="user.enabled">
-                            <button type="submit"><span stm-text="user.enabled ? 'Disable' : 'Enable'">Toggle</span></button>
-                        </form>
-                        <form class="form-row inline" stm-action="user.grant">
-                            <input type="text" name="permission" placeholder="permission" required>
-                            <button type="submit">Grant</button>
-                        </form>
-                        <form class="form-row inline" stm-action="user.revoke">
-                            <input type="text" name="permission" placeholder="permission" required>
-                            <button type="submit">Revoke</button>
-                        </form>
-                        <form class="form-row inline" stm-action="user.delete">
-                            <button type="submit">Delete</button>
-                        </form>
-                    </td>
-                </tr>
-            </tbody>
-        </table>
+        <div class="table-wrap" stm-if="users != null">
+            <table class="table-hover">
+                <thead>
+                    <tr>
+                        <th>Username</th>
+                        <th>Email</th>
+                        <th>Enabled</th>
+                        <th>Permissions</th>
+                        <th>Actions</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr stm-for-user-in="users.users">
+                        <td stm-text="user.username">user</td>
+                        <td stm-text="user.email">email</td>
+                        <td stm-text="user.enabled ? 'Yes' : 'No'">Yes</td>
+                        <td>
+                            <code stm-for-permission-in="user.permissions"><span stm-text="permission">permission</span> </code>
+                        </td>
+                        <td>
+                            <div class="cluster">
+                                <form class="cluster" stm-action="user.set_enabled">
+                                    <input type="checkbox" name="enabled" stm-attribute.checked="user.enabled">
+                                    <button class="btn btn-sm" type="submit"><span stm-text="user.enabled ? 'Disable' : 'Enable'">Toggle</span></button>
+                                </form>
+                                <form class="cluster" stm-action="user.grant">
+                                    <input type="text" name="permission" placeholder="permission" required>
+                                    <button class="btn btn-sm" type="submit">Grant</button>
+                                </form>
+                                <form class="cluster" stm-action="user.revoke">
+                                    <input type="text" name="permission" placeholder="permission" required>
+                                    <button class="btn btn-sm" type="submit">Revoke</button>
+                                </form>
+                                <form class="cluster" stm-action="user.delete">
+                                    <button class="btn btn-sm btn-danger" type="submit">Delete</button>
+                                </form>
+                            </div>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </div>
     </section>
 </body>
 </html>