@glw907/cairn-cms 0.57.0 → 0.57.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.57.1
6
+
7
+ Media polish and cutover DX, the first follow-on after the `0.57.0` media stack. The Media Library
8
+ gains the action feedback it lacked: a delete, a rename, and a commit conflict now land on a strip
9
+ that confirms the result or shows the error, instead of a silent page. With the detail slide-over open
10
+ and focus in the search box, Escape now clears the search and leaves the panel open, rather than doing
11
+ both at once. A frontmatter hero marked decorative persists that choice as an additive `decorative` key
12
+ on the `image` object, so a deliberately decorative hero stops reading as needs-alt after a reload (a
13
+ decorative body image still cannot persist the choice, since markdown alt text has no slot for it). The
14
+ reserved-`figure` build error now names the colliding component and points at the fix.
15
+
16
+ The rest is documentation. The public media resolver wiring moved into the required media setup steps
17
+ in both the upgrade guide and the wire-the-delivery guide, since a published `media:` token ships bare
18
+ without it. The reserved-`figure` collision is now a prominent breaking callout. A new
19
+ [content authoring syntax reference](docs/reference/authoring-syntax.md) documents the `cairn:` and
20
+ `media:` token schemes together. The guides now show the `wrangler.toml` binding dialect, the
21
+ `@glw907/cairn-cms/media` import path, the empty-`media.json` bootstrap, and the `.site-main` re-scope
22
+ for the figure placement CSS.
23
+
24
+ No consumer action is required. The `decorative` key is additive and optional, so existing content
25
+ parses and builds unchanged, and the feedback strip, the Escape fix, and the registry error message
26
+ are admin or build-time with no public surface change.
27
+
5
28
  ## 0.57.0
6
29
 
7
30
  Images become first-class. An editor can paste, drag, or insert an image straight into a post, and
@@ -93,6 +116,21 @@ default, so a site serves full-size bytes until it opts in. The wiring steps are
93
116
  in [the media reference](docs/reference/media.md) and
94
117
  [the sveltekit reference](docs/reference/sveltekit.md).
95
118
 
119
+ Consumers must also wire the public media resolver for any public image. The bucket, route, and
120
+ `assets` block make media work for the editor, but a published `![](media:...)` (a body image or a
121
+ frontmatter hero) ships a bare token to the live page unless the site threads a resolver into the
122
+ render path and `createPublicRoutes`. Build one with
123
+ `makeMediaResolver(mediaManifest, normalizeAssets({ bucketBinding: 'MEDIA_BUCKET' }))` from
124
+ `@glw907/cairn-cms/media`, where `mediaManifest` is the committed `src/content/.cairn/media.json`
125
+ (create it as `{}` on a fresh site so the import resolves). The
126
+ [upgrade guide](docs/guides/upgrade-cairn.md) gives the full snippet.
127
+
128
+ Breaking: `figure` is now a reserved directive name. `defineRegistry` throws if a site registers a
129
+ component named `figure`, which hard-fails both `cairn-manifest` and the build. A custom `figure` that
130
+ the engine's built-in figure now covers should be removed so the site adopts the engine's; a `figure`
131
+ that does something else should be renamed. Check too for any hand-authored `:::figure` block in your
132
+ content, which now renders as an engine figure.
133
+
96
134
  Recommended, not required: regenerate the content manifest (`cairn-manifest`) and commit it so the
97
135
  Media Library's `main` where-used is accurate. The `mediaRefs` field is additive, so a site builds
98
136
  without it, but an un-regenerated manifest reads every published media reference as absent until it
@@ -62,6 +62,11 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
62
62
 
63
63
  let { data, form }: Props = $props();
64
64
 
65
+ // The success flash a redirected action carried back: a safe-delete or a metadata edit. The
66
+ // conflict error (data.flashError) renders in the inline error treatment below instead.
67
+ const FLASH_MESSAGE = { deleted: 'Asset deleted.', updated: 'Changes saved.' } as const;
68
+ const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
69
+
65
70
  // --- the per-hash usage facts the screen joins onto each asset ---
