@odla-ai/chapter 0.20.1 → 0.21.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.
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # @odla-ai/chapter
2
2
 
3
- A foundation for **membership sites**. One `defineChapter({...})` config stands up
4
- a full public member site — join/apply → Stripe membership → Google booking
5
- member area — plus an admin console and a CRM, or an **admin-only hub**, on
3
+ A foundation for **membership sites**. One `defineChapter({...})` config resolves
4
+ the shared application engine — join/apply → Stripe membership → Google booking
5
+ member area — plus an admin console and CRM, or an **admin-only hub**, on
6
6
  odla-db + Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
7
- email.
7
+ email. The host still builds the public pages, routing, and brand presentation;
8
+ Chapter supplies their application mechanics.
8
9
 
9
10
  ```sh
10
- npm i @odla-ai/chapter
11
+ npm i --save-exact @odla-ai/chapter@0.21.0
11
12
  ```
12
13
 
13
14
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
@@ -17,12 +18,32 @@ npm i @odla-ai/chapter
17
18
  > and leader → follower record delivery ship. APIs may still tighten before 1.0;
18
19
  > pin the package and run schema/route contract tests in every site.
19
20
 
21
+ ## Agent runbooks
22
+
23
+ The API examples below are not a website-build plan. An LLM or coding agent must
24
+ choose and read one of these version-matched runbooks from the installed npm
25
+ package before changing a site:
26
+
27
+ - **Empty repo or a genuinely new product:** read
28
+ [`runbooks/greenfield.md`](./runbooks/greenfield.md) completely. It covers the
29
+ brand brief, public information architecture, Chapter configuration,
30
+ member/admin surfaces, Cloudflare/ODLA setup, and visual + journey acceptance.
31
+ - **Existing or deployed site:** read
32
+ [`runbooks/adopt-existing.md`](./runbooks/adopt-existing.md) completely. It
33
+ preserves the existing product, URLs, visual identity, data, auth, jobs, and
34
+ rollback boundary while Chapter becomes the primary implementation.
35
+
36
+ Do not combine the two flows. A provisioned canary is not a completed adoption,
37
+ and a greenfield build does not need migration machinery. Both runbooks keep
38
+ secrets out of source and require development proof before production.
39
+
20
40
  ## The shape
21
41
 
22
42
  - **One config, two profiles.** `defineChapter()` validates at import and returns
23
- a resolved engine. `mode: "chapter"` is the full public member site; `mode:
24
- "hub"` is admin-only and CRM-focused. The mode gates the member/join/payment
25
- route surface; everything else (auth, CRM, chrome, provisioning) is shared.
43
+ a resolved engine. `mode: "chapter"` enables the complete public-member
44
+ application profile; `mode: "hub"` is admin-only and CRM-focused. The mode
45
+ gates the member/join/payment route surface; everything else (auth, CRM,
46
+ chrome, provisioning) is shared.
26
47
  `chapter.brand` and `chapter.network` are resolved from this same config, so
27
48
  the browser UI does not need a second identity or follower registry.
28
49
  - **The worker is the package.** `chapterWorker({ chapter })` is the whole
@@ -31,11 +52,15 @@ npm i @odla-ai/chapter
31
52
  `/api/admin/network/{targets,push}`. Chapter mode adds the public
32
53
  member surface (`/api/join-config`, `/api/applications`,
33
54
  `/api/schedule/{slots,book}`, `/api/payments/subscription`,
34
- `/api/webhooks/stripe`) and the admin operational routes (`/api/admin/*`), then
35
- falls back to static assets. Your `src/worker.ts` is ~3 lines. Observability is
36
- a host concern wrap it with `withObservability` from `@odla-ai/o11y`.
37
- Everything brand-specific (prices, policy copy, email templates, scheduling
38
- rules) is read at runtime from a single odla-db `groups` row, never hardcoded.
55
+ `/api/webhooks/stripe`). The `/api/admin/*` handlers are registered in both
56
+ modes; expose only the sections compatible with the selected profile and its
57
+ provisioned namespaces. The Worker then falls back to static assets. Your
58
+ `src/worker.ts` is ~3 lines. Observability is a host concern — wrap it with
59
+ `withObservability` from `@odla-ai/o11y`.
60
+ Operational values that owners may change (prices, policy copy, email
61
+ templates, scheduling rules) are read at runtime from a single odla-db
62
+ `groups` row. Brand identity and build-time tokens remain in the checked-in
63
+ Chapter config.
39
64
  - **A route seam, not a black box.** `chapterWorker({ chapter, routes })` runs
40
65
  your handlers *before* the built-ins (add routes, or override/alias a path),
41
66
  each receiving the same context the built-ins get. The worker entry also exports
@@ -46,13 +71,15 @@ npm i @odla-ai/chapter
46
71
  *or* an odla-db `admins` allowlist, plus a read-only `superAdmins` tier —
47
72
  selected by `auth.source`, defaulting per mode. Escalation guards
48
73
  (`canChangeRole`) are package-enforced.
49
- - **Correctness is packaged, not per-site.** Exactly-once email
50
- (`sendTemplated`/`isAlreadySent`), a non-prod delivery fail-safe
51
- (`planDelivery`), status-never-backwards (`canTransition`), Stripe webhook
52
- integrity (`verifyStripeSignature`) with the webhook as the authoritative writer
53
- of paid/refunded, one-subscription-per-application idempotency, meetings-as-
54
- canonical booking (a rebooking *reschedules* the event, preserving the Meet
55
- link), Google-edit adoption (`reconcileMeetings`), and a one-way CRM projection.
74
+ - **Correctness is packaged, not per-site.** Replay-deduplicated email
75
+ (`sendTemplated`/`isAlreadySent`; concurrent sends still need a serialized
76
+ outbox/provider idempotency for a true exactly-once guarantee), a non-prod
77
+ delivery fail-safe (`planDelivery`), status-never-backwards (`canTransition`),
78
+ Stripe webhook integrity (`verifyStripeSignature`) with the webhook as the
79
+ authoritative writer of paid/refunded, one-subscription-per-application
80
+ idempotency, meetings-as-canonical booking (a rebooking *reschedules* the
81
+ event, preserving the Meet link), Google-edit adoption (`reconcileMeetings`),
82
+ and a one-way CRM projection.
56
83
  - **Provisioning is declarative.** `createChapterIntegration(chapter)` composes
57
84
  the crm namespaces + the chapter namespaces (`applications`, `groups`,
58
85
  `meetings`, `emailLog`, plus the auth tables) + a guarded group-row seed. Drop
@@ -74,36 +101,40 @@ npm i @odla-ai/chapter
74
101
  presentational component without the Clerk browser SDK. (A bundle-graph test
75
102
  enforces this.)
76
103
  - `@odla-ai/chapter/ui/admin` — the Clerk-gated console `ChapterAdmin` + the
77
- section catalog. `<ChapterAdmin chapter={chapter}/>` derives the brand and
78
- standard sections automatically: every configured CRM collection plus the
79
- chapter operational surfaces. Pass `sections` only for a deliberate custom
80
- console. This one needs auth-clerk + crm/ui.
104
+ workspace catalog. In chapter mode, `<ChapterAdmin chapter={chapter}/>`
105
+ defaults to just three top-level workspaces: Dashboard, People, and
106
+ Settings. Operational detail belongs in nested page tabs; CRM record
107
+ operations belong in record tabs. Pass `workspaces` to transform or replace
108
+ that information architecture. This entry needs auth-clerk + crm/ui.
81
109
  - `@odla-ai/chapter/ui` — the full barrel, for back-compat.
82
110
 
83
111
  Authored against React, rendered as Preact via `preact/compat` in the reference
84
112
  sites. Brand tokens (`brandTokens`/`<BrandStyle>`) re-skin all of it from
85
113
  `brand` (now light **and** dark, via `brand.palette` + `brand.paletteDark`).
86
114
 
87
- ### Theme tokens (or the console renders broken)
115
+ ### Theme tokens and scoped branding
88
116
 
89
117
  The UI reads its colors from `--ui-*` design tokens whose **values come from a
