@glw907/cairn-cms 0.62.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/dist/auth/types.d.ts +7 -0
  3. package/dist/components/ComponentInsertDialog.svelte +17 -6
  4. package/dist/components/ConceptList.svelte +25 -4
  5. package/dist/components/cairn-admin.css +175 -2
  6. package/dist/content/advisories.d.ts +5 -0
  7. package/dist/content/advisories.js +17 -9
  8. package/dist/content/field-rules.d.ts +15 -0
  9. package/dist/content/field-rules.js +39 -0
  10. package/dist/content/fields.d.ts +121 -0
  11. package/dist/content/fields.js +30 -0
  12. package/dist/content/fieldset.d.ts +86 -0
  13. package/dist/content/fieldset.js +233 -0
  14. package/dist/content/schema.js +16 -20
  15. package/dist/delivery/public-routes.d.ts +8 -0
  16. package/dist/delivery/public-routes.js +10 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +5 -0
  19. package/dist/log/events.d.ts +1 -1
  20. package/dist/media/index.d.ts +1 -1
  21. package/dist/media/index.js +1 -1
  22. package/dist/media/manifest.d.ts +11 -0
  23. package/dist/media/manifest.js +13 -0
  24. package/dist/render/highlight.d.ts +9 -0
  25. package/dist/render/highlight.js +206 -0
  26. package/dist/render/pipeline.js +12 -1
  27. package/dist/render/registry.d.ts +10 -2
  28. package/dist/render/registry.js +21 -1
  29. package/dist/render/rehype-dispatch.d.ts +2 -6
  30. package/dist/render/rehype-dispatch.js +2 -6
  31. package/dist/render/sanitize-schema.d.ts +10 -0
  32. package/dist/render/sanitize-schema.js +29 -0
  33. package/dist/sveltekit/content-routes.js +9 -7
  34. package/dist/sveltekit/guard.js +10 -0
  35. package/package.json +13 -2
  36. package/src/lib/auth/types.ts +7 -0
  37. package/src/lib/components/ComponentInsertDialog.svelte +17 -6
  38. package/src/lib/components/ConceptList.svelte +41 -4
  39. package/src/lib/content/advisories.ts +24 -15
  40. package/src/lib/content/field-rules.ts +40 -0
  41. package/src/lib/content/fields.ts +127 -0
  42. package/src/lib/content/fieldset.ts +307 -0
  43. package/src/lib/content/schema.ts +9 -13
  44. package/src/lib/delivery/public-routes.ts +19 -1
  45. package/src/lib/index.ts +7 -0
  46. package/src/lib/log/events.ts +1 -0
  47. package/src/lib/media/index.ts +1 -0
  48. package/src/lib/media/manifest.ts +14 -0
  49. package/src/lib/render/highlight.ts +259 -0
  50. package/src/lib/render/pipeline.ts +12 -1
  51. package/src/lib/render/registry.ts +30 -3
  52. package/src/lib/render/rehype-dispatch.ts +2 -6
  53. package/src/lib/render/sanitize-schema.ts +31 -0
  54. package/src/lib/sveltekit/content-routes.ts +9 -7
  55. package/src/lib/sveltekit/guard.ts +15 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,149 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.68.0
