@glw907/cairn-cms 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/components/ConceptList.svelte +216 -75
  3. package/dist/components/ConceptList.svelte.d.ts +6 -4
  4. package/dist/components/MarkdownEditor.svelte +64 -45
  5. package/dist/components/cairn-admin.css +50 -0
  6. package/dist/components/editor-folding.d.ts +4 -3
  7. package/dist/components/editor-folding.js +129 -97
  8. package/dist/components/editor-highlight.js +1 -13
  9. package/dist/content/concepts.js +3 -1
  10. package/dist/content/manifest.d.ts +1 -0
  11. package/dist/content/manifest.js +6 -0
  12. package/dist/content/types.d.ts +5 -0
  13. package/dist/delivery/content-index.js +1 -1
  14. package/dist/delivery/data.d.ts +1 -1
  15. package/dist/delivery/data.js +1 -1
  16. package/dist/sveltekit/content-routes.d.ts +6 -0
  17. package/dist/sveltekit/content-routes.js +11 -6
  18. package/dist/sveltekit/index.d.ts +1 -0
  19. package/dist/vite/index.d.ts +6 -4
  20. package/dist/vite/index.js +11 -7
  21. package/dist/vite/resolve-root.d.ts +16 -0
  22. package/dist/vite/resolve-root.js +16 -0
  23. package/package.json +2 -1
  24. package/src/lib/components/ConceptList.svelte +216 -75
  25. package/src/lib/components/MarkdownEditor.svelte +64 -45
  26. package/src/lib/components/editor-folding.ts +137 -104
  27. package/src/lib/components/editor-highlight.ts +0 -12
  28. package/src/lib/content/concepts.ts +3 -1
  29. package/src/lib/content/manifest.ts +7 -0
  30. package/src/lib/content/types.ts +5 -0
  31. package/src/lib/delivery/content-index.ts +1 -1
  32. package/src/lib/delivery/data.ts +1 -1
  33. package/src/lib/sveltekit/content-routes.ts +17 -6
  34. package/src/lib/sveltekit/index.ts +2 -0
  35. package/src/lib/vite/index.ts +11 -7
  36. package/src/lib/vite/resolve-root.ts +24 -0
  37. /package/dist/{delivery → content}/excerpt.d.ts +0 -0
  38. /package/dist/{delivery → content}/excerpt.js +0 -0
  39. /package/src/lib/{delivery → content}/excerpt.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.56.0
6
+
7
+ Two passes ship together: the markdown editor's folding gets a proper home, and the engine's gates,
8
+ tooling, and docs harden.
9
+
10
+ The editor folds directive containers (`:::name` blocks), and the fold control now lives in a real
11
+ gutter column to the left of the text rather than a chevron hidden in the line. At rest the gutter is
12
+ empty; the chevron reveals when you hover the gutter cell, stays while a block is folded, and shows
13
+ while the caret is inside a block. The control is a real button now, so folding is reachable by
14
+ keyboard and screen reader, where before only unfolding was. The folded-row tint and the "N lines"
15
+ pill carry over unchanged, and the fold scope is the same: directive containers only.
16
+
17
+ For consumers, two additive surface touches from the tooling pass. A concept can now set an optional
18
+ `singular` label, so the create affordances read "New post" instead of "New Posts"; it defaults to the
19
+ concept's `label`, so a concept that sets nothing is unchanged. And `AuthEnv` is now exported from
20
+ `@glw907/cairn-cms/sveltekit` as well as the root, so the `app.d.ts` Platform block can import it from
21
+ the subpath the auth helpers live on (the deploy guide now shows that block verbatim).
22
+
23
+ The rest hardens the engine's own gates and docs. A new `check:reference:signatures` gate compares
24
+ each reference page's declared type signature against the export's real type, so a stale signature in
25
+ an existing page is caught (it found and fixed two on its first run). A plain-Node dist-spawn test
26
+ rot-proofs the `/delivery/data` node-safety guarantee, an admin-shell DOM check guards the drawer
27
+ layout against a silent scoping regression, and the `cairn-manifest` bin now resolves the Vite root
28
+ from the loaded config rather than the current directory. A docs sweep documents the preview frame's
29
+ dual stylesheet emission, the `cairnManifest`-derived `cairn-doctor` inputs, the prerender policy for
30
+ the feed routes, and an interim security contact.
31
+
32
+ No consumer action: every change is additive, the `singular` field is optional, and the folding
33
+ redesign is internal to the admin editor.
34
+
35
+ ## 0.55.0
36
+
37
+ The office list rises to the gold standard. The post and page list gains a triage filter layer and
38
+ self-describing rows, so a concept with a handful of entries reads as content rather than a few bare
39
+ titles.
40
+
41
+ Above the list, a triage bar filters by publish state in the admin's segmented check-and-tint
42
+ grammar: All, Pending edits (the entries on a `cairn/` branch, whether branch-only or live with held
43
+ edits), and Published, each with a live count, plus an orthogonal Hidden toggle for the draft
44
+ entries. The counts come from the loaded set, so they are exact, and the filtering runs client-side
45
+ over the entries already in hand. Search composes with the active filter.
46
+
47
+ Each row now describes itself. A summary line sits under the title, drawn from the entry's
48
+ description or, lacking one, a short excerpt of its body. The Edited badge tints in the brand violet
49
+ as the one state to act on, mirroring the "Publish site (N)" count; Hidden reads as a de-emphasized
50
+ row with an eye-off tag rather than a competing badge; and the foot of the list carries a quiet
51
+ "New" row so a short list always shows its next step. A concept with no entries centers its empty
52
+ state on the page.
53
+
54
+ One data change feeds the rows: the content manifest now indexes a per-entry `summary`, built by the
55
+ same excerpt helper the public delivery already uses. Because the manifest is verified whole-string,
56
+ a site's committed manifest is stale until it is regenerated once.
57
+
58
+ Consumers must: regenerate the content manifest (`npm run cairn:manifest` or `npx cairn-manifest`,
59
+ then commit). The `cairnManifest` build fails closed until the regenerated manifest with the new
60
+ `summary` keys is committed.
61
+
5
62
  ## 0.54.0
6
63
 
7
64
  The editor takes the shell. On an edit route the page is now one context, the desk: the edit page's
@@ -1,9 +1,11 @@
1
1
  <!--
2
2
  @component
3
- One concept's list view as a DaisyUI data-table: a search filter, a result count, sortable Title and
4
- Date headers, a status badge, a formatted date, and client-side pagination with a page-size control.
5
- Filtering, sorting, and paging run over the loaded entries in component state, which suits typical
6
- content sizes. The header New button opens a dialog holding the create form.
3
+ One concept's list view, dressed to the office gold standard. A triage bar partitions by publish
4
+ state (a bordered segmented control with live counts) beside an orthogonal Hidden toggle. The list
5
+ is an enriched sortable table: each row carries a title with a muted summary sub-line, the date, the
6
+ publish-state badge, and a delete action. A draft row de-emphasizes and carries an eye-off Hidden
7
+ tag by the title. A trailing New row at the foot of the card opens the same create dialog as the
8
+ header button. Filtering, sorting, and paging run over the loaded entries in component state.
7
9
  -->
8
10
  <script lang="ts">
9
11
  import { slugify } from '../content/ids.js';
@@ -12,6 +14,7 @@ content sizes. The header New button opens a dialog holding the create form.
12
14
  import DeleteDialog from './DeleteDialog.svelte';
13
15
  import CairnLogo from './CairnLogo.svelte';
14
16
  import { SearchIcon, ArrowUpIcon, ArrowDownIcon, ChevronsUpDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon, Trash2Icon } from './admin-icons.js';
17
+ import EyeOffIcon from '@lucide/svelte/icons/eye-off';
15
18
 
16
19
  interface Props {
17
20
  /** The list load's data: the concept, its entries, and any inline or form errors. */
@@ -31,7 +34,14 @@ content sizes. The header New button opens a dialog holding the create form.
31
34
  );
32
35
 
33
36
  type SortKey = 'title' | 'date';