90
- theme layer, not `@odla-ai/ui/index.css`.** Use the one-import application sheet;
91
- it expands to the token layer, Salt's familiar editorial component treatment,
92
- then the shared component sheet in the required order:
118
+ theme layer, not `@odla-ai/ui/index.css`.** For an admin surface isolated from
119
+ the public site, import one scoped theme, the shared component sheet, and the
120
+ CRM layout:
93
121
 
94
122
  ```ts
95
- import "@odla-ai/ui/themes/salt/app.css";
123
+ import "@odla-ai/ui/fonts/plex.css";
124
+ import "@odla-ai/ui/themes/paper/scope.css";
125
+ import "@odla-ai/ui/index.css";
126
+ import "@odla-ai/crm/ui.css";
96
127
  ```
97
128
 
98
129
  `ChapterAdmin` guards against this at runtime: if a `.panel` has no background
99
130
  (the theme layer is missing), it renders a **loud red banner** at the top of the
100
- console instead of failing silently. To brand it, pass `brand.palette` /
101
- `brand.paletteDark` `brandTokens(brand)` emits both a `:root` block and a
102
- `[data-theme="dark"]` / `prefers-color-scheme: dark` block, so your accent/colors
103
- override the shared visual grammar in both modes. When the `chapter` prop is
104
- present, `ChapterAdmin` installs those brand tokens itself.
131
+ console instead of failing silently. Set `brand.theme`, `brand.colorScheme`,
132
+ and semantic `brand.tokens` / `brand.tokensDark` for normal configuration.
133
+ `brand.palette` / `brand.paletteDark` remain the low-level custom-property
134
+ escape hatch. Chapter scopes these overrides to `[data-chapter-admin]`, so an
135
+ admin brand cannot recolor the document root or vendor sign-in UI.
105
136
 
106
- ## Quick start
137
+ ## API quick start
107
138
 
108
139
  ```ts
109
140
  // src/chapter.config.mjs
@@ -117,7 +148,13 @@ export const chapter = defineChapter({
117
148
  badge: "EX",
118
149
  wordmark: "Example Chapter",
119
150
  tagline: "Capital and craft for durable local businesses.",
120
- palette: { "--ui-accent": "#2f6f4f" },
151
+ theme: "paper",
152
+ colorScheme: "light",
153
+ tokens: {
154
+ accent: "#2f6f4f",
155
+ accentStrong: "#244f3b",
156
+ accentSoft: "#dfece4",
157
+ },
121
158
  fonts: { display: "GT Sectra" },
122
159
  },
123
160
  prices: { standardCents: 100000, foundingDiscountCents: 10000 },
@@ -133,8 +170,11 @@ export default chapterWorker({ chapter });
133
170
  ```
134
171
 
135
172
  ```tsx
136
- // src/app/admin.tsx — brand + familiar sections come from the same config.
137
- import "@odla-ai/ui/themes/salt/app.css";
173
+ // src/app/admin.tsx — brand + familiar workspaces come from the same config.
174
+ import "@odla-ai/ui/fonts/plex.css";
175
+ import "@odla-ai/ui/themes/paper/scope.css";
176
+ import "@odla-ai/ui/index.css";
177
+ import "@odla-ai/crm/ui.css";
138
178
  import { render } from "preact";
139
179
  import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
140
180
  import { chapter } from "../chapter.config.mjs";
@@ -153,11 +193,10 @@ export default {
153
193
  };
154
194
  ```
155
195
 
156
- That is the reusable application shell. The follower's public pages remain
157
- site-owned: start from a reference design and `@odla-ai/ui` marketing
158
- components, then change the wordmark, palette, copy, imagery, and investment
159
- thesis. Do not fork auth, admin routing, CRM, payment, booking, or account logic
160
- to achieve a different brand.
196
+ That is the reusable application shell. Public pages remain site-owned: start
197
+ from the approved product and brand brief plus `@odla-ai/ui` marketing
198
+ components. Do not copy a reference site's identity or fork auth, admin routing,
199
+ CRM, payment, booking, or account logic to achieve a different brand.
161
200
 
162
201
  ### Leader → follower delivery
163
202
 