6
+
7
+ <!-- release-size: minor -->
8
+
9
+ The second pre-cutover engine-hardening pass clears eight engine-misc items: two admin accessibility
10
+ fixes, an engine default-icon fallback, and gate, doc, and tooling hygiene.
11
+
12
+ The component picker dialog now caps its height at 85vh and scrolls its catalog within a held header
13
+ and footer, so a long catalog no longer takes the page over. A repeated content-lifecycle error in the
14
+ concept list now re-announces to a screen reader: the errors route through one polite live region that
15
+ re-speaks an identical message through an invisible nonce, and the visible alerts drop their redundant
16
+ `role` so the message announces once.
17
+
18
+ The component registry ships a default role-to-glyph fallback for the conventional admonition roles
19
+ (`note`, `tip`, `important`, `warning`, `caution`, `info`, `danger`). A component that declares an icon
20
+ field but no `defaultIconByRole` entry for a role now resolves the engine default, which a site's icon
21
+ set styles; a component's own `defaultIconByRole` still wins. The `ComponentDef.icon` and
22
+ `defaultIconByRole` guidance now states the "logically representative, prefer distinct" rule.
23
+
24
+ Three gates and one doc tightened: the admin-prose gate now scans the `.ts` copy modules it skipped, a
25
+ new `check:dev-package` gate type-checks and comment-lints `packages/**` in CI, the two
26
+ `rehype-dispatch` helpers gained real doc contracts, and the friction log marks its killed and shipped
27
+ items resolved so it stops resurfacing dead work.
28
+
29
+ No consumer action is required. The accessibility fixes and the icon fallback are additive; a site using
30
+ the registry's `defaultIcon` may now see an engine default glyph where it previously saw none.
31
+
32
+ ## 0.67.0
33
+
34
+ <!-- release-size: minor -->
35
+
36
+ The Contract v2 `fieldset` validator reaches constraint parity with `defineFields`, the first of two
37
+ pre-cutover engine-hardening passes. Both validators now call one shared constraint module, so they
38
+ cannot drift, and a v1-vs-v2 parity matrix proves they agree on the overlapping field types.
39
+
40
+ The `fieldset` validator gains the checks it lacked. A `text` or `textarea` field now enforces its
41
+ `min`, `max`, `length`, and `pattern`, and a `date` field enforces its `min` and `max`, with the same
42
+ messages `defineFields` produces. A malformed `pattern` now fails at `fieldset()` call time, not on
43
+ every save, the way `defineFields` already compiled patterns at declaration. The validator also reads a
44
+ parsed value, not only a form string: a numeric `number` (a finite `0` included), a `Date` on a
45
+ `datetime` field, the way the `date` field already coerced a parsed `Date`. A `multiselect` given a lone
46
+ scalar (a single hand-edited `tags: news`) coerces it to a single-element list rather than dropping it
47
+ or reporting a misleading "required".
48
+
49
+ No consumer action is required. The `fieldset` surface is still additive and not yet wired into the
50
+ adapter or editor, and the new behavior brings it in line with the long-standing `defineFields` checks.
51
+
52
+ ## 0.66.0
53
+
54
+ <!-- release-size: minor -->
55
+
56
+ Contract v2 begins with an additive `fields.*` field vocabulary, exported beside the existing
57
+ `defineFields` model. The new surface is opt-in and does not yet wire into the adapter or editor, so a
58
+ site on the current field model is unaffected.
59
+
60
+ A concept can declare its fields as a record of `fields.*` constructors, each returning a plain-data
61
+ descriptor. The scalars are `text`, `textarea`, `number`, `select`, `multiselect`, `url`, `email`,
62
+ `date`, `datetime`, and `boolean`, with `image` as the rich leaf. `fieldset(record)` derives a
63
+ server-side validator from those descriptors, returning field-keyed errors or normalized data, and
64
+ exposes Standard Schema v1 at its boundary. `InferFieldset` reads the inferred frontmatter type from a
65
+ fieldset, and `initialValues` resolves each field's `default` for the editor form, including the
66
+ `'today'` sentinel on a date field through an injected clock. The new root-barrel exports are `fields`,
67
+ `fieldset`, `initialValues`, and the types `FieldDescriptor`, `Fieldset`, `InferFieldset`,
68
+ `FieldsetOptions`, and `BehaviorTable`.
69
+
70
+ No consumer action is required. The vocabulary is a foundation; the contract-v2 cutover, a later
71
+ breaking release, migrates concepts off `defineFields` and carries the "Consumers must:" line then.
72
+
73
+ ## 0.65.0
74
+
75
+ <!-- release-size: minor -->
76
+
77
+ Build-time syntax highlighting moves into the engine render pipeline, and the public side gains the
78
+ Waymark design foundation in the showcase template (the scaffolder's Part B2).
79
+
80
+ Fenced code is now highlighted at build time. The render pipeline runs Shiki at build and SSR and
81
+ emits role-bound `.cairn-tok-*` token classes with no inline style and no client highlighter, so the
82
+ reading route ships no highlighter JavaScript and the colors come from the site's theme. The engine
83
+ owns the `.cairn-tok-*` class contract (the way it owns `.cairn-place-*` for figures); a site styles
84
+ the classes from its own `--cairn-code-*` variables. Adds `shiki` and `hast-util-to-string` to the
85
+ engine's dependencies.
86
+
87
+ GFM task-list checkboxes now carry an `aria-label` from their item text, so a screen reader names the
88
+ read-only control. This clears an axe `label` violation on every site while keeping the real disabled
89
+ input the design calls for.
90
+
91
+ No consumer action is required. A site gets highlighting automatically; to color the tokens, style the
92
+ `.cairn-tok-*` classes from a `--cairn-code-*` ramp (the Waymark showcase template does this, bound to
93
+ the DaisyUI roles). The broader Waymark design foundation (the oklch token layer, the bespoke reading
94
+ surface, the chrome, the `/styleguide` route, and the dual-gamut contrast, token-resolution, and
95
+ re-skin CI gates) ships in `examples/showcase`, the deployable starter, not the published engine.
96
+
97
+ ## 0.64.0
98
+
99
+ <!-- release-size: minor -->
100
+
101
+ A small pre-Part-B DX pass fixes two engine warts the scaffolder's template would otherwise bake in,
102
+ and retires a third item that was already resolved.
103
+
104
+ `readCommittedManifest`, exported from `/media`, reads a committed media manifest from an
105
+ `import.meta.glob` result and degrades a missing file to an empty manifest. A fresh site with no
106
+ `src/content/.cairn/media.json` no longer fails its build: the static import that crashed gives way to
107
+ the glob, which returns `{}` for no match. The showcase reads its manifest this way.
108
+
109
+ A new `media.resolver_absent` log event (level `warn`) makes a silently-broken public-image setup
110
+ diagnosable. The public route emits it once, at construction, when media is configured on but no
111
+ `resolveMedia` reached it, so a forgotten resolver wiring becomes a queryable Workers Logs event
112
+ instead of a bare `media:` token on every hero image. `PublicRoutesDeps` gains an optional
113
+ `assetsEnabled` flag a site threads from its resolved asset config.
114
+
115
+ No consumer action is required. A site that wants the no-crash manifest read can adopt
116
+ `readCommittedManifest`, and a site that wants the resolver diagnostic threads `assetsEnabled` into
117
+ `createPublicRoutes`.
118
+
119
+ ## 0.63.0
120
+
121
+ <!-- release-size: minor -->
122
+
123
+ The local-development fake backend moves out of the engine and the showcase into a separate, dev-only
124
+ package, `@glw907/cairn-cms-dev`, the first part of the `create-cairn-site` scaffolder. The package
125
+ holds the in-memory GitHub, R2, D1, and Anthropic doubles and a blessed `devBackendHandle()` that
126
+ installs them and an owner-session bypass, so a site runs `/admin` locally with no cloud accounts. A
127
+ consumer installs it as a `devDependency` and activates it from `hooks.server.ts` behind a
128
+ build-foldable `dev` gate, so a production build eliminates it from the bundle.
129
+
130
+ The auth guard gains a fail-closed tripwire. If `CAIRN_DEV_BACKEND` is set in a deployed runtime, the
131
+ guard refuses the request with a 503 and logs `guard.rejected` with `reason: "dev_backend_in_prod"`.
132
+ It reads the flag from both the Worker `platform.env` and `process.env`, so it fires on Cloudflare and
133
+ adapter-node alike. `AuthEnv` carries a new optional `CAIRN_DEV_BACKEND?: string | boolean` field for
134
+ it.
135
+
136
+ No consumer action is required. The tripwire fires only when the flag is set, and the new package is
137
+ opt-in for sites that want the local dev backend.
138
+
139
+ ## 0.62.2
140
+
141
+ The edit-load address-collision advisory now checks the published corpus only. It fires when an entry
142
+ you are editing collides with an entry already published on `main`, and it no longer reads sibling
143
+ `cairn/<concept>/<id>` branches when an editor opens an entry, so opening the editor adds no GitHub
144
+ reads. The publish-time re-check is unchanged: it stays full cross-branch and still emits the
145
+ `publish.address_collision` log event when a publish overrides another entry's address. No consumer
146
+ action is required.
147
+
5
148
  ## 0.62.1
6
149
 
7
150
  The entry editor gains an advisory channel and its first notice: a cross-branch address-collision
@@ -11,6 +11,13 @@ export interface AuthEnv {
11
11
  AUTH_DB?: D1Database;
12
12
  /** Canonical origin for confirmation links, never read from a request header (spec 7.1, risk H3). */
13
13
  PUBLIC_ORIGIN?: string;
14
+ /**
15
+ * Dev-backend tripwire flag. The dev backend sets this in local development; if it is ever set in
16
+ * a deployed runtime the guard refuses (the build-foldable `dev` gate should have eliminated the
17
+ * dev backend, so a set flag signals a polluted environment). A string from a Worker var or a
18
+ * boolean.
19
+ */
20
+ CAIRN_DEV_BACKEND?: string | boolean;
14
21
  /** Cloudflare Email Sending binding. */
15
22
  EMAIL?: {
16
23
  send(message: {
@@ -197,11 +197,16 @@ function onSearchKeydown(e) {
197
197
 
198
198
  {#if defs.length > 0}
199
199
  <dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
200
- <div class="modal-box {twoPane ? 'max-w-3xl' : ''}">
200
+ <!-- The box caps at 85vh and is a flex column so its header holds while only the body scrolls,
201
+ per the design system's dialog-sizing recipe. The cap rides Tailwind utilities (the utilities
202
+ layer) so it beats DaisyUI's `.modal-box` max-height: 100vh; a components-layer rule loses the
203
+ cascade. overflow-hidden keeps the box from being a second scroll container. Matches TidyReview. -->
204
+ <div class="modal-box flex max-h-[85vh] flex-col overflow-hidden {twoPane ? 'max-w-3xl' : ''}">
201
205
  <!-- The shared header: at the configure step it carries the Back control and the
202
206
  "Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
203
- plain "Insert a component" title. -->
204
- <div class="mb-3 flex items-center gap-3">
207
+ plain "Insert a component" title. It holds (flex-none) while the body scrolls, per the
208
+ design system's dialog-sizing recipe. -->
209
+ <div class="mb-3 flex flex-none items-center gap-3">
205
210
  {#if picked && !editing}
206
211
  <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
207
212
  <svg class="h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M165.7 202.3a8 8 0 0 1-11.4 11.4l-80-80a8 8 0 0 1 0-11.4l80-80a8 8 0 0 1 11.4 11.4L91.3 128Z" /></svg>
@@ -219,6 +224,9 @@ function onSearchKeydown(e) {
219
224
  </div>
220
225
 
221
226
  {#if picked}
227
+ <!-- The configure body is the box's scroll container (flex-1, min-h-0): the shared header
228
+ above holds while the form scrolls within the 85vh cap. -->
229
+ <div class="-mr-1 flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
222
230
  {#key picked}
223
231
  {#if twoPane}
224
232
  <!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
@@ -275,9 +283,10 @@ function onSearchKeydown(e) {
275
283
  {@render configureForm(picked)}
276
284
  {/if}
277
285
  {/key}
286
+ </div>
278
287
  {:else}
279
288
  {#if showSearch}
280
- <div class="mb-3 flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
289
+ <div class="mb-3 flex flex-none items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
281
290
  <svg class="ec-glyph h-4 w-4 text-[var(--color-muted)]" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M229.7 218.3 179.6 168.2A92.2 92.2 0 1 0 168.2 179.6l50.1 50.1a8 8 0 0 0 11.4-11.4ZM40 112a72 72 0 1 1 72 72 72.1 72.1 0 0 1-72-72Z" /></svg>
282
291
  <input
283
292
  type="search"
@@ -301,8 +310,10 @@ function onSearchKeydown(e) {
301
310
  <button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
302
311
  </div>
303
312
  {:else}
304
- <!-- One scroll region holds every group, so the arrow keys roam the whole catalog. -->
305
- <div data-cairn-pk-list>
313
+ <!-- One scroll region holds every group, so the arrow keys roam the whole catalog. It
314
+ is the box's scroll container (flex-1, min-h-0): the header above holds while the
315
+ list scrolls within the 85vh cap. -->
316
+ <div data-cairn-pk-list class="-mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
306
317
  {#each groups as group (group.heading)}
307
318
  <div class="mt-3 first:mt-0">
308
319
  {#if group.heading}
@@ -120,6 +120,22 @@ const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-bas
120
120
  const publishedAllMessage = $derived(
121
121
  data.publishedAll !== null && data.publishedAll > 0 ? `Published ${data.publishedAll} ${data.publishedAll === 1 ? "entry" : "entries"}.` : ""
122
122
  );
123
+ const lifecycleError = $derived(
124
+ deleteRefused ? `This ${data.label.toLowerCase()} could not be deleted. ${deleteRefused.inboundLinks.length} ${deleteRefused.inboundLinks.length === 1 ? "page links" : "pages link"} to it.` : data.formError ?? data.error ?? ""
125
+ );
126
+ let announceNonce = $state(0);
127
+ function nonce() {
128
+ return announceNonce % 2 === 0 ? "" : "​";
129
+ }
130
+ let lastSubmit;
131
+ $effect(() => {
132
+ const submit = form ?? data;
133
+ if (submit !== lastSubmit) {
134
+ lastSubmit = submit;
135
+ if (lifecycleError) announceNonce++;
136
+ }
137
+ });
138
+ const liveError = $derived(lifecycleError ? `${lifecycleError}${nonce()}` : "");
123
139
  </script>
124
140
 
125
141
  <!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
@@ -145,20 +161,25 @@ const publishedAllMessage = $derived(
145
161
  {#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
146
162
  below keeps its styling without a role and the message is announced once. -->
147
163
  <div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
164
+ <!-- One persistent polite region announces the lifecycle errors, re-announcing a repeat through the
165
+ nonce. The visible alerts below keep their styling and drop the live `role` (a fresh-inserted
166
+ role element announces inconsistently and clobbers a repeat), so the message is announced once. -->
167
+ <div class="sr-only" aria-live="polite">{liveError}</div>
148
168
  {#if publishedAllMessage}
149
169
  <div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
150
170
  {/if}
151
171
  {#if data.formError}
152
- <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
172
+ <div class="alert alert-error mb-4 text-sm">{data.formError}</div>
153
173
  {/if}
154
174
  {#if data.error}
155
- <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
175
+ <div class="alert alert-warning mb-4 text-sm">{data.error}</div>
156
176
  {/if}
157
177
 
158
178
  {#if deleteRefused}
159
179
  <!-- A `?/delete` was refused: name the blockers up front, matching the editor's refusal banner,
160
- so the author sees why without re-opening a dialog. -->
161
- <div role="alert" aria-label="This {data.label.toLowerCase()} could not be deleted" class="alert alert-error mb-4 flex-col items-start text-sm">
180
+ so the author sees why without re-opening a dialog. The polite region above announces it, so
181
+ the box itself carries no role or label (a bare div with an aria-label gets no accessible name). -->
182
+ <div class="alert alert-error mb-4 flex-col items-start text-sm">
162
183
  <p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
163
184
  <p>{deleteRefused.inboundLinks.length} {deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it. Remove or repoint the {deleteRefused.inboundLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
164
185
  <ul class="mt-1 w-full">
@@ -3361,6 +3361,10 @@
3361
3361
  top: calc(var(--spacing) * 16);
3362
3362
  }
3363
3363
 
3364
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .top-\[-3rem\] {
3365
+ top: -3rem;
3366
+ }
3367
+
3364
3368
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .top-\[0\.875rem\] {
3365
3369
  top: .875rem;
3366
3370
  }
@@ -3389,6 +3393,10 @@
3389
3393
  left: calc(var(--spacing) * 2);
3390
3394
  }
3391
3395
 
3396
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .left-\[var\(--cairn-space-s\)\] {
3397
+ left: var(--cairn-space-s);
3398
+ }
3399
+
3392
3400
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .join {
3393
3401
  --join-ss: 0;
3394
3402
  --join-se: 0;
@@ -3703,6 +3711,10 @@
3703
3711
  z-index: 40;
3704
3712
  }
3705
3713
 
3714
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .z-50 {
3715
+ z-index: 50;
3716
+ }
3717
+
3706
3718
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .order-1 {
3707
3719
  order: 1;
3708
3720
  }
@@ -4075,6 +4087,10 @@
4075
4087
  margin-top: calc(var(--spacing) * 16);
4076
4088
  }
4077
4089
 
4090
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-\[var\(--cairn-space-2xl\)\] {
4091
+ margin-top: var(--cairn-space-2xl);
4092
+ }
4093
+
4078
4094
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-auto {
4079
4095
  margin-top: auto;
4080
4096
  }
@@ -4083,6 +4099,10 @@
4083
4099
  margin-top: 1px;
4084
4100
  }
4085
4101
 
4102
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .-mr-1 {
4103
+ margin-right: calc(var(--spacing) * -1);
4104
+ }
4105
+
4086
4106
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mr-0\.5 {
4087
4107
  margin-right: calc(var(--spacing) * .5);
4088
4108
  }
@@ -4144,6 +4164,10 @@
4144
4164
  margin-bottom: calc(var(--spacing) * 7);
4145
4165
  }
4146
4166
 
4167
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mb-\[var\(--cairn-space-s\)\] {
4168
+ margin-bottom: var(--cairn-space-s);
4169
+ }
4170
+
4147
4171
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .-ml-px {
4148
4172
  margin-left: -1px;
4149
4173
  }
@@ -4702,6 +4726,14 @@
4702
4726
  height: .6875rem;
4703
4727
  }
4704
4728
 
4729
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-\[1\.4rem\] {
4730
+ height: 1.4rem;
4731
+ }
4732
+
4733
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-\[1\.55rem\] {
4734
+ height: 1.55rem;
4735
+ }
4736
+
4705
4737
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-\[26px\] {
4706
4738
  height: 26px;
4707
4739
  }
@@ -4758,6 +4790,10 @@
4758
4790
  max-height: 100%;
4759
4791
  }
4760
4792
 
4793
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-0 {
4794
+ min-height: calc(var(--spacing) * 0);
4795
+ }
4796
+
4761
4797
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-6 {
4762
4798
  min-height: calc(var(--spacing) * 6);
4763
4799
  }
@@ -4916,6 +4952,14 @@
4916
4952
  width: .6875rem;
4917
4953
  }
4918
4954
 
4955
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-\[1\.4rem\] {
4956
+ width: 1.4rem;
4957
+ }
4958
+
4959
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-\[1\.55rem\] {
4960
+ width: 1.55rem;
4961
+ }
4962
+
4919
4963
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-\[3\.25rem\] {
4920
4964
  width: 3.25rem;
4921
4965
  }
@@ -4968,6 +5012,10 @@
4968
5012
  max-width: 30%;
4969
5013
  }
4970
5014
 
5015
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-\[38rem\] {
5016
+ max-width: 38rem;
5017
+ }
5018
+
4971
5019
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-\[40ch\] {
4972
5020
  max-width: 40ch;
4973
5021
  }
@@ -4992,6 +5040,14 @@
4992
5040
  max-width: 640px;
4993
5041
  }
4994
5042
 
5043
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-\[var\(--cairn-measure\)\] {
5044
+ max-width: var(--cairn-measure);
5045
+ }
5046
+
5047
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-\[var\(--cairn-measure-wide\)\] {
5048
+ max-width: var(--cairn-measure-wide);
5049
+ }
5050
+
4995
5051
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-full {
4996
5052
  max-width: 100%;
4997
5053
  }
@@ -5040,7 +5096,7 @@
5040
5096
  flex: none;
5041
5097
  }
5042
5098
 
5043
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .shrink {
5099
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .flex-shrink, :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .shrink {
5044
5100
  flex-shrink: 1;
5045
5101
  }
5046
5102
 
@@ -5250,6 +5306,18 @@
5250
5306
  gap: calc(var(--spacing) * 6);
5251
5307
  }
5252
5308
 
5309
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .gap-\[0\.55rem\] {
5310
+ gap: .55rem;
5311
+ }
5312
+
5313
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .gap-\[var\(--cairn-space-m\)\] {
5314
+ gap: var(--cairn-space-m);
5315
+ }
5316
+
5317
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .gap-\[var\(--cairn-space-s\)\] {
5318
+ gap: var(--cairn-space-s);
5319
+ }
5320
+
5253
5321
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .gap-px {
5254
5322
  gap: 1px;
5255
5323
  }
@@ -5476,6 +5544,15 @@
5476
5544
  }
5477
5545
  }
5478
5546
 
5547
+ @layer daisyui.l1.l2 {
5548
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .badge-outline {
5549
+ color: var(--badge-color);
5550
+ --badge-bg: #0000;
5551
+ background-image: none;
5552
+ border-color: currentColor;
5553
+ }
5554
+ }
5555
+
5479
5556
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--cairn-card-border\)_70\%\,transparent\)\] {
5480
5557
  border-color: var(--cairn-card-border);
5481
5558
  }
@@ -5546,7 +5623,7 @@
5546
5623
  }
5547
5624
  }
5548
5625
 
5549
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--cairn-card-border\)\] {
5626
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color\:var\(--cairn-card-border\)\], :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--cairn-card-border\)\] {
5550
5627
  border-color: var(--cairn-card-border);
5551
5628
  }
5552
5629
 
@@ -6245,6 +6322,18 @@
6245
6322
  padding-inline: calc(var(--spacing) * 7);
6246
6323
  }
6247
6324
 
6325
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-\[0\.1rem\] {
6326
+ padding-inline: .1rem;
6327
+ }
6328
+
6329
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-\[0\.9rem\] {
6330
+ padding-inline: .9rem;
6331
+ }
6332
+
6333
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-\[var\(--cairn-space-m\)\] {
6334
+ padding-inline: var(--cairn-space-m);
6335
+ }
6336
+
6248
6337
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-px {
6249
6338
  padding-inline: 1px;
6250
6339
  }
@@ -6313,10 +6402,26 @@
6313
6402
  padding-block: calc(var(--spacing) * 16);
6314
6403
  }
