@glw907/cairn-cms 0.29.0 → 0.34.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 (64) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/components/AdminLayout.svelte +372 -44
  3. package/dist/components/AdminLayout.svelte.d.ts +5 -4
  4. package/dist/components/CairnLogo.svelte +28 -0
  5. package/dist/components/CairnLogo.svelte.d.ts +15 -0
  6. package/dist/components/ComponentForm.svelte +1 -1
  7. package/dist/components/ConceptList.svelte +240 -45
  8. package/dist/components/ConceptList.svelte.d.ts +12 -2
  9. package/dist/components/ConfirmPage.svelte +20 -3
  10. package/dist/components/EditPage.svelte +12 -7
  11. package/dist/components/LoginPage.svelte +27 -5
  12. package/dist/components/ManageEditors.svelte +8 -5
  13. package/dist/components/NavTree.svelte +2 -2
  14. package/dist/components/admin-icons.d.ts +13 -0
  15. package/dist/components/admin-icons.js +15 -0
  16. package/dist/components/cairn-admin.css +5516 -37
  17. package/dist/components/cairn-favicon.d.ts +2 -0
  18. package/dist/components/cairn-favicon.js +7 -0
  19. package/dist/components/chrome-guard.d.ts +9 -0
  20. package/dist/components/chrome-guard.js +55 -0
  21. package/dist/components/fonts/BricolageGrotesque-OFL.txt +93 -0
  22. package/dist/components/fonts/Figtree-OFL.txt +93 -0
  23. package/dist/components/fonts/bricolage-grotesque.woff2 +0 -0
  24. package/dist/components/fonts/figtree.woff2 +0 -0
  25. package/dist/index.d.ts +0 -2
  26. package/dist/index.js +4 -1
  27. package/dist/render/authoring.d.ts +3 -0
  28. package/dist/render/authoring.js +5 -0
  29. package/dist/render/registry.d.ts +2 -0
  30. package/dist/render/registry.js +15 -0
  31. package/dist/render/rehype-dispatch.d.ts +9 -6
  32. package/dist/render/rehype-dispatch.js +12 -6
  33. package/dist/render/remark-directives.js +1 -1
  34. package/dist/sveltekit/content-routes.d.ts +12 -1
  35. package/dist/sveltekit/content-routes.js +37 -13
  36. package/dist/sveltekit/guard.js +32 -0
  37. package/dist/sveltekit/https-required-page.d.ts +5 -0
  38. package/dist/sveltekit/https-required-page.js +216 -0
  39. package/package.json +16 -2
  40. package/src/lib/components/AdminLayout.svelte +372 -44
  41. package/src/lib/components/CairnLogo.svelte +28 -0
  42. package/src/lib/components/ComponentForm.svelte +1 -1
  43. package/src/lib/components/ConceptList.svelte +240 -45
  44. package/src/lib/components/ConfirmPage.svelte +20 -3
  45. package/src/lib/components/EditPage.svelte +12 -7
  46. package/src/lib/components/LoginPage.svelte +27 -5
  47. package/src/lib/components/ManageEditors.svelte +8 -5
  48. package/src/lib/components/NavTree.svelte +2 -2
  49. package/src/lib/components/admin-icons.ts +15 -0
  50. package/src/lib/components/cairn-admin.css +162 -7
  51. package/src/lib/components/cairn-favicon.ts +9 -0
  52. package/src/lib/components/chrome-guard.ts +62 -0
  53. package/src/lib/components/fonts/BricolageGrotesque-OFL.txt +93 -0
  54. package/src/lib/components/fonts/Figtree-OFL.txt +93 -0
  55. package/src/lib/components/fonts/bricolage-grotesque.woff2 +0 -0
  56. package/src/lib/components/fonts/figtree.woff2 +0 -0
  57. package/src/lib/index.ts +4 -2
  58. package/src/lib/render/authoring.ts +7 -0
  59. package/src/lib/render/registry.ts +20 -0
  60. package/src/lib/render/rehype-dispatch.ts +13 -6
  61. package/src/lib/render/remark-directives.ts +1 -1
  62. package/src/lib/sveltekit/content-routes.ts +51 -14
  63. package/src/lib/sveltekit/guard.ts +36 -0
  64. package/src/lib/sveltekit/https-required-page.ts +220 -0
