@glw907/cairn-cms 0.56.0 → 0.56.2

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.
@@ -21,6 +21,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
21
21
  import { beforeNavigate } from '$app/navigation';
22
22
  import { page } from '$app/state';
23
23
  import BlocksIcon from '@lucide/svelte/icons/blocks';
24
+ import SquarePenIcon from '@lucide/svelte/icons/square-pen';
24
25
  import LinkIcon from '@lucide/svelte/icons/link';
25
26
  import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
26
27
  import PanelRightIcon from '@lucide/svelte/icons/panel-right';
@@ -28,7 +29,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
28
29
  import CsrfField from './CsrfField.svelte';
29
30
  import MarkdownEditor from './MarkdownEditor.svelte';
30
31
  import EditorToolbar from './EditorToolbar.svelte';
31
- import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
32
+ import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
32
33
  import LinkPicker from './LinkPicker.svelte';
33
34
  import WebLinkDialog from './WebLinkDialog.svelte';
34
35
  import DeleteDialog from './DeleteDialog.svelte';
@@ -39,7 +40,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
39
40
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
40
41
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
41
42
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
42
- import type { ComponentRegistry } from '../render/registry.js';
43
+ import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
44
+ import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
43
45
  import type { IconSet } from '../render/glyph.js';
44
46
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
45
47
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
@@ -263,6 +265,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
263
265
  // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
264
266
  // landing tab.
265
267
  let mode = $state<'write' | 'preview'>('write');
268
+ // Preview is read-only, so the insert controls the page renders into the toolbar disable with the
269
+ // strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
270
+ // is in scope before its first use.
271
+ const insertDisabled = $derived(mode === 'preview');
266
272
  let previewHtml = $state('');
267
273
  // True after a render call threw, so the preview pane can say so instead of going blank.
268
274
  let previewFailed = $state(false);
@@ -353,6 +359,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
353
359
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
354
360
  const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
355
361
  let insert = $state.raw<(text: string) => void>(() => {});
362
+ // The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
363
+ // routes through it to overwrite an edited block's source span. A no-op until then.
364
+ let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
356
365
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
357
366
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
358
367
  // reads it for the Text field's default.
@@ -366,7 +375,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
366
375
  // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
367
376
  let webLinkDialog = $state<DialogHandle | null>(null);
368
377
  let linkPicker = $state<DialogHandle | null>(null);
369
- let insertDialog = $state<DialogHandle | null>(null);
378
+ // The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
379
+ // drives editComponent(def, values, range) on it, beyond the shared open().
380
+ let insertDialog = $state<ComponentInsertDialog | null>(null);
370
381
  // The lifecycle dialogs, opened from the header's overflow menu.
371
382
  let deleteDialog = $state<DialogHandle | null>(null);
372
383
  let renameDialog = $state<DialogHandle | null>(null);
@@ -379,6 +390,92 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
379
390
  // by, so the toolbar trigger and the dialog appear and disappear together.
380
391
  const hasComponents = $derived(insertableDefs(registry).length > 0);
381
392
 