6315
6404
 
6405
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-\[0\.3rem\] {
6406
+ padding-block: .3rem;
6407
+ }
6408
+
6409
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-\[0\.5rem\] {
6410
+ padding-block: .5rem;
6411
+ }
6412
+
6316
6413
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-\[5px\] {
6317
6414
  padding-block: 5px;
6318
6415
  }
6319
6416
 
6417
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-\[var\(--cairn-space-xl\)\] {
6418
+ padding-block: var(--cairn-space-xl);
6419
+ }
6420
+
6421
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-\[var\(--cairn-space-xs\)\] {
6422
+ padding-block: var(--cairn-space-xs);
6423
+ }
6424
+
6320
6425
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-px {
6321
6426
  padding-block: 1px;
6322
6427
  }
@@ -6341,6 +6446,18 @@
6341
6446
  padding-top: calc(var(--spacing) * 4);
6342
6447
  }
6343
6448
 
6449
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pt-\[var\(--cairn-space-l\)\] {
6450
+ padding-top: var(--cairn-space-l);
6451
+ }
6452
+
6453
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pt-\[var\(--cairn-space-s\)\] {
6454
+ padding-top: var(--cairn-space-s);
6455
+ }
6456
+
6457
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pr-1 {
6458
+ padding-right: calc(var(--spacing) * 1);
6459
+ }
6460
+
6344
6461
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pr-3 {
6345
6462
  padding-right: calc(var(--spacing) * 3);
6346
6463
  }