@@ -1,19 +1,96 @@
1
1
  <!--
2
2
  @component
3
- One concept's list view: every entry as a link to its editor, with title, date, and a draft badge,
4
- plus a new-entry form. The slug auto-derives from the title until the author edits the slug field.
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.
5
7
  -->
6
8
  <script lang="ts">
7
9
  import { slugify } from '../content/ids.js';
8
- import type { ListData } from '../sveltekit/content-routes.js';
10
+ import type { EntrySummary, ListData } from '../sveltekit/content-routes.js';
11
+ import type { InboundLink } from '../content/manifest.js';
12
+ import DeleteDialog from './DeleteDialog.svelte';
13
+ import CairnLogo from './CairnLogo.svelte';
14
+ import { SearchIcon, ArrowUpIcon, ArrowDownIcon, ChevronsUpDownIcon, ChevronLeftIcon, ChevronRightIcon, PlusIcon, Trash2Icon } from './admin-icons.js';
9
15
 
10
16
  interface Props {
11
17
  /** The list load's data: the concept, its entries, and any inline or form errors. */
12
18
  data: ListData;
19
+ /** The `?/delete` action result. A blocked delete returns the refused entry id and the inbound
20
+ * links that link to it (the flat `fail(409, { inboundLinks, id })` shape), so the list names
21
+ * the blockers and refuses (block-until-clean). */
22
+ form?: { id?: string; inboundLinks?: InboundLink[] } | null;
13
23
  }
14
24
 
15
- let { data }: Props = $props();
25
+ let { data, form = null }: Props = $props();
16
26
 
27
+ // The entry a `?/delete` refused, and its inbound links, keyed by the posted id. Null when the
28
+ // last submit succeeded, refused nothing, or none ran.
29
+ const deleteRefused = $derived(
30
+ form?.inboundLinks?.length ? { id: form.id, inboundLinks: form.inboundLinks } : null,
31
+ );
32
+
33
+ type SortKey = 'title' | 'date';
34
+ let query = $state('');
35
+ let sortKey = $state<SortKey>('date');
36
+ // Newest first by default: a dated concept reads most-recent-on-top, the usual CMS convention.
37
+ let sortAsc = $state(false);
38
+ let pageSize = $state(10);
39
+ let page = $state(1);
40
+
41
+ const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
42
+ function formatDate(iso: string | null): string {
43
+ if (!iso) return '';
44
+ const parsed = new Date(`${iso}T00:00:00`);
45
+ return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
46
+ }
47
+
48
+ const filtered = $derived(
49
+ data.entries.filter((e) => e.title.toLowerCase().includes(query.trim().toLowerCase())),
50
+ );
51
+
52
+ // Sort key for one entry: the lowercased title, or the ISO date string (lexical order is
53
+ // chronological). A null date sorts as the empty string.
54
+ function sortValue(entry: EntrySummary): string {
55
+ if (sortKey === 'title') return entry.title.toLowerCase();
56
+ return entry.date ?? '';
57
+ }
58
+
59
+ // Codepoint compare, matching the prior `<`/`>` ordering exactly. Avoids localeCompare so the
60
+ // order stays identical to before this refactor.
61
+ function compareStrings(a: string, b: string): number {
62
+ if (a < b) return -1;
63
+ if (a > b) return 1;
64
+ return 0;
65
+ }
66
+
67
+ const sorted = $derived(
68
+ [...filtered].sort((a, b) => {
69
+ const cmp = compareStrings(sortValue(a), sortValue(b));
70
+ return sortAsc ? cmp : -cmp;
71
+ }),
72
+ );
73
+
74
+ const pageCount = $derived(Math.max(1, Math.ceil(sorted.length / pageSize)));
75
+ // Clamp the page when filtering shrinks the result set below the current page.
76
+ $effect(() => {
77
+ if (page > pageCount) page = pageCount;
78
+ });
79
+ const pageRows = $derived(sorted.slice((page - 1) * pageSize, page * pageSize));
80
+
81
+ function toggleSort(key: SortKey) {
82
+ if (sortKey === key) sortAsc = !sortAsc;
83
+ else {
84
+ sortKey = key;
85
+ sortAsc = true;
86
+ }
87
+ page = 1;
88
+ }
89
+
90
+ // --- create form state, shown in a header-triggered dialog ---
91
+ let createDialog = $state<HTMLDialogElement>();
92
+ // Pending from submit until the create navigation lands, so the button shows a calm working state.
93
+ let creating = $state(false);
17
94
  let title = $state('');
18
95
  let slug = $state('');
19
96
  let slugEdited = $state(false);
@@ -22,64 +99,182 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
22
99
  $effect(() => {
23
100
  dateDefault = new Date().toISOString().slice(0, 10);
24
101
  });
25
-
26
102
  const derivedSlug = $derived(slugEdited ? slug : slugify(title));
27
103
  const slugPlaceholder = $derived(data.dated ? 'my-entry' : 'about-us');
104
+
105
+ // Shared column-header typography: small uppercase muted labels. The sort buttons add their own
106
+ // flex layout and a hover affordance on top of this.
107
+ const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
108
+ const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-base-content`;
28
109
  </script>
29
110
 
30
- <header class="mb-4 flex items-center justify-between">
31
- <h1 class="text-xl font-semibold">{data.label}</h1>
111
+ <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
112
+ <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.label}</h1>
113
+ <div class="flex items-center gap-3 sm:flex-1 sm:flex-wrap sm:justify-end">
114
+ <label class="input input-sm min-w-0 flex-1 sm:max-w-xs">
115
+ <SearchIcon class="h-4 w-4 opacity-60" aria-hidden="true" />
116
+ <input type="search" aria-label="Search {data.label}" bind:value={query} placeholder="Search {data.label.toLowerCase()}" oninput={() => (page = 1)} />
117
+ </label>
118
+ <button type="button" class="btn btn-primary btn-sm shrink-0" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
119
+ <PlusIcon class="h-4 w-4" /> New {data.label}
120
+ </button>
121
+ </div>
32
122
  </header>
33
123
 
34
124
  {#if data.formError}
35
125
  <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
36
126
  {/if}
37
-
38
127
  {#if data.error}
39
128
  <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
40
129
  {/if}
41
130
 
42
- <div class="rounded-box border border-base-300 bg-base-100 mb-6">
43
- {#if data.entries.length === 0}
44
- <p class="p-4 text-sm opacity-70">No entries yet.</p>
45
- {:else}
46
- <ul class="menu w-full">
47
- {#each data.entries as entry (entry.id)}
131
+ {#if deleteRefused}
132
+ <!-- A `?/delete` was refused: name the blockers up front, matching the editor's refusal banner,
133
+ so the author sees why without re-opening a dialog. -->
134
+ <div role="alert" aria-label="This {data.label.toLowerCase()} could not be deleted" class="alert alert-error mb-4 flex-col items-start text-sm">
135
+ <p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
136
+ <p>{deleteRefused.inboundLinks.length} {deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it. Remove or repoint the {deleteRefused.inboundLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
137
+ <ul class="mt-1 w-full">
138
+ {#each deleteRefused.inboundLinks as link (link.concept + '/' + link.id)}
48
139
  <li>
49
- <a href={`/admin/${data.conceptId}/${entry.id}`} class="flex items-center justify-between">
50
- <span>{entry.title}</span>
51
- <span class="flex items-center gap-2 text-xs text-[var(--color-muted)]">
52
- {#if entry.date}<span>{entry.date}</span>{/if}
53
- {#if entry.draft}<span class="badge badge-warning badge-sm">Draft</span>{/if}
54
- </span>
55
- </a>
140
+ <a class="link" href={`/admin/${link.concept}/${link.id}`}>{link.title}</a>
56
141
  </li>
57
142
  {/each}
58
143
  </ul>
144
+ </div>
145
+ {/if}
146
+
147
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 overflow-x-auto shadow-[var(--cairn-shadow)]">
148
+ {#if data.entries.length === 0}
149
+ <div class="flex flex-col items-center gap-4 px-6 py-16 text-center">
150
+ <CairnLogo class="h-12 w-12 text-primary opacity-30" />
151
+ <div class="space-y-1">
152
+ <p class="font-semibold text-base-content">No {data.label.toLowerCase()} yet</p>
153
+ <p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
154
+ </div>
155
+ <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
156
+ <PlusIcon class="h-4 w-4" /> New {data.label}
157
+ </button>
158
+ </div>
159
+ {:else if sorted.length === 0}
160
+ <div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
161
+ <SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
162
+ <p class="text-sm text-[var(--color-muted)]">No {data.label.toLowerCase()} match <span class="font-medium text-base-content">"{query}"</span>.</p>
163
+ </div>
164
+ {:else}
165
+ <table class="table">
166
+ <thead>
167
+ <tr class="border-base-300">
168
+ <th aria-sort={sortKey === 'title' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
169
+ <button type="button" class={sortButton} aria-label="Sort by title" onclick={() => toggleSort('title')}>
170
+ Title
171
+ {#if sortKey === 'title'}
172
+ {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
173
+ {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
174
+ </button>
175
+ </th>
176
+ {#if data.dated}
177
+ <th class="hidden sm:table-cell" aria-sort={sortKey === 'date' ? (sortAsc ? 'ascending' : 'descending') : 'none'}>
178
+ <button type="button" class={sortButton} aria-label="Sort by date" onclick={() => toggleSort('date')}>
179
+ Date
180
+ {#if sortKey === 'date'}
181
+ {#if sortAsc}<ArrowUpIcon class="h-3 w-3" aria-hidden="true" />{:else}<ArrowDownIcon class="h-3 w-3" aria-hidden="true" />{/if}
182
+ {:else}<ChevronsUpDownIcon class="h-3 w-3 opacity-40" aria-hidden="true" />{/if}
183
+ </button>
184
+ </th>
185
+ {/if}
186
+ <th class={headerLabel}>Status</th>
187
+ <th class="text-right"><span class="sr-only">Actions</span></th>
188
+ </tr>
189
+ </thead>
190
+ <tbody>
191
+ {#each pageRows as entry (entry.id)}
192
+ <tr class="transition-colors hover:bg-base-200/60">
193
+ <td><a class="font-medium hover:text-primary hover:underline" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a></td>
194
+ {#if data.dated}<td class="hidden text-sm text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
195
+ <td>
196
+ {#if entry.draft}<span class="badge badge-warning badge-sm font-medium">Draft</span>
197
+ {:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
198
+ </td>
199
+ <td class="text-right">
200
+ {#if deleteRefused?.id === entry.id}
201
+ <!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
202
+ <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} />
203
+ {:else}
204
+ <form method="POST" action="?/delete">
205
+ <input type="hidden" name="id" value={entry.id} />
206
+ <button type="submit" class="btn btn-ghost btn-sm" aria-label="Delete {entry.title}">
207
+ <Trash2Icon class="h-4 w-4 text-error" />
208
+ </button>
209
+ </form>
210
+ {/if}
211
+ </td>
212
+ </tr>
213
+ {/each}
214
+ </tbody>
215
+ </table>
59
216
  {/if}
60
217
  </div>
61
218
 
62
- <form method="POST" action="?/create" class="rounded-box border border-base-300 bg-base-100 flex flex-col gap-3 p-4">
63
- <h2 class="text-sm font-semibold">New entry</h2>
64
- <label class="flex flex-col gap-1">
65
- <span class="text-sm font-medium">Title</span>
66
- <input class="input" name="title" bind:value={title} required />
67
- </label>
68
- <label class="flex flex-col gap-1">
69
- <span class="text-sm font-medium">Slug</span>
70
- <input
71
- class="input"
72
- name="slug"
73
- placeholder={slugPlaceholder}
74
- value={derivedSlug}
75
- oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
76
- />
77
- </label>
78
- {#if data.dated}
79
- <label class="flex flex-col gap-1">
80
- <span class="text-sm font-medium">Date</span>
81
- <input class="input" type="date" name="date" value={dateDefault} />
82
- </label>
83
- {/if}
84
- <button type="submit" class="btn btn-primary self-start">Create</button>
85
- </form>
219
+ {#if data.entries.length > 0}
220
+ <div class="mb-6 flex flex-wrap items-center justify-between gap-2 text-sm">
221
+ <span role="status" class="text-[var(--color-muted)]">{sorted.length} of {data.entries.length}</span>
222
+ <div class="flex items-center gap-2">
223
+ <label class="flex items-center gap-1">
224
+ <span class="sr-only">Rows per page</span>
225
+ <select class="select select-sm" bind:value={pageSize} onchange={() => (page = 1)} aria-label="Rows per page">
226
+ <option value={10}>10</option>
227
+ <option value={25}>25</option>
228
+ <option value={50}>50</option>
229
+ </select>
230
+ </label>
231
+ <button type="button" class="btn btn-sm btn-ghost" aria-label="Previous page" disabled={page <= 1} onclick={() => (page -= 1)}>
232
+ <ChevronLeftIcon class="h-4 w-4" />
233
+ </button>
234
+ <span>Page {page} of {pageCount}</span>
235
+ <button type="button" class="btn btn-sm btn-ghost" aria-label="Next page" disabled={page >= pageCount} onclick={() => (page += 1)}>
236
+ <ChevronRightIcon class="h-4 w-4" />
237
+ </button>
238
+ </div>
239
+ </div>
240
+ {/if}
241
+
242
+ <dialog class="modal" aria-labelledby="cairn-create-dialog-title" bind:this={createDialog}>
243
+ <div class="modal-box">
244
+ <div class="mb-3 flex items-center justify-between">
245
+ <h2 id="cairn-create-dialog-title" class="text-base font-semibold">New {data.label}</h2>
246
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => createDialog?.close()}>✕</button>
247
+ </div>
248
+ <form method="POST" action="?/create" onsubmit={() => (creating = true)} class="flex flex-col gap-3">
249
+ <label class="flex flex-col gap-1">
250
+ <span class="text-sm font-medium">Title</span>
251
+ <input class="input w-full" name="title" bind:value={title} required />
252
+ </label>
253
+ <label class="flex flex-col gap-1">
254
+ <span class="text-sm font-medium">Slug</span>
255
+ <input
256
+ class="input w-full"
257
+ name="slug"
258
+ placeholder={slugPlaceholder}
259
+ value={derivedSlug}
260
+ oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
261
+ />
262
+ </label>
263
+ {#if data.dated}
264
+ <label class="flex flex-col gap-1">
265
+ <span class="text-sm font-medium">Date</span>
266
+ <input class="input w-full" type="date" name="date" value={dateDefault} />
267
+ </label>
268
+ {/if}
269
+ <div class="modal-action">
270
+ <button type="button" class="btn btn-sm" onclick={() => createDialog?.close()}>Cancel</button>
271
+ <button type="submit" class="btn btn-sm btn-primary" disabled={creating}>
272
+ {#if creating}<span class="loading loading-spinner loading-xs" aria-hidden="true"></span> Creating…{:else}Create{/if}
273
+ </button>
274
+ </div>
275
+ </form>
276
+ </div>
277
+ <form method="dialog" class="modal-backdrop">
278
+ <button tabindex="-1" aria-label="Close">close</button>
279
+ </form>
280
+ </dialog>
@@ -5,6 +5,8 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
5
5
  -->
6
6
  <script lang="ts">
7
7
  import './cairn-admin.css';
8
+ import CairnLogo from './CairnLogo.svelte';
9
+ import { cairnFaviconHref } from './cairn-favicon.js';
8
10
 
9
11
  interface Props {
10
12
  /** The confirm load's data: the token to submit, the site name, and an optional error. */
@@ -15,20 +17,35 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
15
17
  </script>
16
18
 
17
19
  <svelte:head>
20
+ <title>Confirm sign-in · Cairn</title>
21
+ <link rel="icon" href={cairnFaviconHref} />
18
22
  <meta name="robots" content="noindex, nofollow" />
19
23
  </svelte:head>
20
24
 
21
- <div data-theme="cairn-admin" class="bg-base-200 text-base-content flex min-h-screen items-center justify-center p-4">
22
- <div class="rounded-box border border-base-300 bg-base-100 w-full max-w-sm p-6 text-center shadow">
23
- <h1 class="mb-4 text-lg font-semibold">Sign in to {data.siteName}</h1>
25
+ <!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
26
+ level in (a class on the theme element itself would not match). -->
27
+ <div data-theme="cairn-admin">
28
+ <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
29
+ <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 text-center shadow-[var(--cairn-shadow)]">
30
+ <div class="mb-6 flex items-center justify-center gap-2">
31
+ <CairnLogo class="h-8 w-8 text-primary" />
32
+ <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
33
+ </div>
34
+
24
35
  {#if data.error || !data.token}
36
+ <h1 class="mb-2 text-lg font-semibold">This link didn't work</h1>
25
37
  <div role="alert" class="alert alert-error text-sm">This sign-in link is invalid or expired.</div>
26
38
  <a href="/admin/login" class="btn btn-ghost btn-sm mt-4">Request a new link</a>
27
39
  {:else}
40
+ <h1 class="text-lg font-semibold">Almost there</h1>
41
+ <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Confirm to finish signing in to {data.siteName}.</p>
28
42
  <form method="POST">
29
43
  <input type="hidden" name="token" value={data.token} />
30
44
  <button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
31
45
  </form>
32
46
  {/if}
33
47
  </div>
48
+
49
+ <p class="text-xs text-[var(--color-muted)]">Powered by Cairn</p>
50
+ </div>
34
51
  </div>
@@ -42,6 +42,9 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
42
42
  // body, so it falls back to the committed data.body. untrack() captures the initial value without
43
43
  // subscribing to future prop changes.
44
44
  let body = $state(untrack(() => form?.body ?? data.body));
45
+ // True from the moment the save form submits until the navigation it triggers replaces the page,
46
+ // so the Save button shows a calm "Saving…" state instead of looking inert.
47
+ let saving = $state(false);
45
48
  let showPreview = $state(false);
46
49
  let previewHtml = $state('');
47
50
  let insert = $state.raw<(text: string) => void>(() => {});
@@ -148,9 +151,9 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
148
151
  }
149
152
  </script>
150
153
 
151
- <header class="mb-4 flex items-center justify-between gap-2">
154
+ <header class="mb-6 flex items-center justify-between gap-2">
152
155
  <div>
153
- <h1 class="text-xl font-semibold">{data.title}</h1>
156
+ <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.title}</h1>
154
157
  <p class="text-xs text-[var(--color-muted)]">{data.label}: {data.id}</p>
155
158
  </div>
156
159
  <div class="flex items-center gap-2">
@@ -217,11 +220,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
217
220
  </div>
218
221
  {/if}
219
222
 
220
- <form method="POST" action="?/save" class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
223
+ <form method="POST" action="?/save" onsubmit={() => (saving = true)} class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
221
224
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
222
225
 
223
226
  <div class="lg:order-1">
224
- <div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
227
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
225
228
  <MarkdownEditor
226
229
  bind:value={body}
227
230
  name="body"
@@ -234,7 +237,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
234
237
  <section
235
238
  id="cairn-preview"
236
239
  aria-label="Preview"
237
- class="rounded-box border border-base-300 bg-base-100 prose mt-4 max-w-none p-4"
240
+ class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 prose mt-4 max-w-none p-4 shadow-[var(--cairn-shadow)]"
238
241
  >
239
242
  {@html previewHtml}
240
243
  </section>
@@ -242,7 +245,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
242
245
  </div>
243
246
 
244
247
  <aside class="lg:order-2 mt-4 lg:mt-0">
245
- <fieldset class="rounded-box border border-base-300 bg-base-100 flex flex-col gap-3 p-4">
248
+ <fieldset class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-3 p-4 shadow-[var(--cairn-shadow)]">
246
249
  <legend class="sr-only">Frontmatter</legend>
247
250
  {#each data.fields as field (field.name)}
248
251
  {#if field.type === 'textarea'}
@@ -301,7 +304,9 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
301
304
  </label>
302
305
  {/if}
303
306
  {/each}
304
- <button type="submit" class="btn btn-primary mt-2">Save</button>
307
+ <button type="submit" class="btn btn-primary mt-3" disabled={saving}>
308
+ {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
309
+ </button>
305
310
  </fieldset>
306
311
  </aside>
307
312
  </form>
@@ -6,6 +6,10 @@ the allowlist, so the page never leaks membership (spec §7.1).
6
6
  -->
7
7
  <script lang="ts">
8
8
  import './cairn-admin.css';
9
+ import { onMount } from 'svelte';
10
+ import CairnLogo from './CairnLogo.svelte';
11
+ import { cairnFaviconHref } from './cairn-favicon.js';
12
+ import { warnIfChromeWrapped } from './chrome-guard.js';
9
13
 
10
14
  interface Props {
11
15
  /** The login load's data: the site name and an optional error. */
@@ -15,16 +19,31 @@ the allowlist, so the page never leaks membership (spec §7.1).
15
19
  }
16
20
 
17
21
  let { data, form }: Props = $props();
22
+
23
+ let rootEl = $state<HTMLElement>();
24
+ onMount(() => {
25
+ if (rootEl) warnIfChromeWrapped(rootEl);
26
+ });
18
27
  </script>
19
28
 
20
29
  <svelte:head>
30
+ <title>Sign in · Cairn</title>
31
+ <link rel="icon" href={cairnFaviconHref} />
21
32
  <meta name="robots" content="noindex, nofollow" />
22
33
  </svelte:head>
23
34
 
24
- <div data-theme="cairn-admin" class="bg-base-200 text-base-content flex min-h-screen items-center justify-center p-4">
25
- <div class="rounded-box border border-base-300 bg-base-100 w-full max-w-sm p-6 shadow">
26
- <h1 class="mb-1 text-lg font-semibold">Sign in to {data.siteName}</h1>
27
- <p class="mb-4 text-sm text-[var(--color-muted)]">Enter your email and we'll send a sign-in link.</p>
35
+ <!-- data-theme on a bare wrapper: the scoped sheet styles descendants, so the layout classes go one
36
+ level in (a class on the theme element itself would not match). -->
37
+ <div data-theme="cairn-admin" bind:this={rootEl}>
38
+ <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
39
+ <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
40
+ <div class="mb-6 flex items-center gap-2">
41
+ <CairnLogo class="h-8 w-8 text-primary" />
42
+ <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
43
+ </div>
44
+
45
+ <h1 class="text-lg font-semibold">Sign in to {data.siteName}</h1>
46
+ <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
28
47
 
29
48
  {#if form?.sent}
30
49
  <div role="status" class="alert alert-success text-sm">
@@ -32,7 +51,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
32
51
  </div>
33
52
  {:else}
34
53
  {#if data.error}
35
- <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one.</div>
54
+ <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
36
55
  {/if}
37
56
  <form method="POST" class="flex flex-col gap-3">
38
57
  <label class="flex flex-col gap-1">
@@ -51,4 +70,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
51
70
  </form>
52
71
  {/if}
53
72
  </div>
73
+
74
+ <p class="text-xs text-[var(--color-muted)]">Powered by Cairn</p>
75
+ </div>
54
76
  </div>
@@ -16,20 +16,23 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
16
16
  }
17
17
 
18
18
  let { data, form }: Props = $props();
19
+
20
+ // Eyebrow styling for the table column headers, matching the concept list.
21
+ const col = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
19
22
  </script>
20
23
 
21
- <header class="mb-4">
22
- <h1 class="text-xl font-semibold">Editors</h1>
24
+ <header class="mb-6">
25
+ <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">Editors</h1>
23
26
  </header>
24
27
 
25
28
  {#if form?.error}
26
29
  <div role="alert" class="alert alert-error mb-4 text-sm">{form.error}</div>
27
30
  {/if}
28
31
 
29
- <div class="overflow-x-auto rounded-box border border-base-300 bg-base-100 mb-6">
32
+ <div class="overflow-x-auto rounded-box border border-[var(--cairn-card-border)] bg-base-100 mb-4 shadow-[var(--cairn-shadow)]">
30
33
  <table class="table">
31
34
  <thead>
32
- <tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Role</th><th scope="col"><span class="sr-only">Actions</span></th></tr>
35
+ <tr><th scope="col" class={col}>Name</th><th scope="col" class={col}>Email</th><th scope="col" class={col}>Role</th><th scope="col"><span class="sr-only">Actions</span></th></tr>
33
36
  </thead>
34
37
  <tbody>
35
38
  {#each data.editors as editor (editor.email)}
@@ -61,7 +64,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
61
64
  </table>
62
65
  </div>
63
66
 
64
- <form method="POST" action="?/add" class="rounded-box border border-base-300 bg-base-100 grid gap-3 p-4 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
67
+ <form method="POST" action="?/add" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
65
68
  <label class="flex flex-col gap-1">
66
69
  <span class="text-sm font-medium">Name</span>
67
70
  <input class="input" name="name" aria-label="Name" required />
@@ -88,7 +88,7 @@ validates on save.
88
88
  }
89
89
  </script>
90
90
 
91
- <h1 class="mb-4 text-xl font-semibold">{data.menu.label}</h1>
91
+ <h1 class="mb-6 text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.menu.label}</h1>
92
92
 
93
93
  {#if data.saved}
94
94
  <div role="status" class="alert alert-success mb-4 text-sm">Navigation saved.</div>
@@ -104,7 +104,7 @@ validates on save.
104
104
  <button type="button" class="btn btn-sm" onclick={addRow}>Add item</button>
105
105
  </div>
106
106
 
107
- <div class="sortable-list-area" style="min-height:2.5rem">
107
+ <div class="sortable-list-area rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-2 shadow-[var(--cairn-shadow)]" style="min-height:2.5rem">
108
108
  <SortableList.Root ondragend={handleDragEnd} aria-label="Navigation items">
109
109
  {#each rows as row, index (row.id)}
110
110
  <SortableList.Item id={row.id} {index} aria-label={`${row.label || 'Untitled'}, level ${row.depth + 1}`}>
@@ -0,0 +1,15 @@
1
+ // The fixed set of Lucide glyphs the admin chrome uses, each a per-icon import so only these ship.
2
+ // Components import from here, which keeps one import surface and documents the chrome's icon set.
3
+ export { default as MenuIcon } from '@lucide/svelte/icons/menu';
4
+ export { default as SearchIcon } from '@lucide/svelte/icons/search';
5
+ export { default as ArrowUpIcon } from '@lucide/svelte/icons/arrow-up';
6
+ export { default as ArrowDownIcon } from '@lucide/svelte/icons/arrow-down';
7
+ export { default as ChevronsUpDownIcon } from '@lucide/svelte/icons/chevrons-up-down';
8
+ export { default as PencilIcon } from '@lucide/svelte/icons/pencil';
9
+ export { default as Trash2Icon } from '@lucide/svelte/icons/trash-2';
10
+ export { default as PlusIcon } from '@lucide/svelte/icons/plus';
11
+ export { default as LogOutIcon } from '@lucide/svelte/icons/log-out';
12
+ export { default as SunIcon } from '@lucide/svelte/icons/sun';
13
+ export { default as MoonIcon } from '@lucide/svelte/icons/moon';
14
+ export { default as ChevronLeftIcon } from '@lucide/svelte/icons/chevron-left';
15
+ export { default as ChevronRightIcon } from '@lucide/svelte/icons/chevron-right';