393
+ // The directive container at the editor caret, reported by MarkdownEditor whenever it changes
394
+ // (null outside any container). The Edit-block control resolves it against the registry and the
395
+ // round-trip safety gate below; its identity is the key the async gate guards against a stale
396
+ // result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
397
+ // matching interface is not exported.
398
+ type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
399
+ let caretComponent = $state<CaretComponent | null>(null);
400
+ // A def is actionable for guided edit when it has a schema (the same notion the insert catalog
401
+ // lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
402
+ // hasSchema so the two surfaces can never drift on what counts as editable.
403
+ function editableDef(name: string | null): ComponentDef | undefined {
404
+ if (!name) return undefined;
405
+ const def = registry?.get(name);
406
+ if (!def) return undefined;
407
+ return hasSchema(def) ? def : undefined;
408
+ }
409
+ // The resolved editability: the def, the source range, and the validated markdown when the caret
410
+ // sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
411
+ // caret; null otherwise. The def, range, and markdown are captured from one caretComponent
412
+ // snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
413
+ // control enables only when this is set.
414
+ let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
415
+ // Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
416
+ // disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
417
+ // one (no guided form either way); 'unsafe' is a known component the safety gate refused.
418
+ let editReason = $state<'none' | 'unsafe'>('none');
419
+ // Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
420
+ // async, so a slow check could resolve after a newer caret move; guard latest-wins on the
421
+ // caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
422
+ // the caret has not moved since the check started. A block whose check did not pass for the
423
+ // current caret never enables edit.
424
+ $effect(() => {
425
+ const current = caretComponent;
426
+ const def = editableDef(current?.name ?? null);
427
+ if (!current || !def) {
428
+ editable = null;
429
+ editReason = 'none';
430
+ return;
431
+ }
432
+ let stale = false;
433
+ void componentRoundTripSafety(current.markdown, def)
434
+ .then((result) => {
435
+ if (stale || caretComponent !== current) return;
436
+ if (result.safe) {
437
+ editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
438
+ editReason = 'none';
439
+ } else {
440
+ editable = null;
441
+ editReason = 'unsafe';
442
+ }
443
+ })
444
+ .catch(() => {
445
+ // A parse throw during the safety check must never leave a stale block enabled. Guarded by
446
+ // the same latest-wins identity, fall back to the safe default of no editable block.
447
+ if (stale || caretComponent !== current) return;
448
+ editable = null;
449
+ editReason = 'none';
450
+ });
451
+ return () => {
452
+ stale = true;
453
+ };
454
+ });
455
+ // The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
456
+ // names the action; the two disabled reasons are honest about why.
457
+ const editBlockLabel = $derived(
458
+ editable
459
+ ? 'Edit the component at the cursor'
460
+ : editReason === 'unsafe'
461
+ ? "This block can't be edited in the form. Edit it as markdown."
462
+ : 'Place the cursor in a component to edit it',
463
+ );
464
+ // Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
465
+ // caret is not on a safe, schema-bearing component. The control stays focusable and announced in
466
+ // this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
467
+ // technology; the dead click is made inert in editBlock().
468
+ const editBlockUnavailable = $derived(insertDisabled || !editable);
469
+ // Activate edit: parse the block into form values, then open the dialog in edit mode over the
470
+ // stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
471
+ // unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
472
+ // from the one editable snapshot, so a newer caret markdown is never paired with an older range.
473
+ async function editBlock() {
474
+ if (insertDisabled || !editable) return;
475
+ const values = await parseComponent(editable.markdown, editable.def);
476
+ insertDialog?.editComponent(editable.def, values, editable.range);
477
+ }
478
+
382
479
  // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
383
480
  // when it has never been published); otherwise the live site matches and it reads Published.