@@ -6353,6 +6470,10 @@
6353
6470
  padding-bottom: calc(var(--spacing) * 1.5);
6354
6471
  }
6355
6472
 
6473
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pb-\[var\(--cairn-space-xl\)\] {
6474
+ padding-bottom: var(--cairn-space-xl);
6475
+ }
6476
+
6356
6477
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pl-3 {
6357
6478
  padding-left: calc(var(--spacing) * 3);
6358
6479
  }
@@ -6377,6 +6498,10 @@
6377
6498
  text-align: right;
6378
6499
  }
6379
6500
 
6501
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .font-\[family-name\:var\(--font-body\)\] {
6502
+ font-family: var(--font-body);
6503
+ }
6504
+
6380
6505
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .font-\[family-name\:var\(--font-display\)\] {
6381
6506
  font-family: var(--font-display);
6382
6507
  }
@@ -6482,12 +6607,34 @@
6482
6607
  font-size: 1.0625rem;
6483
6608
  }
6484
6609
 
6610
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[length\:var\(--cairn-step--1\)\] {
6611
+ font-size: var(--cairn-step--1);
6612
+ }
6613
+
6614
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[length\:var\(--cairn-step-1\)\] {
6615
+ font-size: var(--cairn-step-1);
6616
+ }
6617
+
6618
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[length\:var\(--cairn-step-5\)\] {
6619
+ font-size: var(--cairn-step-5);
6620
+ }
6621
+
6485
6622
  @layer daisyui.l1.l2 {
6486
6623
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .textarea-sm {
6487
6624
  font-size: max(var(--font-size, .75rem), .75rem);
6488
6625
  }
6489
6626
  }
