@calliopelabs/ui 0.7.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +266 -3
  2. package/dist/appearance/AppearancePicker.js +4 -2
  3. package/dist/appearance/AppearanceProvider.d.ts +10 -2
  4. package/dist/appearance/AppearanceProvider.js +6 -2
  5. package/dist/appearance/AppearanceScript.d.ts +15 -4
  6. package/dist/appearance/AppearanceScript.js +11 -5
  7. package/dist/appearance/appearance.js +1 -1
  8. package/dist/appearance/boot.d.ts +13 -2
  9. package/dist/appearance/boot.js +35 -5
  10. package/dist/appearance/product.d.ts +13 -0
  11. package/dist/appearance/product.js +2 -2
  12. package/dist/appearance/runtime-brand.d.ts +106 -0
  13. package/dist/appearance/runtime-brand.js +420 -0
  14. package/dist/css/appearance.js +147 -0
  15. package/dist/css/calliope.css +1221 -0
  16. package/dist/css/components.css +729 -0
  17. package/dist/css/tokens.css +491 -0
  18. package/dist/data/DefinitionList.d.ts +16 -0
  19. package/dist/data/DefinitionList.js +9 -0
  20. package/dist/data/Stat.d.ts +25 -0
  21. package/dist/data/Stat.js +19 -0
  22. package/dist/data/Tag.d.ts +21 -0
  23. package/dist/data/Tag.js +18 -0
  24. package/dist/data/types.d.ts +3 -2
  25. package/dist/data/use-local-table.d.ts +36 -0
  26. package/dist/data/use-local-table.js +127 -0
  27. package/dist/index.d.ts +44 -3
  28. package/dist/index.js +40 -1
  29. package/dist/nav/Nav.d.ts +2 -1
  30. package/dist/nav/Nav.js +34 -15
  31. package/dist/nav/model.d.ts +21 -0
  32. package/dist/page/PageHeader.d.ts +5 -3
  33. package/dist/page/PageHeader.js +5 -4
  34. package/dist/page/Panel.d.ts +19 -0
  35. package/dist/page/Panel.js +12 -0
  36. package/dist/primitives/Avatar.d.ts +37 -0
  37. package/dist/primitives/Avatar.js +43 -0
  38. package/dist/primitives/Field.d.ts +33 -0
  39. package/dist/primitives/Field.js +30 -0
  40. package/dist/primitives/Segmented.d.ts +30 -0
  41. package/dist/primitives/Segmented.js +20 -0
  42. package/dist/primitives/Select.d.ts +40 -0
  43. package/dist/primitives/Select.js +47 -0
  44. package/dist/primitives/Switch.d.ts +7 -0
  45. package/dist/primitives/Switch.js +8 -0
  46. package/dist/primitives/Textarea.d.ts +3 -0
  47. package/dist/primitives/Textarea.js +6 -0
  48. package/dist/provider/messages.d.ts +430 -0
  49. package/dist/provider/messages.js +666 -0
  50. package/dist/shell/BrandMark.d.ts +25 -3
  51. package/dist/shell/BrandMark.js +34 -4
  52. package/dist/shell/RailPanel.js +2 -2
  53. package/dist/shell/RailTree.js +2 -2
  54. package/dist/shell/Sidebar.d.ts +9 -2
  55. package/dist/shell/Sidebar.js +10 -4
  56. package/dist/shell/UserMenu.d.ts +3 -1
  57. package/dist/shell/UserMenu.js +3 -11
  58. package/dist/shell/rail-boot.d.ts +28 -0
  59. package/dist/shell/rail-boot.js +45 -0
  60. package/dist/shell/rail-state.d.ts +6 -1
  61. package/dist/shell/rail-state.js +18 -2
  62. package/dist/styles/preset.css +4 -0
  63. package/dist/styles/theme.css +36 -13
  64. package/dist/theme/css-layer.d.ts +19 -0
  65. package/dist/theme/css-layer.js +171 -0
  66. package/dist/theme/css.d.ts +1 -0
  67. package/dist/theme/css.js +45 -9
  68. package/dist/workflow/ArtifactCard.d.ts +47 -0
  69. package/dist/workflow/ArtifactCard.js +45 -0
  70. package/dist/workflow/DocumentReview.d.ts +74 -0
  71. package/dist/workflow/DocumentReview.js +54 -0
  72. package/dist/workflow/FormReview.d.ts +53 -0
  73. package/dist/workflow/FormReview.js +83 -0
  74. package/dist/workflow/GateBadge.d.ts +11 -0
  75. package/dist/workflow/GateBadge.js +14 -0
  76. package/dist/workflow/MediaReview.d.ts +63 -0
  77. package/dist/workflow/MediaReview.js +166 -0
  78. package/dist/workflow/ReviewPanel.d.ts +41 -0
  79. package/dist/workflow/ReviewPanel.js +59 -0
  80. package/dist/workflow/RunTimeline.d.ts +25 -0
  81. package/dist/workflow/RunTimeline.js +56 -0
  82. package/dist/workflow/WorkflowCard.d.ts +39 -0
  83. package/dist/workflow/WorkflowCard.js +56 -0
  84. package/dist/workflow/WorkflowGallery.d.ts +43 -0
  85. package/dist/workflow/WorkflowGallery.js +69 -0
  86. package/dist/workflow/WorkflowQuestionCard.d.ts +18 -0
  87. package/dist/workflow/WorkflowQuestionCard.js +78 -0
  88. package/dist/workflow/canvas/WorkflowCanvas.d.ts +39 -0
  89. package/dist/workflow/canvas/WorkflowCanvas.js +648 -0
  90. package/dist/workflow/definition.d.ts +408 -0
  91. package/dist/workflow/definition.js +411 -0
  92. package/dist/workflow/editor/WorkflowDiff.d.ts +17 -0
  93. package/dist/workflow/editor/WorkflowDiff.js +25 -0
  94. package/dist/workflow/editor/WorkflowImportPreview.d.ts +19 -0
  95. package/dist/workflow/editor/WorkflowImportPreview.js +50 -0
  96. package/dist/workflow/editor/WorkflowInspector.d.ts +40 -0
  97. package/dist/workflow/editor/WorkflowInspector.js +349 -0
  98. package/dist/workflow/editor/WorkflowJsonView.d.ts +16 -0
  99. package/dist/workflow/editor/WorkflowJsonView.js +40 -0
  100. package/dist/workflow/editor/WorkflowPalette.d.ts +16 -0
  101. package/dist/workflow/editor/WorkflowPalette.js +36 -0
  102. package/dist/workflow/editor/WorkflowProblems.d.ts +20 -0
  103. package/dist/workflow/editor/WorkflowProblems.js +51 -0
  104. package/dist/workflow/editor/WorkflowWorkspace.d.ts +49 -0
  105. package/dist/workflow/editor/WorkflowWorkspace.js +154 -0
  106. package/dist/workflow/gallery.d.ts +21 -0
  107. package/dist/workflow/gallery.js +29 -0
  108. package/dist/workflow/layout.d.ts +49 -0
  109. package/dist/workflow/layout.js +162 -0
  110. package/dist/workflow/model.d.ts +50 -0
  111. package/dist/workflow/model.js +7 -0
  112. package/dist/workflow/palette.d.ts +34 -0
  113. package/dist/workflow/palette.js +70 -0
  114. package/dist/workflow/portable.d.ts +162 -0
  115. package/dist/workflow/portable.js +547 -0
  116. package/dist/workflow/problems.d.ts +38 -0
  117. package/dist/workflow/problems.js +409 -0
  118. package/dist/workflow/question.d.ts +26 -0
  119. package/dist/workflow/question.js +1 -0
  120. package/dist/workflow/run.d.ts +94 -0
  121. package/dist/workflow/run.js +31 -0
  122. package/dist/workflow/workspace.d.ts +33 -0
  123. package/dist/workflow/workspace.js +17 -0
  124. package/package.json +6 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@ It is built Storybook first: every component and every screen pattern is built a