37
+ // The triage runs on two independent axes. A pick-one publish-state partition (`all` passes
38
+ // everything, `pending` is new + edited, `published` is live-as-is) and a separate Hidden
39
+ // toggle that composes with it. They are orthogonal: a published-but-hidden entry is on main
40
+ // (so it counts as Published) and also hidden from the public site (so it counts as Hidden).
41
+ type Partition = 'all' | 'pending' | 'published';
34
42
  let query = $state('');
43
+ let partition = $state<Partition>('all');
44
+ let hiddenOnly = $state(false);
35
45
  let sortKey = $state<SortKey>('date');
36
46
  // Newest first by default: a dated concept reads most-recent-on-top, the usual CMS convention.
37
47
  let sortAsc = $state(false);
@@ -45,10 +55,82 @@ content sizes. The header New button opens a dialog holding the create form.
45
55
  return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
46
56
  }
47
57
 
58
+ // Triage counts over the full loaded set, each axis counted independently. Pending is new +
59
+ // edited (status !== 'published'); Published is live-as-is (status === 'published'); Hidden is
60
+ // the draft rows. The axes overlap: a published-but-hidden entry counts in BOTH Published and
61
+ // Hidden. All is the unconditional total.
62
+ const counts = $derived({
63
+ all: data.entries.length,
64
+ pending: data.entries.filter((e) => e.status !== 'published').length,
65
+ published: data.entries.filter((e) => e.status === 'published').length,
66
+ hidden: data.entries.filter((e) => e.draft).length,
67
+ });
68
+
69
+ // The three publish-state segments, in display order. Each names its partition value, its label,
70
+ // and the count axis it shows; the markup loops this so the segments share one block.
71
+ const segments: { value: Partition; label: string; count: () => number }[] = [
72
+ { value: 'all', label: 'All', count: () => counts.all },
73
+ { value: 'pending', label: 'Pending edits', count: () => counts.pending },
74
+ { value: 'published', label: 'Published', count: () => counts.published },
75
+ ];
76
+
77
+ function matchesPartition(entry: EntrySummary): boolean {
78
+ switch (partition) {
79
+ case 'pending':
80
+ return entry.status !== 'published';
81
+ case 'published':
82
+ return entry.status === 'published';
83
+ default:
84
+ return true;
85
+ }
86
+ }
87
+
88
+ // Compose the partition and the Hidden axis with the search query; sort and paging run
89
+ // downstream. Hidden is orthogonal: when on, it narrows the partition to its draft rows.
48
90
  const filtered = $derived(
49
- data.entries.filter((e) => e.title.toLowerCase().includes(query.trim().toLowerCase())),
91
+ data.entries.filter(
92
+ (e) =>
93
+ matchesPartition(e) &&
94
+ (!hiddenOnly || e.draft === true) &&
95
+ e.title.toLowerCase().includes(query.trim().toLowerCase()),
96
+ ),
50
97
  );
51
98
 
99
+ function setPartition(next: Partition) {
100
+ partition = next;
101
+ page = 1;
102
+ }
103
+
104
+ function toggleHidden() {
105
+ hiddenOnly = !hiddenOnly;
106
+ page = 1;
107
+ }
108
+
109
+ function clearSearch() {
110
+ query = '';
111
+ page = 1;
112
+ }
113
+
114
+ // The triage controls dress to the established footer grammar (the design system's segmented /
115
+ // check-and-tint recipe). Each helper returns a verbatim Tailwind string so the admin CSS
116
+ // build's @source scan reads the utilities whole. The scoped button reset (cairn-admin.css)
117
+ // already strips UA chrome from these bare buttons.
118
+ //
119
+ // A segment of the bordered publish-state control: the shared group border carries the pick-one
120
+ // semantics, so a segment stays borderless; the active one tints and bolds.
121
+ function segButtonClass(pressed: boolean): string {
122
+ return `inline-flex items-center gap-1.5 px-3 py-1 text-[0.8125rem] font-normal ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
123
+ }
124
+ // The standalone Hidden toggle: rounded, transparent until hover, check-and-tint when pressed.
125
+ function hiddenToggleClass(pressed: boolean): string {
126
+ return `inline-flex items-center gap-1.5 rounded-lg px-3 py-1 text-[0.8125rem] font-normal hover:bg-base-content/[0.06] ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
127
+ }
128
+ // Hidden is a row treatment: a draft row de-emphasizes its TITLE by opacity (the title is
129
+ // high-contrast base-content, so it stays above the AA text floor when dimmed). The already-muted
130
+ // summary line is left at full strength: stacking opacity on muted text drops it below 4.5:1 in
131
+ // the light theme, so the dimmed title plus the eye-off tag carry the "hidden" read instead.
132
+ const draftDim = 'opacity-[0.62]';
133
+
52
134
  // Sort key for one entry: the lowercased title, or the ISO date string (lexical order is
53
135
  // chronological). A null date sorts as the empty string.
54
136
  function sortValue(entry: EntrySummary): string {
@@ -101,6 +183,10 @@ content sizes. The header New button opens a dialog holding the create form.
101
183
  });
102
184
  const derivedSlug = $derived(slugEdited ? slug : slugify(title));
103
185
  const slugPlaceholder = $derived(data.dated ? 'my-entry' : 'about-us');
186
+ // The create affordances name one new item, so they read in the singular ("New post"). The
187
+ // descriptor resolves `singular` (defaulting it to the label), so the fallback here only guards an
188
+ // older caller that ships no `singular` on its ListData.
189
+ const createNoun = $derived(data.singular ?? data.label);
104
190
 
105
191
  // Shared column-header typography: small uppercase muted labels. The sort buttons add their own
106
192
  // flex layout and a hover affordance on top of this.
@@ -116,6 +202,12 @@ content sizes. The header New button opens a dialog holding the create form.
116
202
  );
117
203
  </script>
118
204
 
205
+ <!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
206
+ renders only inside the active segment or toggle, so hue never carries the state alone. -->
207
+ {#snippet check()}
208
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
209
+ {/snippet}
210
+
119
211
  <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
120
212
  <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.label}</h1>
121
213
  <div class="flex items-center gap-3 sm:flex-1 sm:flex-wrap sm:justify-end">
@@ -124,7 +216,7 @@ content sizes. The header New button opens a dialog holding the create form.
124
216
  <input type="search" aria-label="Search {data.label}" bind:value={query} placeholder="Search {data.label.toLowerCase()}" oninput={() => (page = 1)} />
125
217
  </label>
126
218
  <button type="button" class="btn btn-primary btn-sm shrink-0" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
127
- <PlusIcon class="h-4 w-4" /> New {data.label}
219
+ <PlusIcon class="h-4 w-4" /> New {createNoun}
128
220
  </button>
129
221
  </div>
130
222
  </header>
@@ -159,82 +251,131 @@ content sizes. The header New button opens a dialog holding the create form.
159
251
  </div>
160
252
  {/if}
161
253
 