6490
6627
 
6628
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .leading-\[var\(--cairn-leading-snug\)\] {
6629
+ --tw-leading: var(--cairn-leading-snug);
6630
+ line-height: var(--cairn-leading-snug);
6631
+ }
6632
+
6633
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .leading-\[var\(--cairn-leading-tight\)\] {
6634
+ --tw-leading: var(--cairn-leading-tight);
6635
+ line-height: var(--cairn-leading-tight);
6636
+ }
6637
+
6491
6638
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .leading-relaxed {
6492
6639
  --tw-leading: var(--leading-relaxed);
6493
6640
  line-height: var(--leading-relaxed);
@@ -6548,6 +6695,16 @@
6548
6695
  letter-spacing: .12em;
6549
6696
  }
6550
6697
 
6698
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[var\(--cairn-tracking-eyebrow\)\] {
6699
+ --tw-tracking: var(--cairn-tracking-eyebrow);
6700
+ letter-spacing: var(--cairn-tracking-eyebrow);
6701
+ }
6702
+
6703
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[var\(--cairn-tracking-tight\)\] {
6704
+ --tw-tracking: var(--cairn-tracking-tight);
6705
+ letter-spacing: var(--cairn-tracking-tight);
6706
+ }
6707
+
6551
6708
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-tight {
6552
6709
  --tw-tracking: var(--tracking-tight);
6553
6710
  letter-spacing: var(--tracking-tight);
@@ -6598,6 +6755,18 @@
6598
6755
  }