8
8
 
9
9
  - React 19 and TypeScript, shipped as per-file ESM with types. No Next-only APIs: the app hands the package its link component.
10
10
  - Tailwind CSS 4: the theme tokens as CSS, plus a preset.
11
- - Radix underneath the overlays and menus; lucide icons.
11
+ - Radix underneath the overlays and menus; @xyflow/react underneath the workflow canvas; lucide icons.
12
12
 
13
13
  ## Install
14
14
 
@@ -240,6 +240,23 @@ export function Shell({ children, can, projects, me }: ShellProps) {
240
240
  }
241
241
  ```
242
242
 
243
+ To fold a rail before first paint, describe it once and hand the same object to the script, the hook and the sidebar. The script stamps `data-rail-<name>` on `<html>` from the saved choice, and until React hydrates the sidebar paints from it, so a person who folded the rail never sees it open first:
244
+
245
+ ```tsx
246
+ // lib/rails.ts (no "use client": the root layout imports it too)
247
+ import type { RailBoot } from "@calliopelabs/ui";
248
+ export const MAIN_RAIL: RailBoot = { key: "astrolift.shell.main", name: "main" };
249
+
250
+ // app/layout.tsx
251
+ <AppearanceScript product={astrolift} rails={[MAIN_RAIL]} />
252
+
253
+ // the shell
254
+ const [sidebarCollapsed, setSidebarCollapsed] = useRailState(MAIN_RAIL);
255
+ <Sidebar railName={MAIN_RAIL.name} collapsed={sidebarCollapsed} ... />
256
+ ```
257
+
258
+ A nav row takes a `badge` (`{ value, label, tone? }`: the value shows, the label is what a screen reader hears) and a `trailing` node, shown expanded and, as a mark on the area's icon, collapsed.
259
+
243
260
  `[` folds the sidebar and `]` the right rail. Below `md` both rails open as drawers from a top bar. Zentinelle docks its assistant in the same slot: a `RailPanel` with `collapsedMode="hidden"`, `width` from `useRailWidth(key, 400, { min: 320, max: 560 })` with `onWidthChange`, and `expanded` with `onExpandedChange` for the full-width view. Keep the conversation in state above the shell: on narrow screens a rail renders in its drawer as well as its hidden desktop place.
244
261
 
245
262
  A page is a `PageFrame`: breadcrumbs from the navigation model (`areaSwitcher`), the title row with its status, context and one primary action, at most one row of tabs, then the body.
@@ -263,7 +280,102 @@ A page is a `PageFrame`: breadcrumbs from the navigation model (`areaSwitcher`),
263
280
  </PageFrame>
264
281
  ```
265
282
 
266
- The package fetches nothing. `DataTable` reads a `TableController`: rows, state, paging, search and sort, all done by the server. An app implements it over its own data layer; Astrolift's `useCursorTable` already returns one.
283
+ The package fetches nothing. `DataTable` reads a `TableController`: rows, state, paging, search and sort, all done by the server. An app implements it over its own data layer; Astrolift's `useCursorTable` already returns one. A list that is wholly in memory uses `useLocalTable(rows, { sortBy, searchText, filter })`, which searches, sorts and pages the whole set.
284
+
285
+ ## Workflows
286
+
287
+ `WorkflowQuestionCard` shows a host-owned runtime question, including several
288
+ fields, optional choices and Unicode answers. Bind `question.id`, its opaque
289
+ `revision`, `answers` and delivery `state` to the same request. `onSubmit` reports
290
+ answer intent only; the host must authorize delivery and set `submitting` before
291
+ starting its request. Use `accepted` when the service accepted the answer and
292
+ `acknowledged` only with correlated worker evidence. For uncertain delivery,
293
+ `onRefresh` reads the original request without submitting it again. A changed
294
+ question identity or revision requires a fresh host-owned answer draft.
295
+
296
+ The card handles loading, empty, read-only, expired and unavailable states. Its
297
+ copy is overridable through the `workflowQuestion` message group. It never starts
298
+ work, grants permission, fetches data or infers acknowledgment from a click.
299
+
300
+ The workflow surfaces are module-neutral: software, content, media, legal, document review, process, paperwork and automations are labels and versions the host supplies, and nothing in the model assumes tickets, repositories or code. They are host-driven: data in, callbacks out. The package never fetches, saves, authorizes or runs anything.
301
+
302
+ - **Gallery.** `WorkflowGallery` is a full page of `WorkflowCard`s with search, a module filter, scope and status filters, grid and list views, and create and import when the host passes their handlers. Its filters are controlled (`filters`, `onFiltersChange`): a host that searches on its server passes them to its query; one that holds every definition uses `filterWorkflows(workflows, filters)`. Empty and no-matches are different states.
303
+ - **Availability is explicit.** A `WorkflowSummary` carries `availability` (`available`, `unsupported` or `missing-capabilities` with the names). Only an available workflow the person may start (`permissions.start` not false) shows an enabled start; otherwise the card says why. A fixture or a story never establishes that a module runs.
304
+ - **Runs and review.** `RunTimeline` shows stages with status, timing, attempts, gates and their inputs and outputs; process and automation runs review through it. `ArtifactList` and `ArtifactCard` show typed artifacts with a preview slot. `ReviewPanel` holds the verdict, anchored comments and approve or reject through `ConfirmDialog`, a rejection always with a reason. `GateBadge` reads a gate in one pill.
305
+ - **Module viewers** plug into `ArtifactCard`'s preview and hand `ReviewPanel` an anchor for the next comment: `MediaReview` (video, audio and images, with timecoded and region comments; `formatTimecode` labels them), `DocumentReview` (paged redlines, clause anchors, annotations and sign-off, for legal and document review) and `FormReview` (extracted fields with confidence beside the source page, accept or correct per field).
306
+ - **Editor.** `WorkflowWorkspace` is the whole editor on one page: `WorkflowPalette`, `WorkflowCanvas` and a side panel with `WorkflowInspector`, `WorkflowProblems` and `WorkflowJsonView`, around a working copy the host holds. Each piece also works alone.
307
+
308
+ ```tsx
309
+ const [filters, setFilters] = useState(DEFAULT_WORKFLOW_FILTERS);
310
+
311
+ <WorkflowGallery
312
+ workflows={filterWorkflows(definitions, filters)}
313
+ modules={installedModules}
314
+ filters={filters}
315
+ onFiltersChange={setFilters}
316
+ onCreate={canCreate ? openEditor : undefined}
317
+ onImport={canImport ? openImport : undefined}
318
+ actionsFor={(w) => ({
319
+ href: `/workflows/${w.id}`,
320
+ onEdit: () => edit(w.id),
321
+ onStart: () => start(w.id),
322
+ onExport: () => exportPackage(w.id),
323
+ permissions: { start: can("start", w), edit: can("edit", w) },
324
+ })}
325
+ />;
326
+ ```
327
+
328
+ Their copy is in the `workflow`, `run`, `review`, `media`, `document`, `form`, `editor` and `workspace` message groups.
329
+
330
+ ### The definition and the registry
331
+
332
+ A `WorkflowDefinition` is named flows of nodes and edges: step, sub-flow, gate, condition, go-to, fan-out, join, variants, evaluator, classifier and check. Edges are `next` (from an exit), `loop` (back to an earlier node, with a limit and an exit condition) and `data` (a typed output to a typed input). Positions live in the document.
333
+
334
+ Nothing about a module is built in. The host hands the editor a `WorkflowRegistry`: the installed modules with their step presets and typed ports (`extends` lets a contract go where a document is wanted), the run facts, condition operators and gate kinds its runtime knows, the capabilities present, the node kinds it runs, and the roles, members, models, harnesses and efforts its pickers offer.
335
+
336
+ ```ts
337
+ const registry: WorkflowRegistry = {
338
+ modules: installedModules.map((m) => ({ ...m, presets: m.stepPresets, types: m.dataTypes })),
339
+ facts: runtime.facts, // { id, label, type, node? }
340
+ operators: runtime.operators, // { id, label, types, unary? }
341
+ gateKinds: runtime.gateKinds, // { id, label, fields: ["approvers", "timeout"] }
342
+ capabilities: factory.capabilities,
343
+ nodeKinds: { variants: { status: "unsupported", reason: "Variants are not run yet." } },
344
+ roles,
345
+ members,
346
+ models,
347
+ harnesses,
348
+ efforts,
349
+ };
350
+ ```
351
+
352
+ - `canConnect(flow, connection, registry)` decides every edge: one per exit (a fan-out excepted), never a cycle, loops only back, data only forward to an input of a type it fits. The canvas lands only what it accepts.
353
+ - `checkDefinition(definition, registry)` returns problems at three levels: `blocking` (holds a publish), `warning`, and `not-run` (this destination does not run the node: module, capability or kind missing). Each points at a flow, node or edge, as a code the `editor` messages name. `nodeAvailability` and `definitionAvailability` return the gallery's `WorkflowAvailability`.
354
+ - Portability: `readWorkflowDefinition` keeps only the fields the format knows. `exportWorkflowPackage` writes a versioned `WorkflowPackage` with what it needs and no credentials, grants or named members; `readWorkflowPackage` reads one back, and `previewWorkflowImport(pkg, registry, replacing?)` says what importing it here would do: module and capability gaps, unknown vocabulary, nodes that would be disabled, gates needing approvers, problems, and the diff.
355
+
356
+ ### The workspace
357
+
358
+ ```tsx
359
+ <WorkflowWorkspace
360
+ definition={draft}
361
+ onChange={setDraft}
362
+ registry={registry}
363
+ saved={savedRevision} // { id, label, at, by, definition }
364
+ published={publishedRevision}
365
+ onSave={(definition, base) => api.saveDraft(id, definition, base?.id)}
366
+ onPublish={(definition) => api.publish(id, definition)}
367
+ onSaveAs={(definition, name) => api.create(name, definition)}
368
+ onExport={(definition) => download(exportWorkflowPackage(definition, registry))}
369
+ onImport={pickPackageFile} // then pass importPreview, onImportConfirm, onImportCancel
370
+ readOnly={!can("edit")}
371
+ />
372
+ ```
373
+
374
+ It marks unsaved edits and a draft that differs from live (`documentStatus`). A newer saved revision is taken when there is nothing to lose; when it moves under unsaved edits, it stops and asks: compare, use theirs, or keep mine. Publishing shows the draft against live (`WorkflowDiff`) and is held while anything blocks. A rejected promise's message is shown and the edits stay. Persistence and authority stay with the host: it never saves, publishes or runs anything itself. Below a 64rem container the palette and the side panel open as drawers.
375
+
376
+ The canvas pans, zooms and fits without going below `READABLE_ZOOM`; loop bodies and fan-out branches fold, and a sub-flow shows in place. Every drag has a keyboard equivalent: Tab to a node or edge, Enter to select, arrows to move, Delete to remove, and Add and Connect in its toolbar.
377
+
378
+ `@xyflow/react` comes with the package; its structural CSS is in the preset.
267
379
 
268
380
  ## Appearance
269
381
 
