@glw907/cairn-cms 0.56.1 → 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.
@@ -58,7 +58,7 @@ function isContainer(node) {
58
58
  }
59
59
  // Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
60
60
  // rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
61
- const toMd = unified().use(remarkStringify, { bullet: '-' });
61
+ const toMd = unified().use(remarkDirective).use(remarkStringify, { bullet: '-' });
62
62
  /** Render mdast children back to trimmed markdown text. */
63
63
  function childrenToText(children) {
64
64
  const root = { type: 'root', children };
@@ -120,6 +120,40 @@ export async function parseComponent(markdown, def) {
120
120
  export function parseRawAttributeKeys(markdown, def) {
121
121
  return rawKeysFromRoot(findComponentRoot(markdown, def));
122
122
  }
123
+ /** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
124
+ * hand can carry more than the schema models (an attribute the def does not list, a child container
125
+ * the def does not declare, slot content the form cannot represent stably), and parsing such a block
126
+ * into the form then re-serializing would silently drop it. The edit affordance is offered only when
127
+ * this returns `{ safe: true }`. Checks run in order and return the first failure:
128
+ *
129
+ * 1. `not-a-component`: the def's opening container is not present.
130
+ * 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
131
+ * 3. `undeclared-child`: the root has a direct child container directive that is not a declared
132
+ * nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
133
+ * 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
134
+ export async function componentRoundTripSafety(markdown, def) {
135
+ const root = findComponentRoot(markdown, def);
136
+ if (!root)
137
+ return { safe: false, reason: 'not-a-component' };
138
+ const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
139
+ for (const key of parseRawAttributeKeys(markdown, def)) {
140
+ if (!declaredKeys.has(key))
141
+ return { safe: false, reason: 'unknown-attribute' };
142
+ }
143
+ const slotNames = new Set(nestedSlots(def).map((s) => s.name));
144
+ for (const child of root.children) {
145
+ if (isContainer(child) && !slotNames.has(child.name)) {
146
+ return { safe: false, reason: 'undeclared-child' };
147
+ }
148
+ }
149
+ // The values are plain strings, booleans, and string arrays in declared (object-key) order, so a
150
+ // stable JSON.stringify is a sufficient deep-equal.
151
+ const v1 = await parseComponent(markdown, def);
152
+ const v2 = await parseComponent(serializeComponent(def, v1), def);
153
+ if (JSON.stringify(v1) !== JSON.stringify(v2))
154
+ return { safe: false, reason: 'not-idempotent' };
155
+ return { safe: true };
156
+ }
123
157
  /** Parse the component once and derive both the guided-form values and the raw attribute keys.
124
158
  * Validation needs both, so this seam spares it the double parse that calling
125
159
  * {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
@@ -146,8 +180,18 @@ function isDirectiveLabel(node) {
146
180
  function readLabel(root) {
147
181
  for (const child of root.children) {
148
182
  const p = child;
149
- if (p.type === 'paragraph' && p.data?.directiveLabel)
150
- return (p.children ?? []).map((c) => c.value ?? '').join('');
183
+ if (p.type !== 'paragraph' || !p.data?.directiveLabel)
184
+ continue;
185
+ const kids = p.children ?? [];
186
+ // When every label child is a plain text node, join the raw `.value`s. That keeps the pure-text
187
+ // path identical to before, so a literal `[` or `]` in the title is not re-escaped by the
188
+ // stringifier (serializeComponent already escapes brackets, and remark un-escapes them on parse).
189
+ // When the label carries inline markdown (a link, bold, emphasis), the text-only join would drop
190
+ // the markup, so stringify the children to recover the full inline source losslessly.
191
+ if (kids.every((c) => c.type === 'text')) {
192
+ return kids.map((c) => c.value ?? '').join('');
193
+ }
194
+ return childrenToText(kids);
151
195
  }
152
196
  return undefined;
153
197
  }
@@ -12,6 +12,16 @@ export async function validateComponent(markdown, def) {
12
12
  }
13
13
  if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
14
14
  errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
15
+ continue;
16
+ }
17
+ if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
18
+ errors[field.key] = field.pattern.message;
19
+ continue;
20
+ }
21
+ if (field.validate) {
22
+ const message = runFieldValidator(def, field.key, () => field.validate(v, values));
23
+ if (typeof message === 'string')
24
+ errors[field.key] = message;
15
25
  }
16
26
  }
17
27
  for (const key of rawKeys) {
@@ -28,3 +38,15 @@ export async function validateComponent(markdown, def) {
28
38
  }
29
39
  return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
30
40
  }
41
+ // Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
42
+ // the field is treated as valid and a dev-time warning names the component and field so the author
43
+ // can find the bug. A returned string is the field error; anything else (null) is clean.
44
+ function runFieldValidator(def, key, call) {
45
+ try {
46
+ return call();
47
+ }
48
+ catch (err) {
49
+ console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
50
+ return null;
51
+ }
52
+ }
@@ -15,6 +15,15 @@ export interface AttributeField {
15
15
  options?: readonly string[];
16
16
  /** Helper text shown under the field. */
17
17
  help?: string;
18
+ /** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
19
+ pattern?: {
20
+ source: string;
21
+ message: string;
22
+ };
23
+ /** A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
24
+ * Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
25
+ * fields. The picker wraps the call in try/catch so an author's throw never crashes the form. */
26
+ validate?: (value: string | boolean, all: ComponentValues) => string | null;
18
27
  }
19
28
  export type SlotKind = 'markdown' | 'inline' | 'repeatable';
20
29
  /** One named content region of a component. The slots named `title` and `body` are special: `title`
@@ -27,6 +36,9 @@ export interface SlotDef {
27
36
  help?: string;
28
37
  /** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
29
38
  itemFields?: AttributeField[];
39
+ /** For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
40
+ * When it returns nothing, the picker falls back to `${label} ${index + 1}`. */
41
+ itemLabel?: (item: Record<string, string | boolean>, index: number) => string;
30
42
  }
31
43
  /** The structured input a component's `build` receives. The engine stamps the component's
32
44
  * attributes and partitions its slots from the rendered hast, so `build` arranges hast and
@@ -64,6 +76,18 @@ export interface ComponentDef {
64
76
  attributes?: AttributeField[];
65
77
  /** The named content regions this component accepts. */
66
78
  slots?: SlotDef[];
79
+ /** A glyph key from the site IconSet, shown beside the label in the picker. */
80
+ icon?: string;
81
+ /** A category heading for the picker. Components order by declaration within a group. */
82
+ group?: string;
83
+ /** Omit from the top-level picker (for a nested or round-trip-only component). */
84
+ hidden?: boolean;
85
+ /** A structured sample the picker seeds the form with and renders through the same path a real
86
+ * insert takes. Declaring `preview` is what opts the component into the two-pane configure layout. */
87
+ preview?: {
88
+ attributes?: Record<string, string | boolean>;
89
+ slots?: Record<string, string | string[]>;
90
+ };
67
91
  }
68
92
  export interface ComponentRegistry {
69
93
  defs: ComponentDef[];
@@ -93,3 +117,7 @@ export interface ComponentValues {
93
117
  /** Seed an empty {@link ComponentValues} from a component's schema: attribute defaults (or '' / false)
94
118
  * and empty slot values ([] for repeatable, '' otherwise). */
95
119
  export declare function emptyValues(def: ComponentDef): ComponentValues;
120
+ /** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
121
+ * with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
122
+ * the def declares no `preview`, returns exactly the {@link emptyValues} output. */
123
+ export declare function previewValues(def: ComponentDef): ComponentValues;
@@ -45,3 +45,15 @@ export function emptyValues(def) {
45
45
  }
46
46
  return { attributes, slots };
47
47
  }
48
+ /** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
49
+ * with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
50
+ * the def declares no `preview`, returns exactly the {@link emptyValues} output. */
51
+ export function previewValues(def) {
52
+ const base = emptyValues(def);
53
+ if (!def.preview)
54
+ return base;
55
+ return {
56
+ attributes: { ...base.attributes, ...def.preview.attributes },
57
+ slots: { ...base.slots, ...def.preview.slots },
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.56.1",
3
+ "version": "0.56.2",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -32,6 +32,7 @@
32
32
  "check:reference:signatures": "npm run package && node scripts/check-reference-signatures.mjs",
33
33
  "check:readiness": "npm run package && node scripts/check-readiness.mjs",
34
34
  "check:docs": "node scripts/docs-links.mjs",
35
+ "check:version": "node scripts/check-version.mjs",
35
36
  "check:prose": "node scripts/check-admin-prose.mjs",
36
37
  "prepare": "npm run package",
37
38
  "check": "svelte-check --tsconfig ./tsconfig.json",
@@ -1,13 +1,17 @@
1
1
  <!--
2
2
  @component
3
- The schema-driven fill form for one component. It holds the working ComponentValues, seeded from
4
- emptyValues(def), and renders attribute fields and the title/body and other non-repeatable slots.
5
- Submit (Task 6) serializes and validates through buildComponentInsert and calls onInsert with the
6
- markdown. Back returns to the picker. This is not a nested HTML form; Insert calls a callback.
3
+ The schema-driven fill form for one component, the left column of the configure step. It holds the
4
+ working ComponentValues, seeded from previewValues(def) (the emptyValues base with any declared
5
+ preview sample overlaid), and renders attribute fields and the title/body and other slots. Required
6
+ fields carry an asterisk and aria-required, and Insert disables while any required field is empty.
7
+ Submit serializes and validates through buildComponentInsert and calls onInsert with the markdown.
8
+ This is not a nested HTML form; Insert calls a callback. The dialog owns the header (the Insert >
9
+ group breadcrumb and the Back control) and, in the two-pane case, the preview pane; this component
10
+ binds out its live `values` and `incomplete` so the dialog can render that preview.
7
11
  -->
8
12
  <script lang="ts">
9
13
  import { untrack } from 'svelte';
10
- import { emptyValues, type ComponentDef } from '../render/registry.js';
14
+ import { previewValues, type ComponentDef, type ComponentValues } from '../render/registry.js';
11
15
  import { buildComponentInsert } from '../render/component-insert.js';
12
16
  import type { IconSet } from '../render/glyph.js';
13
17
  import IconPicker from './IconPicker.svelte';
@@ -17,15 +21,39 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
17
21
  icons?: IconSet;
18
22
  /** Called with the serialized markdown when the form validates. */
19
23
  onInsert: (markdown: string) => void;
20
- /** Return to the picker. */
21
- onBack: () => void;
24
+ /** The live working values, bound out so a host (the dialog) can render a preview from them. */
25
+ values?: ComponentValues;
26
+ /** True while a required attribute or slot is still empty, bound out so the host's preview can
27
+ * show the incomplete state and the host can mirror the disabled Insert. */
28
+ incomplete?: boolean;
29
+ /** Seed the working values from these instead of the schema's preview sample. The dialog passes
30
+ * it in edit mode to re-open a placed component into its own values; the catalog insert path
31
+ * leaves it unset and keeps the previewValues seed. */
32
+ initial?: ComponentValues;
33
+ /** The submit button's label. The dialog passes 'Update' in edit mode; the insert path keeps
34
+ * the default. */
35
+ submitLabel?: string;
22
36
  }
23
37
 
24
- let { def, icons, onInsert, onBack }: Props = $props();
38
+ let {
39
+ def,
40
+ icons,
41
+ onInsert,
42
+ values = $bindable(),
43
+ incomplete = $bindable(),
44
+ initial,
45
+ submitLabel = 'Insert',
46
+ }: Props = $props();
25
47
 
26
- // Working values, seeded once from the schema. $state makes the nested records deeply reactive.
27
- // untrack marks the seed as a deliberate one-time read of the initial def, not a reactive miss.
28
- let values = $state(untrack(() => emptyValues(def)));
48
+ // Working values, seeded once from `initial` in edit mode, otherwise from the schema and any
49
+ // declared preview sample. $state makes the nested records deeply reactive. untrack marks the
50
+ // seed as a deliberate one-time read, not a reactive miss. previewValues falls back to
51
+ // emptyValues when no preview.
52
+ let working = $state(untrack(() => initial ?? previewValues(def)));
53
+ // Mirror the working values out to the bindable prop so the dialog's preview reads them live.
54
+ $effect(() => {
55
+ values = working;
56
+ });
29
57
 
30
58
  const attributes = $derived(def.attributes ?? []);
31
59
  // Non-repeatable slots render here; the repeatable list is handled separately.
@@ -34,22 +62,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
34
62
 
35
63
  // The live $state proxy array for a repeatable slot, so push/splice stay reactive.
36
64
  function slotItems(name: string): string[] {
37
- const v = values.slots[name];
65
+ const v = working.slots[name];
38
66
  return Array.isArray(v) ? v : [];
39
67
  }
40
68
 
41
69
  // Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
42
70
  // identity instead of index. A mid-list removal then drops the right DOM node and the focused
43
71
  // item follows the data. Ids come from a monotonic module-local counter, never Math.random or
44
- // Date.now. The value arrays in values.slots stay the canonical string lists serializeComponent
45
- // reads, so the emitted markdown is unchanged. emptyValues seeds every repeatable slot to [], so
46
- // the id lists start empty and stay in lockstep with the values through addItem/removeItem.
72
+ // Date.now. The value arrays in working.slots stay the canonical string lists serializeComponent
73
+ // reads, so the emitted markdown is unchanged. The preview seed can fill a repeatable slot, so
74
+ // each seeded item needs a parallel id from the start.
47
75
  let nextId = 0;
48
76
  const itemIds = $state<Record<string, number[]>>(
49
- untrack(() => Object.fromEntries((def.slots ?? []).filter((s) => s.kind === 'repeatable').map((s) => [s.name, []]))),
77
+ untrack(() =>
78
+ Object.fromEntries(
79
+ (def.slots ?? [])
80
+ .filter((s) => s.kind === 'repeatable')
81
+ .map((s) => {
82
+ const seeded = working.slots[s.name];
83
+ const count = Array.isArray(seeded) ? seeded.length : 0;
84
+ return [s.name, Array.from({ length: count }, () => nextId++)];
85
+ }),
86
+ ),
87
+ ),
50
88
  );
51
89
 
52
- // emptyValues and the itemIds seed both cover every repeatable slot, so this read always hits.
90
+ // previewValues and the itemIds seed both cover every repeatable slot, so this read always hits.
53
91
  function slotIds(name: string): number[] {
54
92
  return itemIds[name] ?? [];
55
93
  }
@@ -64,41 +102,106 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
64
102
  slotIds(name).splice(index, 1);
65
103
  }
66
104
 
105
+ // The row label for a repeatable item: the slot's itemLabel over the item's values and index,
106
+ // falling back to `${label} ${i + 1}` when it returns nothing. v1 repeatable items hold a single
107
+ // string under the first item field's key, so the item record passed to itemLabel carries it.
108
+ function rowLabel(slot: (typeof repeatableSlots)[number], value: string, index: number): string {
109
+ const fallback = `${slot.label} ${index + 1}`;
110
+ if (!slot.itemLabel) return fallback;
111
+ const key = slot.itemFields?.[0]?.key ?? 'text';
112
+ const derived = slot.itemLabel({ [key]: value }, index);
113
+ return derived && derived.trim() ? derived : fallback;
114
+ }
115
+
67
116
  // Typed accessors over the unions so explicit value targets stay sound.
68
117
  function asString(key: string): string {
69
- const v = values.attributes[key];
118
+ const v = working.attributes[key];
70
119
  return typeof v === 'string' ? v : '';
71
120
  }
72
121
  function asBool(key: string): boolean {
73
- return values.attributes[key] === true;
122
+ return working.attributes[key] === true;
74
123
  }
75
124
  function slotString(name: string): string {
76
- const v = values.slots[name];
125
+ const v = working.slots[name];
77
126
  return typeof v === 'string' ? v : '';
78
127
  }
79
128
 
80
- // Field-keyed validation errors from the last submit, keyed by attribute key or slot name.
81
- let errors = $state<Record<string, string>>({});
129
+ // A required attribute is unmet only for a text/select/icon field left empty; a boolean is always
130
+ // met (its false is a real choice). A required slot is unmet when its string is empty or its
131
+ // repeatable list has no non-empty item. This drives the asterisk-marked fields, the disabled
132
+ // Insert, and (through the bound `incomplete`) the dialog's incomplete preview state.
133
+ const incompleteState = $derived.by(() => {
134
+ for (const field of attributes) {
135
+ if (!field.required || field.type === 'boolean') continue;
136
+ if (asString(field.key) === '') return true;
137
+ }
138
+ for (const slot of def.slots ?? []) {
139
+ if (!slot.required) continue;
140
+ const v = working.slots[slot.name];
141
+ const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
142
+ if (!filled) return true;
143
+ }
144
+ return false;
145
+ });
146
+ $effect(() => {
147
+ incomplete = incompleteState;
148
+ });
149
+
150
+ // Field-keyed validation errors from the last submit (pattern, validate, select-domain), keyed by
151
+ // attribute key or slot name.
152
+ let submitErrors = $state<Record<string, string>>({});
153
+
154
+ // Fields the editor has touched, so a required-empty error shows after interaction rather than on
155
+ // a fresh open. Keyed by attribute key or slot name.
156
+ let touched = $state<Record<string, boolean>>({});
157
+ function markTouched(key: string): void {
158
+ touched[key] = true;
159
+ }
160
+
161
+ // The visible field errors. Only the required-empty message ("{label} is required.") shows live,
162
+ // for a touched-and-empty required field; pattern and validate errors surface on submit. Both are
163
+ // merged here, the submit errors last so a pattern or validate message wins. Insert stays disabled
164
+ // while incompleteState holds, so a required-empty field never serializes; this surfaces the why
165
+ // next to the field meanwhile.
166
+ const errors = $derived.by(() => {
167
+ const out: Record<string, string> = {};
168
+ for (const field of attributes) {
169
+ if (field.required && field.type !== 'boolean' && touched[field.key] && asString(field.key) === '') {
170
+ out[field.key] = `${field.label} is required.`;
171
+ }
172
+ }
173
+ for (const slot of def.slots ?? []) {
174
+ if (!slot.required) continue;
175
+ const v = working.slots[slot.name];
176
+ const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
177
+ if (touched[slot.name] && !filled) out[slot.name] = `${slot.label} is required.`;
178
+ }
179
+ return { ...out, ...submitErrors };
180
+ });
181
+
182
+ // The form container. Once it is bound, focus its first focusable control so the editor types
183
+ // straight into the form. The effect tracks formEl so it runs when the node lands; the focus call
184
+ // is untracked so a later value change does not steal focus back to the first field.
185
+ let formEl = $state<HTMLElement | null>(null);
186
+ $effect(() => {
187
+ if (!formEl) return;
188
+ untrack(() => formEl!.querySelector<HTMLElement>('input, select, textarea')?.focus());
189
+ });
82
190
 
83
191
  // Serialize and validate through the pure helper. On success clear errors and emit the markdown;
84
192
  // on failure keep the field-keyed errors so each field can show its message and insert nothing.
85
193
  async function submit() {
86
- const result = await buildComponentInsert(def, values);
194
+ const result = await buildComponentInsert(def, working);
87
195
  if (result.ok) {
88
- errors = {};
196
+ submitErrors = {};
89
197
  onInsert(result.markdown);
90
198
  } else {
91
- errors = result.errors;
199
+ submitErrors = result.errors;
92
200
  }
93
201
  }
94
202
  </script>
95
203
 
96
- <div class="flex flex-col gap-3">
97
- <div class="flex items-center justify-between">
98
- <h3 class="text-sm font-semibold">{def.label}</h3>
99
- <button type="button" class="btn btn-ghost btn-xs" onclick={onBack}>Back</button>
100
- </div>
101
-
204
+ <div class="flex flex-col gap-3" bind:this={formEl}>
102
205
  {#each attributes as field (field.key)}
103
206
  {#if field.type === 'boolean'}
104
207
  <label class="label cursor-pointer justify-start gap-2">
@@ -108,19 +211,24 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
108
211
  aria-invalid={Boolean(errors[field.key])}
109
212
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
110
213
  checked={asBool(field.key)}
111
- onchange={(e) => (values.attributes[field.key] = e.currentTarget.checked)}
214
+ onchange={(e) => (working.attributes[field.key] = e.currentTarget.checked)}
112
215
  />
113
216
  <span class="text-sm">{field.label}</span>
114
217
  </label>
115
218
  {:else if field.type === 'select'}
116
219
  <label class="flex flex-col gap-1">
117
- <span class="text-sm font-medium">{field.label}</span>
220
+ <span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
118
221
  <select
119
222
  class="select"
223
+ aria-required={field.required ? 'true' : undefined}
120
224
  aria-invalid={Boolean(errors[field.key])}
121
225
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
122
226
  value={asString(field.key)}
123
- onchange={(e) => (values.attributes[field.key] = e.currentTarget.value)}
227
+ onchange={(e) => {
228
+ working.attributes[field.key] = e.currentTarget.value;
229
+ markTouched(field.key);
230
+ }}
231
+ onblur={() => markTouched(field.key)}
124
232
  >
125
233
  {#if !field.required}<option value="">—</option>{/if}
126
234
  {#each field.options ?? [] as opt (opt)}<option value={opt}>{opt}</option>{/each}
@@ -128,24 +236,29 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
128
236
  </label>
129
237
  {:else if field.type === 'icon' && icons}
130
238
  <div class="flex flex-col gap-1">
131
- <span class="text-sm font-medium">{field.label}</span>
239
+ <span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
132
240
  <IconPicker
133
241
  {icons}
134
242
  label={field.label}
135
243
  value={asString(field.key)}
136
244
  required={field.required ?? false}
137
- onChange={(name) => (values.attributes[field.key] = name)}
245
+ onChange={(name) => (working.attributes[field.key] = name)}
138
246
  />
139
247
  </div>
140
248
  {:else}
141
249
  <label class="flex flex-col gap-1">
142
- <span class="text-sm font-medium">{field.label}</span>
250
+ <span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
143
251
  <input
144
252
  class="input"
253
+ aria-required={field.required ? 'true' : undefined}
145
254
  aria-invalid={Boolean(errors[field.key])}
146
255
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
147
256
  value={asString(field.key)}
148
- oninput={(e) => (values.attributes[field.key] = e.currentTarget.value)}
257
+ oninput={(e) => {
258
+ working.attributes[field.key] = e.currentTarget.value;
259
+ markTouched(field.key);
260
+ }}
261
+ onblur={() => markTouched(field.key)}
149
262
  />
150
263
  </label>
151
264
  {/if}
@@ -155,25 +268,35 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
155
268
  {#each flatSlots as slot (slot.name)}
156
269
  {#if slot.kind === 'markdown'}
157
270
  <label class="flex flex-col gap-1">
158
- <span class="text-sm font-medium">{slot.label}</span>
271
+ <span class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
159
272
  <textarea
160
273
  class="textarea"
274
+ aria-required={slot.required ? 'true' : undefined}
161
275
  aria-invalid={Boolean(errors[slot.name])}
162
276
  aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
163
277
  rows={3}
164
278
  value={slotString(slot.name)}
165
- oninput={(e) => (values.slots[slot.name] = e.currentTarget.value)}
279
+ oninput={(e) => {
280
+ working.slots[slot.name] = e.currentTarget.value;
281
+ markTouched(slot.name);
282
+ }}
283
+ onblur={() => markTouched(slot.name)}
166
284
  ></textarea>
167
285
  </label>
168
286
  {:else}
169
287
  <label class="flex flex-col gap-1">
170
- <span class="text-sm font-medium">{slot.label}</span>
288
+ <span class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
171
289
  <input
172
290
  class="input"
291
+ aria-required={slot.required ? 'true' : undefined}
173
292
  aria-invalid={Boolean(errors[slot.name])}
174
293
  aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
175
294
  value={slotString(slot.name)}
176
- oninput={(e) => (values.slots[slot.name] = e.currentTarget.value)}
295
+ oninput={(e) => {
296
+ working.slots[slot.name] = e.currentTarget.value;
297
+ markTouched(slot.name);
298
+ }}
299
+ onblur={() => markTouched(slot.name)}
177
300
  />
178
301
  </label>
179
302
  {/if}
@@ -184,11 +307,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
184
307
  {@const items = slotItems(slot.name)}
185
308
  {@const ids = slotIds(slot.name)}
186
309
  <fieldset class="rounded-box border border-[var(--cairn-card-border)] flex flex-col gap-2 p-2">
187
- <legend class="text-sm font-medium">{slot.label}</legend>
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. -->
310
+ <legend class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</legend>
311
+ <!-- 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. The visible row tag derives from itemLabel, falling back to the indexed label. -->
189
312
  {#each ids as id, i (id)}
313
+ {@const label = rowLabel(slot, items[i] ?? '', i)}
190
314
  <div class="flex items-center gap-2">
191
- <input class="input input-sm flex-1" aria-label={`${slot.label} ${i + 1}`} bind:value={items[i]} />
315
+ <span class="flex-none text-xs text-[var(--color-muted)]">{label}</span>
316
+ <input
317
+ class="input input-sm flex-1"
318
+ aria-label={`${slot.label} ${i + 1}`}
319
+ bind:value={items[i]}
320
+ />
192
321
  <button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
193
322
  </div>
194
323
  {/each}
@@ -197,5 +326,5 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
197
326
  </fieldset>
198
327
  {/each}
199
328
 
200
- <button type="button" class="btn btn-primary btn-sm mt-2" onclick={submit}>Insert</button>
329
+ <button type="button" class="btn btn-primary btn-sm mt-2 self-start" disabled={incompleteState} onclick={submit}>{submitLabel}</button>
201
330
  </div>