6599
6756
  }
6600
6757
 
6758
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[Npx\] {
6759
+ color: Npx;
6760
+ }
6761
+
6762
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[Nrem\] {
6763
+ color: Nrem;
6764
+ }
6765
+
6766
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[color\:var\(--cairn-muted\)\] {
6767
+ color: var(--cairn-muted);
6768
+ }
6769
+
6601
6770
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[var\(--cairn-error-ink\)\] {
6602
6771
  color: var(--cairn-error-ink);
6603
6772
  }
@@ -7358,6 +7527,10 @@
7358
7527
  }
7359
7528
  }
7360
7529
 
7530
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .focus\:top-\[var\(--cairn-space-s\)\]:focus {
7531
+ top: var(--cairn-space-s);
7532
+ }
7533
+
7361
7534
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .focus-visible\:border-\[color-mix\(in_oklab\,var\(--color-primary\)_70\%\,transparent\)\]:focus-visible {
7362
7535
  border-color: var(--color-primary);
7363
7536
  }
@@ -32,6 +32,11 @@ export interface AddressEntry {
32
32
  }
33
33
  /** Permalink to the distinct entries that resolve to it, across main and every open branch. */
34
34
  export type AddressIndex = Map<string, AddressEntry[]>;
35
+ /**
36
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
37
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
38
+ */
39
+ export declare function mainAddressIndex(manifest: Manifest): AddressIndex;
35
40
  /**
36
41
  * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
37
42
  * plus every open cairn/* branch (resolved from its edited markdown).
@@ -14,17 +14,11 @@ function push(index, permalink, entry) {
14
14
  index.set(permalink, [entry]);
15
15
  }
16
16
  /**
17
- * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
18
- * plus every open cairn/* branch (resolved from its edited markdown).
19
- *
20
- * The build fails open: a branch read that throws and a permalink that cannot resolve are both caught
21
- * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
22
- * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
17
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
18
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
23
19
  */
