@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 +38 -0
- package/dist/components/CairnMediaLibrary.svelte +21 -1
- package/dist/components/EditPage.svelte +1 -0
- package/dist/components/MediaHeroField.svelte +17 -8
- package/dist/components/MediaHeroField.svelte.d.ts +13 -5
- package/dist/content/frontmatter.js +5 -0
- package/dist/content/types.d.ts +2 -0
- package/dist/content/validate.js +4 -0
- package/dist/render/registry.js +1 -1
- package/dist/sveltekit/content-routes.d.ts +9 -0
- package/dist/sveltekit/content-routes.js +12 -3
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +21 -1
- package/src/lib/components/EditPage.svelte +1 -0
- package/src/lib/components/MediaHeroField.svelte +17 -8
- package/src/lib/content/frontmatter.ts +4 -0
- package/src/lib/content/types.ts +2 -0
- package/src/lib/content/validate.ts +3 -0
- package/src/lib/render/registry.ts +1 -1
- package/src/lib/sveltekit/content-routes.ts +19 -3
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 `` (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
|
|
6
|
-
reads. cairn stays markdown-first, so this is a structured-data form field, never a
|
|
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
|
|
11
|
-
inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies
|
|
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 (``)
|
|
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
|
|
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
|
|
40
|
-
* reads. cairn stays markdown-first, so this is a structured-data form field, never a
|
|
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
|
|
45
|
-
* inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies
|
|
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 (``)
|
|
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
|
}
|
package/dist/content/types.d.ts
CHANGED
|
@@ -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
|
package/dist/content/validate.js
CHANGED
|
@@ -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
|
}
|
package/dist/render/registry.js
CHANGED
|
@@ -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(
|
|
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
|
@@ -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
|
|
6
|
-
reads. cairn stays markdown-first, so this is a structured-data form field, never a
|
|
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
|
|
11
|
-
inputs (`<name>.src`, `<name>.alt`, `<name>.caption`). "Use this image" copies
|
|
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 (``)
|
|
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
|
|
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
|
}
|
package/src/lib/content/types.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|