384
481
  const status = $derived.by(() => {
@@ -563,10 +660,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
563
660
  mode = m;
564
661
  }
565
662
 
566
- // Preview is read-only, so the insert controls the page renders into the toolbar disable with
567
- // the strip's own format buttons.
568
- const insertDisabled = $derived(mode === 'preview');
569
-
570
663
  // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
571
664
  // strip or the surface, without claiming the keys page-wide. The listener attaches
572
665
  // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
@@ -903,6 +996,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
903
996
  >
904
997
  <BlocksIcon class="h-4 w-4" aria-hidden="true" />
905
998
  </button>
999
+ <!-- Edit block re-opens the component at the caret into the guided form. It is
1000
+ unavailable while Preview shows (like the insert controls) and whenever the caret is
1001
+ not on a safe, schema-bearing component; the tooltip names the reason in each state.
1002
+ The unavailable state uses aria-disabled, not the native disabled attribute, so the
1003
+ control stays focusable and its reason reaches assistive technology; the disabled
1004
+ look rides a class and editBlock() early-returns so the dead click is inert. -->
1005
+ <button
1006
+ type="button"
1007
+ class="btn btn-sm btn-ghost btn-square"
1008
+ class:btn-disabled={editBlockUnavailable}
1009
+ aria-haspopup="dialog"
1010
+ aria-label={editBlockLabel}
1011
+ title={editBlockLabel}
1012
+ aria-disabled={editBlockUnavailable}
1013
+ onclick={editBlock}
1014
+ >
1015
+ <SquarePenIcon class="h-4 w-4" aria-hidden="true" />
1016
+ </button>
906
1017
  {/if}
907
1018
  <button
908
1019
  type="button"
@@ -950,6 +1061,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
950
1061
  name="body"
951
1062
  {surface}
952
1063
  registerInsert={(fn) => (insert = fn)}
1064
+ onComponentAtCaret={(info) => (caretComponent = info)}
1065
+ registerReplaceRange={(fn) => (replaceRange = fn)}
953
1066
  registerInsertLink={(fn) => (insertLink = fn)}
954
1067
  registerGetSelection={(fn) => (getSelection = fn)}
955
1068
  registerFormat={(fn) => (format = fn)}
@@ -1239,7 +1352,16 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1239
1352
  <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
1240
1353
  tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
1241
1354
  through their exported open(). -->
1242
- <ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
1355
+ <ComponentInsertDialog
1356
+ bind:this={insertDialog}
1357
+ trigger={false}
1358
+ {registry}
1359
+ {insert}
1360
+ update={(range, md) => replaceRange(range.from, range.to, md)}
1361
+ {icons}
1362
+ {render}
1363
+ preview={data.preview}
1364
+ />
1243
1365
  <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
1244
1366
  <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
1245
1367
 
@@ -10,6 +10,16 @@ through the adapter's render. Swapping the editor stays a one-file change.
10
10
  <script lang="ts">
11
11
  import { onMount, onDestroy } from 'svelte';
12
12
  import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
13
+ import { fenceScan, caretContainerRange, directiveOpenerName } from './markdown-directives.js';
14
+
15
+ /** The directive container at the caret: the opener's name, the block's markdown, and the
16
+ * document character offsets of its inclusive line range. */
17
+ interface ComponentAtCaret {
18
+ name: string | null;
19
+ markdown: string;
20
+ from: number;
21
+ to: number;
22
+ }
13
23
 
14
24
  interface Props {
15
25
  /** The markdown source; bindable so the parent reads edits back. */
@@ -24,6 +34,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
24
34
  registerGetSelection?: (get: () => string) => void;
25
35
  /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
26
36
  registerFormat?: (format: (kind: FormatKind) => void) => void;
37
+ /** Reports the directive container at the caret (or null when outside any container) whenever
38
+ * the reported value changes; the host resolves it against the registry to offer Edit-block. */
39
+ onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
40
+ /** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
41
+ * calls it to write an edited block back over its original range. */
42
+ registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
27
43
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
28
44
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
29
45
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
@@ -43,6 +59,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
43
59
  registerInsertLink,
44
60
  registerGetSelection,
45
61
  registerFormat,
62
+ onComponentAtCaret,
63
+ registerReplaceRange,
46
64
  completionSources = [],
47
65
  focusMode = false,
48
66
  typewriter = false,
@@ -365,6 +383,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
365
383
  surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
366
384
  EditorView.updateListener.of((update) => {
367
385
  if (update.docChanged) value = update.state.doc.toString();
386
+ // A doc edit can change the block's span and a caret move can change which block the
387
+ // caret sits in, so the reporter runs on either; the dedupe below absorbs the no-ops.
388
+ if (onComponentAtCaret && (update.docChanged || update.selectionSet))
389
+ reportComponentAtCaret(update.state);
368
390
  }),
369
391
  ],
370
392
  }),
@@ -374,6 +396,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
374
396
  registerInsertLink?.(insertLink);
375
397
  registerGetSelection?.(selectedText);
376
398
  registerFormat?.(applyFormat);
399
+ registerReplaceRange?.(replaceRange);
400
+ // Report the caret's starting container once the editor exists, so a caret that mounts inside
401
+ // a block is known without waiting for the first move.
402
+ if (onComponentAtCaret) reportComponentAtCaret(view.state);
377
403
  mounted = true;
378
404
  });
379
405
 
@@ -406,6 +432,55 @@ through the adapter's render. Swapping the editor stays a one-file change.
406
432
  });
407
433
  });
408
434
 