24
- export async function buildAddressIndex(repo, token, concepts, manifest) {
20
+ export function mainAddressIndex(manifest) {
25
21
  const index = new Map();
26
- // The main arm: the manifest already carries each entry's resolved permalink, so this is a pure
27
- // reverse map with no per-file read.
28
22
  for (const entry of manifest.entries) {
29
23
  push(index, entry.permalink, {
30
24
  concept: entry.concept,
@@ -33,6 +27,20 @@ export async function buildAddressIndex(repo, token, concepts, manifest) {
33
27
  source: 'main',
34
28
  });
35
29
  }
30
+ return index;
31
+ }
32
+ /**
33
+ * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
34
+ * plus every open cairn/* branch (resolved from its edited markdown).
35
+ *
36
+ * The build fails open: a branch read that throws and a permalink that cannot resolve are both caught
37
+ * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
38
+ * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
39
+ */
40
+ export async function buildAddressIndex(repo, token, concepts, manifest) {
41
+ // The main arm: the manifest already carries each entry's resolved permalink, so seed from the
42
+ // synchronous main-only index and union the branch arm on top.
43
+ const index = mainAddressIndex(manifest);
36
44
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
37
45
  // path is derivable from the branch name, so no tree-listing is needed.
38
46
  const names = await listBranches(repo, PENDING_PREFIX, token);