66
71
  /** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
67
72
  function usageCount(hash: string): number {
@@ -189,6 +194,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
189
194
  // The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
190
195
  // close (the non-modal region recipe: focus moves in on open, back to the origin on close).
191
196
  let panelOrigin: HTMLElement | null = null;
197
+ let panelEl = $state<HTMLElement | null>(null);
192
198
  let closeButton = $state<HTMLButtonElement | null>(null);
193
199
  let deleteDialog = $state<HTMLDialogElement | null>(null);
194
200
 
@@ -210,8 +216,11 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
210
216
  // Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
211
217
  // way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
212
218
  // dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
219
+ // Escape is also the native clear gesture for the toolbar's type="search" input, so the close
220
+ // fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
221
+ // panel exactly as the user left it, while an Escape with focus in the panel still closes it.
213
222
  function onWindowKeydown(e: KeyboardEvent) {
214
- if (e.key === 'Escape' && selected && !deleteDialog?.open) {
223
+ if (e.key === 'Escape' && selected && !deleteDialog?.open && panelEl?.contains(document.activeElement)) {
215
224
  e.preventDefault();
216
225
  closePanel();
217
226
  }
@@ -444,6 +453,16 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
444
453
  </button>
445
454
  </header>
446
455
 
456
+ <!-- The action feedback strip (the office flash grammar). A persistent polite live region carries
457
+ the success message, so an inserted-fresh element is announced reliably; the visible alert below
458
+ keeps its styling without a role. The strip never steals focus. -->
459
+ <div class="sr-only" aria-live="polite">{flashMessage}</div>
460
+ {#if flashMessage}
461
+ <div class="alert alert-success mb-4 text-sm">{flashMessage}</div>
462
+ {/if}
463
+ {#if data.flashError}
464
+ <div role="alert" class="alert alert-error mb-4 text-sm">{data.flashError}</div>
465
+ {/if}
447
466
  {#if data.error}
448
467
  <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
449
468
  {/if}
@@ -676,6 +695,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
676
695
  and focus returns to the originating tile or row (the region-with-focus-management recipe).
677
696
  Below the narrow breakpoint the same panel reads as a bottom sheet (the responsive treatment). -->
678
697
  <aside
698
+ bind:this={panelEl}
679
699
  role="region"
680
700
  aria-label="{asset.displayName} details"
681
701
  class="fixed inset-x-0 bottom-0 z-30 flex max-h-[85vh] flex-col rounded-t-2xl border-t border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)] sm:inset-x-auto sm:bottom-0 sm:right-0 sm:top-16 sm:max-h-none sm:w-[22rem] sm:rounded-t-none sm:border-l sm:border-t-0"
@@ -1572,6 +1572,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1572
1572
  bind:this={heroFieldRefs[field.name]}
1573
1573
  field={{ name: field.name, label: field.label }}
1574
1574
  value={heroValue}
1575
+ decorative={heroValue?.decorative ?? false}
1575
1576
  mediaLibrary={mediaLibrary}
1576
1577
  conceptId={data.conceptId}
1577
1578
  id={data.id}
@@ -2,14 +2,21 @@
2
2
  @component
3
3
  The hero image frontmatter field: the persistent details-panel field that sets a concept's lead
4
4
  picture, the one image that both leads the page and becomes the social card. It edits the structured
5
- value `{ src, alt, caption }` and writes it to three hidden form inputs the save path's decode arm
6
- reads. cairn stays markdown-first, so this is a structured-data form field, never a WYSIWYG canvas.
5
+ value `{ src, alt, caption, decorative }` and writes it to four hidden form inputs the save path's
6
+ decode arm reads. cairn stays markdown-first, so this is a structured-data form field, never a
7
+ WYSIWYG canvas.
7
8
 
8
9
  The field renders inside the edit form (the EditPage details loop). A nested <form> would break SSR,
9
10
  so the field carries no <form> of its own: the working inputs in the dialog (the alt input, the
10
- caption input) carry no name and never submit, and the committed value rides three named hidden
11
- inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies the dialog's working
12
- state into those hidden inputs; the form's own Save commits them.
11
+ caption input) carry no name and never submit, and the committed value rides four named hidden
12
+ inputs (`<name>.src`, `<name>.alt`, `<name>.caption`, `<name>.decorative`). "Use this image" copies
13
+ the dialog's working state into those hidden inputs; the form's own Save commits them.
14
+
15
+ The decorative choice persists for the frontmatter hero because the hero value is an object with a
16
+ slot for it. A reload then tells a deliberately decorative hero apart from a left-blank alt, so a
17
+ decorative hero no longer trips the needs-alt notice. A decorative body image (`![](media:...)`)
18
+ cannot persist the same choice, since markdown alt has no slot for it, so a decorative body image
19
+ still reads as needs-alt on reload.
13
20
 
14
21
  At rest, when a hero is set, the field is one row at sibling weight: the resolved thumbnail, the
15
22
  display name, an alt-status chip (Described, Needs alt, or Decorative, each a glyph plus a label,
@@ -49,7 +56,7 @@ popover's runUpload but resolves to this field, not an editor placeholder.
49
56
  /** The field descriptor: the form input name base and the visible label. */