435
+ // The last value handed to onComponentAtCaret, so the reporter fires only on a change. The
436
+ // identity compared is name + markdown + from + to. A pure caret move within one block leaves all
437
+ // four unchanged, so it does not refire; an edit inside the block changes the markdown even when
438
+ // it keeps the same length (an equal-length replacement leaves from and to unchanged), so the
439
+ // markdown must be part of the equality or such an edit would keep a stale report.
440
+ let lastCaretReport: ComponentAtCaret | null = null;
441
+
442
+ // Compute the directive container at the caret from a CodeMirror state and report it through
443
+ // onComponentAtCaret, deduped so a caret move within the same block does not refire. fenceScan
444
+ // lines are 0-based; doc.line(n) is 1-based, so the line range maps with a +1 on each bound.
445
+ function reportComponentAtCaret(state: import('@codemirror/state').EditorState) {
446
+ const doc = state.doc;
447
+ const lines: string[] = [];
448
+ for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
449
+ const caretLine = doc.lineAt(state.selection.main.head).number - 1;
450
+ const range = caretContainerRange(fenceScan(lines), caretLine);
451
+ let next: ComponentAtCaret | null = null;
452
+ if (range) {
453
+ const fromPos = doc.line(range.fromLine + 1).from;
454
+ const toPos = doc.line(range.toLine + 1).to;
455
+ next = {
456
+ name: directiveOpenerName(lines[range.fromLine] ?? ''),
457
+ markdown: doc.sliceString(fromPos, toPos),
458
+ from: fromPos,
459
+ to: toPos,
460
+ };
461
+ }
462
+ const prev = lastCaretReport;
463
+ const same =
464
+ prev === next ||
465
+ (prev !== null &&
466
+ next !== null &&
467
+ prev.name === next.name &&
468
+ prev.markdown === next.markdown &&
469
+ prev.from === next.from &&
470
+ prev.to === next.to);
471
+ if (same) return;
472
+ lastCaretReport = next;
473
+ onComponentAtCaret?.(next);
474
+ }
475
+
476
+ // Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
477
+ // dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
478
+ function replaceRange(from: number, to: number, text: string) {
479
+ if (!view) return;
480
+ view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
481
+ view.focus();
482
+ }
483
+
409
484
  function insertAtCursor(text: string) {
410
485
  if (!view) {
411
486
  value = value ? `${value}\n\n${text}` : text;
@@ -1,4 +1,12 @@
1
1
  import { type FormatKind } from './markdown-format.js';
2
+ /** The directive container at the caret: the opener's name, the block's markdown, and the
3
+ * document character offsets of its inclusive line range. */
4
+ interface ComponentAtCaret {
5
+ name: string | null;
6
+ markdown: string;
7
+ from: number;
8
+ to: number;
9
+ }
2
10
  interface Props {
3
11
  /** The markdown source; bindable so the parent reads edits back. */
4
12
  value: string;
@@ -12,6 +20,12 @@ interface Props {
12
20
  registerGetSelection?: (get: () => string) => void;
13
21
  /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
14
22
  registerFormat?: (format: (kind: FormatKind) => void) => void;
23
+ /** Reports the directive container at the caret (or null when outside any container) whenever
24
+ * the reported value changes; the host resolves it against the registry to offer Edit-block. */
25
+ onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
26
+ /** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
27
+ * calls it to write an edited block back over its original range. */
28
+ registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
15
29
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
16
30
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
17
31
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
@@ -1142,6 +1142,35 @@
1142
1142
  }
1143
1143
  }
1144
1144
 
1145
+ @layer daisyui.l1.l2 {
1146
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
1147
+ background-color: var(--color-base-content);
1148
+ }
1149
+
1150
+ @supports (color: color-mix(in lab, red, red)) {
1151
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
1152
+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
1153
+ }
1154
+ }
1155
+
1156
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
1157
+ box-shadow: none;
1158
+ }
1159
+
1160
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled {
1161
+ pointer-events: none;
1162
+ --btn-border: #0000;
1163
+ --btn-noise: none;
1164
+ --btn-fg: var(--color-base-content);
1165
+ }
1166
+
1167
+ @supports (color: color-mix(in lab, red, red)) {
1168
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled {
1169
+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
1170
+ }
1171
+ }
1172
+ }
1173
+
1145
1174
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pointer-events-none {
1146
1175
  pointer-events: none;
1147
1176
  }
@@ -3929,6 +3958,10 @@
3929
3958
  height: calc(var(--spacing) * 1.5);
3930
3959
  }
3931
3960
 
3961
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-2\.5 {
3962
+ height: calc(var(--spacing) * 2.5);
3963
+ }
3964
+
3932
3965
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-3 {
3933
3966
  height: calc(var(--spacing) * 3);
3934
3967
  }
@@ -4023,6 +4056,10 @@
4023
4056
  width: calc(var(--spacing) * 1.5);
4024
4057
  }
4025
4058
 
4059
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-2\.5 {
4060
+ width: calc(var(--spacing) * 2.5);
4061
+ }
4062
+
4026
4063
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-3 {
4027
4064
  width: calc(var(--spacing) * 3);
4028
4065
  }
@@ -4241,6 +4278,10 @@
4241
4278
  cursor: pointer;
4242
4279
  }
4243
4280
 
4281
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .resize {
4282
+ resize: both;
4283
+ }
4284
+
4244
4285
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .appearance-none {
4245
4286
  appearance: none;
4246
4287
  }
@@ -4455,6 +4496,11 @@
4455
4496
  border-width: 0;
4456
4497
  }
4457
4498
 
