@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,117 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.34.0
6
+
7
+ A deployed admin request that arrives over http now gets a clear, branded help page instead of the
8
+ framework's opaque CSRF 403. The magic-link sign-in posts a JS-free form, and the framework rejects a
9
+ form POST unless the request carries a matching https origin, so an admin reached over http cannot sign
10
+ in. The auth guard detects that case on a deployed host and serves a self-contained page that names the
11
+ problem, links to the https version for one-click recovery, and gives the exact Cloudflare fix (Always
12
+ Use HTTPS). The page matches the admin design system in light and dark. Local `wrangler dev` over http
13
+ is exempt.
14
+
15
+ The release also adds a `check:prose` gate (`scripts/check-admin-prose.mjs`, in CI) that scans the admin
16
+ components' user-facing strings for AI-writing tells, since the component copy ships compiled and a
17
+ consuming site's prose tooling never sees it.
18
+
19
+ Consumers may: force HTTPS at the edge (Always Use HTTPS plus HSTS), which the deploy guide now requires.
20
+ The help page is a fallback for the window before that is set, not a substitute.
21
+
22
+ ## 0.33.0
23
+
24
+ The admin isolates itself from host chrome. A dev-only guard in the admin and login roots walks the
25
+ ancestor chain on mount and logs one `console.error` when a width-constraining ancestor sits between the
26
+ admin root and `<body>`, the sign that a site's root layout is wrapping the admin in its own nav, footer,
27
+ or container. The guard compiles out of production and changes no rendering. The canonical route pattern
28
+ is documented and demonstrated: a chrome-free root layout plus a URL-transparent `(site)` group that
29
+ holds the public chrome and `app.css`, so the host chrome never wraps `/admin`. The showcase gains a
30
+ `(site)` group with plain-CSS chrome, which proves the admin renders fully styled on a site that uses
31
+ neither Tailwind nor DaisyUI.
32
+
33
+ This closes the global at-rule note carried since the self-styling foundation. The compiled admin sheet
34
+ holds DaisyUI `@keyframes` and Tailwind `@property` rules that are document-global by CSS spec, but the
35
+ sheet is code-split to the admin roots that import it, so it loads only on `/admin`, and the route pattern
36
+ keeps the host's CSS off `/admin` from the other side. A boundary test pins that the admin sheet is
37
+ imported only by the admin roots.
38
+
39
+ Consumers must: keep the host root layout chrome-free and move the public chrome plus `app.css` into a
40
+ `(site)` route group, so the host chrome never wraps `/admin`. A site already on this structure needs no
41
+ change. The dev guard names the problem in the console if a root layout still wraps the admin.
42
+
43
+ ## 0.32.0
44
+
45
+ The admin gets a real CMS UX. The concept list is now a searchable, sortable data-table with status
46
+ badges, formatted dates, per-row delete, and pagination. The sidebar carries an icon per nav item and
47
+ a user menu with sign-out. The topbar is sticky and shows breadcrumbs. The admin has a dark mode, with
48
+ a topbar toggle that persists through a cookie and follows the OS preference on a first visit. The admin
49
+ icons are Lucide, added as a runtime dependency.
50
+
51
+ This release also fixes the self-styled admin so its drawer sidebar renders: the stylesheet build now
52
+ flattens CSS nesting before scoping (so DaisyUI's `lg:drawer-open` reveal is not severed from its
53
+ parent), and the admin layout carries `data-theme` on a wrapper so the drawer's own classes are scoped
54
+ descendants. The build gained `lightningcss` as a build-only devDependency for the flatten step; this
55
+ does not affect a consumer's runtime.
56
+
57
+ A frontend-design polish pass then refined the look. The Warm Stone light and dark palettes gained
58
+ clearer surface layering and crisper borders, the sidebar an active state in a soft primary tint, and
59
+ the list table refined column labels, row hover, and cleaner entry-title links. The list now defaults
60
+ to newest-first. A reduced-motion preference is honored inside the admin. A scoped anchor reset
61
+ restores the no-underline, inherit-color default the omitted Preflight used to provide.
62
+
63
+ A design-identity pass then gave the admin its own look. Cairn has a wordmark set in Bricolage
64
+ Grotesque over a body face of Figtree, both self-hosted as variable woff2 under the SIL Open Font
65
+ License, so the admin makes no webfont network call. An app-icon brand tile sits at the top of the
66
+ sidebar with the Cairn cairn-stack mark, a CC0 public-domain glyph, beside a CMS chip. The surfaces
67
+ moved to softer radii and floating cards over a calm warm-neutral ground, with a soft violet lift on
68
+ the primary button. The sidebar and the topbar share one flat header strip, so their intersection
69
+ reads as a single plane.
70
+
71
+ The nav now groups its entries. The core Cairn functions live in one collapsible group, and a
72
+ developer's own admin extensions sit in their own custom-named groups at the same level. Each group's
73
+ open or collapsed state persists through a `cairn-admin-nav-collapsed` cookie that the layout load
74
+ reads for a no-flash first paint, the way the theme cookie already works. A command palette opens with
75
+ Cmd/Ctrl+K or the topbar search box, jumps to any admin destination, and runs a couple of actions like
76
+ the theme toggle. The login and confirm screens carry the same wordmark, voice, and favicon.
77
+
78
+ Two more rendering fixes landed in this window. The login and confirm screens centered on a wrapper
79
+ rather than the themed element, so they now fill the viewport like the rest of the admin. The command
80
+ palette closed its dialog from a result link's own click handler, and closing a native dialog mid-click
81
+ cancelled the navigation, so a destination did nothing; a destination now navigates and the palette
82
+ closes once the new route lands.
83
+
84
+ This is additive for a consumer that mounts the admin through the documented routes. The engine now
85
+ depends on `@lucide/svelte`, which installs transitively, so no consumer action is required. A new
86
+ `listDeleteAction` is available on the content routes for wiring per-row delete on the list page; the
87
+ showcase wires it as the list `?/delete` action.
88
+
89
+ ## 0.31.0
90
+
91
+ The admin now ships its own stylesheet. The engine compiles the admin's Tailwind utilities and
92
+ DaisyUI component classes, scoped under the admin `data-theme`, and the admin styles itself on any
93
+ host with no Tailwind or DaisyUI of its own. The compiled sheet leaks no global rule, so it never
94
+ touches the host's pages.
95
+
96
+ Consumers may: remove any Tailwind `@source` entry that existed only to generate the admin's classes;
97
+ the admin no longer depends on the host's Tailwind or DaisyUI build. A host that already provides
98
+ DaisyUI globally keeps working, since the engine's scoped rules are low-specificity (`:where`) and
99
+ the class names match; a later pass moves the admin out of the host's chrome entirely.
100
+
101
+ ## 0.30.0
102
+
103
+ Carved a `@glw907/cairn-cms/render` authoring subpath for the component-authoring toolkit. `iconSpan`,
104
+ `cardShell`, `headRow`, the re-homed `isElement`, and the new `strAttr` now live there, so the root barrel
105
+ stays lean and a component `build()` imports its helpers from one obvious place. Added `strAttr(ctx, key)`,
106
+ a string-attribute reader, a configurable `headRow` heading level that defaults to 2, a
107
+ `registry.iconField(name)` accessor, and a `defineRegistry` guard that fails a component declaring
108
+ `defaultIconByRole` with no `type:'icon'` attribute. Dropped `rehypeDispatch` from the public surface, so
109
+ `createRenderer` is the one public render pipeline.
110
+
111
+ Consumers must: import `iconSpan`, `cardShell`, `headRow`, `isElement`, and `strAttr` from
112
+ `@glw907/cairn-cms/render` instead of the package root, and replace any direct `rehypeDispatch` use with
113
+ `createRenderer`. A component that sets `defaultIconByRole` with no `type:'icon'` attribute now fails
114
+ `defineRegistry`; give it an icon attribute or drop `defaultIconByRole`.
115
+
5
116
  ## 0.29.0
6
117
 
7
118
  Consolidated the URL-identity model. A content entry's id, slug, date, and permalink are now derived in
@@ -2,12 +2,23 @@
2
2
  @component
3
3
  The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
4
4
  data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
5
- root sets `data-theme="cairn-admin"` and imports the self-contained Warm Stone theme, so the
6
- admin looks identical on every host regardless of the site's own theme.
5
+ root sets `data-theme` to the resolved light or dark theme (seeded from the SSR'd cookie choice,
6
+ flipped by the topbar toggle) and imports the self-contained Warm Stone theme, so the admin looks
7
+ identical on every host regardless of the site's own theme.
7
8
  -->
8
9
  <script lang="ts">
9
- import type { Snippet } from 'svelte';
10
+ import { onMount, untrack, type Component, type Snippet } from 'svelte';
10
11
  import type { LayoutData } from '../sveltekit/content-routes.js';
12
+ import { MenuIcon, LogOutIcon, SunIcon, MoonIcon, ChevronRightIcon, SearchIcon } from './admin-icons.js';
13
+ import CairnLogo from './CairnLogo.svelte';
14
+ import { cairnFaviconHref } from './cairn-favicon.js';
15
+ import { warnIfChromeWrapped } from './chrome-guard.js';
16
+ import FileTextIcon from '@lucide/svelte/icons/file-text';
17
+ import SignpostIcon from '@lucide/svelte/icons/signpost';
18
+ import SettingsIcon from '@lucide/svelte/icons/settings';
19
+ import UsersIcon from '@lucide/svelte/icons/users';
20
+ import BlocksIcon from '@lucide/svelte/icons/blocks';
21
+ import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
11
22
  import './cairn-admin.css';
12
23
 
13
24
  interface Props {
@@ -19,62 +30,379 @@ admin looks identical on every host regardless of the site's own theme.
19
30
 
20
31
  let { data, children }: Props = $props();
21
32
 
33
+ // Persist an admin preference for a year, path-scoped to /admin so the cookie never reaches the
34
+ // host's own pages.
35
+ function writeAdminCookie(name: string, value: string) {
36
+ document.cookie = `${name}=${value}; path=/admin; max-age=31536000; samesite=lax`;
37
+ }
38
+
39
+ // A nav entry. `href` makes it a link; without one it is an inert stub (a developer-tool slot the
40
+ // extension mechanism has not wired yet).
22
41
  interface NavItem {
23
- href: string;
24
42
  label: string;
25
- owner?: boolean;
43
+ icon: Component;
44
+ href?: string;
26
45
  }
27
46
 
28
- const navItems: NavItem[] = $derived([
29
- ...data.concepts.map((c) => ({ href: `/admin/${c.id}`, label: c.label })),
30
- ...(data.navLabel ? [{ href: '/admin/nav', label: data.navLabel }] : []),
31
- { href: '/admin/editors', label: 'Editors', owner: true },
47
+ // The core Cairn functions, all in one group: the content concepts, the nav-menu editor (when the
48
+ // site configures one; a signpost, kept distinct from the Settings gear), the site Settings, and
49
+ // the owner-only Editors.
50
+ const coreItems: NavItem[] = $derived([
51
+ ...data.concepts.map((c) => ({ label: c.label, icon: FileTextIcon, href: `/admin/${c.id}` })),
52
+ ...(data.navLabel ? [{ label: data.navLabel, icon: SignpostIcon, href: '/admin/nav' }] : []),
53
+ { label: 'Settings', icon: SettingsIcon, href: '/admin/settings' },
54
+ ...(data.canManageEditors ? [{ label: 'Editors', icon: UsersIcon, href: '/admin/editors' }] : []),
32
55
  ]);
33
56
 
34
- const visibleNav = $derived(navItems.filter((item) => !item.owner || data.canManageEditors));
57
+ // The developer-extension groups: each custom-named, with its own items, collapsible like the core
58
+ // group. The CairnExtension seam will supply these; until it lands they are inert example stubs that
59
+ // show the shape, multiple named groups kept visually apart from the core functions.
60
+ const extensionGroups: { name: string; items: NavItem[] }[] = [
61
+ { name: 'Marketing', items: [
62
+ { label: 'Campaigns', icon: BlocksIcon },
63
+ { label: 'Audiences', icon: BlocksIcon },
64
+ ] },
65
+ { name: 'Shop', items: [
66
+ { label: 'Products', icon: BlocksIcon },
67
+ { label: 'Orders', icon: BlocksIcon },
68
+ ] },
69
+ ];
70
+
71
+ // Up to two uppercase initials from the display name, falling back to '?' for an empty name.
72
+ function initialsOf(displayName: string): string {
73
+ const letters = displayName
74
+ .split(/\s+/)
75
+ .filter(Boolean)
76
+ .slice(0, 2)
77
+ .map((word) => word[0]?.toUpperCase() ?? '')
78
+ .join('');
79
+ return letters || '?';
80
+ }
81
+
82
+ const initials = $derived(initialsOf(data.user.displayName));
35
83
 
36
84
  function isActive(href: string): boolean {
37
85
  return data.pathname === href || data.pathname.startsWith(`${href}/`);
38
86
  }
87
+
88
+ // Which nav groups are collapsed. Seeded once from the SSR'd cookie (so a collapsed group renders
89
+ // collapsed with no flash), then owned by the toggle below, which mirrors each change to the cookie.
90
+ let collapsed = $state(new Set(untrack(() => data.collapsedNav)));
91
+
92
+ function onToggleSection(label: string, open: boolean) {
93
+ const next = new Set(collapsed);
94
+ if (open) next.delete(label);
95
+ else next.add(label);
96
+ collapsed = next;
97
+ const value = [...next].map((entry) => encodeURIComponent(entry)).join(',');
98
+ writeAdminCookie('cairn-admin-nav-collapsed', value);
99
+ }
100
+
101
+ let drawerOpen = $state(false);
102
+
103
+ function onKeydown(e: KeyboardEvent) {
104
+ if (e.key.toLowerCase() === 'b' && (e.metaKey || e.ctrlKey)) {
105
+ e.preventDefault();
106
+ drawerOpen = !drawerOpen;
107
+ }
108
+ if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
109
+ e.preventDefault();
110
+ openPalette();
111
+ }
112
+ }
113
+
114
+ // Close the mobile drawer and the command palette whenever the active path changes (a nav click
115
+ // navigated). Closing the palette here, after the navigation lands, avoids racing a synchronous
116
+ // close() against a result link's own navigation, which would cancel it.
117
+ $effect(() => {
118
+ data.pathname;
119
+ drawerOpen = false;
120
+ paletteDialog?.close();
121
+ });
122
+
123
+ // Seed from the SSR'd theme once. The live theme is owned by this state and the toggle, so the
124
+ // initial read of data.theme is intentional and untracked to keep it out of any reactive graph.
125
+ let theme = $state<'cairn-admin' | 'cairn-admin-dark'>(untrack(() => data.theme));
126
+
127
+ // First mount with no persisted choice follows the OS preference. A returning user's cookie was
128
+ // already honored by the layout load (data.theme), so this only fires on a first-ever visit.
129
+ $effect(() => {
130
+ const hasCookie = document.cookie.split('; ').some((c) => c.startsWith('cairn-admin-theme='));
131
+ if (!hasCookie && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
132
+ theme = 'cairn-admin-dark';
133
+ }
134
+ });
135
+
136
+ function toggleTheme() {
137
+ theme = theme === 'cairn-admin' ? 'cairn-admin-dark' : 'cairn-admin';
138
+ writeAdminCookie('cairn-admin-theme', theme);
139
+ }
140
+
141
+ // The command palette: a quick jump-to over the admin's destinations plus a couple of actions, so
142
+ // the topbar carries something productive. Opened by the topbar trigger or Cmd/Ctrl+K.
143
+ interface Command {
144
+ label: string;
145
+ icon: Component;
146
+ href?: string;
147
+ external?: boolean;
148
+ action?: () => void;
149
+ }
150
+
151
+ let paletteDialog = $state<HTMLDialogElement>();
152
+ let paletteList = $state<HTMLUListElement>();
153
+ let paletteQuery = $state('');
154
+
155
+ // The bare data-theme wrapper is the admin root the dev chrome-guard measures from.
156
+ let rootEl = $state<HTMLElement>();
157
+ onMount(() => {
158
+ if (rootEl) warnIfChromeWrapped(rootEl);
159
+ });
160
+
161
+ const paletteCommands = $derived<Command[]>([
162
+ ...coreItems.map((item) => ({ label: item.label, icon: item.icon, href: item.href })),
163
+ { label: 'View the live site', icon: ExternalLinkIcon, href: '/', external: true },
164
+ theme === 'cairn-admin'
165
+ ? { label: 'Switch to dark mode', icon: MoonIcon, action: toggleTheme }
166
+ : { label: 'Switch to light mode', icon: SunIcon, action: toggleTheme },
167
+ ]);
168
+ const paletteResults = $derived(
169
+ paletteCommands.filter((c) => c.label.toLowerCase().includes(paletteQuery.trim().toLowerCase())),
170
+ );
171
+
172
+ function openPalette() {
173
+ if (paletteDialog?.open) return; // showModal throws on an already-open dialog
174
+ paletteQuery = '';
175
+ paletteDialog?.showModal();
176
+ }
177
+ // An action command (theme toggle). Link commands are real <a> elements that navigate on click, so
178
+ // the Enter shortcut clicks the first result element and both paths share the one navigation.
179
+ function runCommand(cmd: Command) {
180
+ paletteDialog?.close();
181
+ cmd.action?.();
182
+ }
183
+ function submitPalette() {
184
+ (paletteList?.querySelector('a, button') as HTMLElement | null)?.click();
185
+ }
186
+
187
+ interface Crumb {
188
+ label: string;
189
+ href?: string;
190
+ }
191
+
192
+ // Path-derived breadcrumbs: the concept label (from the nav) then the entry id segment. Only the
193
+ // /admin/<concept>/<id> depth shows a trail; a bare concept list shows just the concept.
194
+ const crumbs = $derived.by<Crumb[]>(() => {
195
+ const segs = data.pathname.split('/').filter(Boolean); // ['admin', concept, id?]
196
+ if (segs.length < 2 || segs[0] !== 'admin') return [];
197
+ const conceptId = segs[1];
198
+ const concept = data.concepts.find((c) => c.id === conceptId);
199
+ const out: Crumb[] = [{ label: concept?.label ?? conceptId, href: `/admin/${conceptId}` }];
200
+ if (segs[2]) out.push({ label: decodeURIComponent(segs[2]) });
201
+ return out;
202
+ });
203
+
204
+ // The browser-tab title: the deepest breadcrumb (the active concept or entry), then the brand.
205
+ const pageTitle = $derived(crumbs.length ? crumbs[crumbs.length - 1].label : 'Admin');
39
206
  </script>
40
207
 
41
- <div data-theme="cairn-admin" class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
42
- <input id="cairn-drawer" type="checkbox" class="drawer-toggle" />
43
-
44
- <div class="drawer-content flex flex-col">
45
- <div class="navbar bg-base-100 border-b border-base-300">
46
- <div class="flex-none lg:hidden">
47
- <label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
48
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
49
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
50
- </svg>
51
- </label>
208
+ <svelte:head>
209
+ <title>{pageTitle} · {data.siteName}</title>
210
+ <link rel="icon" href={cairnFaviconHref} />
211
+ </svelte:head>
212
+
213
+ <svelte:window onkeydown={onKeydown} />
214
+
215
+ <!-- data-theme sits on a bare wrapper, not on the drawer itself: every admin rule is scoped as a
216
+ descendant of the theme root (`:where([data-theme]) .drawer`), so a class on the theme element
217
+ itself never matches. Keeping the drawer and its base/utility classes one level in lets the
218
+ scoped sheet style them. -->
219
+ <div data-theme={theme} bind:this={rootEl}>
220
+ <div class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
221
+ <input id="cairn-drawer" type="checkbox" class="drawer-toggle" bind:checked={drawerOpen} />
222
+
223
+ <div class="drawer-content flex flex-col">
224
+ <!-- The topbar is a flat, opaque continuation of the sidebar's brand band: same surface and the
225
+ same hairline, no shadow, so the two form one clean header strip across the sidebar seam. -->
226
+ <div class="navbar bg-base-100 border-b border-[var(--cairn-card-border)] sticky top-0 z-30 gap-2 px-4 lg:px-8">
227
+ <div class="flex-none lg:hidden">
228
+ <label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
229
+ <MenuIcon class="h-5 w-5" />
230
+ </label>
231
+ </div>
232
+ <!-- Context on the left: the breadcrumb trail inside an entry, the site name on a bare list.
233
+ Hidden on small screens to leave room for the palette trigger. -->
234
+ <div class="hidden min-w-0 max-w-[30%] flex-none truncate sm:block">
235
+ {#if crumbs.length > 1}
236
+ <nav aria-label="Breadcrumb" class="breadcrumbs text-sm">
237
+ <ul>
238
+ {#each crumbs as crumb (crumb.href ?? crumb.label)}
239
+ <li>{#if crumb.href}<a href={crumb.href}>{crumb.label}</a>{:else}{crumb.label}{/if}</li>
240
+ {/each}
241
+ </ul>
242
+ </nav>
243
+ {:else}
244
+ <span class="font-semibold tracking-tight">{data.siteName}</span>
245
+ {/if}
246
+ </div>
247
+ <!-- The command-palette trigger fills the center: a quick jump-to over the admin, opened here
248
+ or with Cmd/Ctrl+K. -->
249
+ <div class="flex min-w-0 flex-1 justify-center">
250
+ <button
251
+ type="button"
252
+ onclick={openPalette}
253
+ 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"
254
+ >
255
+ <SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
256
+ <span class="truncate">Search or jump to&hellip;</span>
257
+ <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>
258
+ </button>
259
+ </div>
260
+ <div class="flex-none">
261
+ <button type="button" class="btn btn-square btn-ghost" aria-label="Toggle theme" onclick={toggleTheme}>
262
+ {#if theme === 'cairn-admin'}<MoonIcon class="h-5 w-5" />{:else}<SunIcon class="h-5 w-5" />{/if}
263
+ </button>
264
+ </div>
52
265
  </div>
53
- <div class="flex-1 px-2 font-semibold">{data.siteName}</div>
54
- <div class="flex-none px-2 text-sm text-[var(--color-muted)]">{data.user.displayName}</div>
266
+
267
+ <main class="flex-1 p-4 lg:p-8">
268
+ {@render children()}
269
+ </main>
270
+
271
+ <dialog bind:this={paletteDialog} class="modal" aria-label="Search or jump to">
272
+ <div class="modal-box max-w-xl self-start p-0 sm:mt-[12vh]">
273
+ <div class="flex items-center gap-2 border-b border-[var(--cairn-card-border)] px-4">
274
+ <SearchIcon class="h-4 w-4 shrink-0 text-[var(--color-muted)]" aria-hidden="true" />
275
+ <input
276
+ bind:value={paletteQuery}
277
+ type="text"
278
+ aria-label="Search or jump to"
279
+ placeholder="Search or jump to…"
280
+ class="w-full bg-transparent py-3.5 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
281
+ onkeydown={(e) => {
282
+ if (e.key === 'Enter') {
283
+ e.preventDefault();
284
+ submitPalette();
285
+ }
286
+ }}
287
+ />
288
+ </div>
289
+ {#if paletteResults.length}
290
+ <ul bind:this={paletteList} class="menu max-h-[60vh] w-full gap-0.5 overflow-y-auto p-2">
291
+ {#each paletteResults as cmd (cmd.label)}
292
+ <li>
293
+ {#if cmd.href}
294
+ <!-- An internal link navigates and the pathname effect closes the palette once the route lands,
295
+ so it carries no onclick (closing here would cancel the navigation). An external link
296
+ opens a new tab and leaves this page, so it closes the palette itself. -->
297
+ <a
298
+ href={cmd.href}
299
+ target={cmd.external ? '_blank' : undefined}
300
+ rel={cmd.external ? 'noopener' : undefined}
301
+ onclick={cmd.external ? () => paletteDialog?.close() : undefined}
302
+ >
303
+ <cmd.icon class="h-4 w-4 text-[var(--color-muted)]" aria-hidden="true" />
304
+ {cmd.label}
305
+ {#if cmd.external}<ExternalLinkIcon class="ml-auto h-3.5 w-3.5 opacity-50" aria-hidden="true" />{/if}
306
+ </a>
307
+ {:else}
308
+ <button type="button" onclick={() => runCommand(cmd)}>
309
+ <cmd.icon class="h-4 w-4 text-[var(--color-muted)]" aria-hidden="true" />
310
+ {cmd.label}
311
+ </button>
312
+ {/if}
313
+ </li>
314
+ {/each}
315
+ </ul>
316
+ {:else}
317
+ <p class="px-4 py-6 text-center text-sm text-[var(--color-muted)]">No matches for "{paletteQuery}".</p>
318
+ {/if}
319
+ </div>
320
+ <form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
321
+ </dialog>
55
322
  </div>
56
323
 
57
- <main class="flex-1 p-4 lg:p-8">
58
- {@render children()}
59
- </main>
60
- </div>
324
+ <div class="drawer-side">
325
+ <label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
326
+ <nav class="bg-base-100 flex min-h-full w-64 flex-col border-r border-[var(--cairn-card-border)]" aria-label="Site content">
327
+ <!-- Brand band, the same height as the topbar. The mark sits in a filled "app-icon" tile, which
328
+ anchors the corner as a deliberate brand object rather than a washed box. The logo and
329
+ wordmark link to the admin home. -->
330
+ <div class="flex h-16 flex-none items-center border-b border-[var(--cairn-card-border)] px-3">
331
+ <a href="/admin" aria-label="Cairn admin home" class="flex items-center gap-2.5 rounded-field px-2 py-1.5 transition-colors hover:bg-base-content/[0.05]">
332
+ <span class="flex h-8 w-8 items-center justify-center rounded-xl bg-primary text-primary-content shadow-sm">
333
+ <CairnLogo class="h-5 w-5" />
334
+ </span>
335
+ <span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
336
+ <span class="rounded-md border border-base-300 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-[var(--color-muted)]">CMS</span>
337
+ </a>
338
+ </div>
339
+
340
+ <div class="flex-1 space-y-1 overflow-y-auto py-4">
341
+ {#snippet navSection(label: string, items: NavItem[])}
342
+ <details class="px-2" open={!collapsed.has(label)} ontoggle={(e) => onToggleSection(label, e.currentTarget.open)}>
343
+ <summary class="group/sec flex cursor-pointer select-none items-center gap-2 rounded-field bg-base-content/[0.04] py-2 pl-5 pr-3 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)] transition-colors hover:bg-base-content/[0.08] hover:text-base-content">
344
+ <span class="truncate">{label}</span>
345
+ <ChevronRightIcon class="cairn-caret ml-auto h-3 w-3 shrink-0 opacity-50 transition-opacity group-hover/sec:opacity-90" aria-hidden="true" />
346
+ </summary>
347
+ <ul class="menu menu-sm mt-1 w-full gap-0.5 p-0">
348
+ {#each items as item (item.href ?? item.label)}
349
+ <li>
350
+ {#if item.href}
351
+ <a
352
+ href={item.href}
353
+ class={isActive(item.href)
354
+ ? 'bg-primary/10 font-semibold text-primary'
355
+ : 'font-medium text-[var(--color-subtle)]'}
356
+ aria-current={isActive(item.href) ? 'page' : undefined}
357
+ >
358
+ <item.icon class="h-4 w-4" aria-hidden="true" />
359
+ {item.label}
360
+ </a>
361
+ {:else}
362
+ <span
363
+ class="cursor-default font-medium text-[var(--color-muted)] opacity-60"
364
+ aria-disabled="true"
365
+ title="A slot for a site developer's own admin tool. Not wired yet."
366
+ >
367
+ <item.icon class="h-4 w-4" aria-hidden="true" />
368
+ {item.label}
369
+ </span>
370
+ {/if}
371
+ </li>
372
+ {/each}
373
+ </ul>
374
+ </details>
375
+ {/snippet}
61
376
 
62
- <div class="drawer-side">
63
- <label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
64
- <nav class="bg-base-100 min-h-full w-64 border-r border-base-300 p-4" aria-label="Site content">
65
- <div class="menu-title mb-2 px-2 text-xs uppercase tracking-wide text-[var(--color-muted)]">Content</div>
66
- <ul class="menu menu-lg w-full">
67
- {#each visibleNav as item (item.href)}
68
- <li>
69
- <a href={item.href} class:menu-active={isActive(item.href)} aria-current={isActive(item.href) ? 'page' : undefined}>
70
- {item.label}
71
- </a>
72
- </li>
73
- {/each}
74
- </ul>
75
- <form method="POST" action="/admin/auth/logout" class="mt-6 px-2">
76
- <button type="submit" class="btn btn-ghost btn-sm btn-block">Sign out</button>
77
- </form>
78
- </nav>
377
+ <!-- Core is the built-in Cairn functions; each developer group sits at the same level. All
378
+ are peer collapsible sections. The extension groups are inert stubs until the
379
+ CairnExtension seam supplies them. -->
380
+ {@render navSection('Core', coreItems)}
381
+ {#each extensionGroups as group (group.name)}
382
+ {@render navSection(group.name, group.items)}
383
+ {/each}
384
+ </div>
385
+
386
+ <div class="flex-none border-t border-[var(--cairn-card-border)] px-5 py-4">
387
+ <div class="flex items-center gap-3">
388
+ <div class="avatar avatar-placeholder">
389
+ <div class="bg-neutral text-neutral-content w-9 rounded-full">
390
+ <span class="text-sm">{initials}</span>
391
+ </div>
392
+ </div>
393
+ <div class="min-w-0 flex-1">
394
+ <div class="truncate text-sm font-medium">{data.user.displayName}</div>
395
+ <div class="truncate text-xs text-[var(--color-muted)]">{data.user.email}</div>
396
+ <div class="text-xs capitalize text-[var(--color-subtle)]">{data.user.role}</div>
397
+ </div>
398
+ </div>
399
+ <form method="POST" action="/admin/auth/logout" class="mt-4">
400
+ <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
401
+ <LogOutIcon class="h-4 w-4" /> Sign out
402
+ </button>
403
+ </form>
404
+ </div>
405
+ </nav>
406
+ </div>
79
407
  </div>
80
408
  </div>
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Component, type Snippet } from 'svelte';
2
2
  import type { LayoutData } from '../sveltekit/content-routes.js';
3
3
  import './cairn-admin.css';
4
4
  interface Props {
@@ -10,9 +10,10 @@ interface Props {
10
10
  /**
11
11
  * The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
12
12
  * data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
13
- * root sets `data-theme="cairn-admin"` and imports the self-contained Warm Stone theme, so the
14
- * admin looks identical on every host regardless of the site's own theme.
13
+ * root sets `data-theme` to the resolved light or dark theme (seeded from the SSR'd cookie choice,
14
+ * flipped by the topbar toggle) and imports the self-contained Warm Stone theme, so the admin looks
15
+ * identical on every host regardless of the site's own theme.
15
16
  */
16
- declare const AdminLayout: import("svelte").Component<Props, {}, "">;
17
+ declare const AdminLayout: Component<Props, {}, "">;
17
18
  type AdminLayout = ReturnType<typeof AdminLayout>;
18
19
  export default AdminLayout;
@@ -0,0 +1,28 @@
1
+ <!--
2
+ @component
3
+ The cairn brand mark: a stack of stones, drawn in `currentColor` so it takes the brand color from its
4
+ container under either theme. Decorative, so callers pair it with the visible "Cairn" wordmark for the
5
+ accessible name.
6
+
7
+ Artwork: the "cairn" icon from the Temaki icon set (https://github.com/ideditor/temaki), released into
8
+ the public domain under CC0 1.0 (no attribution required). Recorded here as provenance.
9
+ -->
10
+ <script lang="ts">
11
+ interface Props {
12
+ /** Utility classes for sizing and color, e.g. `h-7 w-7 text-primary`. */
13
+ class?: string;
14
+ }
15
+ let { class: className = '' }: Props = $props();
16
+ </script>
17
+
18
+ <svg
19
+ class={className}
20
+ viewBox="0 0 15 15"
21
+ fill="currentColor"
22
+ aria-hidden="true"
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ >
25
+ <path
26
+ d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 6.28 14ZM6.92 4.5C6.67 4.5 5 4.43 5 3.88C5 3.07 5.75 2.51 5.96 2.35C6.36 2.03 6.32 1.62 6.54 1.27C6.84 0.79 7.61 0.5 7.88 0.5C8.1 0.5 8.75 0.9 9.23 1.42C9.45 1.66 10 2.77 10 3.12C10 4.22 9.36 4.5 8.85 4.5C8.33 4.5 8.15 4.5 6.92 4.5ZM3.68 8.22C3 7.73 3.67 6.86 4.57 6.21C5.38 5.63 5.92 5.96 6.79 5.7C8.33 5.24 9.02 5.72 9.02 5.72L10.9 6.82C12.03 7.63 10.99 7.67 10.38 8.56C9.79 9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"
27
+ />
28
+ </svg>
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ /** Utility classes for sizing and color, e.g. `h-7 w-7 text-primary`. */
3
+ class?: string;
4
+ }
5
+ /**
6
+ * The cairn brand mark: a stack of stones, drawn in `currentColor` so it takes the brand color from its
7
+ * container under either theme. Decorative, so callers pair it with the visible "Cairn" wordmark for the
8
+ * accessible name.
9
+ *
10
+ * Artwork: the "cairn" icon from the Temaki icon set (https://github.com/ideditor/temaki), released into
11
+ * the public domain under CC0 1.0 (no attribution required). Recorded here as provenance.
12
+ */
13
+ declare const CairnLogo: import("svelte").Component<Props, {}, "">;
14
+ type CairnLogo = ReturnType<typeof CairnLogo>;
15
+ export default CairnLogo;
@@ -183,7 +183,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
183
183
  {#each repeatableSlots as slot (slot.name)}
184
184
  {@const items = slotItems(slot.name)}
185
185
  {@const ids = slotIds(slot.name)}
186
- <fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
186
+ <fieldset class="rounded-box border border-[var(--cairn-card-border)] flex flex-col gap-2 p-2">
187
187
  <legend class="text-sm font-medium">{slot.label}</legend>
188
188
  <!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
189
189
  {#each ids as id, i (id)}