162
- <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
163
- {#if data.entries.length === 0}
164
- <div class="flex flex-col items-center gap-4 px-6 py-16 text-center">
165
- <CairnLogo class="h-12 w-12 text-primary opacity-30" />
166
- <div class="space-y-1">
167
- <p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
168
- <p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
169
- </div>
170
- <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
171
- <PlusIcon class="h-4 w-4" /> New {data.label}
172
- </button>
254
+ {#if data.entries.length > 0}
255
+ <!-- The triage filters on two independent axes, dressed to the footer grammar. The publish-state
256
+ partition is one bordered segmented control: the shared border carries the pick-one semantics
257
+ and the active segment tints with a check (the non-color cue, WCAG 1.4.1). The Hidden toggle
258
+ is a separate standalone check-and-tint toggle that composes with the active partition. Each
259
+ count dims to muted when zero, so a sparse list never jumps. -->
260
+ <div class="mb-4 flex flex-wrap items-center gap-3">
261
+ <div role="group" aria-label="Filter by publish state" class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]">
262
+ {#each segments as seg, i (seg.value)}
263
+ <button type="button" class="{segButtonClass(partition === seg.value)} {i > 0 ? 'border-l border-[var(--cairn-card-border)]' : ''}" aria-pressed={partition === seg.value} onclick={() => setPartition(seg.value)}>
264
+ {#if partition === seg.value}{@render check()}{/if}
265
+ {seg.label}<span class="tabular-nums">{seg.count()}</span>
266
+ </button>
267
+ {/each}
173
268
  </div>
174
- {:else if sorted.length === 0}
175
- <div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
176
- <SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
177
- <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
269
+ <span class="h-5 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></span>
270
+ <button type="button" class={hiddenToggleClass(hiddenOnly)} aria-pressed={hiddenOnly} onclick={toggleHidden}>
271
+ {#if hiddenOnly}{@render check()}{/if}
272
+ Hidden<span class="tabular-nums">{counts.hidden}</span>
273
+ </button>
274
+ </div>
275
+ {/if}
276
+
277
+ {#if data.entries.length === 0}
278
+ <!-- The empty state owns the content area (no card): the cairn mark, concept-named copy, and the
279
+ create CTA centered on a tall fill, so a first-run office reads as composed. -->
280
+ <div class="flex min-h-[56vh] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
281
+ <CairnLogo class="h-12 w-12 text-primary opacity-30" />
282
+ <div class="space-y-1">
283
+ <p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
284
+ <p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
178
285
  </div>
179
- {:else}
180
- <table class="table">
181
- <thead>
182
- <tr class="border-base-300">
183
- <th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
184
- <button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
185
- Title
186
- {#if sortKey === 'title'}
187
- {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
188
- {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
189
- </button>
190
- </th>
191
- {#if data.dated}
192
- <th class="hidden sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
193
- <button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
194
- Date
195
- {#if sortKey === 'date'}
286
+ <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
287
+ <PlusIcon class="h-4 w-4" /> New {createNoun}
288
+ </button>
289
+ </div>
290
+ {:else}
291
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
292
+ {#if sorted.length === 0}
293
+ <!-- A filter or a search narrowed the list to zero; the entries exist, none match. Offer the
294
+ way back: a search query clears, a filter is named in the copy. -->
295
+ <div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
296
+ <SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
297
+ {#if query.trim()}
298
+ <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
299
+ <button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={clearSearch}>Clear search</button>
300
+ {:else}
301
+ <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match this filter.</p>
302
+ {/if}
303
+ </div>
304
+ {:else}
305
+ <table class="table">
306
+ <thead>
307
+ <tr class="border-base-300">
308
+ <th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
309
+ <button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
310
+ Title
311
+ {#if sortKey === 'title'}
196
312
  {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
197
313
  {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
198
314
  </button>
199
315
  </th>
200
- {/if}
201
- <th class={headerLabel}>Status</th>
202
- <th class="text-right"><span class="sr-only">Actions</span></th>
203
- </tr>
204
- </thead>
205
- <tbody>
206
- {#each pageRows as entry (entry.id)}
207
- <tr class="transition-colors hover:bg-base-200/60">
208
- <td><a class="font-medium hover:text-primary hover:underline" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a></td>
209
- {#if data.dated}<td class="hidden text-sm text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
210
- <td>
211
- <div class="flex flex-wrap items-center gap-1">
316
+ {#if data.dated}
317
+ <th class="hidden w-28 sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
318
+ <button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
319
+ Date
320
+ {#if sortKey === 'date'}
321
+ {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
322
+ {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
323
+ </button>
324
+ </th>
325
+ {/if}
326
+ <th class="{headerLabel} w-28">Status</th>
327
+ <th class="w-12 text-right"><span class="sr-only">Actions</span></th>
328
+ </tr>
329
+ </thead>
330
+ <tbody>
331
+ {#each pageRows as entry (entry.id)}
332
+ <tr class="transition-colors hover:bg-base-200/60">
333
+ <td class="max-w-0">
334
+ <a class="block truncate font-semibold hover:text-primary hover:underline {entry.draft ? draftDim : ''}" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a>
335
+ {#if entry.draft}
336
+ <!-- Hidden is a row treatment, not a status badge: the row de-emphasizes and an
337
+ eye-off tag sits by the title, leaving the Status cell to its publish badge. -->
338
+ <span class="mt-0.5 inline-flex items-center gap-1 text-[0.6875rem] font-semibold uppercase tracking-[0.02em] text-[var(--color-muted)]">
339
+ <EyeOffIcon class="h-3 w-3" aria-hidden="true" />Hidden
340
+ </span>
341
+ {/if}
342
+ {#if entry.summary}
343
+ <div data-summary class="mt-0.5 truncate text-[0.8125rem] text-[var(--color-muted)]">{entry.summary}</div>
344
+ {/if}
345
+ </td>
346
+ {#if data.dated}<td class="hidden w-28 text-sm tabular-nums text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
347
+ <td class="w-28">
212
348
  {#if entry.status === 'new'}<span class="badge badge-info badge-sm font-medium">New</span>
213
- {:else if entry.status === 'edited'}<span class="badge badge-warning badge-sm font-medium">Edited</span>
349
+ {:else if entry.status === 'edited'}<span class="badge badge-sm border-transparent bg-primary/10 font-medium text-primary">Edited</span>
214
350
  {:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
215
- {#if entry.draft}<span class="badge badge-neutral badge-sm font-medium">Hidden</span>{/if}
216
- </div>
217
- </td>
218
- <td class="text-right">
219
- {#if deleteRefused?.id === entry.id}
220
- <!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
221
- <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
222
- {:else}
223
- <form method="POST" action="?/delete">
224
- <CsrfField />
225
- <input type="hidden" name="id" value={entry.id} />
226
- <button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
227
- <Trash2Icon class="h-4 w-4 text-error" />
228
- </button>
229
- </form>
230
- {/if}
231
- </td>
232
- </tr>
233
- {/each}
234
- </tbody>
235
- </table>
236
- {/if}
237
- </div>
351
+ </td>
352
+ <td class="w-12 text-right">
353
+ {#if deleteRefused?.id === entry.id}
354
+ <!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
355
+ <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
356
+ {:else}
357
+ <form method="POST" action="?/delete">
358
+ <CsrfField />
359
+ <input type="hidden" name="id" value={entry.id} />
360
+ <button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
361
+ <Trash2Icon class="h-4 w-4 text-error" />
362
+ </button>
363
+ </form>
364
+ {/if}
365
+ </td>
366
+ </tr>
367
+ {/each}
368
+ </tbody>
369
+ </table>
370
+ <!-- The create affordance baked into the list body: a full-width borderless foot row so a
371
+ short list always shows its next step rather than just stopping. Same action as the
372
+ header New button. -->
373
+ <button type="button" class="flex w-full items-center gap-2 border-t border-[var(--cairn-card-border)] px-6 py-3 text-sm font-medium text-primary hover:bg-primary/[0.06]" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
374
+ <PlusIcon class="h-4 w-4" /> New {createNoun}
375
+ </button>
376
+ {/if}
377
+ </div>
378
+ {/if}
238
379
 
239
380
  {#if data.entries.length > 0}
240
381
  <div class="mb-6 flex flex-wrap items-center justify-between gap-2 text-sm">
@@ -262,7 +403,7 @@ content sizes. The header New button opens a dialog holding the create form.
262
403
  <dialog class="modal" aria-labelledby="cairn-create-dialog-title" bind:this={createDialog}>
263
404
  <div class="modal-box">
264
405
  <div class="mb-3 flex items-center justify-between">
265
- <h2 id="cairn-create-dialog-title" class="text-base font-semibold">New {data.label}</h2>
406
+ <h2 id="cairn-create-dialog-title" class="text-base font-semibold">New {createNoun}</h2>
266
407
  <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => createDialog?.close()}>✕</button>
267
408
  </div>
268
409
  <form method="POST" action="?/create" onsubmit={() => (creating = true)} class="flex flex-col gap-3">
@@ -8,10 +8,12 @@ interface Props {
8
8
  form?: Partial<DeleteRefusal> | null;
9
9
  }
10
10
  /**
11
- * One concept's list view as a DaisyUI data-table: a search filter, a result count, sortable Title and
12
- * Date headers, a status badge, a formatted date, and client-side pagination with a page-size control.
13
- * Filtering, sorting, and paging run over the loaded entries in component state, which suits typical
14
- * content sizes. The header New button opens a dialog holding the create form.
11
+ * One concept's list view, dressed to the office gold standard. A triage bar partitions by publish
12
+ * state (a bordered segmented control with live counts) beside an orthogonal Hidden toggle. The list
13
+ * is an enriched sortable table: each row carries a title with a muted summary sub-line, the date, the
14
+ * publish-state badge, and a delete action. A draft row de-emphasizes and carries an eye-off Hidden
15
+ * tag by the title. A trailing New row at the foot of the card opens the same create dialog as the
16
+ * header button. Filtering, sorting, and paging run over the loaded entries in component state.
15
17
  */
16
18
  declare const ConceptList: import("svelte").Component<Props, {}, "">;
17
19
  type ConceptList = ReturnType<typeof ConceptList>;
@@ -100,23 +100,20 @@ through the adapter's render. Swapping the editor stays a one-file change.
100
100
  `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
101
101
  // With `active`, the row's own (deepest) bar takes the full-strength -active mix at the same
102
102
  // 2px width. The emphasis is strength only: a rail column carrying both an active and a
103
- // quiet segment (two sibling containers at one depth) keeps one weight top to bottom.
104
- // With `dropInnermost`, the row's own deepest bar is omitted: a fold chevron replaces it on a
105
- // paired opener row, so the bar would double the chevron's positional cue. The outer bars and
106
- // their spacers stay, so the nesting still reads.
107
- const rails = (depth: number, active = false, dropInnermost = false): string => {
103
+ // quiet segment (two sibling containers at one depth) keeps one weight top to bottom. A paired
104
+ // opener row paints its full rail like any other fence row; the fold chevron lives in the gutter
105
+ // column left of the rails, so the opener no longer drops its innermost bar.
106
+ const rails = (depth: number, active = false): string => {
108
107
  const layers: string[] = [];
109
108
  for (let d = 1; d <= depth; d++) {
110
109
  const edge = 8 * d - 6;
111
110
  if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
112
- if (dropInnermost && d === depth) continue;
113
111
  const own = active && d === depth;
114
112
  layers.push(
115
113
  `inset ${edge}px 0 0 0 ${own ? railColor('active', '100%') : railColor(d, railFallbacks[d - 1] ?? '92%')}`,
116
114
  );
117
115
  }
118
- // A depth-1 opener drops its only bar, so the row paints no rail at all.
119
- return layers.length ? layers.join(', ') : 'none';
116
+ return layers.join(', ');
120
117
  };
121
118
  const directiveInk = {
122
119
  backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
@@ -132,14 +129,6 @@ through the adapter's render. Swapping the editor stays a one-file change.
132
129
  `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
133
130
  railRules[row('')] = { boxShadow: rails(depth) };
134
131
  railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
135
- // A paired opener row drops its own innermost bar (the fold chevron stands in its place),
136
- // both quiet and caret-active. The extra opener class outranks the base fence rule above.
137
- railRules[`.cm-cairn-directive-fence.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
138
- boxShadow: rails(depth, false, true),
139
- };
140
- railRules[`.cm-cairn-caret-block.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
141
- boxShadow: rails(depth, true, true),
142
- };
143
132
  }
144
133
  const theme = EditorView.theme(
145
134
  {
@@ -199,39 +188,67 @@ through the adapter's render. Swapping the editor stays a one-file change.
199
188
  },
200
189
  '.cm-cairn-directive-leaf': directiveInk,
201
190
  '.cm-cairn-directive-inline': directiveInk,
202
- // Container folding. The fold band is the 28px gutter click target on an opener row; the
203
- // line is the positioning context so the chevron sits over the container's own bar x. The
204
- // band is laid over the gutter (a zero-width inline widget at line start, expanded by the
205
- // absolute children), so only the gutter shows the pointer cursor, never the opener text.
206
- '.cm-line:has(.cm-cairn-fold-band)': { position: 'relative' },
207
- '.cm-cairn-fold-band': {
208
- position: 'absolute',
209
- left: '0',
210
- top: '0',
211
- width: '28px',
212
- height: '100%',
191
+ // Container folding lives in a real gutter column now, not an in-text band. The gutter is a
192
+ // fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
193
+ // the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
194
+ // caret is inside the container. One rotating chevron in the directive ink; the rails carry
195
+ // depth, so the ink does not restep. The lone gutter's wrapper loses its default background
196
+ // and border so the column blends into the quiet surface.
197
+ // Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
198
+ // only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
199
+ // or lint gutter would need its own chrome and a narrower selector here.
200
+ '.cm-gutters': { backgroundColor: 'transparent', border: '0', color: 'inherit' },
201
+ // 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
202
+ '.cm-cairn-fold-gutter': { width: '24px' },
203
+ '.cm-cairn-fold-gutter .cm-gutterElement': { display: 'flex', alignItems: 'stretch', padding: '0' },
204
+ '.cm-cairn-fold-btn': {
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ justifyContent: 'center',
208
+ width: '100%',
209
+ padding: '0',
210
+ background: 'transparent',
211
+ border: '0',
213
212
  cursor: 'pointer',
214
- zIndex: '1',
213
+ color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
215
214
  },
216
- '.cm-cairn-fold-band svg': {
217
- position: 'absolute',
218
- top: '50%',
219
- transform: 'translateY(-50%)',
215
+ '.cm-cairn-fold-btn svg': {
220
216
  width: '11px',
221
217
  height: '11px',
222
- // The chevron fades in on rail-band hover; folded and caret-inside states force it on.
218
+ // Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
219
+ // force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
223
220
  opacity: '0',
224
- transition: 'opacity 120ms ease',
225
- color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
221
+ transition: 'opacity 120ms ease, transform 120ms ease',
222
+ },
223
+ // Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
224
+ // so a focused control shows its glyph, not just the ring.
225
+ '.cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg':
226
+ { opacity: '1' },
227
+ // Folded rotates the single chevron to point right; caret-active takes the stronger ink.
228
+ '.cm-cairn-fold-folded svg': { transform: 'rotate(-90deg)' },
229
+ '.cm-cairn-fold-active': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
230
+ // A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
231
+ // the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
232
+ '.cm-cairn-fold-btn:focus-visible': {
233
+ outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
234
+ outlineOffset: '-2px',
235
+ borderRadius: '4px',
236
+ },
237
+ '.cm-cairn-fold-pill:focus-visible': {
238
+ outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
239
+ outlineOffset: '1px',
240
+ },
241
+ // No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
242
+ // and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
243
+ // states still read at full strength on touch rather than this rule clamping them to 0.65.
244
+ '@media (hover: none)': {
245
+ '.cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg': { opacity: '0.65' },
226
246
  },
227
- '.cm-cairn-fold-band:hover svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg': {
228
- opacity: '1',
247
+ // Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
248
+ '@media (prefers-reduced-motion: reduce)': {
249
+ '.cm-cairn-fold-btn svg': { transition: 'none' },
250
+ '.cm-cairn-fold-flash': { transition: 'none' },
229
251
  },
230
- // The chevron steps its ink with the container's depth, matching the label inks; the
231
- // caret-inside state takes the strongest ink.
232
- '.cm-cairn-fold-depth-1 svg': { color: 'var(--color-accent)' },
233
- '.cm-cairn-fold-depth-3 svg': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
234
- '.cm-cairn-fold-active svg': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
235
252
  // The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
236
253
  // so folded spots read in a scan. The rails are inset box-shadows on the same line element
237
254
  // and render above this background, so the rail column runs through the wash unbroken.
@@ -274,9 +291,11 @@ through the adapter's render. Swapping the editor stays a one-file change.
274
291
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
275
292
  backgroundColor: 'transparent',
276
293
  },
277
- // The fold machinery dims with its row: a folded opener row under focus mode drops its
278
- // chevron, pill, and wash to the dim tone like any other machinery line.
279
- '.cm-cairn-focus-dim .cm-cairn-fold-band svg, .cm-cairn-focus-dim .cm-cairn-fold-pill': {
294
+ // The fold pill dims with its folded opener row like any machinery line (the pill is a
295
+ // widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
296
+ // cannot reach by descendant selector, and it is already hidden at rest and forced visible
297
+ // only when folded or caret-active, so a folded chevron stays findable without a dim rule.
298
+ '.cm-cairn-focus-dim .cm-cairn-fold-pill': {
280
299
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
281
300
  },
282
301
  '.cm-cairn-focus-dim.cm-cairn-folded-row': { backgroundColor: 'transparent' },