4499
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[1\.5px\] {
4500
+ border-style: var(--tw-border-style);
4501
+ border-width: 1.5px;
4502
+ }
4503
+
4458
4504
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-t {
4459
4505
  border-top-style: var(--tw-border-style);
4460
4506
  border-top-width: 1px;
@@ -4475,6 +4521,11 @@
4475
4521
  border-left-width: 1px;
4476
4522
  }
4477
4523
 
4524
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-dashed {
4525
+ --tw-border-style: dashed;
4526
+ border-style: dashed;
4527
+ }
4528
+
4478
4529
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-none {
4479
4530
  --tw-border-style: none;
4480
4531
  border-style: none;
@@ -4489,6 +4540,26 @@
4489
4540
  }
4490
4541
  }
4491
4542
 
4543
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_35\%\,var\(--cairn-card-border\)\)\] {
4544
+ border-color: var(--color-error);
4545
+ }
4546
+
4547
+ @supports (color: color-mix(in lab, red, red)) {
4548
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_35\%\,var\(--cairn-card-border\)\)\] {
4549
+ border-color: color-mix(in oklab,var(--color-error) 35%,var(--cairn-card-border));
4550
+ }
4551
+ }
4552
+
4553
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_55\%\,var\(--cairn-card-border\)\)\] {
4554
+ border-color: var(--color-error);
4555
+ }
4556
+
4557
+ @supports (color: color-mix(in lab, red, red)) {
4558
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_55\%\,var\(--cairn-card-border\)\)\] {
4559
+ border-color: color-mix(in oklab,var(--color-error) 55%,var(--cairn-card-border));
4560
+ }
4561
+ }
4562
+
4492
4563
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--cairn-card-border\)\] {
4493
4564
  border-color: var(--cairn-card-border);
4494
4565
  }
@@ -4497,10 +4568,18 @@
4497
4568
  border-color: var(--color-base-300);
4498
4569
  }
4499
4570
 
4571
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-current {
4572
+ border-color: currentColor;
4573
+ }
4574
+
4500
4575
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-transparent {
4501
4576
  border-color: #0000;
4502
4577
  }
4503
4578
 
4579
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-t-transparent {
4580
+ border-top-color: #0000;
4581
+ }
4582
+
4504
4583
  @layer daisyui.l1.l2 {
4505
4584
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :where(:not(ul, details, .menu-title, .btn)).menu-active {
4506
4585
  --tw-outline-style: none;
@@ -4531,6 +4610,16 @@
4531
4610
  border: none;
4532
4611
  }
4533
4612
 
4613
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--color-error\)_5\%\,transparent\)\] {
4614
+ background-color: var(--color-error);
4615
+ }
4616
+
4617
+ @supports (color: color-mix(in lab, red, red)) {
4618
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--color-error\)_5\%\,transparent\)\] {
4619
+ background-color: color-mix(in oklab,var(--color-error) 5%,transparent);
4620
+ }
4621
+ }
4622
+
4534
4623
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[var\(--cairn-card-border\)\] {
4535
4624
  background-color: var(--cairn-card-border);
4536
4625
  }
@@ -4630,6 +4719,10 @@
4630
4719
  padding: calc(var(--spacing) * 4);
4631
4720
  }
4632
4721
 
4722
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-5 {
4723
+ padding: calc(var(--spacing) * 5);
4724
+ }
4725
+
4633
4726
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-6 {
4634
4727
  padding: calc(var(--spacing) * 6);
4635
4728
  }
@@ -4720,6 +4813,10 @@
4720
4813
  padding-block: calc(var(--spacing) * 0);
4721
4814
  }
4722
4815
 
4816
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-0\.5 {
4817
+ padding-block: calc(var(--spacing) * .5);
4818
+ }
4819
+
4723
4820
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-1 {
4724
4821
  padding-block: calc(var(--spacing) * 1);
4725
4822
  }
@@ -4756,6 +4853,10 @@
4756
4853
  padding-block: calc(var(--spacing) * 8);
4757
4854
  }
4758
4855
 
4856
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-12 {
4857
+ padding-block: calc(var(--spacing) * 12);
4858
+ }
4859
+
4759
4860
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-14 {
4760
4861
  padding-block: calc(var(--spacing) * 14);
4761
4862
  }
@@ -4780,6 +4881,10 @@
4780
4881
  padding-right: calc(var(--spacing) * 3);
4781
4882
  }
4782
4883
 