@@ -248,24 +287,24 @@ records, not `crm_link` relation rows; create or curate `works_at` links locally
248
287
 
249
288
  #### Custom leader consoles must mount the sharing UI
250
289
 
251
- Automatic “Share with …” actions come from chapter's standard collection
252
- sections. They are present when the console uses
253
- `<ChapterAdmin chapter={chapter} />`. Passing an explicit `sections` array
254
- replaces that catalog, so a custom console must deliberately compose it:
290
+ Automatic “Share with …” actions come from Chapter's standard People workspace.
291
+ They are present when the console uses `<ChapterAdmin chapter={chapter} />`.
292
+ Passing a `workspaces` array replaces that catalog, so a custom console must
293
+ deliberately compose it:
255
294
 
256
295
  ```tsx
257
296
  import {
258
297
  ChapterAdmin,
259
- defaultAdminSections,
298
+ defaultAdminWorkspaces,
260
299
  } from "@odla-ai/chapter/ui/admin";
261
300
 
262
- const sections = [
263
- customOverviewSection,
264
- ...defaultAdminSections(chapter),
301
+ const workspaces = [
302
+ customOperationsWorkspace,
303
+ ...defaultAdminWorkspaces(chapter),
265
304
  ];
266
305
 
267
306
  render(
268
- <ChapterAdmin chapter={chapter} sections={sections} />,
307
+ <ChapterAdmin chapter={chapter} workspaces={workspaces} />,
269
308
  document.getElementById("admin-root"),
270
309
  );
271
310
  ```
@@ -307,13 +346,50 @@ Only after this contract passes against development origins should the leader
307
346
  target be changed to a production follower origin and the matching production
308
347
  vault values be installed.
309
348
 
310
- Section navigation defaults to `/admin/?tab=people`, not
311
- `/admin/people`. Query routing deliberately works in both leader-style SPAs and
312
- ordinary static subdirectories. Legacy path links still open, and
313
- `routing="path"` remains available only for hosts with an explicit SPA fallback.
349
+ Admin navigation defaults to link-backed fragments:
350
+ `/admin/#people/person/record-id/profile`. The four segments are workspace,
351
+ nested view, selected record, and record-detail tab. This keeps meaningful,
352
+ reloadable URLs without requiring a server-side SPA fallback, and it avoids
353
+ competing with Clerk's sign-in hash: while signed out, Chapter carries the
354
+ requested state through the redirect query and canonicalizes it after sign-in.
355
+ Legacy `?tab=` and path links still open, while `routing="query"` and
356
+ `routing="path"` remain compatibility modes.
357
+
358
+ ### Admin information architecture and migration
359
+
360
+ The default chapter console deliberately has only three top-level workspaces:
361
+
362
+ - **Dashboard** — Overview and Billing page tabs.
363
+ - **People** — configured CRM collections as page tabs, then a master/detail
364
+ record view with Stage, Profile, communications, scheduling, Billing, Notes,
365
+ Connections, Access, and Sharing record tabs when those capabilities apply.
366
+ - **Settings** — Calendar and Email page tabs.
367
+
368
+ Use `chrome="editorial"` (the default), `chrome="none"` when the host supplies
369
+ its own site header, or `renderHeader` for a fully custom header. The deprecated
370
+ `chrome="topbar"` and `sections` APIs remain available for migration, but do
371
+ not add operational detail back to the global site navigation.
372
+
373
+ When upgrading a flat console:
374
+
375
+ 1. Import the scoped UI theme, `@odla-ai/ui/index.css`, and
376
+ `@odla-ai/crm/ui.css`.
377
+ 2. Remove top-level Billing, Email, Calendar, or collection links that duplicate
378
+ the standard nested tabs.
379
+ 3. Replace `sections` with `workspaces`, or omit it to accept the defaults.
380
+ 4. Remove host-owned record drawers and compose extra operations with
381
+ `collectionSection` or CRM `extendRecordTabs`.
382
+ 5. Change generated links to `adminRouteHref`; keep legacy query/path URLs only
383
+ as inbound compatibility links.
384
+ 6. Verify refresh, back/forward navigation, keyboard tab behavior, mobile
385
+ list/detail switching, and brand containment before deleting old routes.
314
386
 
315
387
  ## Adopting into an existing site
316
388
 
389
+ For the complete ordered conversion and cutover procedure, use
390
+ [`runbooks/adopt-existing.md`](./runbooks/adopt-existing.md). The notes below
391
+ are the package-specific behavior reference, not a complete adoption plan.
392
+
317
393
  A real conversion (the site this was extracted from) went from a 2,094-line
318
394
  worker to 6 lines and deleted ~2,500 lines. The order that worked:
319
395
 
@@ -419,13 +495,15 @@ These bite silently — a smoke test won't catch them:
419
495
 
420
496
  ### Install + scope notes
421
497
 
422
- - **Your first admin is seeded in odla Studio, by hand — and the email must be
423
- lowercased.** Neither `admins` nor `superAdmins` is ever written by a worker
424
- route or a provisioning seed; that is deliberate, so nothing running in the app
425
- (or injected into a page) can grant admin. The gate looks the email up
426
- lowercased, so a row saved as `Ada@Example.com` matches nothing and the account
427
- silently isn't an admin with no error to tell you. Save it lowercase, then
428
- confirm by signing in, not by looking at the row.
498
+ - **Bootstrap the first admin according to the selected auth source.** Chapter
499
+ mode defaults to Clerk claims: set the first operator's Clerk
500
+ `public_metadata.role` to `admin`. If that operator also needs the
501
+ read-only cross-site tier, add their lowercase email to `superAdmins` in odla
502
+ Studio. Hub mode defaults to table auth: add the first operator's lowercase
503
+ email to `admins` in Studio. Neither allowlist is ever written by a worker
504
+ route or provisioning seed; that is deliberate, so the running app cannot
505
+ grant itself admin. Confirm the result by signing in, not merely by inspecting
506
+ the row.
429
507
  - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
430
508
  this package's manifest, because only one entry imports it: the worker verifies
431
509
  JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
@@ -434,11 +512,18 @@ These bite silently — a smoke test won't catch them:
434
512
  `@odla-ai/auth-clerk/invitations` when you want to send your own branded
435
513
  invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
436
514
  half, so prefer the narrower entry.
437
- - **Known-good version set** (installs clean, no flags): `@odla-ai/chapter` 0.20.0,
438
- `@odla-ai/ui` 0.10.0, `@odla-ai/crm` 0.1.4, `@odla-ai/db` 0.6.6,
439
- `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1, CLI 0.17.1, plus `jose` and
440
- react (or `preact` + `preact/compat`). chapter is the package that pulls the
441
- others together, so this is the line to check when an install complains.
515
+ - **Known-good application set** (installs clean, no flags):
516
+ `@odla-ai/chapter` 0.21.0, `@odla-ai/ui` 0.11.0, `@odla-ai/crm` 0.2.0,
517
+ `@odla-ai/db` 0.6.6,
518
+ `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1,
519
+ `@odla-ai/auth-clerk` 0.4.0, `@odla-ai/o11y` 2.2.2, `jose` 6.2.3, React
520
+ 19.2.7, and `react-dom` 19.2.7.
521
+ - **Known-good React host toolchain:** `@odla-ai/cli` 0.17.1,
522
+ `@odla-ai/security` 0.3.1, `@cloudflare/workers-types` 4.20260702.1,
523
+ `@types/react` 19.2.17, `@types/react-dom` 19.2.3,
524
+ `@vitejs/plugin-react` 6.0.3, TypeScript 6.0.3, Vite 8.1.4, Vitest 4.1.10,
525
+ and Wrangler 4.107.0. The greenfield runbook standardizes on this exact React
526
+ matrix; a Preact host needs its own tested compatibility set.
442
527
  - **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
443
528
  the peer conflict that tells you a pair is unsupported. If you need it, find out
444
529
  why first.
@@ -0,0 +1,80 @@
1
+ // src/brand.ts
2
+ function paletteVar(key) {
3
+ return key.startsWith("--") ? key : `--${key}`;
4
+ }
5
+ function cleanValue(value) {
6
+ return value.replace(/[<>{};]/g, "").trim();
7
+ }
8
+ function paletteDecls(palette) {
9
+ const decls = [];
10
+ for (const [key, value] of Object.entries(palette ?? {})) {
11
+ if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
12
+ }
13
+ return decls;
14
+ }
15
+ var TOKEN_VARS = {
16
+ background: "--ui-bg",
17
+ surface: "--ui-surface",
18
+ surface2: "--ui-surface-2",
19
+ text: "--ui-text",
20
+ textMuted: "--ui-text-muted",
21
+ textFaint: "--ui-text-faint",
22
+ border: "--ui-border",
23
+ borderStrong: "--ui-border-strong",
24
+ accent: "--ui-accent",
25
+ accentStrong: "--ui-accent-strong",
26
+ accentSoft: "--ui-accent-soft",
27
+ onAccent: "--ui-on-accent",
28
+ good: "--ui-good",
29
+ warn: "--ui-warn",
30
+ danger: "--ui-danger",
31
+ chart1: "--ui-chart-1",
32
+ chart2: "--ui-chart-2",
33
+ chartPositive: "--ui-chart-pos",
34
+ chartNegative: "--ui-chart-neg"
35
+ };
36
+ function semanticDecls(tokens) {
37
+ return Object.entries(tokens ?? {}).flatMap(
38
+ ([key, value]) => typeof value === "string" && value.trim() ? [`${TOKEN_VARS[key]}: ${cleanValue(value)};`] : []
39
+ );
40
+ }
41
+ function brandTokens(brand, options = {}) {
42
+ if (!brand) return "";
43
+ const light = [...paletteDecls(brand.palette), ...semanticDecls(brand.tokens)];
44
+ const fonts = brand.fonts;
45
+ if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
46
+ if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
47
+ if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
48
+ const dark = [...paletteDecls(brand.paletteDark), ...semanticDecls(brand.tokensDark)];
49
+ const selector = options.selector ?? ":root";
50
+ const darkSelector = selector === ":root" ? ':root[data-theme="dark"]' : `${selector}[data-theme="dark"]`;
51
+ const systemSelector = selector === ":root" ? ':root:not([data-theme="light"])' : `${selector}:not([data-theme="light"])`;
52
+ let css = light.length ? `${selector} {
53
+ ${light.join("\n ")}
54
+ }
55
+ ` : "";
56
+ if (dark.length) {
57
+ const block = `{
58
+ ${dark.join("\n ")}
59
+ }`;
60
+ css += `${darkSelector} ${block}
61
+ @media (prefers-color-scheme: dark) {
62
+ ${systemSelector} ${block}
63
+ }
64
+ `;
65
+ }
66
+ return css;
67
+ }
68
+
69
+ // src/ui/brand-style.tsx
70
+ import { jsx } from "react/jsx-runtime";
71
+ function BrandStyle(props) {
72
+ const css = brandTokens(props.brand, { selector: props.selector });
73
+ if (!css) return null;
74
+ return /* @__PURE__ */ jsx("style", { children: css });
75
+ }
76
+
77
+ export {
78
+ BrandStyle
79
+ };
80
+ //# sourceMappingURL=chunk-3JG5X2LT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/brand.ts","../src/ui/brand-style.tsx"],"sourcesContent":["// Brand tokens (H4). defineChapter accepts a `brand` block; this turns it into a\n// `:root { --…: … }` CSS block, so a chapter re-skins the WHOLE UI — the\n// @odla-ai/ui components, the admin shell, and the member islands, which all read\n// --ui-* design tokens — from one config instead of hand-writing inline CSS.\n//\n// Pure string generation, so it is unit-testable and can be emitted at\n// build/SSR time into the page <head> (no flash of unstyled content), or via the\n// <BrandStyle> component from @odla-ai/chapter/ui.\nimport type { ChapterBrand, ChapterBrandTokens } from \"./types\";\n\n// A palette entry is either a direct custom property (already `--…`, e.g.\n// `--ui-accent` to retheme components) or a bare name we expose as `--<name>`\n// (e.g. `moss` → `--moss`, for a site to reference in its own CSS).\nfunction paletteVar(key: string): string {\n return key.startsWith(\"--\") ? key : `--${key}`;\n}\n\n// Strip characters that could break out of a `--var: value;` declaration or the\n// surrounding <style>. Brand config is trusted author input, so this is a\n// belt-and-suspenders guard, not a security boundary.\nfunction cleanValue(value: string): string {\n return value.replace(/[<>{};]/g, \"\").trim();\n}\n\nfunction paletteDecls(palette: Record<string, string> | undefined): string[] {\n const decls: string[] = [];\n for (const [key, value] of Object.entries(palette ?? {})) {\n if (typeof value === \"string\" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);\n }\n return decls;\n}\n\nconst TOKEN_VARS: Record<keyof ChapterBrandTokens, string> = {\n background: \"--ui-bg\",\n surface: \"--ui-surface\",\n surface2: \"--ui-surface-2\",\n text: \"--ui-text\",\n textMuted: \"--ui-text-muted\",\n textFaint: \"--ui-text-faint\",\n border: \"--ui-border\",\n borderStrong: \"--ui-border-strong\",\n accent: \"--ui-accent\",\n accentStrong: \"--ui-accent-strong\",\n accentSoft: \"--ui-accent-soft\",\n onAccent: \"--ui-on-accent\",\n good: \"--ui-good\",\n warn: \"--ui-warn\",\n danger: \"--ui-danger\",\n chart1: \"--ui-chart-1\",\n chart2: \"--ui-chart-2\",\n chartPositive: \"--ui-chart-pos\",\n chartNegative: \"--ui-chart-neg\",\n};\n\nfunction semanticDecls(tokens: ChapterBrandTokens | undefined): string[] {\n return Object.entries(tokens ?? {}).flatMap(([key, value]) =>\n typeof value === \"string\" && value.trim()\n ? [`${TOKEN_VARS[key as keyof ChapterBrandTokens]}: ${cleanValue(value)};`]\n : [],\n );\n}\n\n/**\n * Build the CSS that maps a chapter's brand onto the design tokens the UI reads:\n * each `palette` entry becomes a custom property (light, and dark unless\n * `paletteDark` overrides), and `fonts` (display/body/numeral) map to\n * `--ui-font-display` / `--ui-font-sans` / `--ui-font-numeral`. The dark block is\n * emitted under both `:root[data-theme=\"dark\"]` (the odla-ui theme toggle) and\n * `@media (prefers-color-scheme: dark)`. Returns \"\" when there is nothing to\n * theme. These are brand OVERRIDES on top of a base theme — they do not replace\n * the theme layer the components need (see {@link brandTokens} usage in the docs).\n */\nexport function brandTokens(\n brand: ChapterBrand | undefined,\n options: { selector?: string } = {},\n): string {\n if (!brand) return \"\";\n const light = [...paletteDecls(brand.palette), ...semanticDecls(brand.tokens)];\n const fonts = brand.fonts;\n if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);\n if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);\n if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);\n const dark = [...paletteDecls(brand.paletteDark), ...semanticDecls(brand.tokensDark)];\n const selector = options.selector ?? \":root\";\n const darkSelector = selector === \":root\" ? ':root[data-theme=\"dark\"]' : `${selector}[data-theme=\"dark\"]`;\n const systemSelector = selector === \":root\"\n ? ':root:not([data-theme=\"light\"])'\n : `${selector}:not([data-theme=\"light\"])`;\n\n let css = light.length ? `${selector} {\\n ${light.join(\"\\n \")}\\n}\\n` : \"\";\n if (dark.length) {\n const block = `{\\n ${dark.join(\"\\n \")}\\n}`;\n css += `${darkSelector} ${block}\\n@media (prefers-color-scheme: dark) {\\n ${systemSelector} ${block}\\n}\\n`;\n }\n return css;\n}\n","// Client-side convenience for brand tokens: render the chapter's brand as a\n// <style> tag. Prefer emitting brandTokens() into the page <head> at build/SSR\n// time (no flash); use this when that isn't available (e.g. a pure SPA mount).\nimport { brandTokens } from \"../brand.js\";\nimport type { ChapterBrand } from \"../types\";\n\n/** Props for {@link BrandStyle}. */\nexport interface BrandStyleProps {\n brand: ChapterBrand | undefined;\n /** CSS selector receiving the variables. Default `:root`. */\n selector?: string;\n}\n\n/** Render a chapter's brand tokens as an inline <style> block (or nothing when\n * there is no brand to theme). */\nexport function BrandStyle(props: BrandStyleProps) {\n const css = brandTokens(props.brand, { selector: props.selector });\n if (!css) return null;\n return <style>{css}</style>;\n}\n"],"mappings":";AAaA,SAAS,WAAW,KAAqB;AACvC,SAAO,IAAI,WAAW,IAAI,IAAI,MAAM,KAAK,GAAG;AAC9C;AAKA,SAAS,WAAW,OAAuB;AACzC,SAAO,MAAM,QAAQ,YAAY,EAAE,EAAE,KAAK;AAC5C;AAEA,SAAS,aAAa,SAAuD;AAC3E,QAAM,QAAkB,CAAC;AACzB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,WAAW,CAAC,CAAC,GAAG;AACxD,QAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAG,OAAM,KAAK,GAAG,WAAW,GAAG,CAAC,KAAK,WAAW,KAAK,CAAC,GAAG;AAAA,EACvG;AACA,SAAO;AACT;AAEA,IAAM,aAAuD;AAAA,EAC3D,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,UAAU;AAAA,EACV,MAAM;AAAA,EACN,WAAW;AAAA,EACX,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,eAAe;AAAA,EACf,eAAe;AACjB;AAEA,SAAS,cAAc,QAAkD;AACvE,SAAO,OAAO,QAAQ,UAAU,CAAC,CAAC,EAAE;AAAA,IAAQ,CAAC,CAAC,KAAK,KAAK,MACtD,OAAO,UAAU,YAAY,MAAM,KAAK,IACpC,CAAC,GAAG,WAAW,GAA+B,CAAC,KAAK,WAAW,KAAK,CAAC,GAAG,IACxE,CAAC;AAAA,EACP;AACF;AAYO,SAAS,YACd,OACA,UAAiC,CAAC,GAC1B;AACR,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,QAAQ,CAAC,GAAG,aAAa,MAAM,OAAO,GAAG,GAAG,cAAc,MAAM,MAAM,CAAC;AAC7E,QAAM,QAAQ,MAAM;AACpB,MAAI,OAAO,QAAS,OAAM,KAAK,sBAAsB,WAAW,MAAM,OAAO,CAAC,GAAG;AACjF,MAAI,OAAO,KAAM,OAAM,KAAK,mBAAmB,WAAW,MAAM,IAAI,CAAC,GAAG;AACxE,MAAI,OAAO,QAAS,OAAM,KAAK,sBAAsB,WAAW,MAAM,OAAO,CAAC,GAAG;AACjF,QAAM,OAAO,CAAC,GAAG,aAAa,MAAM,WAAW,GAAG,GAAG,cAAc,MAAM,UAAU,CAAC;AACpF,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,eAAe,aAAa,UAAU,6BAA6B,GAAG,QAAQ;AACpF,QAAM,iBAAiB,aAAa,UAChC,oCACA,GAAG,QAAQ;AAEf,MAAI,MAAM,MAAM,SAAS,GAAG,QAAQ;AAAA,IAAS,MAAM,KAAK,MAAM,CAAC;AAAA;AAAA,IAAU;AACzE,MAAI,KAAK,QAAQ;AACf,UAAM,QAAQ;AAAA,IAAQ,KAAK,KAAK,MAAM,CAAC;AAAA;AACvC,WAAO,GAAG,YAAY,IAAI,KAAK;AAAA;AAAA,IAA8C,cAAc,IAAI,KAAK;AAAA;AAAA;AAAA,EACtG;AACA,SAAO;AACT;;;AC7ES;AAHF,SAAS,WAAW,OAAwB;AACjD,QAAM,MAAM,YAAY,MAAM,OAAO,EAAE,UAAU,MAAM,SAAS,CAAC;AACjE,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,oBAAC,WAAO,eAAI;AACrB;","names":[]}