@glw907/cairn-cms 0.56.2 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +347 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/EditPage.svelte +347 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The combobox picker over the site's committed media library, read-only. The host (Task 6's insert
|
|
4
|
+
popover) passes the projected library in as a prop and receives the chosen asset through onselect,
|
|
5
|
+
which hands back the asset entry, its media: reference token, and the manifest alt to prefill the
|
|
6
|
+
placement.
|
|
7
|
+
|
|
8
|
+
This is a real WAI-ARIA combobox over a listbox: focus stays in the search input at all times, and
|
|
9
|
+
aria-activedescendant moves the active option through arrow keys. The input never loses DOM focus
|
|
10
|
+
during navigation, so a screen reader follows the active row through the input's owned listbox
|
|
11
|
+
rather than a roving tabindex. Two separate aria-live regions report the results count and narrate
|
|
12
|
+
the active row, so one announcement never clobbers the other.
|
|
13
|
+
|
|
14
|
+
Each row carries a decorative thumbnail (the bare delivery path under transformations: false), the
|
|
15
|
+
display name, and a needs-alt flag (a glyph plus a label, never hue alone) when the asset's alt is
|
|
16
|
+
empty. Search filters across the display name and the alt, case-insensitive.
|
|
17
|
+
|
|
18
|
+
The media-type facet (Images, Documents) is a designed-in seam: it renders only once the library
|
|
19
|
+
holds more than one distinct top-level content type, so the structure is present without dead UI
|
|
20
|
+
while a site stores images only.
|
|
21
|
+
-->
|
|
22
|
+
<script module lang="ts">
|
|
23
|
+
// The picker's library entry is the shared node-safe projection (../media/library-entry), not a
|
|
24
|
+
// type from editor-media.ts: importing that module would pull CodeMirror into a bundle, which the
|
|
25
|
+
// editor-boundary test bars. Re-exported so the insert popover keeps importing it from here.
|
|
26
|
+
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
27
|
+
export type { MediaLibraryEntry };
|
|
28
|
+
|
|
29
|
+
/** The picked asset the picker emits to its host: the library entry, its media: reference token,
|
|
30
|
+
* and the manifest alt to prefill the placement. */
|
|
31
|
+
export interface MediaSelection {
|
|
32
|
+
/** The chosen library entry. */
|
|
33
|
+
entry: MediaLibraryEntry;
|
|
34
|
+
/** The media: reference token (`media:<slug>.<hash>`) to commit at the caret. */
|
|
35
|
+
ref: string;
|
|
36
|
+
/** The asset's manifest alt, prefilling the placement; empty means the placement needs alt. */
|
|
37
|
+
alt: string;
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script lang="ts">
|
|
42
|
+
import { mediaToken } from '../media/reference.js';
|
|
43
|
+
// The bare delivery path under transformations: false (the same path the Task 3 source chip uses).
|
|
44
|
+
// SEAM: when transformations are on, the row thumbnail should request the `thumb` preset URL
|
|
45
|
+
// instead of the bare path; that is a later transformations-on refinement.
|
|
46
|
+
import { publicPath } from '../media/naming.js';
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
/** The committed media library projection, keyed by the 16-hex content hash. */
|
|
50
|
+
library: Record<string, MediaLibraryEntry>;
|
|
51
|
+
/** Emit the chosen asset to the host: the entry, its media: reference, and the manifest alt. */
|
|
52
|
+
onselect: (selection: MediaSelection) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let { library, onselect }: Props = $props();
|
|
56
|
+
|
|
57
|
+
// A stable id base so the listbox and each option carry unique ids the combobox can point at.
|
|
58
|
+
// $props.id() is Svelte's deterministic, hydration-stable id source (the same value SSR and the
|
|
59
|
+
// client), unlike a Math.random() base that would mismatch across hydration. It must initialize a
|
|
60
|
+
// top-level variable directly, so the prefix is composed in a second declaration.
|
|
61
|
+
const uid = $props.id();
|
|
62
|
+
const idBase = `cairn-mp-${uid}`;
|
|
63
|
+
const listboxId = `${idBase}-listbox`;
|
|
64
|
+
|
|
65
|
+
let query = $state('');
|
|
66
|
+
// The media-type facet selection: 'all' or a top-level content type ('image', 'application').
|
|
67
|
+
let typeFilter = $state<string>('all');
|
|
68
|
+
// The index of the active option within the filtered list, or -1 for none active yet.
|
|
69
|
+
let activeIndex = $state(-1);
|
|
70
|
+
|
|
71
|
+
const entries = $derived(Object.values(library));
|
|
72
|
+
|
|
73
|
+
// The distinct top-level content types in the library, in first-seen order. The facet is a seam:
|
|
74
|
+
// it renders only when more than one distinct type exists, so a site storing images only sees no
|
|
75
|
+
// dead UI. ('image/webp' and 'image/png' both fold to 'image'.)
|
|
76
|
+
const distinctTypes = $derived.by(() => {
|
|
77
|
+
const seen: string[] = [];
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const top = e.contentType.split('/')[0] ?? '';
|
|
80
|
+
if (top && !seen.includes(top)) seen.push(top);
|
|
81
|
+
}
|
|
82
|
+
return seen;
|
|
83
|
+
});
|
|
84
|
+
const showFacet = $derived(distinctTypes.length > 1);
|
|
85
|
+
|
|
86
|
+
/** The label for a top-level content type, for the facet chips. */
|
|
87
|
+
function typeLabel(top: string): string {
|
|
88
|
+
if (top === 'image') return 'Images';
|
|
89
|
+
if (top === 'application') return 'Documents';
|
|
90
|
+
return top.charAt(0).toUpperCase() + top.slice(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The filtered, displayed options: the type facet first, then a case-insensitive substring match
|
|
94
|
+
// across the display name and the alt. Order follows the library's insertion order.
|
|
95
|
+
const filtered = $derived.by(() => {
|
|
96
|
+
const q = query.trim().toLowerCase();
|
|
97
|
+
return entries.filter((e) => {
|
|
98
|
+
if (typeFilter !== 'all' && (e.contentType.split('/')[0] ?? '') !== typeFilter) return false;
|
|
99
|
+
if (!q) return true;
|
|
100
|
+
return (
|
|
101
|
+
e.displayName.toLowerCase().includes(q) || e.alt.toLowerCase().includes(q)
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Keep the active index in range as the filtered list narrows; a filter that drops the active row
|
|
107
|
+
// clears the active descendant rather than pointing at a gone option.
|
|
108
|
+
$effect(() => {
|
|
109
|
+
if (activeIndex >= filtered.length) activeIndex = filtered.length === 0 ? -1 : filtered.length - 1;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** The per-row option id, used by aria-activedescendant and the live narration. */
|
|
113
|
+
function optionId(i: number): string {
|
|
114
|
+
return `${idBase}-opt-${i}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const activeEntry = $derived(activeIndex >= 0 ? (filtered[activeIndex] ?? null) : null);
|
|
118
|
+
const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : undefined);
|
|
119
|
+
|
|
120
|
+
// The active-row narration text, kept in its own live region so it never clobbers the count.
|
|
121
|
+
const activeNarration = $derived(
|
|
122
|
+
activeEntry
|
|
123
|
+
? `${activeEntry.displayName}${activeEntry.alt.trim() === '' ? ', needs alt text' : ''}, ${activeIndex + 1} of ${filtered.length}`
|
|
124
|
+
: '',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** Build a selection from a library entry: its media: token and its manifest alt. */
|
|
128
|
+
function select(entry: MediaLibraryEntry) {
|
|
129
|
+
onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onKeydown(e: KeyboardEvent) {
|
|
133
|
+
if (e.key === 'ArrowDown') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
if (filtered.length === 0) return;
|
|
136
|
+
// Clamp at the last option (a deliberate non-wrap, fine per the task).
|
|
137
|
+
activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
|
|
138
|
+
} else if (e.key === 'ArrowUp') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
if (filtered.length === 0) return;
|
|
141
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
142
|
+
} else if (e.key === 'Home') {
|
|
143
|
+
if (filtered.length === 0) return;
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
activeIndex = 0;
|
|
146
|
+
} else if (e.key === 'End') {
|
|
147
|
+
if (filtered.length === 0) return;
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
activeIndex = filtered.length - 1;
|
|
150
|
+
} else if (e.key === 'Enter') {
|
|
151
|
+
if (activeEntry) {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
select(activeEntry);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Escape is handled by the host popover (Task 6); let it bubble.
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<div class="flex flex-col gap-3">
|
|
161
|
+
<!-- The media-type facet: a designed-in seam, rendered only past one distinct stored type. -->
|
|
162
|
+
{#if showFacet}
|
|
163
|
+
<div data-testid="cairn-mp-facet" class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by type">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="btn btn-xs {typeFilter === 'all' ? 'btn-primary' : 'btn-ghost'}"
|
|
167
|
+
aria-pressed={typeFilter === 'all'}
|
|
168
|
+
onclick={() => (typeFilter = 'all')}>All</button>
|
|
169
|
+
{#each distinctTypes as top (top)}
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
class="btn btn-xs {typeFilter === top ? 'btn-primary' : 'btn-ghost'}"
|
|
173
|
+
aria-pressed={typeFilter === top}
|
|
174
|
+
onclick={() => (typeFilter = top)}>{typeLabel(top)}</button>
|
|
175
|
+
{/each}
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
<!-- The combobox: focus stays in this input; aria-activedescendant tracks the active option. -->
|
|
180
|
+
<div class="flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
181
|
+
<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>
|
|
182
|
+
<input
|
|
183
|
+
bind:value={query}
|
|
184
|
+
onkeydown={onKeydown}
|
|
185
|
+
type="text"
|
|
186
|
+
role="combobox"
|
|
187
|
+
class="w-full border-0 bg-transparent p-0 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
|
|
188
|
+
placeholder="Search the media library"
|
|
189
|
+
aria-label="Search the media library"
|
|
190
|
+
aria-expanded={filtered.length > 0}
|
|
191
|
+
aria-controls={listboxId}
|
|
192
|
+
aria-activedescendant={activeDescendant}
|
|
193
|
+
autocomplete="off"
|
|
194
|
+
spellcheck="false"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- The count region: polite, separate from the active-row narration so neither clobbers the other. -->
|
|
199
|
+
<p class="sr-only" role="status" aria-live="polite">
|
|
200
|
+
{filtered.length} {filtered.length === 1 ? 'image' : 'images'}
|
|
201
|
+
</p>
|
|
202
|
+
<!-- The active-row narration, its own polite region. -->
|
|
203
|
+
<p class="sr-only" aria-live="polite">{activeNarration}</p>
|
|
204
|
+
|
|
205
|
+
<!-- The listbox always renders, even with no matches, so the combobox's aria-controls always
|
|
206
|
+
resolves to a real element (WCAG 1.3.1, 4.1.2): an aria-controls pointing at a node that does
|
|
207
|
+
not exist is a broken relationship. The no-match copy lives inside the listbox; the rows render
|
|
208
|
+
only when there are matches. The whole listbox is the input's owned popup, so a click on a row
|
|
209
|
+
selects without moving focus out of the input. -->
|
|
210
|
+
<ul id={listboxId} role="listbox" aria-label="Media library" class="flex max-h-72 flex-col gap-0.5 overflow-auto p-0">
|
|
211
|
+
{#if filtered.length === 0}
|
|
212
|
+
<li class="flex flex-col items-center gap-2 px-6 py-10 text-center">
|
|
213
|
+
<p class="text-sm text-[var(--color-muted)]">
|
|
214
|
+
{#if entries.length === 0}
|
|
215
|
+
No images in the library yet.
|
|
216
|
+
{:else}
|
|
217
|
+
Nothing matches <span class="font-medium text-base-content">"{query.trim()}"</span>.
|
|
218
|
+
{/if}
|
|
219
|
+
</p>
|
|
220
|
+
</li>
|
|
221
|
+
{:else}
|
|
222
|
+
{#each filtered as entry, i (entry.hash)}
|
|
223
|
+
<!-- A listbox option carries no keyboard handler of its own: in the ARIA combobox pattern the
|
|
224
|
+
keyboard model lives on the input (arrows move aria-activedescendant, Enter selects), and
|
|
225
|
+
focus never leaves the input. The click handler is the pointer path to the same select. -->
|
|
226
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
227
|
+
<li
|
|
228
|
+
id={optionId(i)}
|
|
229
|
+
role="option"
|
|
230
|
+
aria-selected={i === activeIndex}
|
|
231
|
+
class="flex cursor-pointer items-start gap-3 rounded-field px-2 py-2 {i === activeIndex
|
|
232
|
+
? 'bg-base-content/[0.08]'
|
|
233
|
+
: 'hover:bg-base-content/[0.04]'}"
|
|
234
|
+
onclick={() => select(entry)}
|
|
235
|
+
>
|
|
236
|
+
<img
|
|
237
|
+
src={publicPath(entry.slug, entry.hash, entry.ext, 'slug')}
|
|
238
|
+
alt=""
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
class="h-10 w-10 flex-none rounded-box border border-[var(--cairn-card-border)] object-cover"
|
|
241
|
+
/>
|
|
242
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
243
|
+
<span class="truncate text-sm font-medium">{entry.displayName || entry.slug || entry.hash}</span>
|
|
244
|
+
{#if entry.alt.trim() === ''}
|
|
245
|
+
<!-- The needs-alt flag: a glyph plus a label, never hue alone (the spec a11y rule),
|
|
246
|
+
matching the Task 3 source-chip treatment. -->
|
|
247
|
+
<span class="inline-flex items-center gap-1 text-xs font-medium text-[var(--cairn-warning-ink)]">
|
|
248
|
+
<span aria-hidden="true">⚠</span>
|
|
249
|
+
<span>Needs alt</span>
|
|
250
|
+
</span>
|
|
251
|
+
{/if}
|
|
252
|
+
</span>
|
|
253
|
+
</li>
|
|
254
|
+
{/each}
|
|
255
|
+
{/if}
|
|
256
|
+
</ul>
|
|
257
|
+
</div>
|
|
@@ -13,3 +13,15 @@ export { default as SunIcon } from '@lucide/svelte/icons/sun';
|
|
|
13
13
|
export { default as MoonIcon } from '@lucide/svelte/icons/moon';
|
|
14
14
|
export { default as ChevronLeftIcon } from '@lucide/svelte/icons/chevron-left';
|
|
15
15
|
export { default as ChevronRightIcon } from '@lucide/svelte/icons/chevron-right';
|
|
16
|
+
export { default as ChevronDownIcon } from '@lucide/svelte/icons/chevron-down';
|
|
17
|
+
export { default as UploadIcon } from '@lucide/svelte/icons/upload';
|
|
18
|
+
export { default as LayoutGridIcon } from '@lucide/svelte/icons/layout-grid';
|
|
19
|
+
export { default as ListIcon } from '@lucide/svelte/icons/list';
|
|
20
|
+
export { default as ImageOffIcon } from '@lucide/svelte/icons/image-off';
|
|
21
|
+
export { default as CheckIcon } from '@lucide/svelte/icons/check';
|
|
22
|
+
export { default as TriangleAlertIcon } from '@lucide/svelte/icons/triangle-alert';
|
|
23
|
+
export { default as XIcon } from '@lucide/svelte/icons/x';
|
|
24
|
+
export { default as CopyIcon } from '@lucide/svelte/icons/copy';
|
|
25
|
+
export { default as FileTextIcon } from '@lucide/svelte/icons/file-text';
|
|
26
|
+
export { default as ClockIcon } from '@lucide/svelte/icons/clock';
|
|
27
|
+
export { default as Link2OffIcon } from '@lucide/svelte/icons/link-2-off';
|
|
@@ -83,8 +83,29 @@
|
|
|
83
83
|
--color-success-content: oklch(98% 0.012 150);
|
|
84
84
|
--color-warning: oklch(75% 0.15 70);
|
|
85
85
|
--color-warning-content: oklch(26% 0.05 70);
|
|
86
|
+
/* The on-surface warning INK, for small warning TEXT (the needs-alt label and the editor chip's
|
|
87
|
+
needs-alt marker). --color-warning is tuned as a FILL behind dark content, so as small text on a
|
|
88
|
+
light surface it sits near 2.2:1, well under the 4.5:1 AA floor (WCAG 1.4.3). This darker ink is
|
|
89
|
+
the readable text counterpart, mirroring how --color-accent is locked for on-surface text.
|
|
90
|
+
Locked pair: on base-100 measures 5.98:1, on the 8% accent chip tint the source chip uses 5.59:1.
|
|
91
|
+
Do not lighten without re-checking both. */
|
|
92
|
+
--cairn-warning-ink: oklch(50% 0.13 70);
|
|
93
|
+
/* The on-surface POSITIVE ink, for small confirming TEXT (the hero field's "Described" alt-status
|
|
94
|
+
chip). The green counterpart to the warning ink, tuned as readable small text on a light surface
|
|
95
|
+
rather than a fill. Locked pair: on base-100 measures ~4.9:1, clearing the 4.5:1 AA floor (WCAG
|
|
96
|
+
1.4.3). Do not lighten without re-checking. */
|
|
97
|
+
--color-positive-ink: oklch(48% 0.12 150);
|
|
86
98
|
--color-error: oklch(56% 0.2 25);
|
|
87
99
|
--color-error-content: oklch(98% 0.012 25);
|
|
100
|
+
/* The danger family for the safe-delete dialog: the on-surface error INK (the "these would break"
|
|
101
|
+
label and the danger-soft Delete button text), a soft error TINT (the break-list surface), and a
|
|
102
|
+
danger BORDER (the tint's hairline and the type-to-confirm input). --color-error is tuned as a
|
|
103
|
+
button fill, so the ink is the readable small-text counterpart. Locked: the ink on base-100
|
|
104
|
+
measures ~5.2:1 and on the error tint ~4.9:1, clearing the 4.5:1 AA floor (WCAG 1.4.3). Do not
|
|
105
|
+
lighten the ink or darken the tint without re-checking both. */
|
|
106
|
+
--cairn-error-ink: oklch(50% 0.19 25);
|
|
107
|
+
--cairn-error-tint: oklch(96% 0.03 25);
|
|
108
|
+
--cairn-error-border: oklch(85% 0.06 25);
|
|
88
109
|
|
|
89
110
|
/* Accessible muted text tones: >= 4.5:1 contrast on base-100/base-200. */
|
|
90
111
|
--color-muted: oklch(48% 0.01 75);
|
|
@@ -180,8 +201,24 @@
|
|
|
180
201
|
--color-success-content: oklch(20% 0.04 150);
|
|
181
202
|
--color-warning: oklch(80% 0.14 70);
|
|
182
203
|
--color-warning-content: oklch(24% 0.05 70);
|
|
204
|
+
/* The on-surface warning INK on dark, for small warning TEXT (the needs-alt label and the editor
|
|
205
|
+
chip's marker). The dark warning fill already reads on its dark surfaces, so this ink keeps a
|
|
206
|
+
value near it. Locked pair: on dark base-100 measures 8.61:1, on the 8% accent chip tint 6.20:1.
|
|
207
|
+
Do not darken without re-checking both. */
|
|
208
|
+
--cairn-warning-ink: oklch(80% 0.14 70);
|
|
209
|
+
/* The on-surface POSITIVE ink on dark, for the hero field's "Described" alt-status chip. The dark
|
|
210
|
+
counterpart to the warning ink. Locked pair: on dark base-100 measures ~7:1. Do not darken
|
|
211
|
+
without re-checking. */
|
|
212
|
+
--color-positive-ink: oklch(78% 0.12 150);
|
|
183
213
|
--color-error: oklch(70% 0.18 25);
|
|
184
214
|
--color-error-content: oklch(20% 0.04 25);
|
|
215
|
+
/* The danger family on dark, the counterpart to the light root's. The ink reads as small danger
|
|
216
|
+
text on the dark bases and on the dark error tint; the tint and border carry the safe-delete
|
|
217
|
+
dialog's surfaces. Locked: the ink on dark base-100 measures ~7:1 and on the dark error tint
|
|
218
|
+
~5.3:1. Do not darken the ink or lighten the tint without re-checking both. */
|
|
219
|
+
--cairn-error-ink: oklch(78% 0.15 25);
|
|
220
|
+
--cairn-error-tint: oklch(28% 0.06 25);
|
|
221
|
+
--cairn-error-border: oklch(40% 0.09 25);
|
|
185
222
|
|
|
186
223
|
/* Accessible muted text tones on the dark bases. */
|
|
187
224
|
--color-muted: oklch(72% 0.01 75);
|