4884
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pb-1\.5 {
4885
+ padding-bottom: calc(var(--spacing) * 1.5);
4886
+ }
4887
+
4783
4888
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pl-3 {
4784
4889
  padding-left: calc(var(--spacing) * 3);
4785
4890
  }
@@ -4854,6 +4959,10 @@
4854
4959
  }
4855
4960
  }
4856
4961
 
4962
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.7rem\] {
4963
+ font-size: .7rem;
4964
+ }
4965
+
4857
4966
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.75rem\] {
4858
4967
  font-size: .75rem;
4859
4968
  }
@@ -5290,6 +5399,10 @@
5290
5399
  color: var(--color-muted);
5291
5400
  }
5292
5401
 
5402
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .first\:mt-0:first-child {
5403
+ margin-top: calc(var(--spacing) * 0);
5404
+ }
5405
+
5293
5406
  @media (hover: hover) {
5294
5407
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-base-200:hover {
5295
5408
  background-color: var(--color-base-200);
@@ -5380,6 +5493,12 @@
5380
5493
  }
5381
5494
  }
5382
5495
 
5496
+ @media (prefers-reduced-motion: reduce) {
5497
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .motion-reduce\:animate-none {
5498
+ animation: none;
5499
+ }
5500
+ }
5501
+
5383
5502
  @media (width >= 40rem) {
5384
5503
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .sm\:mt-\[12vh\] {
5385
5504
  margin-top: 12vh;
@@ -5552,6 +5671,12 @@
5552
5671
  }
5553
5672
  }
5554
5673
 
5674
+ @media (width >= 64rem) {
5675
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .lg\:grid-cols-2 {
5676
+ grid-template-columns: repeat(2, minmax(0, 1fr));
5677
+ }
5678
+ }
5679
+
5555
5680
  @media (width >= 64rem) {
5556
5681
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .lg\:grid-cols-\[1fr_17rem\] {
5557
5682
  grid-template-columns: 1fr 17rem;
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The directive name from a container opener line (`:::callout{...}` -> `callout`,
3
+ * `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
4
+ * same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
5
+ * non-fence line returns null.
6
+ */
7
+ export declare function directiveOpenerName(line: string): string | null;
1
8
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
2
9
  export declare function directiveLineKind(line: string): 'fence' | 'leaf' | null;
3
10
  /** One pass over the document: each line's container depth alongside its fence role. */
@@ -14,6 +14,16 @@ const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
14
14
  // directive forms. The depth scan tracks these so a documented ::: example inside a code block
15
15
  // never opens a real container.
16
16
  const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
17
+ /**
18
+ * The directive name from a container opener line (`:::callout{...}` -> `callout`,
19
+ * `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
20
+ * same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
21
+ * non-fence line returns null.
22
+ */
23
+ export function directiveOpenerName(line) {
24
+ const m = FENCE.exec(line);
25
+ return m && m[2] ? m[2] : null;
26
+ }
17
27
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
18
28
  export function directiveLineKind(line) {
19
29
  if (FENCE.test(line))
@@ -7,6 +7,26 @@ export declare function parseComponent(markdown: string, def: ComponentDef): Pro
7
7
  /** The raw attribute keys present on the component's opening directive, read from the parsed tree
8
8
  * (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
9
9
  export declare function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[];
10
+ /** The result of {@link componentRoundTripSafety}: whether re-opening a placed block into the
11
+ * guided form and re-serializing it is provably lossless. */
12
+ export type RoundTripSafety = {
13
+ safe: true;
14
+ } | {
15
+ safe: false;
16
+ reason: 'unknown-attribute' | 'undeclared-child' | 'not-idempotent' | 'not-a-component';
17
+ };
18
+ /** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
19
+ * hand can carry more than the schema models (an attribute the def does not list, a child container
20
+ * the def does not declare, slot content the form cannot represent stably), and parsing such a block
21
+ * into the form then re-serializing would silently drop it. The edit affordance is offered only when
22
+ * this returns `{ safe: true }`. Checks run in order and return the first failure:
23
+ *
24
+ * 1. `not-a-component`: the def's opening container is not present.
25
+ * 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
26
+ * 3. `undeclared-child`: the root has a direct child container directive that is not a declared
27
+ * nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
28
+ * 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
29
+ export declare function componentRoundTripSafety(markdown: string, def: ComponentDef): Promise<RoundTripSafety>;
10
30
  /** Parse the component once and derive both the guided-form values and the raw attribute keys.
11
31
  * Validation needs both, so this seam spares it the double parse that calling
12
32
  * {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */