@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
@@ -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
@@ -149,7 +154,7 @@ export function createContentRoutes(runtime, deps = {}) {
149
154
  const formError = event.url.searchParams.get('error');
150
155
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
151
156
  const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
152
- const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
157
+ const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
153
158
  let token;
154
159
  try {
155
160
  token = await mintToken(event.platform?.env ?? {});
@@ -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 };
@@ -10,3 +10,4 @@ export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-a
10
10
  export { healthLoad, type HealthData } from './health.js';
11
11
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
12
12
  export type { GithubKeyEnv } from '../github/credentials.js';
13
+ export type { AuthEnv } from '../auth/types.js';
@@ -25,10 +25,12 @@ export declare function buildManifestFromVite(opts: CairnManifestOptions, root:
25
25
  * buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
26
26
  export declare function cairnManifest(opts: CairnManifestOptions): Plugin;
27
27
  /** Regenerate the committed manifest from the consumer's corpus and write it to the configured
28
- * manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
29
- * options off the instance, evaluates the write-mode virtual module through the build's own
30
- * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
31
- * so the write logic is testable apart from the CLI shell. */
28
+ * manifestPath. It searches for the consumer's Vite config from `cwd`, derives the authoritative
29
+ * Vite root from the loaded config (so a configured `root` or a non-root cwd resolves correctly),
30
+ * reads the cairnManifest plugin's options off the instance, evaluates the write-mode virtual
31
+ * module through the build's own resolution, and writes the serialized manifest under the Vite
32
+ * root. The cairn-manifest bin calls this; it is exported so the write logic is testable apart
33
+ * from the CLI shell. */
32
34
  export declare function writeManifest(cwd?: string): Promise<void>;
33
35
  /** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
34
36
  export interface AdapterFacts {
@@ -1,5 +1,6 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
+ import { resolveViteRoot } from './resolve-root.js';
3
4
  /** The key the cairnManifest plugin stashes its options under, so the write path can read them off the
4
5
  * plugin instance in the consumer's loaded config without re-parsing the config file. */
5
6
  const CAIRN_OPTIONS = Symbol.for('cairn-cms.manifest-options');
@@ -121,10 +122,12 @@ export function cairnManifest(opts) {
121
122
  return plugin;
122
123
  }
123
124
  /** Regenerate the committed manifest from the consumer's corpus and write it to the configured
124
- * manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
125
- * options off the instance, evaluates the write-mode virtual module through the build's own
126
- * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
127
- * so the write logic is testable apart from the CLI shell. */
125
+ * manifestPath. It searches for the consumer's Vite config from `cwd`, derives the authoritative
126
+ * Vite root from the loaded config (so a configured `root` or a non-root cwd resolves correctly),
127
+ * reads the cairnManifest plugin's options off the instance, evaluates the write-mode virtual
128
+ * module through the build's own resolution, and writes the serialized manifest under the Vite
129
+ * root. The cairn-manifest bin calls this; it is exported so the write logic is testable apart
130
+ * from the CLI shell. */
128
131
  export async function writeManifest(cwd = process.cwd()) {
129
132
  const { loadConfigFromFile } = await import('vite');
130
133
  const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd);
@@ -135,11 +138,12 @@ export async function writeManifest(cwd = process.cwd()) {
135
138
  if (!opts) {
136
139
  throw new Error('cairn-manifest: the Vite config has no cairnManifest() plugin. Add it so the bin shares the build options.');
137
140
  }
138
- const serialized = await buildManifestFromVite(opts, cwd);
141
+ const root = resolveViteRoot(loaded, cwd);
142
+ const serialized = await buildManifestFromVite(opts, root);
139
143
  const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
140
144
  // The manifest path is app-root-absolute (a leading slash relative to the project), so resolve it
141
- // against cwd, not the filesystem root.
142
- const outPath = join(cwd, manifestPath.replace(/^\//, ''));
145
+ // against the Vite root, not the filesystem root or the config-search cwd.
146
+ const outPath = join(root, manifestPath.replace(/^\//, ''));
143
147
  await mkdir(dirname(outPath), { recursive: true });
144
148
  await writeFile(outPath, serialized);
145
149
  }
@@ -0,0 +1,16 @@
1
+ /** The shape of `loadConfigFromFile`'s result that the root derivation reads: the config file's own
2
+ * path and its `root` field. Typed structurally so the helper is testable without a real load. */
3
+ export interface LoadedViteConfig {
4
+ /** The resolved path of the config file Vite loaded. */
5
+ path: string;
6
+ /** The user config, of which only `root` is read here. */
7
+ config: {
8
+ root?: string;
9
+ };
10
+ }
11
+ /** The authoritative Vite root for the manifest bin, derived from the loaded config the way Vite
12
+ * resolves a relative `root`: against the config file's own directory, not cwd. An absolute `root`
13
+ * stands as given, and no `root` falls back to `cwd` (the directory the bin was run from). This
14
+ * separates the config-search dir (cwd) from the Vite root, so a non-root cwd or a config that
15
+ * sets `root` reads and writes the manifest under the real app root. */
16
+ export declare function resolveViteRoot(loaded: LoadedViteConfig, cwd: string): string;
@@ -0,0 +1,16 @@
1
+ // The manifest bin's root derivation, split out so it is unit-testable without widening the public
2
+ // /vite surface (only src/lib/vite/index.ts is the package subpath; this sibling is internal).
3
+ import { dirname, isAbsolute, resolve } from 'node:path';
4
+ /** The authoritative Vite root for the manifest bin, derived from the loaded config the way Vite
5
+ * resolves a relative `root`: against the config file's own directory, not cwd. An absolute `root`
6
+ * stands as given, and no `root` falls back to `cwd` (the directory the bin was run from). This
7
+ * separates the config-search dir (cwd) from the Vite root, so a non-root cwd or a config that
8
+ * sets `root` reads and writes the manifest under the real app root. */
9
+ export function resolveViteRoot(loaded, cwd) {
10
+ const root = loaded.config.root;
11
+ if (!root)
12
+ return cwd;
13
+ if (isAbsolute(root))
14
+ return root;
15
+ return resolve(dirname(loaded.path), root);
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -29,6 +29,7 @@
29
29
  "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
30
30
  "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
31
31
  "check:reference": "npm run package && node scripts/reference-coverage.mjs",
32
+ "check:reference:signatures": "npm run package && node scripts/check-reference-signatures.mjs",
32
33
  "check:readiness": "npm run package && node scripts/check-readiness.mjs",
33
34
  "check:docs": "node scripts/docs-links.mjs",
34
35
  "check:prose": "node scripts/check-admin-prose.mjs",
@@ -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">