50
57
  field: { name: string; label: string };
51
58
  /** The initial committed value, from `data.frontmatter[field.name]`. */
52
- value?: { src: string; alt: string; caption?: string };
59
+ value?: { src: string; alt: string; caption?: string; decorative?: boolean };
53
60
  /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
54
61
  * Defaults false; a fresh field with an empty alt reads as needs-alt. */
55
62
  decorative?: boolean;
@@ -444,12 +451,14 @@ popover's runUpload but resolves to this field, not an editor placeholder.
444
451
  </p>
445
452
  {/if}
446
453
 
447
- <!-- The committed value rides three named hidden inputs the save path's decode arm reads. They sit
454
+ <!-- The committed value rides four named hidden inputs the save path's decode arm reads. They sit
448
455
  inside the edit form (this component renders in the detailFields loop), so they submit; the
449
- dialog's working inputs carry no name and never submit. -->
456
+ dialog's working inputs carry no name and never submit. The decorative input persists the
457
+ explicit decorative choice so a reload tells it apart from a left-blank alt. -->
450
458
  <input type="hidden" name="{field.name}.src" value={committedSrc} />
451
459
  <input type="hidden" name="{field.name}.alt" value={committedAlt} />
452
460
  <input type="hidden" name="{field.name}.caption" value={committedCaption} />
461
+ <input type="hidden" name="{field.name}.decorative" value={committedDecorative ? 'true' : ''} />
453
462
  </div>
454
463
 
455
464
  <!-- The edit dialog: a native modal (focus trap and Escape for free). It sits at the end of the
@@ -11,6 +11,7 @@ interface Props {
11
11
  src: string;
12
12
  alt: string;
13
13
  caption?: string;
14
+ decorative?: boolean;
14
15
  };
15
16
  /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
16
17
  * Defaults false; a fresh field with an empty alt reads as needs-alt. */
@@ -36,14 +37,21 @@ interface Props {
36
37
  /**
37
38
  * The hero image frontmatter field: the persistent details-panel field that sets a concept's lead
38
39
  * picture, the one image that both leads the page and becomes the social card. It edits the structured
39
- * value `{ src, alt, caption }` and writes it to three hidden form inputs the save path's decode arm
40
- * reads. cairn stays markdown-first, so this is a structured-data form field, never a WYSIWYG canvas.
40
+ * value `{ src, alt, caption, decorative }` and writes it to four hidden form inputs the save path's
41
+ * decode arm reads. cairn stays markdown-first, so this is a structured-data form field, never a
42
+ * WYSIWYG canvas.
41
43
  *
42
44
  * The field renders inside the edit form (the EditPage details loop). A nested <form> would break SSR,
43
45
  * so the field carries no <form> of its own: the working inputs in the dialog (the alt input, the
44
- * caption input) carry no name and never submit, and the committed value rides three named hidden
45
- * inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies the dialog's working
46
- * state into those hidden inputs; the form's own Save commits them.
46
+ * caption input) carry no name and never submit, and the committed value rides four named hidden
47
+ * inputs (`<name>.src`, `<name>.alt`, `<name>.caption`, `<name>.decorative`). "Use this image" copies
48
+ * the dialog's working state into those hidden inputs; the form's own Save commits them.
49
+ *
50
+ * The decorative choice persists for the frontmatter hero because the hero value is an object with a
51
+ * slot for it. A reload then tells a deliberately decorative hero apart from a left-blank alt, so a
52
+ * decorative hero no longer trips the needs-alt notice. A decorative body image (`![](media:...)`)
53
+ * cannot persist the same choice, since markdown alt has no slot for it, so a decorative body image
54
+ * still reads as needs-alt on reload.
47
55
  *
48
56
  * At rest, when a hero is set, the field is one row at sibling weight: the resolved thumbnail, the
49
57
  * display name, an alt-status chip (Described, Needs alt, or Decorative, each a glyph plus a label,
@@ -37,6 +37,11 @@ export function frontmatterFromForm(fields, form) {
37
37
  const caption = String(form.get(`${field.name}.caption`) ?? '').trim();
38
38
  if (caption !== '')
39
39
  value.caption = caption;
40
+ // An explicit decorative choice persists so a reload tells it apart from a left-blank alt.
41
+ // The key is dropped otherwise to keep committed frontmatter minimal.
42
+ const decorative = String(form.get(`${field.name}.decorative`) ?? '');
43
+ if (decorative === 'true')
44
+ value.decorative = true;
40
45
  data[field.name] = value;
41
46
  break;
42
47
  }
@@ -89,6 +89,8 @@ export interface ImageValue {
89
89
  src: string;
90
90
  alt: string;
91
91
  caption?: string;
92
+ /** An explicit decorative choice: an empty alt that is not debt. Omitted unless true. */
93
+ decorative?: boolean;
92
94
  }
93
95
  /**
94
96
  * A validator's verdict. On success it carries the normalized frontmatter to commit; on
@@ -56,6 +56,10 @@ export function validateFields(fields, frontmatter) {
56
56
  const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
57
57
  if (caption !== '')
58
58
  normalized.caption = caption;
59
+ // An explicit decorative choice carries through; it is never required and never a save
60
+ // block. A missing or non-boolean value drops the key, like a blank caption.
61
+ if (obj.decorative === true)
62
+ normalized.decorative = true;
59
63
  data[field.name] = normalized;
60
64
  }
61
65
  }
@@ -17,7 +17,7 @@ function findIconField(def) {
17
17
  export function defineRegistry({ components }) {
18
18
  for (const c of components) {
19
19
  if (c.name === 'figure') {
20
- throw new Error('cairn: "figure" is a reserved directive name handled by the engine render step; a component cannot use it');
20
+ throw new Error(`cairn: component "${c.name}" uses "figure", a reserved directive name handled by the engine render step: remove it if the engine's built-in figure now covers your use, or rename it otherwise`);
21
21
  }
22
22
  if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
23
23
  throw new Error(`cairn: component "${c.name}" sets defaultIconByRole but declares no type:'icon' attribute, so the default icon can never render`);
@@ -128,7 +128,16 @@ export interface MediaLibraryData {
128
128
  assets: MediaLibraryEntry[];
129
129
  /** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
130
130
  usage: Record<string, MediaUsageInfo>;
131
+ /** The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
132
+ * load, distinct from a prior action's conflict error (see `flashError`), so a read failure and a
133
+ * redirected commit conflict never overwrite each other. */
131
134
  error: string | null;
135
+ /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
136
+ * `?updated=1`, null otherwise. The component renders a polite success strip for each. */
137
+ flash: 'deleted' | 'updated' | null;
138
+ /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
139
+ * its own slot rather than the degraded-load `error` above, so the two never collide. */
140
+ flashError: string | null;
132
141
  }
133
142
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
134
143
  export interface ContentEvent extends EventBase<GithubKeyEnv> {
@@ -219,12 +219,21 @@ export function createContentRoutes(runtime, deps = {}) {
219
219
  * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
220
220
  async function mediaLibraryLoad(event) {
221
221
  requireSession(event);
222
+ // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
223
+ // `?publishedAll` grammar: a deleted/updated success flag and a commit-conflict error. The
224
+ // conflict error rides its own slot so it never collides with the degraded-load `error` below.
225
+ let flash = null;
226
+ if (event.url.searchParams.get('deleted') === '1')
227
+ flash = 'deleted';
228
+ else if (event.url.searchParams.get('updated') === '1')
229
+ flash = 'updated';
230
+ const flashError = event.url.searchParams.get('error');
222
231
  let token;
223
232
  try {
224
233
  token = await mintToken(event.platform?.env ?? {});
225
234
  }
226
235
  catch {
227
- return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.' };
236
+ return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
228
237
  }
229
238
  // Union the media manifest by hash: main's rows first, then any branch hash not already present.
230
239
  // Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
@@ -253,7 +262,7 @@ export function createContentRoutes(runtime, deps = {}) {
253
262
  catch {
254
263
  // A wholesale read failure leaves whatever rows were already unioned; the screen lists them
255
264
  // with no usage overlay rather than failing.
256
- return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.' };
265
+ return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.', flash, flashError };
257
266
  }
258
267
  const assets = [...union.values()].map(mediaLibraryEntry);
259
268
  // Build the where-used overlay from main's content manifest plus the open branches. A failure
@@ -272,7 +281,7 @@ export function createContentRoutes(runtime, deps = {}) {
272
281
  catch {
273
282
  usage = {};
274
283
  }
275
- return { assets, usage, error: null };
284
+ return { assets, usage, error: null, flash, flashError };
276
285
  }
277
286
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
278
287
  async function createAction(event) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.57.0",
3
+ "version": "0.57.1",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -62,6 +62,11 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
62
62
 
63
63
  let { data, form }: Props = $props();
64
64
 
65
+ // The success flash a redirected action carried back: a safe-delete or a metadata edit. The
66
+ // conflict error (data.flashError) renders in the inline error treatment below instead.
67
+ const FLASH_MESSAGE = { deleted: 'Asset deleted.', updated: 'Changes saved.' } as const;
68
+ const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
69
+
65
70
  // --- the per-hash usage facts the screen joins onto each asset ---
66
71
  /** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
67
72
  function usageCount(hash: string): number {
@@ -189,6 +194,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
189
194
  // The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
190
195
  // close (the non-modal region recipe: focus moves in on open, back to the origin on close).
191
196
  let panelOrigin: HTMLElement | null = null;
197
+ let panelEl = $state<HTMLElement | null>(null);
192
198
  let closeButton = $state<HTMLButtonElement | null>(null);
193
199
  let deleteDialog = $state<HTMLDialogElement | null>(null);
194
200
 
@@ -210,8 +216,11 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
210
216
  // Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
211
217
  // way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
212
218
  // dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
219
+ // Escape is also the native clear gesture for the toolbar's type="search" input, so the close
220
+ // fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
221
+ // panel exactly as the user left it, while an Escape with focus in the panel still closes it.
213
222
  function onWindowKeydown(e: KeyboardEvent) {
214
- if (e.key === 'Escape' && selected && !deleteDialog?.open) {
223
+ if (e.key === 'Escape' && selected && !deleteDialog?.open && panelEl?.contains(document.activeElement)) {
215
224
  e.preventDefault();
216
225
  closePanel();
217
226
  }
@@ -444,6 +453,16 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
444
453
  </button>
445
454
  </header>
446
455
 
456
+ <!-- The action feedback strip (the office flash grammar). A persistent polite live region carries
457
+ the success message, so an inserted-fresh element is announced reliably; the visible alert below
458
+ keeps its styling without a role. The strip never steals focus. -->
459
+ <div class="sr-only" aria-live="polite">{flashMessage}</div>
460
+ {#if flashMessage}
461
+ <div class="alert alert-success mb-4 text-sm">{flashMessage}</div>
462
+ {/if}
463
+ {#if data.flashError}
464
+ <div role="alert" class="alert alert-error mb-4 text-sm">{data.flashError}</div>
465
+ {/if}
447
466
  {#if data.error}
448
467
  <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
449
468
  {/if}
@@ -676,6 +695,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
676
695
  and focus returns to the originating tile or row (the region-with-focus-management recipe).
677
696
  Below the narrow breakpoint the same panel reads as a bottom sheet (the responsive treatment). -->
678
697
  <aside
698
+ bind:this={panelEl}
679
699
  role="region"
680
700
  aria-label="{asset.displayName} details"
681
701
  class="fixed inset-x-0 bottom-0 z-30 flex max-h-[85vh] flex-col rounded-t-2xl border-t border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)] sm:inset-x-auto sm:bottom-0 sm:right-0 sm:top-16 sm:max-h-none sm:w-[22rem] sm:rounded-t-none sm:border-l sm:border-t-0"
@@ -1572,6 +1572,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1572
1572
  bind:this={heroFieldRefs[field.name]}
1573
1573
  field={{ name: field.name, label: field.label }}
1574
1574
  value={heroValue}
1575
+ decorative={heroValue?.decorative ?? false}
1575
1576
  mediaLibrary={mediaLibrary}
1576
1577
  conceptId={data.conceptId}
1577
1578
  id={data.id}
@@ -2,14 +2,21 @@
2
2
  @component
3
3
  The hero image frontmatter field: the persistent details-panel field that sets a concept's lead
4
4
  picture, the one image that both leads the page and becomes the social card. It edits the structured
5
- value `{ src, alt, caption }` and writes it to three hidden form inputs the save path's decode arm
6
- reads. cairn stays markdown-first, so this is a structured-data form field, never a WYSIWYG canvas.
5
+ value `{ src, alt, caption, decorative }` and writes it to four hidden form inputs the save path's
6
+ decode arm reads. cairn stays markdown-first, so this is a structured-data form field, never a
7
+ WYSIWYG canvas.
7
8
 
8
9
  The field renders inside the edit form (the EditPage details loop). A nested <form> would break SSR,
9
10
  so the field carries no <form> of its own: the working inputs in the dialog (the alt input, the
10
- caption input) carry no name and never submit, and the committed value rides three named hidden
11
- inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies the dialog's working
12
- state into those hidden inputs; the form's own Save commits them.
11
+ caption input) carry no name and never submit, and the committed value rides four named hidden
12
+ inputs (`<name>.src`, `<name>.alt`, `<name>.caption`, `<name>.decorative`). "Use this image" copies
13
+ the dialog's working state into those hidden inputs; the form's own Save commits them.
14
+
15
+ The decorative choice persists for the frontmatter hero because the hero value is an object with a
16
+ slot for it. A reload then tells a deliberately decorative hero apart from a left-blank alt, so a
17
+ decorative hero no longer trips the needs-alt notice. A decorative body image (`![](media:...)`)
18
+ cannot persist the same choice, since markdown alt has no slot for it, so a decorative body image
19
+ still reads as needs-alt on reload.
13
20
 
14
21
  At rest, when a hero is set, the field is one row at sibling weight: the resolved thumbnail, the
15
22
  display name, an alt-status chip (Described, Needs alt, or Decorative, each a glyph plus a label,
@@ -49,7 +56,7 @@ popover's runUpload but resolves to this field, not an editor placeholder.
49
56
  /** The field descriptor: the form input name base and the visible label. */
50
57
  field: { name: string; label: string };
51
58
  /** The initial committed value, from `data.frontmatter[field.name]`. */
52
- value?: { src: string; alt: string; caption?: string };
59
+ value?: { src: string; alt: string; caption?: string; decorative?: boolean };
53
60
  /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
54
61
  * Defaults false; a fresh field with an empty alt reads as needs-alt. */
55
62
  decorative?: boolean;
@@ -444,12 +451,14 @@ popover's runUpload but resolves to this field, not an editor placeholder.
444
451
  </p>
445
452
  {/if}
446
453
 
447
- <!-- The committed value rides three named hidden inputs the save path's decode arm reads. They sit
454
+ <!-- The committed value rides four named hidden inputs the save path's decode arm reads. They sit
448
455
  inside the edit form (this component renders in the detailFields loop), so they submit; the
449
- dialog's working inputs carry no name and never submit. -->
456
+ dialog's working inputs carry no name and never submit. The decorative input persists the
457
+ explicit decorative choice so a reload tells it apart from a left-blank alt. -->
450
458
  <input type="hidden" name="{field.name}.src" value={committedSrc} />
451
459
  <input type="hidden" name="{field.name}.alt" value={committedAlt} />
452
460
  <input type="hidden" name="{field.name}.caption" value={committedCaption} />
461
+ <input type="hidden" name="{field.name}.decorative" value={committedDecorative ? 'true' : ''} />
453
462
  </div>
454
463
 
455
464
  <!-- The edit dialog: a native modal (focus trap and Escape for free). It sits at the end of the
@@ -42,6 +42,10 @@ export function frontmatterFromForm(
42
42
  };
43
43
  const caption = String(form.get(`${field.name}.caption`) ?? '').trim();
44
44
  if (caption !== '') value.caption = caption;
45
+ // An explicit decorative choice persists so a reload tells it apart from a left-blank alt.
46
+ // The key is dropped otherwise to keep committed frontmatter minimal.
47
+ const decorative = String(form.get(`${field.name}.decorative`) ?? '');
48
+ if (decorative === 'true') value.decorative = true;
45
49
  data[field.name] = value;
46
50
  break;
47
51
  }
@@ -109,6 +109,8 @@ export interface ImageValue {
109
109
  src: string;
110
110
  alt: string;
111
111
  caption?: string;
112
+ /** An explicit decorative choice: an empty alt that is not debt. Omitted unless true. */
113
+ decorative?: boolean;
112
114
  }
113
115
 
114
116
  /**
@@ -60,6 +60,9 @@ export function validateFields(
60
60
  };
61
61
  const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
62
62
  if (caption !== '') normalized.caption = caption;
63
+ // An explicit decorative choice carries through; it is never required and never a save
64
+ // block. A missing or non-boolean value drops the key, like a blank caption.
65
+ if (obj.decorative === true) normalized.decorative = true;
63
66
  data[field.name] = normalized;
64
67
  }
65
68
  }
@@ -129,7 +129,7 @@ export function defineRegistry({ components }: { components: ComponentDef[] }):
129
129
  for (const c of components) {
130
130
  if (c.name === 'figure') {
131
131
  throw new Error(
132
- 'cairn: "figure" is a reserved directive name handled by the engine render step; a component cannot use it',
132
+ `cairn: component "${c.name}" uses "figure", a reserved directive name handled by the engine render step: remove it if the engine's built-in figure now covers your use, or rename it otherwise`,
133
133
  );
134
134
  }
135
135
  if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
@@ -152,7 +152,16 @@ export interface MediaLibraryData {
152
152
  assets: MediaLibraryEntry[];
153
153
  /** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
154
154
  usage: Record<string, MediaUsageInfo>;
155
+ /** The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
156
+ * load, distinct from a prior action's conflict error (see `flashError`), so a read failure and a
157
+ * redirected commit conflict never overwrite each other. */
155
158
  error: string | null;
159
+ /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
160
+ * `?updated=1`, null otherwise. The component renders a polite success strip for each. */
161
+ flash: 'deleted' | 'updated' | null;
162
+ /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
163
+ * its own slot rather than the degraded-load `error` above, so the two never collide. */
164
+ flashError: string | null;
156
165
  }
157
166
 
158
167
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -448,11 +457,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
448
457
  * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
449
458
  async function mediaLibraryLoad(event: ContentEvent): Promise<MediaLibraryData> {
450
459
  requireSession(event);
460
+ // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
461
+ // `?publishedAll` grammar: a deleted/updated success flag and a commit-conflict error. The
462
+ // conflict error rides its own slot so it never collides with the degraded-load `error` below.
463
+ let flash: MediaLibraryData['flash'] = null;
464
+ if (event.url.searchParams.get('deleted') === '1') flash = 'deleted';
465
+ else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
466
+ const flashError = event.url.searchParams.get('error');
451
467
  let token: string;
452
468
  try {
453
469
  token = await mintToken(event.platform?.env ?? {});
454
470
  } catch {
455
- return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.' };
471
+ return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
456
472
  }
457
473
 
458
474
  // Union the media manifest by hash: main's rows first, then any branch hash not already present.
@@ -484,7 +500,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
484
500
  } catch {
485
501
  // A wholesale read failure leaves whatever rows were already unioned; the screen lists them
486
502
  // with no usage overlay rather than failing.
487
- return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.' };
503
+ return { assets: [...union.values()].map(mediaLibraryEntry), usage: {}, error: 'Could not load media.', flash, flashError };
488
504
  }
489
505
  const assets = [...union.values()].map(mediaLibraryEntry);
490
506
 
@@ -504,7 +520,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
504
520
  usage = {};
505
521
  }
506
522
 
507
- return { assets, usage, error: null };
523
+ return { assets, usage, error: null, flash, flashError };
508
524
  }
509
525
 
510
526
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */