@glw907/cairn-cms 0.59.0 → 0.60.1

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 (106) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +12 -41
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +486 -0
  7. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +786 -918
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/IconPicker.svelte +23 -53
  17. package/dist/components/LinkPicker.svelte +34 -58
  18. package/dist/components/LoginPage.svelte +14 -27
  19. package/dist/components/ManageEditors.svelte +3 -15
  20. package/dist/components/MarkdownEditor.svelte +688 -789
  21. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  22. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  23. package/dist/components/MediaCaptureCard.svelte +18 -57
  24. package/dist/components/MediaFigureControl.svelte +32 -71
  25. package/dist/components/MediaHeroField.svelte +210 -329
  26. package/dist/components/MediaInsertPopover.svelte +156 -283
  27. package/dist/components/MediaPicker.svelte +67 -131
  28. package/dist/components/NavTree.svelte +46 -78
  29. package/dist/components/RenameDialog.svelte +16 -43
  30. package/dist/components/ShortcutsDialog.svelte +9 -13
  31. package/dist/components/ShortcutsGrid.svelte +1 -2
  32. package/dist/components/TidyReview.svelte +355 -0
  33. package/dist/components/TidyReview.svelte.d.ts +47 -0
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +768 -0
  36. package/dist/components/editor-tidy.d.ts +31 -0
  37. package/dist/components/editor-tidy.js +199 -0
  38. package/dist/components/index.d.ts +1 -0
  39. package/dist/components/index.js +1 -0
  40. package/dist/components/markdown-directives.d.ts +16 -0
  41. package/dist/components/markdown-directives.js +34 -0
  42. package/dist/components/objective-errors.d.ts +30 -0
  43. package/dist/components/objective-errors.js +113 -0
  44. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  45. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  46. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  47. package/dist/components/spellcheck-worker.d.ts +80 -0
  48. package/dist/components/spellcheck-worker.js +161 -0
  49. package/dist/components/spellcheck.d.ts +148 -0
  50. package/dist/components/spellcheck.js +553 -0
  51. package/dist/components/tidy-categorize.d.ts +67 -0
  52. package/dist/components/tidy-categorize.js +392 -0
  53. package/dist/components/tidy-diff.d.ts +60 -0
  54. package/dist/components/tidy-diff.js +147 -0
  55. package/dist/components/tidy-validate.d.ts +37 -0
  56. package/dist/components/tidy-validate.js +174 -0
  57. package/dist/content/compose.d.ts +1 -1
  58. package/dist/content/compose.js +11 -0
  59. package/dist/content/site-dictionary.d.ts +31 -0
  60. package/dist/content/site-dictionary.js +82 -0
  61. package/dist/content/types.d.ts +25 -0
  62. package/dist/delivery/CairnHead.svelte +8 -11
  63. package/dist/doctor/checks-local.d.ts +1 -0
  64. package/dist/doctor/checks-local.js +55 -6
  65. package/dist/doctor/index.js +2 -1
  66. package/dist/log/events.d.ts +1 -1
  67. package/dist/nav/site-config.d.ts +98 -0
  68. package/dist/nav/site-config.js +132 -0
  69. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  70. package/dist/sveltekit/admin-dispatch.js +6 -2
  71. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  72. package/dist/sveltekit/cairn-admin.js +22 -3
  73. package/dist/sveltekit/content-routes.d.ts +135 -1
  74. package/dist/sveltekit/content-routes.js +351 -3
  75. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  76. package/dist/sveltekit/tidy-prompt.js +118 -0
  77. package/package.json +11 -2
  78. package/src/lib/components/CairnAdmin.svelte +3 -0
  79. package/src/lib/components/CairnTidySettings.svelte +553 -0
  80. package/src/lib/components/EditPage.svelte +371 -2
  81. package/src/lib/components/MarkdownEditor.svelte +168 -1
  82. package/src/lib/components/TidyReview.svelte +463 -0
  83. package/src/lib/components/cairn-admin.css +25 -0
  84. package/src/lib/components/editor-tidy.ts +241 -0
  85. package/src/lib/components/index.ts +1 -0
  86. package/src/lib/components/markdown-directives.ts +35 -0
  87. package/src/lib/components/objective-errors.ts +155 -0
  88. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  89. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  90. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  91. package/src/lib/components/spellcheck-worker.ts +279 -0
  92. package/src/lib/components/spellcheck.ts +693 -0
  93. package/src/lib/components/tidy-categorize.ts +460 -0
  94. package/src/lib/components/tidy-diff.ts +196 -0
  95. package/src/lib/components/tidy-validate.ts +202 -0
  96. package/src/lib/content/compose.ts +11 -1
  97. package/src/lib/content/site-dictionary.ts +84 -0
  98. package/src/lib/content/types.ts +25 -0
  99. package/src/lib/doctor/checks-local.ts +59 -5
  100. package/src/lib/doctor/index.ts +2 -0
  101. package/src/lib/log/events.ts +7 -1
  102. package/src/lib/nav/site-config.ts +197 -0
  103. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  104. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  105. package/src/lib/sveltekit/content-routes.ts +504 -4
  106. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -9,177 +9,115 @@ widths, reported to the host through `onDevice`. The writing-mode toggles live i
9
9
  footer (the bottom strip carries the writing environment; this strip acts on the text). The glyphs
10
10
  are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
11
11
  -->
12
- <script lang="ts">
13
- import type { Snippet } from 'svelte';
14
- import type { FormatKind } from './markdown-format.js';
15
- import { deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
16
-
17
- interface Props {
18
- /** Apply a markdown transform to the editor's current selection. */
19
- format: (kind: FormatKind) => void;
20
- /** Which pane the editor card shows; the segmented control reflects it. */
21
- mode: 'write' | 'preview';
22
- /** Ask the host to switch panes. */
23
- onMode: (m: 'write' | 'preview') => void;
24
- /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
25
- device?: PreviewDeviceId;
26
- /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
27
- * while Preview shows. */
28
- onDevice?: (id: PreviewDeviceId) => void;
29
- /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
30
- insertControls?: Snippet;
31
- }
32
-
33
- let {
34
- format,
35
- mode,
36
- onMode,
37
- device = 'desktop',
38
- onDevice,
39
- insertControls,
40
- }: Props = $props();
41
-
42
- // Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
43
- // markup stays declarative (no per-icon raw html). Paths follow the house outline style.
44
- type ToolButton = { kind: FormatKind; label: string; paths: string[] };
45
-
46
- // Labels carry the shortcut where one exists. "Ctrl" is written literally for macOS readers
47
- // too; detecting the platform buys little for what it costs.
48
- const textButtons: ToolButton[] = [
49
- { kind: 'bold', label: 'Bold (Ctrl+B)', paths: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'] },
50
- { kind: 'italic', label: 'Italic (Ctrl+I)', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
51
- ];
52
-
53
- const structureButtons: ToolButton[] = [
54
- {
55
- kind: 'h2',
56
- label: 'Heading (Ctrl+Alt+2)',
57
- paths: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1'],
58
- },
59
- {
60
- kind: 'h3',
61
- label: 'Smaller heading (Ctrl+Alt+3)',
62
- paths: [
63
- 'M4 12h8',
64
- 'M4 18V6',
65
- 'M12 18V6',
66
- 'M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2',
67
- 'M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2',
68
- ],
69
- },
70
- { kind: 'ul', label: 'Bulleted list (Ctrl+Shift+8)', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
71
- {
72
- kind: 'ol',
73
- label: 'Numbered list (Ctrl+Shift+7)',
74
- paths: ['M10 12h11', 'M10 18h11', 'M10 6h11', 'M4 10h2', 'M4 6h1v4', 'M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'],
75
- },
76
- {
77
- kind: 'quote',
78
- label: 'Quote (Ctrl+Shift+9)',
79
- paths: [
80
- 'M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
81
- 'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
82
- ],
83
- },
84
- // The everyday formats promoted out of the More menu onto the strip (after Quote, before
85
- // More), per mockup screen 1. They keep the strip's glyph grammar.
86
- { kind: 'code', label: 'Inline code (Ctrl+E)', paths: ['m9 8-4 4 4 4', 'm15 8 4 4-4 4'] },
87
- {
88
- kind: 'strike',
89
- label: 'Strikethrough',
90
- paths: ['M14 12a4 4 0 0 1 0 8H8', 'M16 4H9.5a3.5 3.5 0 0 0-1.4 6.7', 'M4 12h16'],
91
- },
92
- {
93
- kind: 'table',
94
- label: 'Table',
95
- paths: ['M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', 'M3 10h18', 'M10 3v18'],
96
- },
97
- ];
98
-
99
- const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
100
- // The check glyph marking an active pick, shared by the More menu's toggles and the device list.
101
- const checkPaths = ['M20 6 9 17l-5-5'];
102
-
103
- // The trimmed overflow: the block formats that stay rare. A divider splits the code block from
104
- // the rest (the spec keeps "code block and the rest" behind the ellipsis once inline code,
105
- // strikethrough, and table promote into the strip).
106
- const moreItems: { kind: FormatKind; label: string; divideBefore?: boolean }[] = [
107
- { kind: 'codeblock', label: 'Code block' },
108
- { kind: 'hr', label: 'Horizontal rule', divideBefore: true },
109
- { kind: 'task', label: 'Task list' },
110
- ];
111
-
112
- // The More menu's popover element and its open state, mirrored from the toggle event into
113
- // aria-expanded on the trigger.
114
- let moreMenu = $state<HTMLUListElement | null>(null);
115
- let moreOpen = $state(false);
116
-
117
- // Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
118
- function hideMenu(menu: HTMLUListElement | null) {
119
- if (menu?.matches(':popover-open')) menu.hidePopover();
120
- }
121
-
122
- function pickMore(kind: FormatKind) {
123
- format(kind);
124
- hideMenu(moreMenu);
125
- }
126
-
127
- // The device menu's popover element and its open state, mirrored from the toggle event into
128
- // aria-expanded on the trigger (the More menu's pattern).
129
- let deviceMenu = $state<HTMLUListElement | null>(null);
130
- let deviceOpen = $state(false);
131
- const activeDevice = $derived(previewDevice(device));
132
- // Whether the device trigger renders as the capsule's third segment.
133
- const showDeviceTrigger = $derived(mode === 'preview' && !!onDevice);
134
-
135
- function pickDevice(id: PreviewDeviceId) {
136
- onDevice?.(id);
137
- hideMenu(deviceMenu);
138
- }
139
-
140
- let toolbarEl = $state<HTMLDivElement | null>(null);
141
- // The roving tab stop's position among the strip's enabled top-level controls. The Write/Preview
142
- // tabs join the toolbar's roving order instead of managing their own arrow keys: the ARIA toolbar
143
- // pattern allows either, and one arrow model over the whole strip is the simpler of the two.
144
- let roving = $state(0);
145
-
146
- /** The strip's top-level controls in DOM order: every enabled button outside the More menu's
147
- * popover and outside the insert controls' dialogs. The host's insertControls render their own
148
- * buttons, so the set is queried, not declared. */
149
- function rovingControls(): HTMLElement[] {
150
- if (!toolbarEl) return [];
151
- return Array.from(toolbarEl.querySelectorAll<HTMLElement>('button')).filter(
152
- (el) => !el.hasAttribute('disabled') && !el.closest('[popover]') && !el.closest('dialog'),
153
- );
154
- }
155
-
156
- // Keep exactly one tab stop. Runs on mount (the snippet's buttons render synchronously, so the
157
- // first pass sees them) and again whenever the stop moves or a mode switch changes which
158
- // controls are enabled.
159
- $effect(() => {
160
- void mode;
161
- const items = rovingControls();
162
- if (items.length === 0) return;
163
- const stop = Math.min(roving, items.length - 1);
164
- // Write the clamp back so the stored stop never drifts from the displayed one across a
165
- // Preview round trip. The effect reads roving, so the guarded write re-runs it once and
166
- // converges (the second pass computes the same stop and writes nothing).
167
- if (stop !== roving) roving = stop;
168
- for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
169
- });
170
-
171
- function onToolbarKeydown(e: KeyboardEvent) {
172
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
173
- // Leave the keys alone inside the open More menu; its items are not part of the roving order.
174
- if ((e.target as HTMLElement | null)?.closest('[popover]')) return;
175
- const items = rovingControls();
176
- if (items.length === 0) return;
177
- const current = items.indexOf(document.activeElement as HTMLElement);
178
- const base = current >= 0 ? current : Math.min(roving, items.length - 1);
179
- roving = (base + (e.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
180
- items[roving].focus();
181
- e.preventDefault();
12
+ <script lang="ts">import { deviceLabel, previewDevice, previewDevices } from "./preview-doc.js";
13
+ let {
14
+ format,
15
+ mode,
16
+ onMode,
17
+ device = "desktop",
18
+ onDevice,
19
+ insertControls
20
+ } = $props();
21
+ const textButtons = [
22
+ { kind: "bold", label: "Bold (Ctrl+B)", paths: ["M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"] },
23
+ { kind: "italic", label: "Italic (Ctrl+I)", paths: ["M19 4h-9", "M14 20H5", "M15 4 9 20"] }
24
+ ];
25
+ const structureButtons = [
26
+ {
27
+ kind: "h2",
28
+ label: "Heading (Ctrl+Alt+2)",
29
+ paths: ["M4 12h8", "M4 18V6", "M12 18V6", "M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"]
30
+ },
31
+ {
32
+ kind: "h3",
33
+ label: "Smaller heading (Ctrl+Alt+3)",
34
+ paths: [
35
+ "M4 12h8",
36
+ "M4 18V6",
37
+ "M12 18V6",
38
+ "M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2",
39
+ "M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"
40
+ ]
41
+ },
42
+ { kind: "ul", label: "Bulleted list (Ctrl+Shift+8)", paths: ["M8 6h13", "M8 12h13", "M8 18h13", "M3 6h.01", "M3 12h.01", "M3 18h.01"] },
43
+ {
44
+ kind: "ol",
45
+ label: "Numbered list (Ctrl+Shift+7)",
46
+ paths: ["M10 12h11", "M10 18h11", "M10 6h11", "M4 10h2", "M4 6h1v4", "M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"]
47
+ },
48
+ {
49
+ kind: "quote",
50
+ label: "Quote (Ctrl+Shift+9)",
51
+ paths: [
52
+ "M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z",
53
+ "M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z"
54
+ ]
55
+ },
56
+ // The everyday formats promoted out of the More menu onto the strip (after Quote, before
57
+ // More), per mockup screen 1. They keep the strip's glyph grammar.
58
+ { kind: "code", label: "Inline code (Ctrl+E)", paths: ["m9 8-4 4 4 4", "m15 8 4 4-4 4"] },
59
+ {
60
+ kind: "strike",
61
+ label: "Strikethrough",
62
+ paths: ["M14 12a4 4 0 0 1 0 8H8", "M16 4H9.5a3.5 3.5 0 0 0-1.4 6.7", "M4 12h16"]
63
+ },
64
+ {
65
+ kind: "table",
66
+ label: "Table",
67
+ paths: ["M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", "M3 10h18", "M10 3v18"]
182
68
  }
69
+ ];
70
+ const ellipsisPaths = ["M5 12h.01", "M12 12h.01", "M19 12h.01"];
71
+ const checkPaths = ["M20 6 9 17l-5-5"];
72
+ const moreItems = [
73
+ { kind: "codeblock", label: "Code block" },
74
+ { kind: "hr", label: "Horizontal rule", divideBefore: true },
75
+ { kind: "task", label: "Task list" }
76
+ ];
77
+ let moreMenu = $state(null);
78
+ let moreOpen = $state(false);
79
+ function hideMenu(menu) {
80
+ if (menu?.matches(":popover-open")) menu.hidePopover();
81
+ }
82
+ function pickMore(kind) {
83
+ format(kind);
84
+ hideMenu(moreMenu);
85
+ }
86
+ let deviceMenu = $state(null);
87
+ let deviceOpen = $state(false);
88
+ const activeDevice = $derived(previewDevice(device));
89
+ const showDeviceTrigger = $derived(mode === "preview" && !!onDevice);
90
+ function pickDevice(id) {
91
+ onDevice?.(id);
92
+ hideMenu(deviceMenu);
93
+ }
94
+ let toolbarEl = $state(null);
95
+ let roving = $state(0);
96
+ function rovingControls() {
97
+ if (!toolbarEl) return [];
98
+ return Array.from(toolbarEl.querySelectorAll("button")).filter(
99
+ (el) => !el.hasAttribute("disabled") && !el.closest("[popover]") && !el.closest("dialog")
100
+ );
101
+ }
102
+ $effect(() => {
103
+ void mode;
104
+ const items = rovingControls();
105
+ if (items.length === 0) return;
106
+ const stop = Math.min(roving, items.length - 1);
107
+ if (stop !== roving) roving = stop;
108
+ for (const [i, el] of items.entries()) el.setAttribute("tabindex", i === stop ? "0" : "-1");
109
+ });
110
+ function onToolbarKeydown(e) {
111
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
112
+ if (e.target?.closest("[popover]")) return;
113
+ const items = rovingControls();
114
+ if (items.length === 0) return;
115
+ const current = items.indexOf(document.activeElement);
116
+ const base = current >= 0 ? current : Math.min(roving, items.length - 1);
117
+ roving = (base + (e.key === "ArrowRight" ? 1 : -1) + items.length) % items.length;
118
+ items[roving].focus();
119
+ e.preventDefault();
120
+ }
183
121
  </script>
184
122
 
185
123
  {#snippet strokeIcon(paths: string[])}
@@ -6,60 +6,30 @@ field is optional, a None radio clears the value. A roving tabindex keeps a sing
6
6
  keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
7
7
  IconSet path data, matching the renderer's 256-unit viewBox.
8
8
  -->
9
- <script lang="ts">
10
- import { tick } from 'svelte';
11
- import type { IconSet } from '../render/glyph.js';
12
-
13
- interface Props {
14
- /** The site's glyph name to SVG path-data map. */
15
- icons: IconSet;
16
- /** The currently selected glyph name, or '' for none. */
17
- value: string;
18
- /** When false, a None choice is offered. */
19
- required: boolean;
20
- /** Called with the new glyph name (or '' for none). */
21
- onChange: (name: string) => void;
22
- /** The group's accessible name, threaded from the field label. Defaults to Icon. */
23
- label?: string;
24
- }
25
-
26
- let { icons, value, required, onChange, label = 'Icon' }: Props = $props();
27
-
28
- // The radiogroup container, used to move focus with the selection per the ARIA radiogroup pattern.
29
- let group: HTMLDivElement;
30
-
31
- const names = $derived(Object.keys(icons));
32
- // The selectable keys in DOM order: the optional None choice ('') first, then each glyph name.
33
- // Arrow-key navigation walks this list, and the roving tabindex marks the selected key (or the
34
- // first key when nothing is selected) as the single tab stop.
35
- const choices = $derived(required ? names : ['', ...names]);
36
- const tabStop = $derived(choices.includes(value) ? value : choices[0]);
37
-
38
- function move(delta: number): void {
39
- // Navigate relative to the focused element (the current tab stop), not the bound value. In a
40
- // required group with no value, tabStop is the first radio while value is '', so a value-based
41
- // origin would skip the first step.
42
- const from = Math.max(0, choices.indexOf(tabStop));
43
- const next = (from + delta + choices.length) % choices.length;
44
- onChange(choices[next]);
45
- // The roving tabindex updates reactively, so wait for the DOM then move focus onto the new tab
46
- // stop. The keydown handler runs only when focus is already inside the group, so this never
47
- // steals focus on mount.
48
- void tick().then(() => {
49
- const target = group?.querySelector<HTMLElement>('[tabindex="0"]');
50
- target?.focus();
51
- });
52
- }
53
-
54
- function onKeydown(e: KeyboardEvent): void {
55
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
56
- e.preventDefault();
57
- move(1);
58
- } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
59
- e.preventDefault();
60
- move(-1);
61
- }
9
+ <script lang="ts">import { tick } from "svelte";
10
+ let { icons, value, required, onChange, label = "Icon" } = $props();
11
+ let group;
12
+ const names = $derived(Object.keys(icons));
13
+ const choices = $derived(required ? names : ["", ...names]);
14
+ const tabStop = $derived(choices.includes(value) ? value : choices[0]);
15
+ function move(delta) {
16
+ const from = Math.max(0, choices.indexOf(tabStop));
17
+ const next = (from + delta + choices.length) % choices.length;
18
+ onChange(choices[next]);
19
+ void tick().then(() => {
20
+ const target = group?.querySelector('[tabindex="0"]');
21
+ target?.focus();
22
+ });
23
+ }
24
+ function onKeydown(e) {
25
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
26
+ e.preventDefault();
27
+ move(1);
28
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
29
+ e.preventDefault();
30
+ move(-1);
62
31
  }
32
+ }
63
33
  </script>
64
34
 
65
35
  <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
@@ -6,65 +6,41 @@ showing its date and each draft marked. Picking a target inserts a cairn: intern
6
6
  editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
7
7
  conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
8
8
  -->
9
- <script lang="ts">
10
- import type { LinkTarget } from '../content/manifest.js';
11
- import { formatCairnToken } from '../content/links.js';
12
-
13
- interface Props {
14
- /** The site's link targets, from the committed manifest (editLoad ships them). */
15
- linkTargets: LinkTarget[];
16
- /** Insert an inline cairn link at the editor cursor. */
17
- insert: (href: string, title: string) => void;
18
- /** Disable the trigger; the host sets it while Preview shows. */
19
- disabled?: boolean;
20
- /** Render the built-in Link to page trigger. False mounts only the dialog, for a host that
21
- * supplies its own trigger and opens the dialog through the exported open(). */
22
- trigger?: boolean;
23
- }
24
-
25
- let { linkTargets, insert, disabled = false, trigger = true }: Props = $props();
26
-
27
- let dialog = $state<HTMLDialogElement | null>(null);
28
- let query = $state('');
29
-
30
- // Group filtered targets by concept, Pages first then Posts then any other concept, so the list
31
- // reads in a stable order. The filter is a case-insensitive title substring.
32
- const ORDER: Record<string, number> = { pages: 0, posts: 1 };
33
- function rank(concept: string): number {
34
- return ORDER[concept] ?? 2;
35
- }
36
- function heading(concept: string): string {
37
- if (concept === 'pages') return 'Pages';
38
- if (concept === 'posts') return 'Posts';
39
- return concept.charAt(0).toUpperCase() + concept.slice(1);
40
- }
41
-
42
- const groups = $derived.by(() => {
43
- const q = query.trim().toLowerCase();
44
- const matched = q ? linkTargets.filter((t) => t.title.toLowerCase().includes(q)) : linkTargets;
45
- const byConcept = new Map<string, LinkTarget[]>();
46
- for (const t of matched) {
47
- const list = byConcept.get(t.concept) ?? [];
48
- list.push(t);
49
- byConcept.set(t.concept, list);
50
- }
51
- return [...byConcept.entries()]
52
- .map(([concept, items]) => ({ concept, heading: heading(concept), items }))
53
- .sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
54
- });
55
-
56
- /** Open the picker programmatically, for a host that drives it without the trigger. */
57
- export function open() {
58
- query = '';
59
- dialog?.showModal();
60
- }
61
- function close() {
62
- dialog?.close();
63
- }
64
- function choose(target: LinkTarget) {
65
- insert(formatCairnToken(target), target.title);
66
- close();
9
+ <script lang="ts">import { formatCairnToken } from "../content/links.js";
10
+ let { linkTargets, insert, disabled = false, trigger = true } = $props();
11
+ let dialog = $state(null);
12
+ let query = $state("");
13
+ const ORDER = { pages: 0, posts: 1 };
14
+ function rank(concept) {
15
+ return ORDER[concept] ?? 2;
16
+ }
17
+ function heading(concept) {
18
+ if (concept === "pages") return "Pages";
19
+ if (concept === "posts") return "Posts";
20
+ return concept.charAt(0).toUpperCase() + concept.slice(1);
21
+ }
22
+ const groups = $derived.by(() => {
23
+ const q = query.trim().toLowerCase();
24
+ const matched = q ? linkTargets.filter((t) => t.title.toLowerCase().includes(q)) : linkTargets;
25
+ const byConcept = /* @__PURE__ */ new Map();
26
+ for (const t of matched) {
27
+ const list = byConcept.get(t.concept) ?? [];
28
+ list.push(t);
29
+ byConcept.set(t.concept, list);
67
30
  }
31
+ return [...byConcept.entries()].map(([concept, items]) => ({ concept, heading: heading(concept), items })).sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
32
+ });
33
+ export function open() {
34
+ query = "";
35
+ dialog?.showModal();
36
+ }
37
+ function close() {
38
+ dialog?.close();
39
+ }
40
+ function choose(target) {
41
+ insert(formatCairnToken(target), target.title);
42
+ close();
43
+ }
68
44
  </script>
69
45
 
70
46
  {#if trigger}
@@ -4,33 +4,20 @@ The magic-link sign-in page. A plain form POST to the named `?/request` action (
4
4
  `requestAction`); no client SDK. The success message is identical whether or not the email is on
5
5
  the allowlist, so the page never leaks membership (spec §7.1).
6
6
  -->
7
- <script lang="ts">
8
- import './cairn-admin.css';
9
- import { onMount } from 'svelte';
10
- import MailCheckIcon from '@lucide/svelte/icons/mail-check';
11
- import InfoIcon from '@lucide/svelte/icons/info';
12
- import CairnLogo from './CairnLogo.svelte';
13
- import CsrfField from './CsrfField.svelte';
14
- import { cairnFaviconHref } from './cairn-favicon.js';
15
- import { warnIfChromeWrapped } from './chrome-guard.js';
16
-
17
- interface Props {
18
- /** The login load's data: the site name, an optional error, and the CSRF token. */
19
- data: { siteName: string; error: string | null; csrf: string };
20
- /** The action result. `sent` is true once a request was accepted; `status` discriminates the
21
- * neutral, send-error, and throttled outcomes. */
22
- form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
23
- }
24
-
25
- let { data, form }: Props = $props();
26
-
27
- let rootEl = $state<HTMLElement>();
28
- // Lets a mistyped address go back to the form without a reload, even though the server still
29
- // reports `sent`. The success copy never reveals whether the email was on the allowlist.
30
- let dismissed = $state(false);
31
- onMount(() => {
32
- if (rootEl) warnIfChromeWrapped(rootEl);
33
- });
7
+ <script lang="ts">import "./cairn-admin.css";
8
+ import { onMount } from "svelte";
9
+ import MailCheckIcon from "@lucide/svelte/icons/mail-check";
10
+ import InfoIcon from "@lucide/svelte/icons/info";
11
+ import CairnLogo from "./CairnLogo.svelte";
12
+ import CsrfField from "./CsrfField.svelte";
13
+ import { cairnFaviconHref } from "./cairn-favicon.js";
14
+ import { warnIfChromeWrapped } from "./chrome-guard.js";
15
+ let { data, form } = $props();
16
+ let rootEl = $state();
17
+ let dismissed = $state(false);
18
+ onMount(() => {
19
+ if (rootEl) warnIfChromeWrapped(rootEl);
20
+ });
34
21
  </script>
35
22
 
36
23
  <svelte:head>
@@ -6,21 +6,9 @@ last-owner anti-lockout rule itself is enforced server-side (editors-routes). Ac
6
6
  named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
7
7
  dispatcher defines.
8
8
  -->
9
- <script lang="ts">
10
- import CsrfField from './CsrfField.svelte';
11
- import type { Editor } from '../auth/types.js';
12
-
13
- interface Props {
14
- /** The editors load's data: the allowlist and the acting owner's email. */
15
- data: { editors: Editor[]; self: string };
16
- /** The last action's result (an error message when it failed). */
17
- form: { error?: string; ok?: boolean } | null;
18
- }
19
-
20
- let { data, form }: Props = $props();
21
-
22
- // Eyebrow styling for the table column headers, matching the concept list.
23
- const col = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
9
+ <script lang="ts">import CsrfField from "./CsrfField.svelte";
10
+ let { data, form } = $props();
11
+ const col = "text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]";
24
12
  </script>
25
13
 
26
14
  <header class="mb-6">