@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 +153 -68
- package/dist/chunk-3JG5X2LT.js +80 -0
- package/dist/chunk-3JG5X2LT.js.map +1 -0
- package/dist/chunk-AOTWFOH4.js +1481 -0
- package/dist/chunk-AOTWFOH4.js.map +1 -0
- package/dist/{chunk-LME2IHY5.js → chunk-ILGUJXSB.js} +1 -1
- package/dist/chunk-ILGUJXSB.js.map +1 -0
- package/dist/index.cjs +61 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -19
- package/dist/index.d.ts +64 -19
- package/dist/index.js +61 -6
- package/dist/index.js.map +1 -1
- package/dist/{types-D2fGiNG8.d.ts → types-CXDiDj_s.d.ts} +36 -8
- package/dist/ui/admin/index.d.ts +85 -84
- package/dist/ui/admin/index.js +8 -2
- package/dist/ui/index.d.ts +3 -2
- package/dist/ui/index.js +9 -3
- package/dist/ui/member/index.d.ts +3 -1
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +28 -16
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +36 -8
- package/dist/worker/index.d.ts +36 -8
- package/dist/worker/index.js +28 -16
- package/dist/worker/index.js.map +1 -1
- package/package.json +5 -4
- package/runbooks/adopt-existing.md +514 -0
- package/runbooks/greenfield.md +662 -0
- package/dist/chunk-LME2IHY5.js.map +0 -1
- package/dist/chunk-MKXHZMAP.js +0 -51
- package/dist/chunk-MKXHZMAP.js.map +0 -1
- package/dist/chunk-UIWFEESI.js +0 -922
- package/dist/chunk-UIWFEESI.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# @odla-ai/chapter
|
|
2
2
|
|
|
3
|
-
A foundation for **membership sites**. One `defineChapter({...})` config
|
|
4
|
-
|
|
5
|
-
member area — plus an admin console and
|
|
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"`
|
|
24
|
-
"hub"` is admin-only and CRM-focused. The mode
|
|
25
|
-
route surface; everything else (auth, CRM,
|
|
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`)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.**
|
|
50
|
-
(`sendTemplated`/`isAlreadySent
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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`.**
|
|
91
|
-
|
|
92
|
-
|
|
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/
|
|
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.
|
|
101
|
-
`brand.
|
|
102
|
-
`
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
137
|
-
import "@odla-ai/ui/
|
|
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.
|
|
157
|
-
|
|
158
|
-
components
|
|
159
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
298
|
+
defaultAdminWorkspaces,
|
|
260
299
|
} from "@odla-ai/chapter/ui/admin";
|
|
261
300
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
...
|
|
301
|
+
const workspaces = [
|
|
302
|
+
customOperationsWorkspace,
|
|
303
|
+
...defaultAdminWorkspaces(chapter),
|
|
265
304
|
];
|
|
266
305
|
|
|
267
306
|
render(
|
|
268
|
-
<ChapterAdmin chapter={chapter}
|
|
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
|
-
|
|
311
|
-
`/admin/
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
- **
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
438
|
-
`@odla-ai/
|
|
439
|
-
`@odla-ai/
|
|
440
|
-
|
|
441
|
-
|
|
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":[]}
|