@odla-ai/chapter 0.20.2 → 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
@@ -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.20.2
11
+ npm i --save-exact @odla-ai/chapter@0.21.0
12
12
  ```
13
13
 
14
14
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
@@ -101,34 +101,38 @@ secrets out of source and require development proof before production.
101
101
  presentational component without the Clerk browser SDK. (A bundle-graph test
102
102
  enforces this.)
103
103
  - `@odla-ai/chapter/ui/admin` — the Clerk-gated console `ChapterAdmin` + the
104
- section catalog. `<ChapterAdmin chapter={chapter}/>` derives the brand and
105
- standard sections automatically: every configured CRM collection plus the
106
- chapter operational surfaces. Pass `sections` only for a deliberate custom
107
- 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.
108
109
  - `@odla-ai/chapter/ui` — the full barrel, for back-compat.
109
110
 
110
111
  Authored against React, rendered as Preact via `preact/compat` in the reference
111
112
  sites. Brand tokens (`brandTokens`/`<BrandStyle>`) re-skin all of it from
112
113
  `brand` (now light **and** dark, via `brand.palette` + `brand.paletteDark`).
113
114
 
114
- ### Theme tokens (or the console renders broken)
115
+ ### Theme tokens and scoped branding
115
116
 
116
117
  The UI reads its colors from `--ui-*` design tokens whose **values come from a
117
- theme layer, not `@odla-ai/ui/index.css`.** Use the one-import application sheet;
118
- it expands to the token layer, Salt's familiar editorial component treatment,
119
- 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:
120
121
 
121
122
  ```ts
122
- 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";
123
127
  ```
124
128
 
125
129
  `ChapterAdmin` guards against this at runtime: if a `.panel` has no background
126
130
  (the theme layer is missing), it renders a **loud red banner** at the top of the
127
- console instead of failing silently. To brand it, pass `brand.palette` /
128
- `brand.paletteDark` `brandTokens(brand)` emits both a `:root` block and a
129
- `[data-theme="dark"]` / `prefers-color-scheme: dark` block, so your accent/colors
130
- override the shared visual grammar in both modes. When the `chapter` prop is
131
- 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.
132
136
 
133
137
  ## API quick start
134
138
 
@@ -144,7 +148,13 @@ export const chapter = defineChapter({
144
148
  badge: "EX",
145
149
  wordmark: "Example Chapter",
146
150
  tagline: "Capital and craft for durable local businesses.",
147
- palette: { "--ui-accent": "#2f6f4f" },
151
+ theme: "paper",
152
+ colorScheme: "light",
153
+ tokens: {
154
+ accent: "#2f6f4f",
155
+ accentStrong: "#244f3b",
156
+ accentSoft: "#dfece4",
157
+ },
148
158
  fonts: { display: "GT Sectra" },
149
159
  },
150
160
  prices: { standardCents: 100000, foundingDiscountCents: 10000 },
@@ -160,8 +170,11 @@ export default chapterWorker({ chapter });
160
170
  ```
161
171
 
162
172
  ```tsx
163
- // src/app/admin.tsx — brand + familiar sections come from the same config.
164
- 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";
165
178
  import { render } from "preact";
166
179
  import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
167
180
  import { chapter } from "../chapter.config.mjs";
@@ -274,24 +287,24 @@ records, not `crm_link` relation rows; create or curate `works_at` links locally
274
287
 
275
288
  #### Custom leader consoles must mount the sharing UI
276
289
 
277
- Automatic “Share with …” actions come from chapter's standard collection
278
- sections. They are present when the console uses
279
- `<ChapterAdmin chapter={chapter} />`. Passing an explicit `sections` array
280
- 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:
281
294
 
282
295
  ```tsx
283
296
  import {
284
297
  ChapterAdmin,
285
- defaultAdminSections,
298
+ defaultAdminWorkspaces,
286
299
  } from "@odla-ai/chapter/ui/admin";
287
300
 
288
- const sections = [
289
- customOverviewSection,
290
- ...defaultAdminSections(chapter),
301
+ const workspaces = [
302
+ customOperationsWorkspace,
303
+ ...defaultAdminWorkspaces(chapter),
291
304
  ];
292
305
 
293
306
  render(
294
- <ChapterAdmin chapter={chapter} sections={sections} />,
307
+ <ChapterAdmin chapter={chapter} workspaces={workspaces} />,
295
308
  document.getElementById("admin-root"),
296
309
  );
297
310
  ```
@@ -333,10 +346,43 @@ Only after this contract passes against development origins should the leader
333
346
  target be changed to a production follower origin and the matching production
334
347
  vault values be installed.
335
348
 
336
- Section navigation defaults to `/admin/?tab=people`, not
337
- `/admin/people`. Query routing deliberately works in both leader-style SPAs and
338
- ordinary static subdirectories. Legacy path links still open, and
339
- `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.
340
386
 
341
387
  ## Adopting into an existing site
342
388
 
@@ -467,7 +513,7 @@ These bite silently — a smoke test won't catch them:
467
513
  invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
468
514
  half, so prefer the narrower entry.
469
515
  - **Known-good application set** (installs clean, no flags):
470
- `@odla-ai/chapter` 0.20.2, `@odla-ai/ui` 0.10.0, `@odla-ai/crm` 0.1.4,
516
+ `@odla-ai/chapter` 0.21.0, `@odla-ai/ui` 0.11.0, `@odla-ai/crm` 0.2.0,
471
517
  `@odla-ai/db` 0.6.6,
472
518
  `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1,
473
519
  `@odla-ai/auth-clerk` 0.4.0, `@odla-ai/o11y` 2.2.2, `jose` 6.2.3, React
@@ -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":[]}