@glw907/cairn-cms 0.53.0 → 0.55.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 (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/components/AdminLayout.svelte +52 -19
  3. package/dist/components/ConceptList.svelte +210 -73
  4. package/dist/components/ConceptList.svelte.d.ts +6 -4
  5. package/dist/components/EditPage.svelte +372 -110
  6. package/dist/components/EditPage.svelte.d.ts +2 -1
  7. package/dist/components/EditorToolbar.svelte +26 -10
  8. package/dist/components/MarkdownEditor.svelte +108 -14
  9. package/dist/components/MarkdownHelpDialog.svelte +5 -0
  10. package/dist/components/ShortcutsDialog.svelte +37 -0
  11. package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
  12. package/dist/components/ShortcutsGrid.svelte +18 -0
  13. package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
  14. package/dist/components/cairn-admin.css +184 -104
  15. package/dist/components/editor-folding.d.ts +7 -0
  16. package/dist/components/editor-folding.js +331 -0
  17. package/dist/components/editor-highlight.js +55 -6
  18. package/dist/components/editor-shortcuts.d.ts +16 -0
  19. package/dist/components/editor-shortcuts.js +36 -0
  20. package/dist/components/markdown-directives.d.ts +17 -0
  21. package/dist/components/markdown-directives.js +41 -0
  22. package/dist/components/topbar-context.d.ts +13 -0
  23. package/dist/components/topbar-context.js +17 -0
  24. package/dist/content/manifest.d.ts +1 -0
  25. package/dist/content/manifest.js +6 -0
  26. package/dist/delivery/content-index.js +1 -1
  27. package/dist/delivery/data.d.ts +1 -1
  28. package/dist/delivery/data.js +1 -1
  29. package/dist/sveltekit/content-routes.d.ts +3 -0
  30. package/dist/sveltekit/content-routes.js +10 -5
  31. package/package.json +1 -1
  32. package/src/lib/components/AdminLayout.svelte +52 -19
  33. package/src/lib/components/ConceptList.svelte +210 -73
  34. package/src/lib/components/EditPage.svelte +372 -110
  35. package/src/lib/components/EditorToolbar.svelte +26 -10
  36. package/src/lib/components/MarkdownEditor.svelte +108 -14
  37. package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
  38. package/src/lib/components/ShortcutsDialog.svelte +37 -0
  39. package/src/lib/components/ShortcutsGrid.svelte +18 -0
  40. package/src/lib/components/cairn-admin.css +24 -11
  41. package/src/lib/components/editor-folding.ts +356 -0
  42. package/src/lib/components/editor-highlight.ts +54 -4
  43. package/src/lib/components/editor-shortcuts.ts +42 -0
  44. package/src/lib/components/markdown-directives.ts +42 -0
  45. package/src/lib/components/topbar-context.ts +30 -0
  46. package/src/lib/content/manifest.ts +7 -0
  47. package/src/lib/delivery/content-index.ts +1 -1
  48. package/src/lib/delivery/data.ts +1 -1
  49. package/src/lib/sveltekit/content-routes.ts +13 -5
  50. /package/dist/{delivery → content}/excerpt.d.ts +0 -0
  51. /package/dist/{delivery → content}/excerpt.js +0 -0
  52. /package/src/lib/{delivery → content}/excerpt.ts +0 -0
@@ -4,7 +4,7 @@
4
4
  // every operation reads the descriptor and its routing rule, never a hardcoded concept id.
5
5
  import { parseMarkdown } from '../content/frontmatter.js';
6
6
  import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
7
- import { deriveExcerpt, wordCount } from './excerpt.js';
7
+ import { deriveExcerpt, wordCount } from '../content/excerpt.js';
8
8
  /** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
9
9
  export function fromGlob(record) {
10
10
  return Object.entries(record).map(([path, raw]) => ({ path, raw }));
@@ -5,7 +5,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
5
5
  export { createSiteIndexes } from './site-indexes.js';
6
6
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
- export { deriveExcerpt, wordCount } from './excerpt.js';
8
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
9
9
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
10
10
  export type { FeedChannel, FeedItem } from './feeds.js';
11
11
  export { buildSitemap } from './sitemap.js';
@@ -5,7 +5,7 @@ export { createContentIndex, fromGlob } from './content-index.js';
5
5
  export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
6
6
  export { createSiteIndexes } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
- export { deriveExcerpt, wordCount } from './excerpt.js';
8
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
9
9
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
10
10
  export { buildSitemap } from './sitemap.js';
11
11
  export { buildRobots } from './robots.js';
@@ -44,6 +44,9 @@ export interface EntrySummary {
44
44
  draft: boolean;
45
45
  /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
46
46
  status: 'published' | 'edited' | 'new';
47
+ /** The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
48
+ * frontmatter/body excerpt for a pending one, and null when neither yields text. */
49
+ summary: string | null;
47
50
  }
48
51
  /** The concept list view's data. */
49
52
  export interface ListData {
@@ -6,6 +6,8 @@ import { redirect, error, fail } from '@sveltejs/kit';
6
6
  import { findConcept } from '../content/concepts.js';
7
7
  import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
+ import { deriveExcerpt } from '../content/excerpt.js';
10
+ import { asString } from '../content/identity.js';
9
11
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
10
12
  import { appCredentials } from '../github/credentials.js';
11
13
  import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
@@ -108,14 +110,17 @@ export function createContentRoutes(runtime, deps = {}) {
108
110
  try {
109
111
  const raw = await readRaw(repo, file.path, token);
110
112
  if (raw === null)
111
- return { id: file.id, title: file.id, date: null, draft: false, status };
112
- const { frontmatter } = parseMarkdown(raw);
113
+ return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
114
+ const { frontmatter, body } = parseMarkdown(raw);
113
115
  const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
114
116
  const date = dateInputValue(frontmatter.date) || null;
115
- return { id: file.id, title, date, draft: frontmatter.draft === true, status };
117
+ // Normalize an empty excerpt to null, so a pending row matches EntrySummary's `string | null`
118
+ // contract (the published builder already coalesces with `?? null`).
119
+ const summary = deriveExcerpt(body, { description: asString(frontmatter.description) }) || null;
120
+ return { id: file.id, title, date, draft: frontmatter.draft === true, status, summary };
116
121
  }
117
122
  catch {
118
- return { id: file.id, title: file.id, date: null, draft: false, status };
123
+ return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
119
124
  }
120
125
  }
121
126
  /** Read an entry's list row from its pending branch, so a pending title or draft change shows
@@ -177,7 +182,7 @@ export function createContentRoutes(runtime, deps = {}) {
177
182
  .sort((a, b) => b.id.localeCompare(a.id));
178
183
  const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
179
184
  ? pendingRow(concept, e.id, 'edited', token)
180
- : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
185
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published', summary: e.summary ?? null }));
181
186
  const listed = new Set(rows.map((e) => e.id));
182
187
  const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
183
188
  return { ...base, entries: [...entries, ...newRows], error: null };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -11,6 +11,7 @@ identical on every host regardless of the site's own theme.
11
11
  import type { LayoutData } from '../sveltekit/content-routes.js';
12
12
  import CsrfField from './CsrfField.svelte';
13
13
  import { CSRF_CONTEXT_KEY } from './csrf-context.js';
14
+ import { provideTopbar, type TopbarHolder } from './topbar-context.js';
14
15
  import { MenuIcon, LogOutIcon, SunIcon, MoonIcon, ChevronRightIcon, SearchIcon } from './admin-icons.js';
15
16
  import CairnLogo from './CairnLogo.svelte';
16
17
  import { cairnFaviconHref } from './cairn-favicon.js';
@@ -225,6 +226,16 @@ identical on every host regardless of the site's own theme.
225
226
 
226
227
  // The browser-tab title: the deepest breadcrumb (the active concept or entry), then the brand.
227
228
  const pageTitle = $derived(crumbs.length ? crumbs[crumbs.length - 1].label : 'Admin');
229
+
230
+ // A desk route is an open document (/admin/<concept>/<id>): the third path segment is the entry.
231
+ // The band has one job there, so the topbar drops the palette trigger and the site-wide Publish
232
+ // button and renders the document's own desk controls instead.
233
+ const isDeskRoute = $derived(data.pathname.split('/').filter(Boolean).length > 2);
234
+
235
+ // The topbar context portal: a reactive holder a descendant document fills with its desk snippet.
236
+ // EditPage registers on mount and nulls it on teardown; the office routes leave it null.
237
+ let topbar = $state<TopbarHolder>({ desk: null, zen: false });
238
+ provideTopbar(topbar);
228
239
  </script>
229
240
 
230
241
  <svelte:head>
@@ -239,16 +250,30 @@ identical on every host regardless of the site's own theme.
239
250
  itself never matches. Keeping the drawer and its base/utility classes one level in lets the
240
251
  scoped sheet style them. -->
241
252
  <div data-theme={theme} bind:this={rootEl}>
242
- <div class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
253
+ <!-- The persistent desktop sidebar (lg:drawer-open) recedes inside an open document: a desk route
254
+ renders the drawer shell without it, so the nav starts closed at desktop width and the
255
+ manuscript takes the shell. This resolves at SSR from data.pathname (isDeskRoute), never in an
256
+ effect, so the chrome-free state does not flash. The checkbox still governs the overlay, so the
257
+ toggle (and Cmd/Ctrl+B) reopens the nav over the document on demand. -->
258
+ <div class="drawer min-h-screen bg-base-200 text-base-content" class:lg:drawer-open={!isDeskRoute}>
243
259
  <input id="cairn-drawer" type="checkbox" class="drawer-toggle" bind:checked={drawerOpen} />
244
260
 
245
261
  <div class="drawer-content flex flex-col">
262
+ <!-- Zen (rung 4) drops the whole topbar element, not just its contents: a desk document
263
+ registers zen through the topbar holder and the band slides away entirely. The desk's
264
+ three clusters include AdminLayout-owned chrome (the drawer toggle, the breadcrumb), so
265
+ emptying the band would leave that chrome behind; the band must be GONE. The manuscript
266
+ and EditPage's own floating zen chip carry on below. -->
267
+ {#if !topbar.zen}
246
268
  <!-- The topbar is a flat, opaque continuation of the sidebar's brand band: same surface and the
247
269
  same hairline, no shadow, so the two form one clean header strip across the sidebar seam.
248
270
  The height is pinned to the brand band's h-16 (a content-driven navbar drifts with font
249
271
  metrics, and the two border-bottoms stop meeting at the seam). -->
250
272
  <div class="navbar bg-base-100 border-b border-[var(--cairn-card-border)] sticky top-0 z-30 h-16 min-h-16 gap-2 px-4 py-0 lg:px-8">
251
- <div class="flex-none lg:hidden">
273
+ <!-- The drawer toggle is hidden at desktop width on the office routes (the persistent sidebar
274
+ stands in for it); on a desk route the sidebar is closed, so the toggle stays visible and
275
+ reopens the nav as an overlay. -->
276
+ <div class="flex-none" class:lg:hidden={!isDeskRoute}>
252
277
  <label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
253
278
  <MenuIcon class="h-5 w-5" />
254
279
  </label>
@@ -268,25 +293,32 @@ identical on every host regardless of the site's own theme.
268
293
  <span class="font-semibold tracking-tight">{data.siteName}</span>
269
294
  {/if}
270
295
  </div>
271
- <!-- The command-palette trigger fills the center: a quick jump-to over the admin, opened here
272
- or with Cmd/Ctrl+K. -->
273
- <div class="flex min-w-0 flex-1 justify-center">
274
- <button
275
- type="button"
276
- onclick={openPalette}
277
- class="flex w-full max-w-md items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-200/70 px-3 py-1.5 text-sm text-[var(--color-muted)] transition-colors hover:bg-base-200 hover:text-base-content"
278
- >
279
- <SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
280
- <span class="truncate">Search or jump to&hellip;</span>
281
- <kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">&#8984;K</kbd>
282
- </button>
283
- </div>
284
- {#if pendingCount > 0}
285
- <div class="flex-none">
286
- <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
287
- Publish site ({pendingCount})
296
+ {#if isDeskRoute}
297
+ <!-- An open document takes the band: the registered desk snippet (the status and action
298
+ clusters) fills the row to the right of the breadcrumb. The palette trigger and the
299
+ site-wide Publish button stand down so the band has one job here. -->
300
+ {@render topbar.desk?.()}
301
+ {:else}
302
+ <!-- The command-palette trigger fills the center: a quick jump-to over the admin, opened
303
+ here or with Cmd/Ctrl+K. -->
304
+ <div class="flex min-w-0 flex-1 justify-center">
305
+ <button
306
+ type="button"
307
+ onclick={openPalette}
308
+ class="flex w-full max-w-md items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-200/70 px-3 py-1.5 text-sm text-[var(--color-muted)] transition-colors hover:bg-base-200 hover:text-base-content"
309
+ >
310
+ <SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
311
+ <span class="truncate">Search or jump to&hellip;</span>
312
+ <kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">&#8984;K</kbd>
288
313
  </button>
289
314
  </div>
315
+ {#if pendingCount > 0}
316
+ <div class="flex-none">
317
+ <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
318
+ Publish site ({pendingCount})
319
+ </button>
320
+ </div>
321
+ {/if}
290
322
  {/if}
291
323
  <div class="flex-none">
292
324
  <button type="button" class="btn btn-square btn-ghost" aria-label="Toggle theme" onclick={toggleTheme}>
@@ -294,6 +326,7 @@ identical on every host regardless of the site's own theme.
294
326
  </button>
295
327
  </div>
296
328
  </div>
329
+ {/if}
297
330
 
298
331
  <main class="flex-1 p-4 lg:px-10 lg:py-8">
299
332
  {@render children()}
@@ -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 {
@@ -116,6 +198,12 @@ content sizes. The header New button opens a dialog holding the create form.
116
198
  );
117
199
  </script>
118
200
 
201
+ <!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
202
+ renders only inside the active segment or toggle, so hue never carries the state alone. -->
203
+ {#snippet check()}
204
+ <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>
205
+ {/snippet}
206
+
119
207
  <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
120
208
  <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.label}</h1>
121
209
  <div class="flex items-center gap-3 sm:flex-1 sm:flex-wrap sm:justify-end">
@@ -159,82 +247,131 @@ content sizes. The header New button opens a dialog holding the create form.
159
247
  </div>
160
248
  {/if}
161
249
 
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>
250
+ {#if data.entries.length > 0}
251
+ <!-- The triage filters on two independent axes, dressed to the footer grammar. The publish-state
252
+ partition is one bordered segmented control: the shared border carries the pick-one semantics
253
+ and the active segment tints with a check (the non-color cue, WCAG 1.4.1). The Hidden toggle
254
+ is a separate standalone check-and-tint toggle that composes with the active partition. Each
255
+ count dims to muted when zero, so a sparse list never jumps. -->
256
+ <div class="mb-4 flex flex-wrap items-center gap-3">
257
+ <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)]">
258
+ {#each segments as seg, i (seg.value)}
259
+ <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)}>
260
+ {#if partition === seg.value}{@render check()}{/if}
261
+ {seg.label}<span class="tabular-nums">{seg.count()}</span>
262
+ </button>
263
+ {/each}
173
264
  </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>
265
+ <span class="h-5 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></span>
266
+ <button type="button" class={hiddenToggleClass(hiddenOnly)} aria-pressed={hiddenOnly} onclick={toggleHidden}>
267
+ {#if hiddenOnly}{@render check()}{/if}
268
+ Hidden<span class="tabular-nums">{counts.hidden}</span>
269
+ </button>
270
+ </div>
271
+ {/if}
272
+
273
+ {#if data.entries.length === 0}
274
+ <!-- The empty state owns the content area (no card): the cairn mark, concept-named copy, and the
275
+ create CTA centered on a tall fill, so a first-run office reads as composed. -->
276
+ <div class="flex min-h-[56vh] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
277
+ <CairnLogo class="h-12 w-12 text-primary opacity-30" />
278
+ <div class="space-y-1">
279
+ <p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
280
+ <p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
178
281
  </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'}
282
+ <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
283
+ <PlusIcon class="h-4 w-4" /> New {data.label}
284
+ </button>
285
+ </div>
286
+ {:else}
287
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
288
+ {#if sorted.length === 0}
289
+ <!-- A filter or a search narrowed the list to zero; the entries exist, none match. Offer the
290
+ way back: a search query clears, a filter is named in the copy. -->
291
+ <div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
292
+ <SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
293
+ {#if query.trim()}
294
+ <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
295
+ <button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={clearSearch}>Clear search</button>
296
+ {:else}
297
+ <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match this filter.</p>
298
+ {/if}
299
+ </div>
300
+ {:else}
301
+ <table class="table">
302
+ <thead>
303
+ <tr class="border-base-300">
304
+ <th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
305
+ <button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
306
+ Title
307
+ {#if sortKey === 'title'}
196
308
  {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
197
309
  {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
198
310
  </button>
199
311
  </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">
312
+ {#if data.dated}
313
+ <th class="hidden w-28 sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
314
+ <button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
315
+ Date
316
+ {#if sortKey === 'date'}
317
+ {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
318
+ {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
319
+ </button>
320
+ </th>
321
+ {/if}
322
+ <th class="{headerLabel} w-28">Status</th>
323
+ <th class="w-12 text-right"><span class="sr-only">Actions</span></th>
324
+ </tr>
325
+ </thead>
326
+ <tbody>
327
+ {#each pageRows as entry (entry.id)}
328
+ <tr class="transition-colors hover:bg-base-200/60">
329
+ <td class="max-w-0">
330
+ <a class="block truncate font-semibold hover:text-primary hover:underline {entry.draft ? draftDim : ''}" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a>
331
+ {#if entry.draft}
332
+ <!-- Hidden is a row treatment, not a status badge: the row de-emphasizes and an
333
+ eye-off tag sits by the title, leaving the Status cell to its publish badge. -->
334
+ <span class="mt-0.5 inline-flex items-center gap-1 text-[0.6875rem] font-semibold uppercase tracking-[0.02em] text-[var(--color-muted)]">
335
+ <EyeOffIcon class="h-3 w-3" aria-hidden="true" />Hidden
336
+ </span>
337
+ {/if}
338
+ {#if entry.summary}
339
+ <div data-summary class="mt-0.5 truncate text-[0.8125rem] text-[var(--color-muted)]">{entry.summary}</div>
340
+ {/if}
341
+ </td>
342
+ {#if data.dated}<td class="hidden w-28 text-sm tabular-nums text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
343
+ <td class="w-28">
212
344
  {#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>
345
+ {:else if entry.status === 'edited'}<span class="badge badge-sm border-transparent bg-primary/10 font-medium text-primary">Edited</span>
214
346
  {: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>
347
+ </td>
348
+ <td class="w-12 text-right">
349
+ {#if deleteRefused?.id === entry.id}
350
+ <!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
351
+ <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
352
+ {:else}
353
+ <form method="POST" action="?/delete">
354
+ <CsrfField />
355
+ <input type="hidden" name="id" value={entry.id} />
356
+ <button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
357
+ <Trash2Icon class="h-4 w-4 text-error" />
358
+ </button>
359
+ </form>
360
+ {/if}
361
+ </td>
362
+ </tr>
363
+ {/each}
364
+ </tbody>
365
+ </table>
366
+ <!-- The create affordance baked into the list body: a full-width borderless foot row so a
367
+ short list always shows its next step rather than just stopping. Same action as the
368
+ header New button. -->
369
+ <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()}>
370
+ <PlusIcon class="h-4 w-4" /> New {data.label}
371
+ </button>
372
+ {/if}
373
+ </div>
374
+ {/if}
238
375
 
239
376
  {#if data.entries.length > 0}
240
377
  <div class="mb-6 flex flex-wrap items-center justify-between gap-2 text-sm">