@@ -273,6 +385,157 @@ The person sets four axes, per product: the **ground** (black, charcoal, graphit
273
385
  - An org policy that arrives after sign-in goes in through `useAppearance().setPolicy`. An app that keeps the preference on its server passes `onChange` and hands the server's copy back through `applyServerAppearance`.
274
386
  - Status colours never move: lime ok, amber warn, red error, green info, whatever the accent or ground. Tests hold the generated CSS to that, and check WCAG contrast for every ground with every accent.
275
387
 
388
+ ### Runtime brand
389
+
390
+ A product that runs per customer (one deployment, one brand) takes the brand at runtime, as plain JSON from its server, instead of a second `defineProduct`. The product stays a client-module constant; the brand travels beside it.
391
+
392
+ ```tsx
393
+ // app/layout.tsx (Server Component)
394
+ import { AppearanceScript, htmlAttributes, resolveRuntimeBrand } from "@calliopelabs/ui";
395
+
396
+ const { brand, warnings } = resolveRuntimeBrand(await loadBrand(), studio);
397
+ // log `warnings`: anything dropped or replaced, and why
398
+
399
+ <html lang="en" {...htmlAttributes(studio, brand)} suppressHydrationWarning>
400
+ <head>
401
+ <AppearanceScript product={studio} brand={brand} nonce={nonce} />
402
+ </head>
403
+ <body>
404
+ {/* a "use client" wrapper: <AppearanceProvider product={studio} brand={brand}> */}
405
+ <Providers brand={brand}>{children}</Providers>
406
+ </body>
407
+ </html>;
408
+ ```
409
+
410
+ - `RuntimeBrand` carries `name`, `shortName`, `logo` (`https://` or `data:image` URLs), `accent` (`{ dark, light }`, inks optional), `lockAccent`, `sidebar` (a colour for the dark brand-sidebar grounds, or `{ dark, light }` to brand light grounds too), `sidebarMarker`, `grounds` and `defaultGround`.
411
+ - `resolveRuntimeBrand` never throws. Colours must be lowercase `#rrggbb`; anything else is dropped with a warning, so nothing but a colour reaches the `<style>`. An accent under 3:1 on an offered ground falls back to the product's accent for that mode; inks under 4.5:1 become black or white; the sidebar text is picked to read 4.5:1; a marker under 3:1 is drawn in the sidebar text colour. Grounds outside the product are dropped.
412
+ - The brand's accent is offered under the reserved key `brand`, first and as the default. `lockAccent: true` makes it the only accent and turns off custom colours.
413
+ - `defaultGround` as one ground pins it whatever the system says; as `{ dark, light }` it follows the system.
414
+ - `withBrand(product, brand)` is the product as the deployment shows it, and is what `AppearanceProvider` puts in context. `brandStyles(brand)` is the CSS `AppearanceScript` adds; `brandStyleVars(brand, mode)` is the same tokens as an inline style map, for a preview frame that carries its own `data-mode` and `data-ground`.
415
+ - `BrandMark` draws the brand's logo: `logo.mark` in its tile, and `logo.onDark` or `logo.onLight` in place of the name, chosen by how light the sidebar is in each mode (both are in the markup; CSS shows the right one before paint). Collapsed with no mark, it shows `shortName`.
416
+ - Without a brand, nothing changes: the same markup, the same boot script.
417
+
418
+ ## Server-rendered apps
419
+
420
+ Apps that render HTML on the server (Django or Jinja, FastAPI with HTMX, Go templates, Rails with Hotwire, Laravel with Livewire, Phoenix LiveView) cannot take React components. They take the CSS layer instead: the same tokens and the same appearance boot script, as plain files, with no Tailwind and no build step.
421
+
422
+ | File | What it is |
423
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
424
+ | `@calliopelabs/ui/css/tokens.css` | Every token for every product, ground, accent, density and corners, driven by attributes on `<html>` |
425
+ | `@calliopelabs/ui/css/components.css` | Class-based components on those tokens (below) |
426
+ | `@calliopelabs/ui/css/calliope.css` | `tokens.css` and `components.css` in one file |
427
+ | `@calliopelabs/ui/css/appearance.js` | The boot script `AppearanceScript` renders, for the built-in products, plus `window.CalliopeAppearance` |
428
+
429
+ Both are generated from `src/theme/tokens.ts` and `src/appearance/boot.ts`, and a test holds them to the preset's values and to `AppearanceScript`'s behaviour, so they cannot drift from the React side.
430
+
431
+ ### Getting the files
432
+
433
+ From npm, copy them into the app's static files (or serve them from `node_modules` with the app's asset pipeline):
434
+
435
+ ```sh
436
+ npm i @calliopelabs/ui
437
+ cp node_modules/@calliopelabs/ui/dist/css/{calliope.css,appearance.js} static/calliope/
438
+ ```
439
+
440
+ From a CDN, once the version is published to npm, pinned to that version:
441
+
442
+ ```
443
+ https://cdn.jsdelivr.net/npm/@calliopelabs/ui@<version>/dist/css/calliope.css
444
+ https://cdn.jsdelivr.net/npm/@calliopelabs/ui@<version>/dist/css/appearance.js
445
+ ```
446
+
447
+ ### The `<head>`
448
+
449
+ The boot script goes first in `<head>`, as a plain script (no `async`, no `defer`) with the product on it, so the person's ground, accent, density and corners are on `<html>` before anything paints. A Django or Jinja base template:
450
+
451
+ ```html
452
+ <!doctype html>
453
+ <html lang="en">
454
+ <head>
455
+ <meta charset="utf-8" />
456
+ <script src="{% static 'calliope/appearance.js' %}" data-product="astrolift"></script>
457
+ <link rel="stylesheet" href="{% static 'calliope/calliope.css' %}" />
458
+ <link rel="stylesheet" href="{% static 'app.css' %}" />
459
+ </head>
460
+ <body>
461
+ {% block content %}{% endblock %}
462
+ </body>
463
+ </html>
464
+ ```
465
+
466
+ `data-product` is one of `astrolift`, `zentinelle`, `workbench`, `chat-studio`, `loadr` or `cykick`. The script reads the same `localStorage` entry as the React app (`<product>.appearance`), so a person's choice carries between a product's React and server-rendered pages on one origin. Under a strict CSP, serve the file from your origin (it is a file, so no nonce is needed) or inline its contents with the page's nonce.
467
+
468
+ HTMX swaps and Hotwire or LiveView navigations replace `<body>`, not `<html>`, so the stamp survives them. A full page load runs the script again.
469
+
470
+ ### The attributes
471
+
472
+ The stylesheet reads attributes, on `<html>` or on any element below it:
473
+
474
+ | Attribute | Values |
475
+ | -------------- | ----------------------------------------------------------------------------------- |
476
+ | `data-product` | the product: its sidebar colour and its own accent |
477
+ | `data-mode` | `dark` or `light`; must match the ground |
478
+ | `data-ground` | `black`, `charcoal`, `graphite`, `emerald`, `navy` (dark); `paper`, `mist` (light) |
479
+ | `data-accent` | `green`, `copper`, `ice`, `periwinkle`, `amber`, or the product's own (`teal`, ...) |
480
+ | `data-density` | `compact` or `cards` |
481
+ | `data-corners` | `0`, `2`, `4` or `10` (the boot script sets `--radius` inline instead) |
482
+
483
+ A server that keeps the preference itself can render the attributes into the template and skip the script:
484
+
485
+ ```html
486
+ <html
487
+ lang="en"
488
+ data-product="astrolift"
489
+ data-mode="light"
490
+ data-ground="paper"
491
+ data-accent="copper"
492
+ data-density="compact"
493
+ data-corners="2"
494
+ ></html>
495
+ ```
496
+
497
+ The app's own CSS uses the variables: `var(--background)`, `var(--foreground)`, `var(--card)`, `var(--border)`, `var(--primary)`, `var(--primary-foreground)`, `var(--muted-foreground)`, `var(--sidebar)`, the status set (`--success`, `--success-fg`, `--success-bg`, `--success-border`, and the same for `warning`, `info` and `danger`), `var(--radius)` and the density variables (`--row-pad-y`, `--panel-pad`, `--stack-gap`, ...). The fonts are the app's to load, as above.
498
+
499
+ ### Components
500
+
501
+ `components.css` styles the common primitives by class, with the same tokens as the React components, so every ground, accent, density and corners setting reaches them. It sits in the `calliope` cascade layer, so the app's own CSS wins without a fight. The catalog's **CSS layer/Components** section shows each one with its markup.
502
+
503
+ | Component | Markup |
504
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
505
+ | Button | `<button class="cui-btn">`; `cui-btn--outline`, `--secondary`, `--ghost`, `--danger`, `--link`; sizes `--sm`, `--lg`, `--icon` (with an `aria-label`) |
506
+ | Field | `<div class="cui-field">` with `cui-label`, the control, and `cui-hint` or `cui-error` (`aria-describedby`, `aria-invalid="true"`) |
507
+ | Input, textarea | `<input class="cui-input">`, `<textarea class="cui-textarea">` |
508
+ | Select | `<select class="cui-select">` |
509
+ | Checkbox, switch | `<label class="cui-check"><input type="checkbox" class="cui-checkbox"> ...</label>`; a switch is `<input type="checkbox" role="switch" class="cui-switch">` |
510
+ | Card, panel | `cui-card` (padded); `cui-panel` with `cui-panel__header`, `cui-panel__title`, `cui-panel__body` (`--flush` for a table) |
511
+ | Status, badge | `<span class="cui-status cui-status--ok">` (`ok`, `warn`, `error`, `info`; none is muted; `cui-status--pulse` for in flight); `cui-badge` for neutral tags |
512
+ | Table | `<div class="cui-table-wrap" role="region" aria-label="..." tabindex="0"><table class="cui-table">`; `cui-num` on numeric cells |
513
+ | Alert | `<div class="cui-alert cui-alert--warn" role="status">` with `cui-alert__title` and `cui-alert__body`; `role="alert"` for errors |
514
+ | Dialog | `<dialog class="cui-dialog">` with `cui-dialog__header`, `__title`, `__description`, `__body`, and `<form method="dialog" class="cui-dialog__footer">` |
515
+ | Page header | `<header class="cui-page-header">` with `cui-page-header__row`, `__heading` (an `hgroup`), `__title` (the `h1`), `__actions`, `__description` |
516
+ | App shell | `<div class="cui-shell">` with `<aside class="cui-sidebar">` (`cui-sidebar__brand`, a `nav` of `cui-nav` and `cui-nav__link`, `aria-current="page"`) and `<main class="cui-main">` |
517
+ | Helpers | `cui-stack`, `cui-row`, `cui-eyebrow`, `cui-muted`, `cui-mono` |
518
+
519
+ A dialog opens without script through an invoker button, `<button commandfor="delete-app" command="show-modal">`, or with `document.getElementById("delete-app").showModal()` where invoker commands are not yet supported. The footer form closes it and reports which button through `dialog.returnValue`. With HTMX, a server can return the dialog and open it in `hx-on::after-swap`.
520
+
521
+ ### Changing the appearance
522
+
523
+ `appearance.js` leaves a small API on `window.CalliopeAppearance`:
524
+
525
+ ```js
526
+ CalliopeAppearance.set({ ground: "paper" }); // stores the choice and repaints; other axes keep theirs
527
+ CalliopeAppearance.set({ accent: "#d94f8a" }); // a custom accent, kept only where it stands 3:1 off the ground
528
+ CalliopeAppearance.set({ density: "cards", corners: 4 });
529
+ CalliopeAppearance.get(); // { ground, mode, accent, density, corners }, as painted now
530
+ CalliopeAppearance.reset(); // forget the choice; the product's defaults (and the system's light or dark) apply
531
+ CalliopeAppearance.config; // the product's grounds, accents and corners, to build a picker from
532
+ document.addEventListener("calliope:appearance", (event) => console.log(event.detail));
533
+ ```
534
+
535
+ `set` keeps only values the product offers, like the React picker, and resolves through the same boot script, so a page painted by the server and one changed in the browser agree. A settings form posted to the server can call `set` from its response (an HTMX `hx-on::after-request`, for example) or render the attributes on the next page.
536
+
537
+ A product that is not built in sets its sidebar with `[data-product="myapp"] { --brand-sidebar: #0b0b19; }` in its own CSS, picks a family accent, and boots with `CalliopeAppearance.boot(config)`, where `config` is the JSON `bootConfig(defineProduct(...))` returns (or one of `CalliopeAppearance.products`, edited).
538
+
276
539
  ## Copy and languages
277
540
 
278
541
  Every string the package renders is English by default and overridable, one group at a time:
@@ -292,7 +555,7 @@ Node 24 (`.nvmrc`).
292
555
  | `npm run storybook` | The catalog, on port 6006 |
293
556
  | `npm test` | Every test, including one that renders every story and runs the a11y addon on it |
294
557
  | `npm run lint`, `npm run typecheck`, `npm run format:check` | The gates CI runs |
295
- | `npm run theme` | Regenerates `src/styles/theme.css` after a change to `src/theme/tokens.ts` |
558
+ | `npm run theme` | Regenerates `src/styles/theme.css` and the CSS layer after a token change |
296
559
  | `npm run build`, `npm run pack:check` | Builds `dist/` and checks the package a consumer receives |
297
560
  | `npm run build-storybook` | The static catalog |
298
561
 
@@ -13,7 +13,9 @@ import { CORNERS } from "./product.js";
13
13
  /** The sidebar a ground paints for this product, for the theme previews. */
14
14
  function sidebarFor(product, ground) {
15
15
  const sidebar = SURFACES[ground].sidebar;
16
- return sidebar === "brand" ? product.sidebar : sidebar;
16
+ if (sidebar === "brand")
17
+ return product.sidebar;
18
+ return modeFor(ground) === "light" && product.sidebarLight ? product.sidebarLight : sidebar;
17
19
  }
18
20
  function accentOn(accent, ground) {
19
21
  if (!accent)
@@ -44,7 +46,7 @@ export function AppearancePicker({ className }) {
44
46
  const id = React.useId();
45
47
  const accents = product.accents;
46
48
  const activeTheme = product.themes.find((th) => th.ground === appearance.ground && th.accent === appearance.accent);
47
- return (_jsxs("div", { "data-slot": "appearance-picker", className: cn("flex min-w-0 flex-col gap-6", className), children: [locked && (_jsxs("p", { className: "flex items-center gap-2 rounded-lg border border-info-border bg-info-bg px-3 py-2 text-sm text-info-fg", children: [_jsx(LockIcon, { className: "size-4 shrink-0", "aria-hidden": true }), t.locked] })), product.themes.length > 0 && (_jsx(Group, { legend: t.themes, children: _jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-2", children: product.themes.map((theme) => (_jsxs(Choice, { name: `${id}-theme`, checked: activeTheme?.id === theme.id, disabled: locked, onSelect: () => setAppearance({ ground: theme.ground, accent: theme.accent }), className: "flex-col items-stretch gap-2 p-2", children: [_jsx(ThemePreview, { product: product, ground: theme.ground, accent: accents.find((a) => a.key === theme.accent) }), _jsxs("span", { children: [_jsx("span", { className: "block text-sm font-semibold", children: t.themeName(theme) }), _jsx("span", { className: "block text-xs text-muted-foreground", children: t.themeHint(theme) })] })] }, theme.id))) }) })), _jsxs(Group, { legend: t.accent, children: [_jsx("div", { className: "flex flex-wrap gap-2", children: accents.map((accent) => (_jsxs(Choice, { name: `${id}-accent`, checked: appearance.accent === accent.key, disabled: locked, onSelect: () => setAppearance({ accent: accent.key }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full", style: { background: accentOn(accent, appearance.ground) } }), t.accentLabel(accent.key, accent.label)] }, accent.key))) }), _jsx(CustomAccentField, { id: `${id}-custom`, value: isCustomAccent(appearance.accent) ? appearance.accent : null, ground: appearance.ground, disabled: locked, onChange: (accent) => setAppearance({ accent }) })] }), _jsx(Group, { legend: t.ground, children: _jsx("div", { className: "flex flex-wrap gap-2", children: product.grounds.map((ground) => (_jsxs(Choice, { name: `${id}-ground`, checked: appearance.ground === ground, disabled: locked, onSelect: () => setAppearance({ ground }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full border", style: { background: SURFACES[ground].background } }), t.groundLabel(ground, GROUNDS[ground].label), _jsx("span", { className: "text-xs text-muted-foreground", children: t.mode(GROUNDS[ground].mode) })] }, ground))) }) }), _jsx(Group, { legend: t.density, children: _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: DENSITY_KEYS.map((density) => (_jsxs(Choice, { name: `${id}-density`, checked: appearance.density === density, disabled: locked, onSelect: () => setAppearance({ density }), className: "flex-col items-start gap-0.5 px-3 py-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t.densityLabel(density) }), _jsx("span", { className: "text-xs text-muted-foreground", children: t.densityHint(density) })] }, density))) }) }), _jsx(Group, { legend: t.corners, children: _jsx("div", { className: "flex flex-wrap gap-2", children: CORNERS.map((corners) => (_jsxs(Choice, { name: `${id}-corners`, checked: appearance.corners === corners, disabled: locked, onSelect: () => setAppearance({ corners }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 border-t-2 border-l-2 border-current", style: { borderTopLeftRadius: corners } }), t.cornersLabel(corners)] }, corners))) }) }), !locked && (_jsx("div", { children: _jsx(Button, { variant: "outline", size: "sm", onClick: reset, children: t.reset }) }))] }));
49
+ return (_jsxs("div", { "data-slot": "appearance-picker", className: cn("flex min-w-0 flex-col gap-6", className), children: [locked && (_jsxs("p", { className: "flex items-center gap-2 rounded-lg border border-info-border bg-info-bg px-3 py-2 text-sm text-info-fg", children: [_jsx(LockIcon, { className: "size-4 shrink-0", "aria-hidden": true }), t.locked] })), product.themes.length > 0 && (_jsx(Group, { legend: t.themes, children: _jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-2", children: product.themes.map((theme) => (_jsxs(Choice, { name: `${id}-theme`, checked: activeTheme?.id === theme.id, disabled: locked, onSelect: () => setAppearance({ ground: theme.ground, accent: theme.accent }), className: "flex-col items-stretch gap-2 p-2", children: [_jsx(ThemePreview, { product: product, ground: theme.ground, accent: accents.find((a) => a.key === theme.accent) }), _jsxs("span", { children: [_jsx("span", { className: "block text-sm font-semibold", children: t.themeName(theme) }), _jsx("span", { className: "block text-xs text-muted-foreground", children: t.themeHint(theme) })] })] }, theme.id))) }) })), _jsxs(Group, { legend: t.accent, children: [_jsx("div", { className: "flex flex-wrap gap-2", children: accents.map((accent) => (_jsxs(Choice, { name: `${id}-accent`, checked: appearance.accent === accent.key, disabled: locked, onSelect: () => setAppearance({ accent: accent.key }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full", style: { background: accentOn(accent, appearance.ground) } }), t.accentLabel(accent.key, accent.label)] }, accent.key))) }), !product.accentLocked && (_jsx(CustomAccentField, { id: `${id}-custom`, value: isCustomAccent(appearance.accent) ? appearance.accent : null, ground: appearance.ground, disabled: locked, onChange: (accent) => setAppearance({ accent }) }))] }), _jsx(Group, { legend: t.ground, children: _jsx("div", { className: "flex flex-wrap gap-2", children: product.grounds.map((ground) => (_jsxs(Choice, { name: `${id}-ground`, checked: appearance.ground === ground, disabled: locked, onSelect: () => setAppearance({ ground }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full border", style: { background: SURFACES[ground].background } }), t.groundLabel(ground, GROUNDS[ground].label), _jsx("span", { className: "text-xs text-muted-foreground", children: t.mode(GROUNDS[ground].mode) })] }, ground))) }) }), _jsx(Group, { legend: t.density, children: _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: DENSITY_KEYS.map((density) => (_jsxs(Choice, { name: `${id}-density`, checked: appearance.density === density, disabled: locked, onSelect: () => setAppearance({ density }), className: "flex-col items-start gap-0.5 px-3 py-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t.densityLabel(density) }), _jsx("span", { className: "text-xs text-muted-foreground", children: t.densityHint(density) })] }, density))) }) }), _jsx(Group, { legend: t.corners, children: _jsx("div", { className: "flex flex-wrap gap-2", children: CORNERS.map((corners) => (_jsxs(Choice, { name: `${id}-corners`, checked: appearance.corners === corners, disabled: locked, onSelect: () => setAppearance({ corners }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 border-t-2 border-l-2 border-current", style: { borderTopLeftRadius: corners } }), t.cornersLabel(corners)] }, corners))) }) }), !locked && (_jsx("div", { children: _jsx(Button, { variant: "outline", size: "sm", onClick: reset, children: t.reset }) }))] }));
48
50
  }
49
51
  /**
50
52
  * Any colour as the accent (Astrolift palette option A). It applies only once
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import type { Density, Mode } from "../theme/tokens.js";
3
3
  import { type Appearance, type OrgAppearancePolicy } from "./appearance.js";
4
4
  import type { Product } from "./product.js";
5
+ import { type RuntimeBrand } from "./runtime-brand.js";
5
6
  export interface AppearanceContextValue {
6
7
  product: Product;
7
8
  /** What applies now: the org lock, the person's choice, the org default or the product default. */
@@ -21,6 +22,12 @@ export interface AppearanceContextValue {
21
22
  }
22
23
  export interface AppearanceProviderProps {
23
24
  product: Product;
25
+ /**
26
+ * This deployment's brand, the same value AppearanceScript got. Plain
27
+ * JSON, so a Server Component can hand it down; compared by value, so a
28
+ * fresh object with the same content does not re-apply.
29
+ */
30
+ brand?: RuntimeBrand | null;
24
31
  /** The org policy, when the caller already has it; `setPolicy` supplies it later otherwise. */
25
32
  policy?: OrgAppearancePolicy | null;
26
33
  /** Called with the person's new preference (null on reset), for an app that keeps it on its server. */
@@ -42,9 +49,10 @@ export interface AppearanceProviderProps {
42
49
  *
43
50
  * The product config carries component references (navigation icons), so
44
51
  * create this provider in a client module rather than passing the product
45
- * across from a Server Component.
52
+ * across from a Server Component. A runtime `brand` is plain data and may
53
+ * come from one; the context's `product` is the branded one (`withBrand`).
46
54
  */
47
- export declare function AppearanceProvider({ product, policy, onChange, value: controlled, children, }: AppearanceProviderProps): React.JSX.Element;
55
+ export declare function AppearanceProvider({ product: baseProduct, brand, policy, onChange, value: controlled, children, }: AppearanceProviderProps): React.JSX.Element;
48
56
  /** The appearance context. Throws outside an AppearanceProvider. */
49
57
  export declare function useAppearance(): AppearanceContextValue;
50
58
  /** The product in context, or null outside an AppearanceProvider. */
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { subscribeLocal } from "../lib/local-store.js";
5
5
  import { appearanceFromServer, applyAppearance, clearAppearance, normalizePartial, parseStoredAppearance, readAppearance, readStoredAppearance, resolveAppearance, writeAppearance, } from "./appearance.js";
6
+ import { withBrand } from "./runtime-brand.js";
6
7
  const AppearanceContext = React.createContext(null);
7
8
  const LIGHT_QUERY = "(prefers-color-scheme: light)";
8
9
  function subscribeScheme(onChange) {
@@ -27,9 +28,12 @@ const noSubscription = () => () => { };
27
28
  *
28
29
  * The product config carries component references (navigation icons), so
29
30
  * create this provider in a client module rather than passing the product
30
- * across from a Server Component.
31
+ * across from a Server Component. A runtime `brand` is plain data and may
32
+ * come from one; the context's `product` is the branded one (`withBrand`).
31
33
  */
32
- export function AppearanceProvider({ product, policy = null, onChange, value: controlled, children, }) {
34
+ export function AppearanceProvider({ product: baseProduct, brand, policy = null, onChange, value: controlled, children, }) {
35
+ const brandKey = brand ? JSON.stringify(brand) : null;
36
+ const product = React.useMemo(() => withBrand(baseProduct, brandKey ? JSON.parse(brandKey) : null), [baseProduct, brandKey]);
33
37
  const isControlled = controlled !== undefined;
34
38
  const raw = React.useSyncExternalStore(subscribeLocal, () => readStoredAppearance(product), () => null);
35
39
  const scheme = React.useSyncExternalStore(subscribeScheme, systemMode, () => "dark");
@@ -1,16 +1,27 @@
1
+ import { type RailBoot } from "../shell/rail-boot.js";
1
2
  import type { Product } from "./product.js";
3
+ import { type RuntimeBrand } from "./runtime-brand.js";
2
4
  export interface AppearanceScriptProps {
3
5
  product: Product;
6
+ /** This deployment's brand, resolved on the server; pass the same value to AppearanceProvider. */
7
+ brand?: RuntimeBrand | null;
4
8
  /** The page's CSP nonce, when inline style and script need one. */
5
9
  nonce?: string;
10
+ /**
11
+ * Rails to fold before paint: each one's saved state is stamped on <html>
12
+ * as `data-rail-<name>`, and a Sidebar with the same `railName` paints
13
+ * from it until hydration. Pass the same objects to `useRailState`.
14
+ */
15
+ rails?: readonly RailBoot[];
6
16
  }
7
17
  /**
8
18
  * Render in <head>, before anything paints: the product's brand tokens (its
9
- * sidebar and its own accent) and the script that stamps the person's ground,
10
- * accent, density and corners on <html> from localStorage. Without it the page
11
- * paints the defaults and swaps once React hydrates, which reads as a flash.
19
+ * sidebar and its own accent), a runtime brand's tokens when there is one,
20
+ * and the script that stamps the person's ground, accent, density and corners
21
+ * on <html> from localStorage. Without it the page paints the defaults and
22
+ * swaps once React hydrates, which reads as a flash.
12
23
  *
13
24
  * Not a client component: it renders the same markup on the server and needs
14
25
  * no hooks, so a Next root layout can render it directly.
15
26
  */
16
- export declare function AppearanceScript({ product, nonce }: AppearanceScriptProps): import("react").JSX.Element;
27
+ export declare function AppearanceScript({ product, brand, nonce, rails }: AppearanceScriptProps): import("react").JSX.Element;
@@ -1,14 +1,20 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { railStyles } from "../shell/rail-boot.js";
2
3
  import { appearanceScript, productStyles } from "./boot.js";
4
+ import { brandStyles, resolveRuntimeBrand, withBrand } from "./runtime-brand.js";
3
5
  /**
4
6
  * Render in <head>, before anything paints: the product's brand tokens (its
5
- * sidebar and its own accent) and the script that stamps the person's ground,
6
- * accent, density and corners on <html> from localStorage. Without it the page
7
- * paints the defaults and swaps once React hydrates, which reads as a flash.
7
+ * sidebar and its own accent), a runtime brand's tokens when there is one,
8
+ * and the script that stamps the person's ground, accent, density and corners
9
+ * on <html> from localStorage. Without it the page paints the defaults and
10
+ * swaps once React hydrates, which reads as a flash.
8
11
  *
9
12
  * Not a client component: it renders the same markup on the server and needs
10
13
  * no hooks, so a Next root layout can render it directly.
11
14
  */
12
- export function AppearanceScript({ product, nonce }) {
13
- return (_jsxs(_Fragment, { children: [_jsx("style", { nonce: nonce, "data-calliope-ui": "product", dangerouslySetInnerHTML: { __html: productStyles(product) } }), _jsx("script", { nonce: nonce, "data-calliope-ui": "appearance", dangerouslySetInnerHTML: { __html: appearanceScript(product) } })] }));
15
+ export function AppearanceScript({ product, brand, nonce, rails = [] }) {
16
+ const effective = withBrand(product, brand);
17
+ const brandCss = brand ? brandStyles(resolveRuntimeBrand(brand, product).brand) : "";
18
+ const railCss = railStyles(rails);
19
+ return (_jsxs(_Fragment, { children: [_jsx("style", { nonce: nonce, "data-calliope-ui": "product", dangerouslySetInnerHTML: { __html: productStyles(effective) } }), brandCss && (_jsx("style", { nonce: nonce, "data-calliope-ui": "brand", dangerouslySetInnerHTML: { __html: brandCss } })), railCss && (_jsx("style", { nonce: nonce, "data-calliope-ui": "rails", dangerouslySetInnerHTML: { __html: railCss } })), _jsx("script", { nonce: nonce, "data-calliope-ui": "appearance", dangerouslySetInnerHTML: { __html: appearanceScript(effective, rails) } })] }));
14
20
  }
@@ -51,7 +51,7 @@ export function normalizePartial(raw, product) {
51
51
  const out = {};
52
52
  if (isGround(product, v.ground))
53
53
  out.ground = v.ground;
54
- if (isProductAccent(product, v.accent) || isCustomAccent(v.accent))
54
+ if (isProductAccent(product, v.accent) || (isCustomAccent(v.accent) && !product.accentLocked))
55
55
  out.accent = v.accent;
56
56
  if (v.density === "compact" || v.density === "cards")
57
57
  out.density = v.density;
@@ -1,3 +1,4 @@
1
+ import { type RailBoot } from "../shell/rail-boot.js";
1
2
  import { type Mode } from "../theme/tokens.js";
2
3
  import { type Product } from "./product.js";
3
4
  /** What the boot script needs to know about a product: plain data, serialised into the page. */
@@ -20,11 +21,21 @@ export interface BootConfig {
20
21
  };
21
22
  }
22
23
  export declare function bootConfig(product: Product): BootConfig;
24
+ /**
25
+ * The boot script, as source text: plain ES5 in a string, so no bundler,
26
+ * transpiler or coverage tool can rewrite it into something that needs a
27
+ * helper from outside. It is `normalize` + `resolveAppearance` (with no org
28
+ * policy, which cannot be known before paint) + `applyAppearance`, written a
29
+ * second time; boot.test.ts runs it against those over a matrix of stored
30
+ * preferences and system modes, so the two cannot drift apart.
31
+ */
32
+ export declare const BOOT_SOURCE = "function (c) {\n var d = document.documentElement, p = {};\n try {\n var raw = window.localStorage.getItem(c.key);\n var parsed = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\") p = parsed;\n } catch (e) {}\n var light = false;\n try {\n light = window.matchMedia(\"(prefers-color-scheme: light)\").matches;\n } catch (e) {}\n var has = function (o, k) {\n return typeof k === \"string\" && Object.prototype.hasOwnProperty.call(o, k);\n };\n var lum = function (h) {\n var n = parseInt(h.slice(1), 16), rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n var w = [0.2126, 0.7152, 0.0722], s = 0;\n for (var i = 0; i < 3; i++) {\n var v = rgb[i] / 255;\n s += w[i] * (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));\n }\n return s;\n };\n var ratio = function (x, y) {\n var a = lum(x), b = lum(y);\n return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n };\n var ground = has(c.grounds, p.ground) ? p.ground : light ? c.defaults.light : c.defaults.dark;\n var accent = p.accent;\n var custom = typeof accent === \"string\" && /^#[0-9a-f]{6}$/.test(accent) &&\n ratio(accent, c.grounds[ground].background) >= c.minContrast;\n d.dataset.product = c.product;\n d.dataset.ground = ground;\n d.dataset.mode = c.grounds[ground].mode;\n if (custom) {\n d.dataset.accent = \"custom\";\n d.style.setProperty(\"--brand-primary\", accent);\n d.style.setProperty(\"--brand-on-primary\",\n ratio(accent, \"#000000\") >= ratio(accent, \"#ffffff\") ? \"#000000\" : \"#ffffff\");\n } else {\n d.dataset.accent = c.accents.indexOf(accent) > -1 ? accent : c.defaults.accent;\n }\n d.dataset.density = p.density === \"compact\" || p.density === \"cards\" ? p.density : c.defaults.density;\n d.style.setProperty(\"--radius\",\n (c.corners.indexOf(p.corners) > -1 ? p.corners : c.defaults.corners) + \"px\");\n}";
23
33
  /**
24
34
  * The inline script that stamps a product's appearance on <html> before
25
35
  * first paint. Render it in <head> (AppearanceScript does), or write it into
26
- * an HTML template yourself.
36
+ * an HTML template yourself. `rails` also stamps each rail's fold
37
+ * (`data-rail-<name>`) from the entry `useRailState` keeps.
27
38
  */
28
- export declare function appearanceScript(product: Product): string;
39
+ export declare function appearanceScript(product: Product, rails?: readonly RailBoot[]): string;
29
40
  /** The product's brand tokens as a CSS string: its sidebar surface and its own accent. */
30
41
  export declare function productStyles(product: Product): string;
@@ -1,3 +1,4 @@
1
+ import { checkRails } from "../shell/rail-boot.js";
1
2
  import { renderProductCss } from "../theme/css.js";
2
3
  import { MIN_NON_TEXT_CONTRAST } from "../theme/contrast.js";
3
4
  import { GROUNDS, SURFACES } from "../theme/tokens.js";
@@ -9,7 +10,10 @@ export function bootConfig(product) {
9
10
  grounds: Object.fromEntries(product.grounds.map((g) => [g, { mode: GROUNDS[g].mode, background: SURFACES[g].background }])),
10
11
  accents: product.accents.map((a) => a.key),
11
12
  corners: [...CORNERS],
12
- minContrast: MIN_NON_TEXT_CONTRAST,
13
+ // A locked accent takes no custom colour. No pair of colours reaches
14
+ // 22:1 (the most is 21), so the script refuses every custom accent
15
+ // without a second code path.
16
+ minContrast: product.accentLocked ? 22 : MIN_NON_TEXT_CONTRAST,
13
17
  defaults: {
14
18
  dark: product.defaults.ground.dark,
15
19
  light: product.defaults.ground.light,
@@ -27,7 +31,7 @@ export function bootConfig(product) {
27
31
  * second time; boot.test.ts runs it against those over a matrix of stored
28
32
  * preferences and system modes, so the two cannot drift apart.
29
33
  */
30
- const BOOT_SOURCE = `function (c) {
34
+ export const BOOT_SOURCE = `function (c) {
31
35
  var d = document.documentElement, p = {};
32
36
  try {
33
37
  var raw = window.localStorage.getItem(c.key);
@@ -73,6 +77,28 @@ const BOOT_SOURCE = `function (c) {
73
77
  d.style.setProperty("--radius",
74
78
  (c.corners.indexOf(p.corners) > -1 ? p.corners : c.defaults.corners) + "px");
75
79
  }`;
80
+ /**
81
+ * The rails' half of the boot script: each rail's fold from the entry
82
+ * `useRailState` keeps, stamped as `data-rail-<name>`. The default is
83
+ * `narrowerThan(collapsedBelow)`, written out; rail-boot.test.tsx holds the
84
+ * two to the same answer.
85
+ */
86
+ const RAIL_SOURCE = `function (rails) {
87
+ var d = document.documentElement;
88
+ for (var i = 0; i < rails.length; i++) {
89
+ var r = rails[i], saved = null, folded = false;
90
+ try {
91
+ saved = window.localStorage.getItem(r.key);
92
+ } catch (e) {}
93
+ if (saved === "1") folded = true;
94
+ else if (saved !== "0" && r.collapsedBelow) {
95
+ try {
96
+ folded = window.matchMedia("(max-width: " + (r.collapsedBelow - 0.02) + "px)").matches;
97
+ } catch (e) {}
98
+ }
99
+ d.setAttribute("data-rail-" + r.name, folded ? "collapsed" : "expanded");
100
+ }
101
+ }`;
76
102
  /** JSON that is safe inside an inline <script>. */
77
103
  function scriptJson(value) {
78
104
  return JSON.stringify(value).replace(/</g, "\\u003c");
@@ -80,10 +106,14 @@ function scriptJson(value) {
80
106
  /**
81
107
  * The inline script that stamps a product's appearance on <html> before
82
108
  * first paint. Render it in <head> (AppearanceScript does), or write it into
83
- * an HTML template yourself.
109
+ * an HTML template yourself. `rails` also stamps each rail's fold
110
+ * (`data-rail-<name>`) from the entry `useRailState` keeps.
84
111
  */
85
- export function appearanceScript(product) {
86
- return `try{(${BOOT_SOURCE})(${scriptJson(bootConfig(product))})}catch(e){}`;
112
+ export function appearanceScript(product, rails = []) {
113
+ const appearance = `try{(${BOOT_SOURCE})(${scriptJson(bootConfig(product))})}catch(e){}`;
114
+ if (rails.length === 0)
115
+ return appearance;
116
+ return `${appearance}try{(${RAIL_SOURCE})(${scriptJson(checkRails(rails))})}catch(e){}`;
87
117
  }
88
118
  /** The product's brand tokens as a CSS string: its sidebar surface and its own accent. */
89
119
  export function productStyles(product) {
@@ -70,6 +70,7 @@ export interface Product {
70
70
  /** The product's own accent when it has one; null when it uses a family accent. */
71
71
  brandAccent: BrandAccent | null;
72
72
  defaults: {
73
+ /** The ground until someone chooses, per system mode; both the same when a brand pins one. */
73
74
  ground: Record<Mode, Ground>;
74
75
  accent: string;
75
76
  density: Density;
@@ -79,6 +80,18 @@ export interface Product {
79
80
  nav: NavModel;
80
81
  /** localStorage key of the personal appearance preference. */
81
82
  storageKey: string;
83
+ /** Set by `withBrand`: the name for tight spots (a collapsed rail, a phone bar). */
84
+ shortName?: string;
85
+ /** Set by `withBrand`: the deployment's logo, as validated URLs. */
86
+ logo?: {
87
+ mark?: string;
88
+ onDark?: string;
89
+ onLight?: string;
90
+ };
91
+ /** Set by `withBrand`: the brand sidebar on light grounds, when the brand opts in. */
92
+ sidebarLight?: string;
93
+ /** Set by `withBrand`: the brand accent is the only accent, and no custom colour is accepted. */
94
+ accentLocked?: boolean;
82
95
  }
83
96
  /**
84
97
  * Validate a product config and fill in its defaults. Throws on anything
@@ -51,8 +51,8 @@ export function defineProduct(config) {
51
51
  brandAccent = config.accent;
52
52
  if (!SLUG.test(brandAccent.key))
53
53
  fail(id, "brand accent key must be a lowercase slug");
54
- if (brandAccent.key in ACCENTS || brandAccent.key === "custom") {
55
- fail(id, `brand accent key ${brandAccent.key} is taken by the family`);
54
+ if (brandAccent.key in ACCENTS || brandAccent.key === "custom" || brandAccent.key === "brand") {
55
+ fail(id, `brand accent key ${brandAccent.key} is taken by the family or reserved`);
56
56
  }
57
57
  for (const field of ["dark", "onDark", "light", "onLight"]) {
58
58
  if (!HEX.test(brandAccent[field]))