@odla-ai/chapter 0.22.0 → 0.23.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
@@ -8,7 +8,7 @@ email. The host still builds the public pages, routing, and brand presentation;
8
8
  Chapter supplies their application mechanics.
9
9
 
10
10
  ```sh
11
- npm i --save-exact @odla-ai/chapter@0.22.0
11
+ npm i --save-exact @odla-ai/chapter@0.23.0
12
12
  ```
13
13
 
14
14
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
@@ -37,6 +37,12 @@ Do not combine the two flows. A provisioned canary is not a completed adoption,
37
37
  and a greenfield build does not need migration machinery. Both runbooks keep
38
38
  secrets out of source and require development proof before production.
39
39
 
40
+ The repository also contains a build-tested
41
+ [generic Preact reference host](https://github.com/cory/odla-ai/tree/main/examples/chapter-site).
42
+ It is the executable companion to the greenfield runbook: site-owned global
43
+ navigation, public routes, brand, voice, join fields, and member framing wrap
44
+ Chapter-owned application, admin, CRM, payment, and scheduling behavior.
45
+
40
46
  ## The shape
41
47
 
42
48
  - **One config, two profiles.** `defineChapter()` validates at import and returns
@@ -50,14 +56,15 @@ secrets out of source and require development proof before production.
50
56
  Cloudflare `ExportedHandler`. It serves `/api/health`, `/api/config`,
51
57
  `/api/me`, `/api/crm/*`, `/api/network/shared`; configured leaders also get
52
58
  `/api/admin/network/{targets,push}`. Chapter mode adds the public
53
- member surface (`/api/join-config`, `/api/applications`,
59
+ member surface (`/api/join-config`, `/api/join/resume`, `/api/applications`,
54
60
  `/api/schedule/{slots,book}`, `/api/payments/subscription`,
55
61
  `/api/webhooks/stripe`). The `/api/admin/*` handlers are registered in both
56
62
  modes; expose only the sections compatible with the selected profile and its
57
63
  provisioned namespaces. The Worker then falls back to static assets. Your
58
64
  `src/worker.ts` is ~3 lines. Observability is a host concern — wrap it with
59
65
  `withObservability` from `@odla-ai/o11y`.
60
- Operational values that owners may change (prices, policy copy, email
66
+ Unknown `/api/*` paths terminate as JSON `404` responses rather than falling
67
+ through to an SPA document. Operational values that owners may change (prices, policy copy, email
61
68
  templates, scheduling rules) are read at runtime from a single odla-db
62
69
  `groups` row. Brand identity and build-time tokens remain in the checked-in
63
70
  Chapter config.
@@ -94,7 +101,7 @@ secrets out of source and require development proof before production.
94
101
  billing state remain authoritative locally.
95
102
  - **The UI kit, adoptable in pieces.** Three entries, split on the dependency
96
103
  boundary so you never pay for what you don't use:
97
- - `@odla-ai/chapter/ui/member` — **react-only**: `SlotPicker`, the date/timezone
104
+ - `@odla-ai/chapter/ui/member` — **Preact-only**: `SlotPicker`, the date/timezone
98
105
  helpers, `JoinIsland` (form → payment → booking), `MembersArea`,
99
106
  `Rescheduler`, `PaymentStep`, `<BrandStyle>`. It never imports
100
107
  `@odla-ai/auth-clerk` or `@odla-ai/crm`, so you can adopt a single
@@ -108,9 +115,20 @@ secrets out of source and require development proof before production.
108
115
  that information architecture. This entry needs auth-clerk + crm/ui.
109
116
  - `@odla-ai/chapter/ui` — the full barrel, for back-compat.
110
117
 
111
- Authored against React, rendered as Preact via `preact/compat` in the reference
112
- sites. Brand tokens (`brandTokens`/`<BrandStyle>`) re-skin all of it from
118
+ Authored and shipped against Preact. Brand tokens
119
+ (`brandTokens`/`<BrandStyle>`) re-skin all of it from
113
120
  `brand` (now light **and** dark, via `brand.palette` + `brand.paletteDark`).
121
+ - **Voice is part of the brand.** `copy` is a recursively partial
122
+ `ChapterCopy`; `defineChapter()` resolves it into a complete `chapter.copy`
123
+ contract. Packaged join, member, and admin surfaces read that contract, so a
124
+ follower can own terminology and tone without forking behavior. Text remains
125
+ serializable; use render slots when the host needs markup or a different
126
+ composition.
127
+ - **The host owns global navigation.** Admin defaults to `chrome="embedded"` and
128
+ three familiar workspaces. Dashboard/Billing and Calendar/Email remain
129
+ link-backed page tabs; record operations remain record tabs. A host can keep
130
+ one site header around public, join, member, and admin routes without Chapter
131
+ introducing another top-level navigation model.
114
132
 
115
133
  ### Theme tokens and scoped branding
116
134
 
@@ -134,6 +152,26 @@ and semantic `brand.tokens` / `brand.tokensDark` for normal configuration.
134
152
  escape hatch. Chapter scopes these overrides to `[data-chapter-admin]`, so an
135
153
  admin brand cannot recolor the document root or vendor sign-in UI.
136
154
 
155
+ `brand.accent` selects a named `data-ui-accent` family supplied by the chosen
156
+ theme. Each `AdminWorkspace` may set its own `accent` for a scoped page-level
157
+ variation. For a fully custom palette, compile the brand book through the pure
158
+ token entry and adapt it structurally:
159
+
160
+ ```ts
161
+ import { compileBrandTokens } from "@odla-ai/brand/tokens";
162
+ import { chapterBrandFromTokens } from "@odla-ai/chapter";
163
+
164
+ const compiled = compileBrandTokens({ swatches });
165
+ const brand = chapterBrandFromTokens(compiled, {
166
+ theme: "paper",
167
+ wordmark: "Example Chapter",
168
+ });
169
+ ```
170
+
171
+ This carries the compiler's complete light map, derived dark map, and invert
172
+ map into Chapter without adding an `@odla-ai/brand` runtime dependency to
173
+ Chapter itself.
174
+
137
175
  ## API quick start
138
176
 
139
177
  ```ts
@@ -157,8 +195,13 @@ export const chapter = defineChapter({
157
195
  },
158
196
  fonts: { display: "GT Sectra" },
159
197
  },
198
+ copy: {
199
+ join: { form: { submit: "Start the conversation" } },
200
+ admin: { workspaces: { people: "Community" } },
201
+ },
160
202
  prices: { standardCents: 100000, foundingDiscountCents: 10000 },
161
203
  emails: { notificationEmail: "hello@example.com" },
204
+ account: "none",
162
205
  });
163
206
  ```
164
207
 
@@ -198,6 +241,42 @@ from the approved product and brand brief plus `@odla-ai/ui` marketing
198
241
  components. Do not copy a reference site's identity or fork auth, admin routing,
199
242
  CRM, payment, booking, or account logic to achieve a different brand.
200
243
 
244
+ ### Join and member composition
245
+
246
+ `JoinIsland` keeps application mechanics while exposing presentation seams:
247
+
248
+ ```tsx
249
+ <JoinIsland
250
+ config={joinConfig}
251
+ renderStepHeader={({ state }) => <FlowHeading step={state.step} />}
252
+ renderSubmit={({ disabled, submitting }) => (
253
+ <BrandedSubmit disabled={disabled}>{submitting ? "Sending…" : "Apply"}</BrandedSubmit>
254
+ )}
255
+ renderDone={({ booked, membersHref }) => (
256
+ <Confirmation booked={booked} membersHref={membersHref} />
257
+ )}
258
+ payment={{
259
+ appearance: stripeAppearance,
260
+ fonts: stripeFonts,
261
+ renderPriceLines: (lines) => <PriceSummary lines={lines} />,
262
+ }}
263
+ >
264
+ <ApplicationFields />
265
+ </JoinIsland>
266
+ ```
267
+
268
+ `initialState` accepts trusted server state. On a browser redirect,
269
+ `JoinIsland` reads the `application` capability from the query string and asks
270
+ `GET /api/join/resume` for the canonical payment, booking, or done state; raw
271
+ `redirect_status` and `reschedule` values never choose a UI step. Supply
272
+ `loadResume` when a host stores the capability elsewhere.
273
+
274
+ `MembersArea.renderProvisional` receives the loaded application, authenticated
275
+ API function, reload callback, apply URL, and `defaultContent`, so the host can
276
+ wrap or replace the provisional card just as admin workspaces can be composed.
277
+ Nested admin/page/record tabs use fragment anchors by default, not query-string
278
+ state.
279
+
201
280
  ### Leader → follower delivery
202
281
 
203
282
  The leader declares where records may go and exactly which fields each follower
@@ -503,28 +582,23 @@ These bite silently — a smoke test won't catch them:
503
582
  **Re-check this on every chapter upgrade.** Wiring a send changes a site's
504
583
  outbound mail with no local diff — release notes call out send changes
505
584
  explicitly for that reason.
506
- - **Account model — the default is `"none"`, and that is deliberate.**
585
+ - **Account model is an explicit chapter-mode decision.**
507
586
  `account: "create"` makes the Clerk account server-side (so join can say the
508
587
  account is ready), `"invite"` **emails the applicant a Clerk invitation**, and
509
- `"none"` (the default) provisions nothing. Both non-default models need
510
- `clerk_secret_key` in the tenant vault. The default is side-effect-free on
511
- purpose: inviting mails a real person, and a site that never made that choice
512
- must not be sending mail. **You must opt in a site that wants accounts and
513
- doesn't set `account` will silently provision none.** (Changed in 0.15.0: the
514
- default was `"invite"`, which mailed applicants from a config nobody wrote.)
588
+ `"none"` provisions nothing. `defineChapter()` rejects a chapter-mode config
589
+ that omits `account`, so forgetting the decision cannot silently disable or
590
+ enable account provisioning. Both side-effecting models need
591
+ `clerk_secret_key` in the tenant vault. Hub mode resolves to inert `"none"`.
515
592
  - **What lands on the Clerk account (and its `public_metadata` is
516
- client-readable).** Both models write `public_metadata` as
517
- `{ applicationId, profile }`. By default `profile` is every configured
518
- `application.required`/`optional` field Clerk doesn't already carry natively
519
- (so *not* email/firstName/lastName), plus `focus` **which means free-text or
520
- third-party fields like `message` or `referral` are exposed to the browser
521
- unless you curate.** Set `application: { profileFields: ["phone", "state",
522
- "focus", ...] }` to an allowlist and everything else stays db-only. Array fields
593
+ client-readable).** Both side-effecting models write `applicationId` and,
594
+ when explicitly selected fields are present, `profile`. The
595
+ `application.profileFields` default is `[]`, so application details remain
596
+ db-only. Set `profileFields: ["phone", "state", "focus"]` to a deliberate
597
+ browser-readable allowlist. Array fields
523
598
  (`focus`) are clamped to `maxArrayLen` (default 100) and non-primitive elements
524
599
  dropped, so a client can't post an unbounded array into metadata.
525
600
  `applicantProfile(chapter, fields)` is exported to assert the exact shape in a
526
- test before deleting a local override. (Default is back-compat today; expected
527
- to tighten at 1.0.)
601
+ test before deleting a local override.
528
602
  - **Email + input validation.** The field literally named `email` is checked
529
603
  against a permissive email shape (400 on `"notanemail"`, so it fails cleanly
530
604
  here rather than at the downstream Clerk create) — set
@@ -549,12 +623,10 @@ These bite silently — a smoke test won't catch them:
549
623
  one integration test and a join page that quietly stops posting the flag can't
550
624
  go unnoticed.
551
625
 
552
- **If the disclaimer is a compliance record, set
553
- `application: { requireDisclaimerAck: true }`** and a submit with no ack is a
554
- 400 instead of a row with no consent. It defaults to `false` so an upgrade
555
- never starts rejecting traffic; enabling it fails *deterministically* on your
556
- first test submit, not intermittently in production. Expect this to default to
557
- `true` at 1.0.
626
+ `requireDisclaimerAck` now defaults to `true`, and a submit with no ack is a
627
+ 400 instead of a row with no consent. Set it to `false` deliberately only when
628
+ the site renders no consent control. Existing adopters must test this before
629
+ cutover.
558
630
  - **CRM projection points.** chapter projects the person on application submit
559
631
  (`projectApplicant`), not on booking or on webhook status change. If you mirror
560
632
  pipeline stage into the CRM, keep those routes.
@@ -573,26 +645,25 @@ These bite silently — a smoke test won't catch them:
573
645
  route or provisioning seed; that is deliberate, so the running app cannot
574
646
  grant itself admin. Confirm the result by signing in, not merely by inspecting
575
647
  the row.
576
- - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
577
- this package's manifest, because only one entry imports it: the worker verifies
578
- JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
579
- react-only. **Install `@odla-ai/auth-clerk` yourself if (and only if) you adopt
648
+ - **`@odla-ai/auth-clerk` is an optional Chapter peer.** Only one entry imports
649
+ it: the worker verifies JWTs with `jose` via `ctx.verifyUser`, and
650
+ `@odla-ai/chapter/ui/member` does not load the auth package. **Install
651
+ `@odla-ai/auth-clerk` yourself if (and only if) you adopt
580
652
  `@odla-ai/chapter/ui/admin`** or want the themed sign-in components; reach for
581
653
  `@odla-ai/auth-clerk/invitations` when you want to send your own branded
582
654
  invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
583
655
  half, so prefer the narrower entry.
584
656
  - **Known-good application set** (installs clean, no flags):
585
- `@odla-ai/chapter` 0.22.0, `@odla-ai/ui` 0.12.0, `@odla-ai/crm` 0.3.0,
586
- `@odla-ai/db` 0.6.6,
657
+ `@odla-ai/chapter` 0.23.0, `@odla-ai/brand` 0.2.0,
658
+ `@odla-ai/ui` 0.12.1, `@odla-ai/crm` 0.3.1,
659
+ `@odla-ai/db` 0.6.7,
587
660
  `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1,
588
- `@odla-ai/auth-clerk` 0.4.0, `@odla-ai/o11y` 2.2.2, `jose` 6.2.3, React
589
- 19.2.7, and `react-dom` 19.2.7.
590
- - **Known-good React host toolchain:** `@odla-ai/cli` 0.17.1,
661
+ `@odla-ai/auth-clerk` 0.4.1, `@odla-ai/o11y` 2.2.2, `jose` 6.2.3, and
662
+ Preact 10.29.7.
663
+ - **Known-good Preact host toolchain:** `@odla-ai/cli` 0.17.1,
591
664
  `@odla-ai/security` 0.3.1, `@cloudflare/workers-types` 4.20260702.1,
592
- `@types/react` 19.2.17, `@types/react-dom` 19.2.3,
593
- `@vitejs/plugin-react` 6.0.3, TypeScript 6.0.3, Vite 8.1.4, Vitest 4.1.10,
594
- and Wrangler 4.107.0. The greenfield runbook standardizes on this exact React
595
- matrix; a Preact host needs its own tested compatibility set.
665
+ TypeScript 6.0.3, Vite 8.1.4, Vitest 4.1.10, and Wrangler 4.107.0. The
666
+ greenfield runbook standardizes on this exact Preact matrix.
596
667
  - **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
597
668
  the peer conflict that tells you a pair is unsupported. If you need it, find out
598
669
  why first.
@@ -0,0 +1,411 @@
1
+ // src/ui/copy-context.tsx
2
+ import { createContext } from "preact";
3
+ import { useContext } from "preact/hooks";
4
+
5
+ // src/copy-defaults-admin.ts
6
+ var DEFAULT_ADMIN_COPY = {
7
+ auth: {
8
+ checking: "Checking access\u2026",
9
+ notAuthorized: "Not authorized",
10
+ accountNotAuthorized: "{account} isn't on the admin list.",
11
+ thisAccount: "This account",
12
+ signOut: "Sign out",
13
+ loading: "Loading\u2026",
14
+ signInNotConfigured: "Sign-in not configured",
15
+ missingPublishableKey: "No Clerk publishable key is set for this environment yet.",
16
+ noWorkspaces: "No admin workspaces are configured.",
17
+ signInTagline: "Admin sign-in, invite only"
18
+ },
19
+ shell: {
20
+ adminRole: "admin",
21
+ adminConsole: "Admin Console",
22
+ adminName: "Admin",
23
+ navigationLabel: "Admin navigation"
24
+ },
25
+ workspaces: {
26
+ dashboard: "Dashboard",
27
+ overview: "Overview",
28
+ billing: "Billing",
29
+ people: "People",
30
+ settings: "Settings",
31
+ calendar: "Calendar",
32
+ email: "Email",
33
+ dashboardViewsLabel: "Dashboard views",
34
+ settingsViewsLabel: "Settings views",
35
+ collectionsLabel: "CRM collections"
36
+ },
37
+ dashboard: {
38
+ loading: "Loading dashboard\u2026",
39
+ loadFailed: "Couldn't load the dashboard",
40
+ upcomingCalls: "Upcoming calls",
41
+ callsNeedAttention: "{count} need attention",
42
+ noUpcomingCalls: "No upcoming calls.",
43
+ drift: "drift",
44
+ open: "Open",
45
+ meet: "Meet",
46
+ applications: "Applications",
47
+ newMembers: "New members",
48
+ newMembersUnavailable: "Appears once billing is connected.",
49
+ revenueAdded: "Revenue \xB7 annual run rate added",
50
+ revenue: "Revenue",
51
+ revenueUnavailable: "Appears once billing is connected.",
52
+ pipeline: "Pipeline",
53
+ pipelineLabel: "Membership pipeline",
54
+ thisWeek: "+{count} this week",
55
+ noChange: "no change",
56
+ activeMemberships: "Active memberships",
57
+ annualRunRate: "Annual run rate",
58
+ testMode: "test mode"
59
+ },
60
+ billing: {
61
+ loading: "Loading\u2026",
62
+ loadFailed: "Couldn't load billing",
63
+ notConfigured: "Billing isn't configured. No Stripe key is vaulted for this group.",
64
+ active: "Active",
65
+ annualized: "Annualized",
66
+ renewingSoon: "Renewing within 60 days",
67
+ pastDue: "Past due",
68
+ subscriptions: "Subscriptions",
69
+ testMode: "Stripe test mode",
70
+ truncated: "Showing the first 100 subscriptions. The list is truncated.",
71
+ name: "Name",
72
+ email: "Email",
73
+ application: "Application",
74
+ subscription: "Subscription",
75
+ cancelling: "cancelling",
76
+ amount: "Amount",
77
+ renews: "Renews"
78
+ },
79
+ availability: {
80
+ loading: "Loading\u2026",
81
+ loadFailed: "Couldn't load availability",
82
+ saved: "Saved.",
83
+ title: "Booking availability",
84
+ days: "Days",
85
+ startHour: "Start hour (0\u201324)",
86
+ endHour: "End hour (0\u201324)",
87
+ slotMinutes: "Slot length (minutes)",
88
+ minNoticeHours: "Minimum notice (hours)",
89
+ windowDays: "Booking window (days)",
90
+ timezone: "Timezone",
91
+ summaryTemplate: "Event summary template",
92
+ save: "Save availability",
93
+ dayLabels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
94
+ },
95
+ email: {
96
+ loading: "Loading\u2026",
97
+ loadFailed: "Couldn't load email config",
98
+ saved: "Saved.",
99
+ sent: "Sent {template} to {address}{redirected}.",
100
+ testFailed: "Test send did not deliver.",
101
+ delivery: "Delivery",
102
+ notificationAddress: "Notification address",
103
+ replyTo: "Reply-to",
104
+ debugInbox: "Debug inbox",
105
+ debugInboxHint: "Non-production sends redirect here.",
106
+ sendTest: "Send test",
107
+ enabled: "Enabled",
108
+ subject: "Subject",
109
+ body: "Body",
110
+ save: "Save changes",
111
+ sendLog: "Send log",
112
+ sentColumn: "Sent",
113
+ templateColumn: "Template",
114
+ statusColumn: "Status",
115
+ toColumn: "To",
116
+ failed: "failed",
117
+ redirected: "redirected",
118
+ delivered: "delivered"
119
+ },
120
+ meetings: {
121
+ loading: "Loading\u2026",
122
+ loadFailed: "Couldn't load meetings",
123
+ cancelConfirm: "Cancel this call? The calendar provider notifies the attendee.",
124
+ newStartPrompt: "New start time (for example, 2026-08-01 14:00):",
125
+ parseFailed: "Couldn't parse that time.",
126
+ agenda: "Agenda",
127
+ empty: "No meetings.",
128
+ unknown: "(unknown)",
129
+ drift: "drift",
130
+ meet: "Meet",
131
+ reschedule: "Reschedule",
132
+ cancel: "Cancel"
133
+ },
134
+ network: {
135
+ title: "Share to a follower",
136
+ allowlistDescription: "Sends the target's allowlisted fields. Its pipeline and account state remain local.",
137
+ sharing: "Sharing\u2026",
138
+ shareWith: "Share with {target}",
139
+ shared: "Shared with {target}.",
140
+ shareFailed: "Could not share with {target}.",
141
+ deliveryFailed: "Delivery failed."
142
+ },
143
+ records: {
144
+ workflowMissing: "This record is not linked to an application workflow.",
145
+ comms: "Comms",
146
+ commsHistory: "Comms history",
147
+ scheduling: "Scheduling",
148
+ billing: "Billing",
149
+ notes: "Notes",
150
+ access: "Access",
151
+ sharing: "Sharing",
152
+ lifecycle: "Access & lifecycle",
153
+ role: "Role",
154
+ superAdminHint: "Super-admin, managed in odla Studio.",
155
+ noAccount: "No linked account yet. The role is set once they sign in.",
156
+ approve: "Approve",
157
+ refund: "Refund",
158
+ approved: "Approved.",
159
+ refunded: "Refund issued.",
160
+ roleSet: "Role set to {role}.",
161
+ messageSent: "Message sent.",
162
+ messageNotSent: "Message was not sent.",
163
+ loadingTemplates: "Loading templates\u2026",
164
+ template: "Template",
165
+ chooseTemplate: "Choose a template\u2026",
166
+ sending: "Sending\u2026",
167
+ send: "Send",
168
+ noMessages: "No messages recorded.",
169
+ message: "Message",
170
+ newStartPrompt: "New start time:",
171
+ parseFailed: "Couldn't parse that time.",
172
+ cancelConfirm: "Cancel this call?",
173
+ callCancelled: "Call cancelled.",
174
+ callRescheduled: "Call rescheduled.",
175
+ noApplication: "No application is linked to this record.",
176
+ loadingMeetings: "Loading meetings\u2026",
177
+ noMeetings: "No meetings recorded.",
178
+ joinMeeting: "Join meeting",
179
+ openCalendar: "Open calendar",
180
+ reschedule: "Reschedule",
181
+ cancel: "Cancel"
182
+ }
183
+ };
184
+
185
+ // src/copy-defaults.ts
186
+ var DEFAULT_COMMON_COPY = {
187
+ loading: "Loading\u2026",
188
+ saved: "Saved.",
189
+ unknown: "(unknown)"
190
+ };
191
+ var DEFAULT_JOIN_COPY = {
192
+ form: {
193
+ submit: "Submit application",
194
+ submitting: "Submitting\u2026",
195
+ submitFailed: "Your application could not be submitted.",
196
+ unexpectedFailure: "Something went wrong. Please try again."
197
+ },
198
+ booking: {
199
+ unavailable: "Scheduling is briefly unavailable. We'll reach out by email to arrange your call.",
200
+ loadFailed: "Times are briefly unavailable. Please try again.",
201
+ slotTaken: "That time was just taken. Here are the current openings.",
202
+ failed: "The booking could not be completed.",
203
+ loading: "Loading available times\u2026",
204
+ book: "Book this time",
205
+ booking: "Booking\u2026"
206
+ },
207
+ payment: {
208
+ setupFailed: "Payment could not be set up. Please try again.",
209
+ preparing: "Preparing secure payment\u2026",
210
+ pending: "Confirming your payment\u2026",
211
+ processing: "Processing\u2026",
212
+ payAndContinue: "Pay and continue",
213
+ incomplete: "The payment did not complete. Please try again."
214
+ },
215
+ done: {
216
+ label: "You're booked",
217
+ calendarInvite: "A calendar invitation with the video call link is on its way to your email.",
218
+ memberArea: "Go to your member area"
219
+ }
220
+ };
221
+ var DEFAULT_MEMBERS_COPY = {
222
+ loadFailed: "Sign in is briefly unavailable. Please refresh.",
223
+ account: {
224
+ signOut: "Sign out",
225
+ adminConsole: "Admin console"
226
+ },
227
+ provisional: {
228
+ cardLabel: "Your Application",
229
+ applicationNeeded: "One step remains",
230
+ applicationNeededBody: "Your account is ready, and the application that completes it takes a few minutes.",
231
+ apply: "Apply for membership",
232
+ refunded: "Membership refunded",
233
+ refundedBody: "Your fee has been refunded in full and your membership is canceled.",
234
+ active: "Your membership is active.",
235
+ renews: "Your membership is active and renews {date}.",
236
+ introductionCall: "Your introduction call",
237
+ calendarInvite: "A calendar invitation with the video call link is in your email.",
238
+ joinCall: "Join the video call",
239
+ bookCall: "Book your introduction call",
240
+ bookCallBody: "Your application is in. Choose a time below, and a calendar invitation will reach your email.",
241
+ chooseTime: "Choose a time"
242
+ },
243
+ full: {
244
+ welcome: "Welcome back."
245
+ },
246
+ reschedule: {
247
+ changeTime: "Change your time",
248
+ unavailable: "Scheduling is briefly unavailable. We'll reach out by email to arrange your call.",
249
+ loadFailed: "Times are briefly unavailable. Please try again.",
250
+ slotGone: "That time is no longer available. Please pick another.",
251
+ loading: "Loading available times\u2026",
252
+ noTimes: "No open times right now. Please check back soon.",
253
+ rescheduling: "Rescheduling\u2026",
254
+ keepTime: "Keep my current time"
255
+ }
256
+ };
257
+
258
+ // src/copy-resolve.ts
259
+ var DEFAULT_CHAPTER_COPY = {
260
+ common: DEFAULT_COMMON_COPY,
261
+ join: DEFAULT_JOIN_COPY,
262
+ members: DEFAULT_MEMBERS_COPY,
263
+ admin: DEFAULT_ADMIN_COPY
264
+ };
265
+ var isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
266
+ function mergeCopy(base, input, path) {
267
+ if (typeof base === "string") {
268
+ if (input === void 0) return base;
269
+ if (typeof input !== "string") throw new Error(`${path}: must be a string`);
270
+ return input;
271
+ }
272
+ if (Array.isArray(base)) {
273
+ if (input === void 0) return [...base];
274
+ if (!Array.isArray(input) || !input.every((item) => typeof item === "string")) {
275
+ throw new Error(`${path}: must be an array of strings`);
276
+ }
277
+ return [...input];
278
+ }
279
+ if (!isObject(base)) return base;
280
+ if (input !== void 0 && !isObject(input)) throw new Error(`${path}: must be an object`);
281
+ const overrides = input;
282
+ const result = {};
283
+ for (const [key, value] of Object.entries(base)) {
284
+ result[key] = mergeCopy(value, overrides?.[key], `${path}.${key}`);
285
+ }
286
+ return result;
287
+ }
288
+ function resolveChapterCopy(input) {
289
+ return mergeCopy(DEFAULT_CHAPTER_COPY, input, "defineChapter.copy");
290
+ }
291
+ function formatChapterCopy(template, values) {
292
+ return template.replace(
293
+ /\{([a-zA-Z][a-zA-Z0-9]*)\}/g,
294
+ (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
295
+ );
296
+ }
297
+
298
+ // src/ui/copy-context.tsx
299
+ import { jsx } from "preact/jsx-runtime";
300
+ var ChapterCopyContext = createContext(DEFAULT_CHAPTER_COPY);
301
+ function ChapterCopyProvider(props) {
302
+ return /* @__PURE__ */ jsx(ChapterCopyContext.Provider, { value: resolveChapterCopy(props.copy), children: props.children });
303
+ }
304
+ function useChapterCopy() {
305
+ return useContext(ChapterCopyContext);
306
+ }
307
+
308
+ // src/brand.ts
309
+ function paletteVar(key) {
310
+ return key.startsWith("--") ? key : `--${key}`;
311
+ }
312
+ function cleanValue(value) {
313
+ return value.replace(/[<>{};]/g, "").trim();
314
+ }
315
+ function paletteDecls(palette) {
316
+ const decls = [];
317
+ for (const [key, value] of Object.entries(palette ?? {})) {
318
+ if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
319
+ }
320
+ return decls;
321
+ }
322
+ var TOKEN_VARS = {
323
+ background: "--ui-bg",
324
+ surface: "--ui-surface",
325
+ surface2: "--ui-surface-2",
326
+ text: "--ui-text",
327
+ textMuted: "--ui-text-muted",
328
+ textFaint: "--ui-text-faint",
329
+ border: "--ui-border",
330
+ borderStrong: "--ui-border-strong",
331
+ accent: "--ui-accent",
332
+ accentStrong: "--ui-accent-strong",
333
+ accentSoft: "--ui-accent-soft",
334
+ onAccent: "--ui-on-accent",
335
+ good: "--ui-good",
336
+ warn: "--ui-warn",
337
+ danger: "--ui-danger",
338
+ chart1: "--ui-chart-1",
339
+ chart2: "--ui-chart-2",
340
+ chart3: "--ui-chart-3",
341
+ chart4: "--ui-chart-4",
342
+ chart5: "--ui-chart-5",
343
+ chart6: "--ui-chart-6",
344
+ chartPositive: "--ui-chart-pos",
345
+ chartNegative: "--ui-chart-neg",
346
+ contentWidth: "--ui-content-width",
347
+ panelRadius: "--ui-radius-lg",
348
+ panelShadow: "--ui-shadow",
349
+ masterDetailColumns: "--ui-master-detail-columns",
350
+ masterDetailMinHeight: "--ui-master-detail-min-height",
351
+ pagePadding: "--chapter-admin-page-padding",
352
+ workspacePadding: "--chapter-admin-workspace-padding"
353
+ };
354
+ function semanticDecls(tokens) {
355
+ return Object.entries(tokens ?? {}).flatMap(
356
+ ([key, value]) => typeof value === "string" && value.trim() ? [`${TOKEN_VARS[key]}: ${cleanValue(value)};`] : []
357
+ );
358
+ }
359
+ function brandTokens(brand, options = {}) {
360
+ if (!brand) return "";
361
+ const light = [...paletteDecls(brand.palette), ...semanticDecls(brand.tokens)];
362
+ const fonts = brand.fonts;
363
+ if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
364
+ if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
365
+ if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
366
+ const dark = [...paletteDecls(brand.paletteDark), ...semanticDecls(brand.tokensDark)];
367
+ const invert = paletteDecls(brand.paletteInvert);
368
+ const selector = options.selector ?? ":root";
369
+ const darkSelector = selector === ":root" ? ':root[data-theme="dark"]' : `${selector}[data-theme="dark"]`;
370
+ const systemSelector = selector === ":root" ? ':root:not([data-theme="light"])' : `${selector}:not([data-theme="light"])`;
371
+ let css = light.length ? `${selector} {
372
+ ${light.join("\n ")}
373
+ }
374
+ ` : "";
375
+ if (dark.length) {
376
+ const block = `{
377
+ ${dark.join("\n ")}
378
+ }`;
379
+ css += `${darkSelector} ${block}
380
+ @media (prefers-color-scheme: dark) {
381
+ ${systemSelector} ${block}
382
+ }
383
+ `;
384
+ }
385
+ if (invert.length) {
386
+ const invertSelector = selector === ":root" ? ".ui-invert" : `${selector}.ui-invert, ${selector} .ui-invert`;
387
+ css += `${invertSelector} {
388
+ color-scheme: dark;
389
+ ${invert.join("\n ")}
390
+ }
391
+ `;
392
+ }
393
+ return css;
394
+ }
395
+
396
+ // src/ui/brand-style.tsx
397
+ import { jsx as jsx2 } from "preact/jsx-runtime";
398
+ function BrandStyle(props) {
399
+ const css = brandTokens(props.brand, { selector: props.selector });
400
+ if (!css) return null;
401
+ return /* @__PURE__ */ jsx2("style", { children: css });
402
+ }
403
+
404
+ export {
405
+ DEFAULT_CHAPTER_COPY,
406
+ formatChapterCopy,
407
+ ChapterCopyProvider,
408
+ useChapterCopy,
409
+ BrandStyle
410
+ };
411
+ //# sourceMappingURL=chunk-NTZSXAFT.js.map