From b369183cc00ab26a4d479af485c23c552151f430 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:47:23 +0100 Subject: [PATCH 01/27] Fix multiple-choice widget dropping scalar value on first toggle normalizeChoiceSelection returned [] for a scalar string in multiple mode, so the first checkbox toggle rebuilt selection from empty and lost the previously stored choice. Coerce scalar strings to one-element arrays in multiple mode. Adds regression test. Devana: 20260625T114006Z-P1-multiple-choice-scalar-lost --- ...T114006Z-P1-multiple-choice-scalar-lost.md | 56 +++++++++ ...625T114007Z-P1-object-invalid-root-lost.md | 54 ++++++++ ...T114008Z-P1-structure-row-coercion-lost.md | 54 ++++++++ ...114009Z-P2-boolean-string-false-checked.md | 54 ++++++++ ...T114010Z-P2-structure-reorder-wrong-row.md | 54 ++++++++ ...4011Z-P2-single-choice-array-deselected.md | 54 ++++++++ ...114012Z-P2-structure-non-array-empty-ui.md | 54 ++++++++ ...5T120226Z-P3-choice-missing-value-crash.md | 118 ++++++++++++++++++ ...60627T180001Z-P1-link-invalid-root-lost.md | 54 ++++++++ ...80002Z-P2-choices-multiple-string-false.md | 55 ++++++++ ...-P2-numeric-keystroke-intermediate-loss.md | 54 ++++++++ ...27T180004Z-P2-link-alien-fields-persist.md | 50 ++++++++ ...005Z-P2-duplicate-choice-value-collapse.md | 60 +++++++++ ...627T180006Z-P2-structure-min-max-bypass.md | 50 ++++++++ ...80007Z-P2-structure-min-gt-max-deadlock.md | 52 ++++++++ ...180008Z-P2-choices-empty-blocks-options.md | 52 ++++++++ ...9Z-P2-select-subfield-non-string-hidden.md | 52 ++++++++ ...010Z-P2-structure-stale-closure-clobber.md | 55 ++++++++ ...011Z-P2-number-subfield-string-persists.md | 56 +++++++++ src/admin.tsx | 3 + tests/transformations.test.mjs | 5 + 21 files changed, 1096 insertions(+) create mode 100644 .devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md create mode 100644 .devana/20260625T114007Z-P1-object-invalid-root-lost.md create mode 100644 .devana/20260625T114008Z-P1-structure-row-coercion-lost.md create mode 100644 .devana/20260625T114009Z-P2-boolean-string-false-checked.md create mode 100644 .devana/20260625T114010Z-P2-structure-reorder-wrong-row.md create mode 100644 .devana/20260625T114011Z-P2-single-choice-array-deselected.md create mode 100644 .devana/20260625T114012Z-P2-structure-non-array-empty-ui.md create mode 100644 .devana/20260625T120226Z-P3-choice-missing-value-crash.md create mode 100644 .devana/20260627T180001Z-P1-link-invalid-root-lost.md create mode 100644 .devana/20260627T180002Z-P2-choices-multiple-string-false.md create mode 100644 .devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md create mode 100644 .devana/20260627T180004Z-P2-link-alien-fields-persist.md create mode 100644 .devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md create mode 100644 .devana/20260627T180006Z-P2-structure-min-max-bypass.md create mode 100644 .devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md create mode 100644 .devana/20260627T180008Z-P2-choices-empty-blocks-options.md create mode 100644 .devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md create mode 100644 .devana/20260627T180010Z-P2-structure-stale-closure-clobber.md create mode 100644 .devana/20260627T180011Z-P2-number-subfield-string-persists.md diff --git a/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md b/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md new file mode 100644 index 0000000..f4678f1 --- /dev/null +++ b/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md @@ -0,0 +1,56 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: fixed | P1 | high | security=no +DEVANA-KEY: src/admin.tsx:248 | multiple-choice-scalar-lost + +# Multiple-choice widget drops scalar stored value on first toggle + +## Finding + +When `ChoicesField` runs with `multiple: true` but the persisted value is a scalar string (for example `"alpha"`), the first checkbox toggle rebuilds selection from an empty set and emits a new array that omits the previously stored choice. + +## Violated Invariant Or Contract + +Multiple-choice mode should preserve existing string selections when the user adds or removes another choice. A stored scalar in multiple mode is a realistic legacy shape after toggling `multiple` or importing older JSON. + +## Oracle + +`updateChoiceSelection` tests in `tests/transformations.test.mjs` cover array inputs only. `normalizeChoiceSelection` is the seed for every toggle path in `ChoicesField`. + +## Counterexample + +1. Widget options: `{ multiple: true, choices: ["alpha", "beta", "gamma"] }`. +2. Persisted `value: "alpha"`. +3. `normalizeChoiceSelection("alpha", true)` returns `[]` because the value is not an array. +4. User checks `"beta"`. +5. `updateChoiceSelection("alpha", "beta", true, true)` returns `["beta"]`. +6. `"alpha"` is lost from stored JSON without an explicit user action to remove it. + +## Why It Might Matter + +Editors can unknowingly drop an existing selection the first time they interact with a migrated or misconfigured field. Downstream templates that still expect `"alpha"` will read the wrong value after a single click. + +## Proof + +Control-flow trace: + +`ChoicesField` (`multiple=true`) → `normalizeChoiceSelection(value, true)` → `[]` for scalar input → `updateChoiceSelection` seeds `Set([])` → first `onChange` emits only newly checked values. + +## Counterevidence Checked + +Multiple-mode filtering of non-string array entries is intentional and tested. No test covers scalar pre-state in multiple mode. The UI never coerces scalar values to arrays on mount. + +## Suggested Next Step + +Coerce scalar strings to one-element arrays in `normalizeChoiceSelection` when `multiple` is true, or normalize `value` once when `ChoicesField` mounts. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. Confirmed `normalizeChoiceSelection(scalar, true)` returned `[]`, dropping the stored value on first toggle. `normalizeChoiceSelection` now coerces a scalar string to a one-element array in multiple mode, so `updateChoiceSelection` seeds from the existing selection. Added regression test in tests/transformations.test.mjs; full suite (23 tests) passes. + +DEVANA-KEY: src/admin.tsx:248 | multiple-choice-scalar-lost +DEVANA-SUMMARY: fixed | P1 | high | Scalar choice values in multiple mode are discarded on the first checkbox toggle. \ No newline at end of file diff --git a/.devana/20260625T114007Z-P1-object-invalid-root-lost.md b/.devana/20260625T114007Z-P1-object-invalid-root-lost.md new file mode 100644 index 0000000..b8c7a66 --- /dev/null +++ b/.devana/20260625T114007Z-P1-object-invalid-root-lost.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P1 | high | security=no +DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost + +# Object field replaces invalid root value on first edit + +## Finding + +`ObjectField` renders invalid non-object root values as `{}`, but the parent state keeps the original value until the user edits a subfield. The first edit calls `updateObjectValue` against a normalized empty object, so the original array or scalar root is replaced by a partial object. + +## Violated Invariant Or Contract + +Invalid persisted values are normalized for display, but the first mutation should not silently delete the previous root shape without an explicit reset path. + +## Oracle + +`tests/transformations.test.mjs` documents that `normalizeObjectValue(["title"])` becomes `{}`, but does not trace the widget save path. + +## Counterexample + +1. Persisted object-field `value: ["title", "Old"]`. +2. `normalizeObjectValue(value)` returns `{}`; all subfields render empty. +3. User types `"New"` into `title`. +4. `updateObjectValue(["title", "Old"], "title", "New")` normalizes to `{}` and returns `{ title: "New" }`. +5. The original array is gone after one keystroke. + +## Why It Might Matter + +A single subfield edit can destroy recoverable malformed JSON that was still present in storage. Editors may not realize data was dropped because the UI already looked empty. + +## Proof + +Dataflow trace: + +invalid root `value` → `normalizeObjectValue` → `{}` for render → first `onChange` via `updateObjectValue` uses normalized `{}` as base → parent receives new object, original root shape lost. + +## Counterevidence Checked + +Normalization to `{}` for non-objects is intentional for display. Widget does not write back normalized values on mount, so the loss only happens on first edit, not on load alone. + +## Suggested Next Step + +Emit a normalized canonical object on mount when the root shape is invalid, or base updates on the raw parent value after explicit migration. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost +DEVANA-SUMMARY: open | P1 | high | First subfield edit replaces an invalid object root with a partial object and drops the original value. \ No newline at end of file diff --git a/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md b/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md new file mode 100644 index 0000000..663cfd5 --- /dev/null +++ b/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P1 | high | security=no +DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost + +# Structure field drops invalid row payloads on first mutation + +## Finding + +`StructureField` coerces non-object rows to `{}` during render, but the parent array keeps the original row values until any structure mutation runs. The first add, remove, move, or row edit emits the normalized array and permanently discards invalid row payloads such as nested arrays. + +## Violated Invariant Or Contract + +Read-side normalization should not cause silent data loss on the first write unless the user explicitly deletes the row. + +## Oracle + +`tests/transformations.test.mjs` asserts invalid rows normalize to `{}` for display. No test covers parent-state divergence before the first `onChange`. + +## Counterexample + +1. Persisted `value: [{ label: "A" }, ["secret"]]`. +2. `normalizeStructureValue(value)` renders `[{ label: "A" }, {}]`; row 2 looks empty. +3. User clicks Add item. +4. `addStructureItem(items)` uses the normalized in-memory array and `onChange` emits `[{ label: "A" }, {}, {}]`. +5. `["secret"]` is removed from stored JSON without an explicit delete of that payload. + +## Why It Might Matter + +Malformed imported rows can vanish after an unrelated structure action. The editor never saw the hidden payload and cannot restore it from the widget. + +## Proof + +Control-flow trace: + +invalid row in parent `value` → `normalizeStructureValue` → `{}` at render → first `updateItems(...)` / `onChange` emits normalized array only → original non-object row never written back. + +## Counterevidence Checked + +Coercion to `{}` is tested and intentional for rendering. The widget does not auto-normalize parent state on mount, so invalid payloads survive until the first mutation. + +## Suggested Next Step + +Normalize and write back structure values on mount when row shapes are invalid, or preserve raw row data until the user edits that specific row. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost +DEVANA-SUMMARY: open | P1 | high | First structure mutation persists normalized rows and drops invalid row payloads that were still in parent state. \ No newline at end of file diff --git a/.devana/20260625T114009Z-P2-boolean-string-false-checked.md b/.devana/20260625T114009Z-P2-boolean-string-false-checked.md new file mode 100644 index 0000000..98099dc --- /dev/null +++ b/.devana/20260625T114009Z-P2-boolean-string-false-checked.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked + +# Boolean subfield treats string "false" as checked + +## Finding + +Object and structure boolean subfields render `checked={Boolean(value)}` without coercing stored JSON to a real boolean. The string `"false"` is truthy in JavaScript, so the checkbox appears checked while the persisted value remains the string `"false"`. + +## Violated Invariant Or Contract + +README documents boolean subfields as storing a `Boolean`. The UI should reflect boolean semantics for persisted values, not generic truthiness. + +## Oracle + +README stored-value table (`boolean` → `Boolean`). `renderSubField` boolean branch in `src/admin.tsx`. + +## Counterexample + +1. Object subfield `enabled` has persisted value `"false"`. +2. `Boolean("false")` evaluates to `true`. +3. Checkbox renders checked. +4. User saves another subfield without toggling `enabled`. +5. Stored JSON still contains `"false"`, and the UI remains checked. + +## Why It Might Matter + +Imported or hand-edited JSON with string booleans shows the opposite state from what consumers expect. Frontend code comparing against `false` will disagree with the admin UI. + +## Proof + +Counterexample value: + +`{ enabled: "false" }` → `renderSubField` boolean branch → `checked={Boolean("false")}` → checked UI with string payload unchanged. + +## Counterevidence Checked + +No boolean normalization exists in `normalizeObjectValue` or mutators. The field only writes a real boolean after the user toggles the checkbox. Numeric truthy values such as `1` have the same display bug. + +## Suggested Next Step + +Normalize boolean subfield values with strict boolean parsing (`value === true || value === "true"`) before rendering and optionally on load. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked +DEVANA-SUMMARY: open | P2 | medium | String "false" in boolean subfields renders as checked because the widget uses Boolean(value). \ No newline at end of file diff --git a/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md b/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md new file mode 100644 index 0000000..f23bf08 --- /dev/null +++ b/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row + +# Structure reorder can edit the wrong row after move or delete + +## Finding + +`StructureField` uses `key={index}` for each row and binds row edits to the row index from the render that created the handler. After reordering or deleting another row, focus can remain on the same DOM position while the item at that index changes, so subsequent typing updates a different row than the one the editor was working on. + +## Violated Invariant Or Contract + +Row identity should follow item content through reorder and removal. Index-keyed rows plus index-scoped `updateStructureItem(items, index, ...)` break that invariant when the list order changes between render and edit. + +## Oracle + +Standard React list-key guidance and controlled-field behavior for sortable editors. + +## Counterexample + +1. Structure value `[{ title: "A" }, { title: "B" }, { title: "C" }]`. +2. Editor focuses the `title` input in row B at index `1`. +3. Editor clicks Up on row B; `moveStructureItem(items, 1, 0)` yields `[B, A, C]`. +4. React reuses the component at `key={1}`, which now displays item A. +5. Focus remains in index `1`'s input; further typing calls `updateStructureItem(items, 1, ...)` against the stale render snapshot and writes into item A instead of B. + +## Why It Might Matter + +Sortable structure lists can silently corrupt row data during a common reorder workflow. The visible values may look plausible because controlled props match the row currently at that index, but the editor's intent was applied to the wrong item. + +## Proof + +State transition mismatch: + +focus on row index `1` → reorder changes item at index `1` → same index key and handler target index `1` → subsequent `onChange` updates the wrong item. + +## Counterevidence Checked + +Sequential edits without reorder keep indices stable and behave correctly. Pure helpers such as `moveStructureItem` are immutable and correct in isolation. The bug requires reorder/remove between focus and the next edit event. + +## Suggested Next Step + +Use stable row keys derived from item identity or an internal row id, and resolve the target row by id instead of render-time index. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row +DEVANA-SUMMARY: open | P2 | medium | Index-keyed structure rows can apply edits to the wrong item after reorder or delete while focus stays on the same slot. \ No newline at end of file diff --git a/.devana/20260625T114011Z-P2-single-choice-array-deselected.md b/.devana/20260625T114011Z-P2-single-choice-array-deselected.md new file mode 100644 index 0000000..df952f6 --- /dev/null +++ b/.devana/20260625T114011Z-P2-single-choice-array-deselected.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected + +# Single-choice widget ignores array stored values + +## Finding + +When `ChoicesField` runs with `multiple: false`, a persisted array value such as `["alpha"]` is treated as unselected. The UI shows no checked choice, but the parent state keeps the array until the user picks a new option. + +## Violated Invariant Or Contract + +Single-choice mode should either coerce a one-element array to its string value or surface the stored selection. Showing an empty selection while the parent still holds an array breaks the widget's read/write contract. + +## Oracle + +`normalizeChoiceSelection` and `ChoicesField` radio rendering (`value={typeof value === "string" ? value : ""}`). Tests cover scalar single-mode values only. + +## Counterexample + +1. Widget options: `{ choices: ["alpha", "beta"], multiple: false }`. +2. Persisted `value: ["alpha"]`. +3. `normalizeChoiceSelection(["alpha"], false)` returns `[]`. +4. `Radio.Group` receives `value=""`; nothing appears selected. +5. User saves without choosing again; parent state can remain `["alpha"]`. + +## Why It Might Matter + +Legacy multiple-choice data or config changes from `multiple: true` to `false` leave the field looking blank while stored JSON still contains an array. Frontend consumers and the admin UI disagree about the current value. + +## Proof + +Contract mismatch: + +stored `string[]` in single mode → `normalizeChoiceSelection` accepts only `string` → empty selection in UI → no mount-time write-back → array persists unchanged. + +## Counterevidence Checked + +Multiple-mode non-string filtering is intentional and tested. The UI never normalizes legacy shapes on mount. Horizontal and vertical choice renderers share the same selection normalization. + +## Suggested Next Step + +When `multiple` is false, coerce a one-element string array to its sole element for display and initial `onChange` normalization. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected +DEVANA-SUMMARY: open | P2 | medium | Single-choice fields show no selection for array stored values while the array remains in parent state. \ No newline at end of file diff --git a/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md b/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md new file mode 100644 index 0000000..ef0071f --- /dev/null +++ b/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui + +# Structure field hides non-array persisted values + +## Finding + +When a structure field's persisted value is not an array, `normalizeStructureValue` returns `[]` for rendering and the widget shows zero rows. The parent state keeps the original object or scalar until the user performs a structure action, so valid data can be hidden from editors without any warning. + +## Violated Invariant Or Contract + +Structure widgets should either surface malformed persisted data for correction or normalize it back to the parent on load. Rendering an empty list while storage still holds a non-array value breaks the editor's view of stored content. + +## Oracle + +`normalizeStructureValue` behavior tested in `tests/transformations.test.mjs` for invalid inputs, without a widget save-path test. + +## Counterexample + +1. Persisted structure value `{ "0": { label: "A" }, "1": { label: "B" } }` (object map instead of array). +2. `normalizeStructureValue(value)` returns `[]`. +3. `StructureField` renders no rows and no error beyond an empty list. +4. User saves the entry without adding a row. +5. Parent state can remain the original object map, invisible in admin UI. + +## Why It Might Matter + +Imported JSON with the wrong top-level shape looks like an empty field, so editors may add new rows on top of hidden data or publish content believing the structure is blank. + +## Proof + +Dataflow trace: + +non-array persisted `value` → `normalizeStructureValue` → `[]` at render → no mount-time `onChange` → original non-array value remains in parent state while UI shows emptiness. + +## Counterevidence Checked + +Normalization to `[]` for non-array input is intentional for helper semantics. Add/remove controls operate only on the normalized in-memory array. The bug is the parent/UI divergence, not the helper alone. + +## Suggested Next Step + +Detect non-array structure values on mount and either migrate them into an array shape with an explicit `onChange`, or render a recovery warning with the raw value. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui +DEVANA-SUMMARY: open | P2 | medium | Non-array structure values render as an empty list while the original persisted value remains hidden in parent state. \ No newline at end of file diff --git a/.devana/20260625T120226Z-P3-choice-missing-value-crash.md b/.devana/20260625T120226Z-P3-choice-missing-value-crash.md new file mode 100644 index 0000000..e06150c --- /dev/null +++ b/.devana/20260625T120226Z-P3-choice-missing-value-crash.md @@ -0,0 +1,118 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P3 | medium | security=no +DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash + +# Choice object without `value` crashes the whole choices widget + +## Finding + +`normalizeChoices` normalizes *string* choices into `{ value, label }`, but passes +*object* choices through unchanged: + +```ts +export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] { + return (value ?? []).map((choice) => + typeof choice === "string" ? { value: choice, label: choice } : choice, + ); +} +``` + +So a choice object that omits `value` (e.g. `{ label: "X" }`) survives with +`choice.value === undefined`. Downstream, `ChoicesField` renders it through +`choiceInputId(id, choice.value, index)` at `src/admin.tsx:684` (horizontal) and +`src/admin.tsx:737` (vertical multiple), and `choiceInputId` does: + +```ts +function choiceInputId(id: string, value: string, index: number) { + const safeValue = value.replace(/[^a-zA-Z0-9_-]/g, "-") || "choice"; + ... +} +``` + +`undefined.replace(...)` throws `TypeError: Cannot read properties of undefined`, +which propagates out of render and crashes the entire field widget (and any +parent that does not catch it), not just the malformed row. In the single +(non-multiple, non-horizontal) `Radio.Group` branch it does not throw but renders +a `Radio.Item` with `value={undefined}`, producing a non-selectable, broken option. + +## Violated Invariant Or Contract + +`normalizeChoices` is named and used as the normalization boundary for choices: +its return type is `FieldsChoice[]`, and every consumer assumes each element has a +string `value` (used as React `key`, DOM `id` seed, and selection key). The +function enforces this for string inputs but not for object inputs, so the +post-normalization invariant "every choice has a string `value`" does not hold. + +## Oracle + +- Neighboring implementation: the string branch of `normalizeChoices` itself + synthesizes `value`, showing the intended contract is "produce a usable + `value`". The object branch silently breaks that. +- `choiceInputId` (`src/admin.tsx:292`) types its second parameter as `string` + and calls `.replace` on it with no guard — it trusts the normalization step. +- `FieldsChoice` (`src/types.ts:13`) declares `value: string` as required, so all + downstream code assumes it is present. + +## Counterexample + +Schema config authored as serialized JSON/YAML (where TypeScript's required +`value` is not enforced): + +```json +{ + "widget": "fields:choices", + "options": { "choices": [{ "label": "Workers AI", "icon": "AI" }] } +} +``` + +Rendering this widget in the default (multiple) or horizontal layout throws +`TypeError` at `choiceInputId` and the admin field crashes on first paint. + +## Why It Might Matter + +A single mistyped choice (missing `value`) takes down the whole admin field +widget rather than degrading that one option. The README documents authoring +choices in serialized JSON schema, where the TypeScript `value: string` +requirement provides no protection, so this is reachable from ordinary +content-model authoring. Availability/correctness impact on the admin UI. + +## Proof + +Dataflow trace: `options.choices` (object missing `value`) -> +`normalizeChoices` object branch returns it unmodified (`src/admin.tsx:244`) -> +`ChoicesField` maps choices and calls `choiceInputId(id, choice.value, index)` +(`src/admin.tsx:684` / `:737`) -> `value.replace(...)` on `undefined` +(`src/admin.tsx:293`) -> `TypeError` escapes render. + +## Counterevidence Checked + +- `FieldsChoice.value` is typed required, so well-typed TS callers cannot hit + this. Counter: EmDash schema config is commonly serialized JSON/YAML (README + "Examples" / "Choice Icons" sections show JSON choices), where the type guard + does not apply; the string branch's own value-synthesis shows loose input was + anticipated. +- Single (`Radio.Group`) branch does not call `choiceInputId`, so it does not + crash — but it still renders a broken, non-selectable item, so the invariant + violation is real across all three layouts. +- Strongest reason this might be false: it is arguably "garbage-in" config error + rather than a logic defect. It is kept P3 for that reason, but the asymmetry + with the string branch and the hard crash (vs. graceful skip) make it + actionable. + +## Suggested Next Step + +In `normalizeChoices`, drop or repair object choices lacking a string `value` +(e.g. filter them out, or default `value` from `label`), or guard +`choiceInputId` against a non-string `value`. Smallest fix: coerce/guard in +`normalizeChoices` so the post-normalization invariant holds for all branches. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-25: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash +DEVANA-SUMMARY: open | P3 | medium | A choice object missing `value` survives normalizeChoices and crashes the choices widget at choiceInputId in two of three layouts. diff --git a/.devana/20260627T180001Z-P1-link-invalid-root-lost.md b/.devana/20260627T180001Z-P1-link-invalid-root-lost.md new file mode 100644 index 0000000..e589b0c --- /dev/null +++ b/.devana/20260627T180001Z-P1-link-invalid-root-lost.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P1 | high | security=no +DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost + +# Link field first edit drops invalid scalar root + +## Finding + +When a link field's persisted value is a non-object root (for example a bare URL string), the widget normalizes it to `{}` for display but merges edits against that empty object instead of the original root. The first subfield edit replaces the stored value with a partial object and silently discards the original payload. + +## Violated Invariant Or Contract + +`updateLinkValue` is exported as a deterministic link transformer (`CHANGELOG.md`, `tests/transformations.test.mjs`). A user edit should extend the current stored value, not replace an invalid root with a smaller object on the first keystroke. + +## Oracle + +`tests/transformations.test.mjs` covers `normalizeLinkValue("bad")` → `{}` and valid merges, but not the widget save path. `LinkField` reads via `normalizeLinkValue(value)` and writes via `updateLinkValue(data, nextValue)` where `data` is the normalized render snapshot. + +## Counterexample + +1. Persisted link field value: `"https://example.com"` (scalar string root). +2. `LinkField` renders with `data = {}`; value/text inputs appear empty. +3. User types link text `"Home"` without touching the value input. +4. `onChange` emits `{ text: "Home" }`. +5. Original `"https://example.com"` is gone after one edit. + +## Why It Might Matter + +Imported or legacy JSON with scalar link roots can lose URLs or other metadata the editor never surfaced, causing silent data loss on the first save after opening the entry. + +## Proof + +Control-flow trace: invalid root `value` → `normalizeLinkValue` → `{}` at render → `update({ text })` calls `updateLinkValue(data, { text })` with normalized `data`, not raw `value` → parent receives `{ text: "Home" }` instead of merged link object. + +Locations: `normalizeLinkValue` (234–236), `updateLinkValue` (238–240), `LinkField` closure `data` and `update` (596–601). + +## Counterevidence Checked + +`normalizeLinkValue("bad")` → `{}` is intentional helper behavior. No mount-time write-back occurs, so data survives until first edit — same class as `object-invalid-root-lost`, but this is a separate `LinkField` code path not covered by that report. + +## Suggested Next Step + +Align `LinkField.update` with `updateLinkValue(value, nextValue)` using the raw prop, or seed an initial normalized object via `onChange` on mount when the root is invalid. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost +DEVANA-SUMMARY: open | P1 | high | First link subfield edit replaces an invalid scalar root with a partial object and drops the original value. \ No newline at end of file diff --git a/.devana/20260627T180002Z-P2-choices-multiple-string-false.md b/.devana/20260627T180002Z-P2-choices-multiple-string-false.md new file mode 100644 index 0000000..f7cc51d --- /dev/null +++ b/.devana/20260627T180002Z-P2-choices-multiple-string-false.md @@ -0,0 +1,55 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false + +# Choices widget treats string "false" as multiple mode + +## Finding + +`ChoicesField` derives multi-select mode with `Boolean(options?.multiple)`. In serialized JSON, `"multiple": "false"` is a non-empty string and is therefore truthy in JavaScript. The widget enters checkbox/multi mode and emits array payloads even when the schema author intended single-select. + +## Violated Invariant Or Contract + +`ChoicesOptions.multiple` is a boolean flag (`src/types.ts`). `multiple: false` must select radio/single mode and scalar `onChange` strings. + +## Oracle + +`ChoicesOptions.multiple?: boolean` in `src/types.ts`. `Boolean("false") === true` is standard JavaScript semantics. Distinct from `boolean-string-false-checked`, which affects boolean subfields via `Boolean(value)` at render time. + +## Counterexample + +Schema options: `{ "multiple": "false", "choices": ["alpha", "beta"] }` (string, not boolean). + +1. `ChoicesField` sets `multiple = true`. +2. Widget renders checkboxes instead of radios. +3. User selects `"alpha"`. +4. `onChange(["alpha"])` instead of `onChange("alpha")`. + +## Why It Might Matter + +Hand-edited YAML/JSON configs often quote booleans as strings. Frontend templates expecting a scalar choice string receive an array, breaking conditionals and display logic after a seemingly correct schema fix. + +## Proof + +Contract mismatch: caller supplies `multiple: "false"` (string) → `Boolean(options?.multiple)` → `true` → `updateChoiceSelection(..., true)` → array payloads for the session. + +Location: `ChoicesField` line 669. + +## Counterevidence Checked + +TypeScript types `multiple` as `boolean` only; no runtime coercion in this package. Tests (`test/semantics.test.mjs`) use boolean `multiple: true`. EmDash may coerce options before props reach the widget — not visible in this repo. + +## Suggested Next Step + +Normalize with strict boolean parsing (`options?.multiple === true`) or reject non-boolean `multiple` at the widget boundary. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false +DEVANA-SUMMARY: open | P2 | medium | String `"false"` for `options.multiple` enables multi-select mode and array payloads because `Boolean("false")` is true. \ No newline at end of file diff --git a/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md b/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md new file mode 100644 index 0000000..3698372 --- /dev/null +++ b/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md @@ -0,0 +1,54 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss + +# Number and integer subfields reject in-progress numeric input + +## Finding + +Object and structure subfields of type `number` or `integer` parse the full input string on every `change` event via `parseNumericInput` and immediately write the parsed result back as the controlled `value`. Valid intermediate typing states are converted or rejected, blocking decimal entry digit-by-digit and wiping integer fields on partial decimals. A lone minus sign for negative integers is also rejected. + +## Violated Invariant Or Contract + +README documents `number` and `integer` subfields as storing a `Number`, or `undefined` when empty. Editors must allow users to reach valid negative and decimal numbers through normal keyboard entry, not only paste. + +## Oracle + +`tests/numeric-input.test.mjs` validates `parseNumericInput` on finished strings (including `"-9007199254740991"`) but not per-keystroke widget flow. `readInputValue` always calls `parseNumericInput` for `type="number"` inputs (350–361). Controlled `value` at line 383 re-renders from parent state after each parse. + +## Counterexample + +**Decimal on `number` subfield:** User types `3` then `.` to enter `3.14`. `parseNumericInput("3.", "number")` returns `3`; controlled input shows `3`; the decimal separator cannot be entered. + +**Integer wipe:** Stored `count: 12`, user edits toward `13` but transiently produces `"12.3"`. `parseNumericInput("12.3", "integer")` returns `undefined`; `onChange(undefined)` clears the field. + +**Negative integer:** Empty integer field, user types `-` first. `parseNumericInput("-", "integer")` → `Number("-")` is `NaN` → `undefined`; minus is dropped before trailing digits. + +## Why It Might Matter + +Editors cannot reliably enter decimals or negative integers by typing. Existing integer values can be erased by a single mistyped decimal keystroke, causing silent data loss before save. + +## Proof + +Dataflow trace: keystroke → `readInputValue` → `parseNumericInput` → `onChange(parsed)` → controlled `value` re-render removes in-progress string state. + +Locations: `parseNumericInput` (333–347), `readInputValue` (358–359), `renderSubField` `commonProps.value` (383–385). + +## Counterevidence Checked + +Pasting a complete value like `3.14` or `-5` in one event succeeds. `tests/numeric-input.test.mjs` covers finished strings only. Some browsers may not surface invalid partials to `onChange`, but the widget layer always parses on change with no draft-state buffer. + +## Suggested Next Step + +Keep a local string draft for numeric inputs and commit parsed numbers on blur or when `parseNumericInput` matches the full input without truncation. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss +DEVANA-SUMMARY: open | P2 | medium | Per-keystroke numeric parsing blocks decimal and negative entry and can wipe integer values on partial decimals. \ No newline at end of file diff --git a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md new file mode 100644 index 0000000..0655d70 --- /dev/null +++ b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md @@ -0,0 +1,50 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist + +# Link field preserves invalid type and target values + +## Finding + +`normalizeLinkValue` and `updateLinkValue` pass through any string `type` and `target` without validating against the `LinkValue` union. The UI can show defaults that do not match persisted JSON, and saves without touching those controls leave alien values in stored data. + +## Violated Invariant Or Contract + +`LinkValue.type` is `"url" | "email" | "tel" | "entry" | "media"` and `LinkValue.target` is `"_blank" | "_self"` (`src/types.ts`). Helpers and the widget should normalize or surface invalid enum members. + +## Oracle + +`LinkValue` type definitions in `src/types.ts`. `LinkField` type select uses `value={data.type ?? "url"}` (616) with a fixed item list. Target checkbox uses `checked={data.target === "_blank"}` (648) and only writes `"_blank"` or `"_self"` on toggle. + +## Counterexample + +**Invalid type:** Load `{ type: "javascript", value: "https://example.com" }`. Select shows `"javascript"` with no matching item. User edits value text and saves; `type: "javascript"` persists. + +**Invalid target:** Load `{ type: "url", value: "https://x.test", target: "_parent" }`. Checkbox is unchecked (`!== "_blank"`). User saves without toggling; `target: "_parent"` persists. + +## Why It Might Matter + +Imported JSON with out-of-union `type` or `target` values survives round-trips through the admin UI. Frontend link renderers expecting the documented unions may mis-handle `_parent` or unknown types. + +## Proof + +Read-path pass-through: `normalizeLinkValue` is `normalizeObjectValue(value) as LinkValue` with no enum check (234–236). Write-path shallow merge in `updateLinkValue` (238–240). UI controls do not normalize alien values on mount or on unrelated edits. + +## Counterevidence Checked + +`tests/transformations.test.mjs` tests invalid roots and valid merges only. `onValueChange` cast `as LinkValue["type"]` (617) adds no runtime guard. Normal UI interaction through the fixed select only produces valid types. + +## Suggested Next Step + +Validate and coerce `type`/`target` in `normalizeLinkValue`, or normalize on mount when values fall outside the union. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist +DEVANA-SUMMARY: open | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. \ No newline at end of file diff --git a/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md b/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md new file mode 100644 index 0000000..e3c6b3f --- /dev/null +++ b/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md @@ -0,0 +1,60 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse + +# Duplicate choice values collapse selection and React keys + +## Finding + +`ChoicesField` keys each choice row with `key={choice.value}` and tracks selection with `selected.has(choice.value)`. When two configured choices share the same `value`, React key collisions occur and both rows share one selection token — checking or unchecking one row affects all rows with that value. + +## Violated Invariant Or Contract + +Each rendered choice should be an independent selectable option. Distinct labels with the same `value` must not mirror checked state or collapse into one logical token. + +## Oracle + +`FieldsChoice.value: string` has no uniqueness constraint, but `ChoicesField` uses `value` as both React list key and selection set member. `test/semantics.test.mjs` dedupes DOM ids via index in `choiceInputId`, not selection identity. + +## Counterexample + +```json +{ + "multiple": true, + "choices": [ + { "value": "plan-a", "label": "Plan A" }, + { "value": "plan-a", "label": "Plan B" } + ] +} +``` + +Stored `value: []`. User checks "Plan B" → `onChange(["plan-a"])`. Both cards render checked. User unchecks "Plan A" → `onChange([])`; both unchecked. User cannot independently select Plan A vs Plan B. + +## Why It Might Matter + +Misconfigured or generated schemas with duplicate `value`s produce a broken editor where distinct options cannot be controlled separately, and saved arrays cannot represent per-label selection. + +## Proof + +State trace: duplicate `value` → `key="plan-a"` collision → `Set(["plan-a"])` → `selected.has("plan-a")` true for both rows → single `updateChoiceSelection` token drives all matching rows. + +Locations: horizontal layout (688, 683), vertical multiple (740, 744), `normalizeChoiceSelection` Set dedup (248–252). + +## Counterevidence Checked + +`normalizeChoices` does not enforce unique values. README examples use distinct values but does not forbid duplicates. `choiceInputId` uses index for DOM ids, so id collision is avoided — the bug is selection/key semantics, not id attributes. + +## Suggested Next Step + +Key rows by index (or generated stable ids) and track selection per row, or reject duplicate `value`s at `normalizeChoices` with a visible configuration error. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse +DEVANA-SUMMARY: open | P2 | medium | Duplicate choice `value`s share React keys and one selection token, so distinct labels cannot be toggled independently. \ No newline at end of file diff --git a/.devana/20260627T180006Z-P2-structure-min-max-bypass.md b/.devana/20260627T180006Z-P2-structure-min-max-bypass.md new file mode 100644 index 0000000..f7cb0db --- /dev/null +++ b/.devana/20260627T180006Z-P2-structure-min-max-bypass.md @@ -0,0 +1,50 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass + +# Structure min and max not enforced on loaded values + +## Finding + +`StructureField` applies `options.min` and `options.max` only to Add/Remove button `disabled` state. Persisted or parent-supplied `value` arrays outside those bounds are rendered and saved as-is. Subfield edits call `onChange` with the full unclamped array. + +## Violated Invariant Or Contract + +`StructureOptions.min` and `max` imply a cardinality floor and ceiling. Remove disabled when `items.length <= min` (537) and add disabled when `items.length >= max` (572) suggest counts should stay within bounds once configured. + +## Oracle + +`StructureOptions.min?: number` and `max?: number` in `src/types.ts`. Button guards reference both limits. `normalizeStructureValue` and structure helpers have no min/max awareness. + +## Counterexample + +**Above max:** `options.max = 2`, `value = [{}, {}, {}]`. UI shows three rows; add is disabled but remove is allowed. User edits a subfield in row 1 and saves → `onChange` emits three rows. + +**Below min:** `options.min = 3`, `value = []`. UI shows zero rows; remove/add guards do not seed rows. User saves without clicking Add → parent remains `[]`. + +## Why It Might Matter + +Imported content or API updates can leave structure fields outside configured limits. The editor presents and persists out-of-bound row counts without normalization or warning. + +## Proof + +Control-flow trace: `items = normalizeStructureValue(value)` (504) with no clamp → handlers call `onChange(nextItems)` directly (513–515) → persisted length unchanged vs `min`/`max`. + +## Counterevidence Checked + +README does not document `min`/`max` semantics explicitly. Limits may be intended as interactive hints only. Widget still disables both add and remove in conflicting configs (see separate `structure-min-gt-max-deadlock` report) rather than ignoring limits entirely. + +## Suggested Next Step + +Clamp or validate `items.length` against `min`/`max` on mount and before each `onChange`, or document that limits apply only to button actions. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass +DEVANA-SUMMARY: open | P2 | medium | Structure `min`/`max` gate buttons only; loaded or edited arrays outside bounds persist unchanged. \ No newline at end of file diff --git a/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md b/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md new file mode 100644 index 0000000..3b203d6 --- /dev/null +++ b/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md @@ -0,0 +1,52 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock + +# Structure min greater than max deadlocks the editor + +## Finding + +When `options.min` exceeds `options.max`, the structure widget can reach a row count where both Add and Remove are disabled simultaneously. The editor cannot move toward satisfying `min` without violating `max`, and no source path warns about or rejects the misconfiguration. + +## Violated Invariant Or Contract + +When both `min` and `max` are set, the widget should allow some reachable item count within both bounds, or reject invalid configuration. Independent `<= min` / `>= max` checks with no reconciliation create an impossible interactive state. + +## Oracle + +`StructureOptions` exposes both as optional `number` with no `min <= max` validation. Remove guard: `items.length <= options.min` (537). Add guard: `items.length >= options.max` (572). + +## Counterexample + +`options: { min: 5, max: 2, fields: [...] }`, `value: []`. + +1. User adds rows until add disables at length 2 (`2 >= 2`). +2. At length 2, remove is disabled (`2 <= 5`). +3. Editor is stuck at 2 items while `min` requires 5. + +## Why It Might Matter + +A single schema typo (`min: 5, max: 2`) makes the structure field uneditable for cardinality changes, blocking content authors without a clear error message. + +## Proof + +State-transition trace: length 2 with `min: 5, max: 2` → add disabled by max → remove disabled by min → no transition increases or decreases row count. + +## Counterevidence Checked + +Misconfigured bounds may be treated as author error outside widget scope. Source actively disables both actions rather than ignoring bad config, producing a reachable deadlock in the admin UI. + +## Suggested Next Step + +Validate `min <= max` when both are set and show a configuration error, or derive effective bounds with `Math.min`/`Math.max`. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock +DEVANA-SUMMARY: open | P2 | medium | When `min > max`, structure add and remove can both disable and leave the editor stuck below `min`. \ No newline at end of file diff --git a/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md b/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md new file mode 100644 index 0000000..7c51da2 --- /dev/null +++ b/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md @@ -0,0 +1,52 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options + +# Empty choices array blocks options alias fallback + +## Finding + +`ChoicesField` resolves the choice list with `normalizeChoices(options?.choices ?? options?.options)`. Nullish coalescing only falls through when `choices` is `null` or `undefined`, not when it is an empty array. An empty `choices: []` prevents reading `options.options` entirely and renders the misconfiguration message. + +## Violated Invariant Or Contract + +`ChoicesOptions` documents both `choices` and `options` as alternate sources for the choice list (`src/types.ts`). Consumers expect both keys to be interchangeable when one is absent or empty. + +## Oracle + +`ChoicesOptions.choices?: FieldsChoice[] | string[]` and `options?: FieldsChoice[] | string[]` in `src/types.ts`. Line 667 uses `??`, not `||` or length check. + +## Counterexample + +`options = { choices: [], options: ["alpha", "beta"] }`, `value: null`. + +1. `choicesList = normalizeChoices([])` → `[]`. +2. Early return at `!choicesList.length` (673–675) shows `choicesRequiresChoices`. +3. `options.options` is never read; user cannot select a value. + +## Why It Might Matter + +Schema generators that default `choices` to `[]` while populating `options` produce a broken widget with no choices rendered, even though the alternate key carries valid data. + +## Proof + +Dataflow trace: `choices: []` is not nullish → `??` does not evaluate `options` → empty list → early return before rendering controls. + +## Counterevidence Checked + +`choices: []` may mean intentional empty configuration. Types list both keys as peers without explicit fallback semantics; `??` behavior makes `[]` win over `options` by design of the operator. + +## Suggested Next Step + +Fall back when `choices` is nullish or empty: `normalizeChoices(options?.choices?.length ? options.choices : options?.options)`. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options +DEVANA-SUMMARY: open | P2 | medium | `choices: []` prevents the `options` alias from supplying choice items because `??` does not treat empty arrays as absent. \ No newline at end of file diff --git a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md new file mode 100644 index 0000000..8384016 --- /dev/null +++ b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md @@ -0,0 +1,52 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden + +# Object select subfields hide non-string stored values + +## Finding + +In `ObjectField` and `StructureField`, select subfields bind `value={typeof value === "string" ? value : ""}`. Persisted non-string values (numbers, arrays, booleans) render as the empty placeholder while parent state remains unchanged until the user explicitly re-selects an option. + +## Violated Invariant Or Contract + +README documents `select` subfields as storing a selected string value. The read path should reflect the persisted selection or normalize alien shapes; showing blank while parent keeps a non-string is a UI/state contract mismatch. + +## Oracle + +README subfield table (`select` → selected string). `renderSubField` select branch (412–425). Distinct from `single-choice-array-deselected`, which affects `ChoicesField`, not object subfield selects. + +## Counterexample + +Object subfield `{ key: "tone", type: "select", options: ["Calm", "Bold"] }`, persisted `{ tone: 1 }` (legacy numeric JSON). + +1. Select receives `value=""`; UI shows blank "Select..." option. +2. User saves without touching tone. +3. Parent state remains `{ tone: 1 }`. + +## Why It Might Matter + +Migrated or imported JSON with wrong-typed select values appears unset in the admin while frontend templates may still read the non-string payload, causing editor/display divergence. + +## Proof + +Dataflow trace: non-string `value[field.key]` → strict `typeof === "string"` guard → `value=""` → no mount-time `onChange` → parent keeps alien type across save. + +## Counterevidence Checked + +Non-string select values may be considered invalid input. No coercion on mount is intentional in similar normalization paths. TypeScript does not enforce stored runtime shapes for subfield values. + +## Suggested Next Step + +Coerce or clear non-string select values on mount, or display a warning when stored type does not match `string`. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden +DEVANA-SUMMARY: open | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. \ No newline at end of file diff --git a/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md b/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md new file mode 100644 index 0000000..08fc4d8 --- /dev/null +++ b/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md @@ -0,0 +1,55 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber + +# Structure handlers can clobber pending edits from stale render snapshot + +## Finding + +`StructureField` handlers close over the `items` array from the render that created them. `updateItems(updateStructureItem(items, index, nextItem))`, `removeStructureItem(items, index)`, and similar calls read that snapshot at event time, not the latest parent `value` prop. A second structure action before the parent prop refreshes can recompose from the old array and drop a pending row edit. + +## Violated Invariant Or Contract + +Each `onChange` from a controlled widget should compose from the current parent-owned value. Writes must not derive payloads from an outdated snapshot after a prior `onChange` already advanced parent state. + +## Oracle + +`updateStructureItem` tests in `tests/transformations.test.mjs` cover pure helpers in isolation, not widget handler composition across back-to-back events. Distinct from `structure-reorder-wrong-row`, which is an index-key/focus issue. + +## Counterexample + +Persisted `value: [{ label: "A" }, { label: "B" }]`. + +1. Render closes handlers over `items = [{ label: "A" }, { label: "B" }]`. +2. User edits row 0 label to `"A2"` → first `onChange` emits `[{ label: "A2" }, { label: "B" }]`. +3. Before parent `value` prop refreshes, user removes row 1 → `removeStructureItem(items, 1)` uses stale snapshot → emits `[{ label: "A" }]`. +4. Parent last-write-wins keeps `[{ label: "A" }]`; the `"A2"` edit is lost. + +## Why It Might Matter + +Fast cross-row edit-and-delete sequences, or parents that debounce or batch updates, can silently lose in-flight edits in persisted JSON. + +## Proof + +Dataflow trace: parent holds updated array after first `onChange` → child handler still reads render-closure `items` → second `onChange` overwrites first with stale-based result. + +Locations: `items` at 504; `updateItems` / row handlers at 525–538, 549, 558, 573; `renderObjectFields` uses render-scoped `item` at 452. + +## Counterevidence Checked + +Synchronous React parents usually re-render before the next discrete event, so many single-step flows stay fresh. `structure-reorder-wrong-row` covers a different mechanism (index keys). No functional updater or `value` re-read at event time exists in source. + +## Suggested Next Step + +Use functional updates that read the latest `value` prop at event time, or latch pending mutations until props catch up. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber +DEVANA-SUMMARY: open | P2 | medium | Structure row handlers compose `onChange` from a render snapshot and can drop a pending edit if a second action runs before props refresh. \ No newline at end of file diff --git a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md new file mode 100644 index 0000000..56fb184 --- /dev/null +++ b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md @@ -0,0 +1,56 @@ +DEVANA-FINDING: v1 +DEVANA-STATE: open | P2 | medium | security=no +DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists + +# Number subfields round-trip non-number stored values unchanged + +## Finding + +`renderSubField` displays number and integer subfields with `value: typeof value === "string" || typeof value === "number" ? value : ""` but only coerces input through `parseNumericInput` on user `change` events. Pre-existing string or non-integer number values are shown and persisted without normalization until the user edits the field. + +## Violated Invariant Or Contract + +README documents `number` and `integer` subfields as storing a `Number`, or `undefined` when empty. Loaded values should be numbers (integers for `integer`), not strings or off-type numbers. + +## Oracle + +README subfield stored-value table (lines 97–98). `parseNumericInput` and `readInputValue` run only on change (350–361), not on mount. + +## Counterexample + +Object subfield `{ key: "count", type: "number" }`, persisted `{ count: "42" }` (string). + +1. Input displays `"42"` because `typeof value === "string"`. +2. User saves without editing count. +3. Parent keeps `count: "42"` (string), not `42` (number). + +For `integer` subfield with `{ priority: 3.14 }`, non-integer number displays and persists until edited. + +## Why It Might Matter + +Imported YAML/JSON often quotes numbers as strings. Frontend templates expecting numeric types receive strings, breaking comparisons and formatting. + +## Proof + +Dataflow trace: alien typed `value[field.key]` → display accepts string → no mount normalization → save without edit preserves wrong type. + +Location: `commonProps.value` (383), `readInputValue`/`parseNumericInput` (350–361). + +## Counterevidence Checked + +README may mean "editor emits Number on edit" rather than "normalize on load". EmDash may validate at another layer — not visible here. `tests/numeric-input.test.mjs` covers `parseNumericInput` only, not load paths. + +## Suggested Next Step + +Normalize number/integer subfield values on mount or when rendering, coercing valid numeric strings and clearing invalid shapes to `undefined`. + +## Agent Handoff + +After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. + +## Status Notes + +- 2026-06-27: open by Devana. Initial report written from static source inspection. + +DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists +DEVANA-SUMMARY: open | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 7f707fb..6153ff1 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -247,6 +247,9 @@ export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoic export function normalizeChoiceSelection(value: unknown, multiple: boolean): string[] { if (multiple) { + if (typeof value === "string") { + return [value]; + } return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === "string"))] : []; diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 7ad0ee0..6a1b3e4 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -105,6 +105,11 @@ test("multiple choice selections preserve order while removing duplicates", () = assert.deepEqual(updateChoiceSelection(["beta", "alpha"], "beta", true, true), ["beta", "alpha"]); }); +test("multiple choice mode preserves a scalar stored value on the first toggle", () => { + assert.deepEqual(normalizeChoiceSelection("alpha", true), ["alpha"]); + assert.deepEqual(updateChoiceSelection("alpha", "beta", true, true), ["alpha", "beta"]); +}); + test("removing and re-adding a multiple choice moves it to the end", () => { const removed = updateChoiceSelection(["beta", "alpha"], "beta", false, true); From 5ac2743e186c6f2c31bce6c627e82e7722f86f22 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:48:54 +0100 Subject: [PATCH 02/27] Mark object-invalid-root-lost as wontfix Non-object roots (array/scalar/null) cannot be represented in an object editor; valid object roots already preserve all keys including extras. Loss only occurs on explicit edit, never on load. Suggested mount-time emit would drop the same data earlier and cause spurious dirty state. Devana: 20260625T114007Z-P1-object-invalid-root-lost --- .devana/20260625T114007Z-P1-object-invalid-root-lost.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devana/20260625T114007Z-P1-object-invalid-root-lost.md b/.devana/20260625T114007Z-P1-object-invalid-root-lost.md index b8c7a66..b102ace 100644 --- a/.devana/20260625T114007Z-P1-object-invalid-root-lost.md +++ b/.devana/20260625T114007Z-P1-object-invalid-root-lost.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P1 | high | security=no +DEVANA-STATE: wontfix | P1 | high | security=no DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost # Object field replaces invalid root value on first edit @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: wontfix. Dataflow confirmed accurate, but the only data that can be "lost" is a non-object root (array/scalar/null), which an object editor cannot represent — there is no key mapping from an array/scalar into named subfields. Verified that *valid* object roots already preserve every key, including ones absent from the fields config (`updateObjectValue({title,extra}, "title", "New")` keeps `extra`). The loss happens only on an explicit edit, never on load, so untouched malformed values remain intact in storage. The suggested mount-time canonicalization would drop the same unrepresentable data earlier while marking every entry with malformed JSON dirty on open — strictly worse UX. No change is data-preserving, so behavior is left intentional. DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost -DEVANA-SUMMARY: open | P1 | high | First subfield edit replaces an invalid object root with a partial object and drops the original value. \ No newline at end of file +DEVANA-SUMMARY: wontfix | P1 | high | First subfield edit replaces an invalid object root with a partial object and drops the original value. \ No newline at end of file From 0a539b0c7636a62e66925cc1c5fb203aca99782d Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:50:11 +0100 Subject: [PATCH 03/27] Mark structure-row-coercion-lost as wontfix Non-object rows are unrepresentable in the per-row object editor; preserving them would persist mixed-type rows that violate the library's tested normalize-every-row-to-object invariant. Object rows already keep all keys. Loss only on explicit mutation, never on load. Devana: 20260625T114008Z-P1-structure-row-coercion-lost --- .devana/20260625T114008Z-P1-structure-row-coercion-lost.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md b/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md index 663cfd5..e3d3171 100644 --- a/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md +++ b/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P1 | high | security=no +DEVANA-STATE: wontfix | P1 | high | security=no DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost # Structure field drops invalid row payloads on first mutation @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: wontfix. Same class as object-invalid-root-lost. Verified `addStructureItem([{label:"A"},["secret"]])` yields `[{label:"A"},{},{}]`, dropping the array row, while object rows keep extra keys (`updateStructureItem` preserves `{label,extra}`). The only data lost is a non-object row, which the per-row object editor cannot represent. The library's documented and tested invariant is "structure values normalize every row to an object" — preserving an array/scalar row would mean persisting mixed-type rows that violate that contract and would still render empty and be uneditable. Loss occurs only on an explicit mutation, never on load. The suggested mount-time writeback drops the same unrepresentable data earlier and marks entries dirty on open. Left intentional. DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost -DEVANA-SUMMARY: open | P1 | high | First structure mutation persists normalized rows and drops invalid row payloads that were still in parent state. \ No newline at end of file +DEVANA-SUMMARY: wontfix | P1 | high | First structure mutation persists normalized rows and drops invalid row payloads that were still in parent state. \ No newline at end of file From f40b4a99c556ab477d15ae51e0ba4bd4754daa34 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:51:31 +0100 Subject: [PATCH 04/27] Fix boolean subfield rendering string "false" as checked The boolean checkbox used Boolean(value), so the truthy string "false" rendered as checked. Add isBooleanChecked helper with strict string interpretation ("true"/"1" checked, "false"/"0"/"" unchecked) and Boolean() semantics for non-strings. Adds regression test. Devana: 20260625T114009Z-P2-boolean-string-false-checked --- ...0625T114009Z-P2-boolean-string-false-checked.md | 5 +++-- src/admin.tsx | 10 +++++++++- tests/transformations.test.mjs | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.devana/20260625T114009Z-P2-boolean-string-false-checked.md b/.devana/20260625T114009Z-P2-boolean-string-false-checked.md index 98099dc..479bc99 100644 --- a/.devana/20260625T114009Z-P2-boolean-string-false-checked.md +++ b/.devana/20260625T114009Z-P2-boolean-string-false-checked.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked # Boolean subfield treats string "false" as checked @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. The boolean subfield branch now uses a new exported `isBooleanChecked(value)` helper instead of `Boolean(value)`. Strings are interpreted strictly (`"true"`/`"1"` → checked; `"false"`/`"0"`/`""` → unchecked, case/whitespace-insensitive); non-string values keep `Boolean()` semantics. Added a regression test covering string/number/boolean/null inputs. Full suite (24 tests) passes. DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked -DEVANA-SUMMARY: open | P2 | medium | String "false" in boolean subfields renders as checked because the widget uses Boolean(value). \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | String "false" in boolean subfields renders as checked because the widget uses Boolean(value). \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 6153ff1..84e8b7b 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -333,6 +333,14 @@ function choiceLabel(choice: FieldsChoice, i18n: FieldsI18nConfig) { return localizedString(choice.label, i18n, choice.value); } +export function isBooleanChecked(value: unknown): boolean { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + } + return Boolean(value); +} + export function parseNumericInput(value: string, type: "number" | "integer") { if (value.trim() === "") { return undefined; @@ -404,7 +412,7 @@ function renderSubField( ) => onChange(event.currentTarget.checked) diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 6a1b3e4..2f7add3 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { addStructureItem, + isBooleanChecked, moveStructureItem, normalizeChoices, normalizeChoiceSelection, @@ -117,6 +118,19 @@ test("removing and re-adding a multiple choice moves it to the end", () => { assert.deepEqual(updateChoiceSelection(removed, "beta", true, true), ["alpha", "beta"]); }); +test("boolean subfield checked state ignores truthy string false", () => { + assert.equal(isBooleanChecked(true), true); + assert.equal(isBooleanChecked("true"), true); + assert.equal(isBooleanChecked("1"), true); + assert.equal(isBooleanChecked(1), true); + assert.equal(isBooleanChecked("false"), false); + assert.equal(isBooleanChecked("0"), false); + assert.equal(isBooleanChecked(""), false); + assert.equal(isBooleanChecked(false), false); + assert.equal(isBooleanChecked(null), false); + assert.equal(isBooleanChecked(undefined), false); +}); + test("single choice selections normalize to the selected string", () => { assert.deepEqual(normalizeChoiceSelection("alpha", false), ["alpha"]); assert.equal(updateChoiceSelection("alpha", "beta", true, false), "beta"); From c684c05e407660c79f5af3dc755f99936fc8d167 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:53:27 +0100 Subject: [PATCH 05/27] Mark structure-reorder-wrong-row as invalid No data corruption: inputs are fully controlled and onChange closures refresh every render, so each slot always edits the item it displays. Reorder via button also moves focus off the input. Index keys are stylistic only here. Devana: 20260625T114010Z-P2-structure-reorder-wrong-row --- .devana/20260625T114010Z-P2-structure-reorder-wrong-row.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md b/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md index f23bf08..566497c 100644 --- a/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md +++ b/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: invalid | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row # Structure reorder can edit the wrong row after move or delete @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: invalid. The data-corruption mechanism (a "stale render snapshot" handler writing to the wrong item) does not occur here. `StructureField` keeps no per-row local state: `items` is recomputed from props on every render, the kumo `Input`/`Textarea` are fully controlled via `value`, and `renderSubField`/`renderObjectFields` are pure. After `moveStructureItem` re-renders the component, `.map` re-runs and every input receives a fresh `onChange` closure bound to the current `items` and `index`, so the input at a given slot always displays AND edits the item currently at that slot — consistent, not corrupting. Counterexample step 5 ("writes into A instead of B") is therefore false: there is no persisted stale closure across renders. Separately, reorder is triggered by clicking the Up/Down `Button`, which moves focus to the button, so the premise "focus remains in the input" cannot arise through the provided UI. Index keys are non-ideal style but harmless with controlled inputs; a stable-id refactor would add a parallel-state-sync surface with no correctness benefit here. DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row -DEVANA-SUMMARY: open | P2 | medium | Index-keyed structure rows can apply edits to the wrong item after reorder or delete while focus stays on the same slot. \ No newline at end of file +DEVANA-SUMMARY: invalid | P2 | medium | Index-keyed structure rows can apply edits to the wrong item after reorder or delete while focus stays on the same slot. \ No newline at end of file From 2d529ba2aac985bd0c7b5b25a3b511de364cb429 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:55:01 +0100 Subject: [PATCH 06/27] Fix single-choice widget ignoring array stored values A string array (e.g. legacy data after switching multiple:true->false) showed no selection. normalizeChoiceSelection now coerces an array to its first string in single mode, and the vertical Radio.Group uses a new normalizeSingleChoice helper so both renderers reflect it. Adds tests. Devana: 20260625T114011Z-P2-single-choice-array-deselected --- ...5T114011Z-P2-single-choice-array-deselected.md | 5 +++-- src/admin.tsx | 15 +++++++++++++-- tests/transformations.test.mjs | 12 ++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.devana/20260625T114011Z-P2-single-choice-array-deselected.md b/.devana/20260625T114011Z-P2-single-choice-array-deselected.md index df952f6..2b9bb45 100644 --- a/.devana/20260625T114011Z-P2-single-choice-array-deselected.md +++ b/.devana/20260625T114011Z-P2-single-choice-array-deselected.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected # Single-choice widget ignores array stored values @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. A one-element (or multi-element) string array is a representable legacy shape (e.g. after switching `multiple: true → false`), so it is coerced to its first string value. `normalizeChoiceSelection(value, false)` now extracts the first string element from an array, which fixes the horizontal single-choice renderer's `selected` set. The vertical `Radio.Group` derived its value separately (`typeof value === "string" ? value : ""`) and is now fed by a new exported `normalizeSingleChoice(value)` helper so it reflects the coerced selection too. `updateChoiceSelection`'s single-mode deselect path also benefits via the same normalization. Added regression tests. Full suite (25 tests) passes. DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected -DEVANA-SUMMARY: open | P2 | medium | Single-choice fields show no selection for array stored values while the array remains in parent state. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Single-choice fields show no selection for array stored values while the array remains in parent state. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 84e8b7b..4b2ab72 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -255,7 +255,18 @@ export function normalizeChoiceSelection(value: unknown, multiple: boolean): str : []; } - return typeof value === "string" ? [value] : []; + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + const first = value.find((item): item is string => typeof item === "string"); + return first === undefined ? [] : [first]; + } + return []; +} + +export function normalizeSingleChoice(value: unknown): string { + return normalizeChoiceSelection(value, false)[0] ?? ""; } export function updateChoiceSelection( @@ -777,7 +788,7 @@ export function ChoicesField({ onChange(updateChoiceSelection(value, String(nextValue), true, false)) } diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 2f7add3..ed4b5c2 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -6,6 +6,7 @@ import { moveStructureItem, normalizeChoices, normalizeChoiceSelection, + normalizeSingleChoice, normalizeLinkValue, normalizeObjectValue, normalizeStructureValue, @@ -136,3 +137,14 @@ test("single choice selections normalize to the selected string", () => { assert.equal(updateChoiceSelection("alpha", "beta", true, false), "beta"); assert.equal(updateChoiceSelection(undefined, "beta", false, false), ""); }); + +test("single choice mode coerces array stored values to their first string", () => { + assert.deepEqual(normalizeChoiceSelection(["alpha"], false), ["alpha"]); + assert.deepEqual(normalizeChoiceSelection(["alpha", "beta"], false), ["alpha"]); + assert.deepEqual(normalizeChoiceSelection([42, "beta"], false), ["beta"]); + assert.deepEqual(normalizeChoiceSelection([], false), []); + assert.equal(normalizeSingleChoice(["alpha"]), "alpha"); + assert.equal(normalizeSingleChoice("alpha"), "alpha"); + assert.equal(normalizeSingleChoice(undefined), ""); + assert.equal(normalizeSingleChoice([]), ""); +}); From d7d972ca2f1c4af8aef13a11f499597790e2156f Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:55:36 +0100 Subject: [PATCH 07/27] Mark structure-non-array-empty-ui as wontfix Same class as the root-coercion findings: value persists intact on load, loss only on explicit action. Mount-time migration causes spurious dirty state and needs a fragile index-map heuristic; a recovery-warning UI is a feature, not a fix. Devana: 20260625T114012Z-P2-structure-non-array-empty-ui --- .devana/20260625T114012Z-P2-structure-non-array-empty-ui.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md b/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md index ef0071f..4be5968 100644 --- a/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md +++ b/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: wontfix | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui # Structure field hides non-array persisted values @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: wontfix. Same class as object-invalid-root-lost and structure-row-coercion-lost. The dataflow is accurate, but the persisted value is left intact on load (no mount-time write), so nothing is lost unless the editor performs an explicit action. The two suggested remedies are both undesirable here: (1) mount-time migration via `onChange` marks every entry with malformed JSON dirty on open and would still need a fragile heuristic to tell an "index map" (`{"0":{...}}`) apart from a normal object, risking mis-migration of legitimate values; (2) a raw-value recovery warning is a new UI/i18n feature, not a correctness fix, and outside the scope of this widget's normalize-for-display contract. Left intentional, consistent with the related root-coercion findings. DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui -DEVANA-SUMMARY: open | P2 | medium | Non-array structure values render as an empty list while the original persisted value remains hidden in parent state. \ No newline at end of file +DEVANA-SUMMARY: wontfix | P2 | medium | Non-array structure values render as an empty list while the original persisted value remains hidden in parent state. \ No newline at end of file From 8d2fbd2ad6fc1f1d88f21feeda51438eb388ea16 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:57:00 +0100 Subject: [PATCH 08/27] Fix choices widget crash on object choice missing value normalizeChoices passed object choices through unchanged, so a choice missing value reached choiceInputId and threw on undefined.replace(). Now every normalized choice has a string value: synthesized from a string label when missing, otherwise the malformed choice is dropped. Adds regression tests. Devana: 20260625T120226Z-P3-choice-missing-value-crash --- ...25T120226Z-P3-choice-missing-value-crash.md | 5 +++-- src/admin.tsx | 18 +++++++++++++++--- tests/transformations.test.mjs | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.devana/20260625T120226Z-P3-choice-missing-value-crash.md b/.devana/20260625T120226Z-P3-choice-missing-value-crash.md index e06150c..1f5576d 100644 --- a/.devana/20260625T120226Z-P3-choice-missing-value-crash.md +++ b/.devana/20260625T120226Z-P3-choice-missing-value-crash.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P3 | medium | security=no +DEVANA-STATE: fixed | P3 | medium | security=no DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash # Choice object without `value` crashes the whole choices widget @@ -113,6 +113,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-25: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. Confirmed `normalizeChoices` passed object choices through untouched, so `{label:"X"}` reached `choiceInputId(id, undefined, index)` → `undefined.replace(...)` → TypeError crashing the widget in the horizontal/multiple layouts (and a broken non-selectable item in single mode). `normalizeChoices` now enforces the post-normalization invariant "every choice has a string `value`": object choices with a string value pass through; those missing it synthesize a value from a string `label` (mirroring the string branch); otherwise the malformed choice is dropped via `flatMap` so one bad option degrades gracefully instead of crashing. Added regression tests; typecheck clean; full suite (26 tests) passes. DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash -DEVANA-SUMMARY: open | P3 | medium | A choice object missing `value` survives normalizeChoices and crashes the choices widget at choiceInputId in two of three layouts. +DEVANA-SUMMARY: fixed | P3 | medium | A choice object missing `value` survives normalizeChoices and crashes the choices widget at choiceInputId in two of three layouts. diff --git a/src/admin.tsx b/src/admin.tsx index 4b2ab72..2132831 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -240,9 +240,21 @@ export function updateLinkValue(value: unknown, nextValue: Partial): } export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] { - return (value ?? []).map((choice) => - typeof choice === "string" ? { value: choice, label: choice } : choice, - ); + return (value ?? []).flatMap((choice) => { + if (typeof choice === "string") { + return [{ value: choice, label: choice }]; + } + if (typeof choice.value === "string") { + return [choice]; + } + // Object choices authored in serialized JSON/YAML can omit the required + // `value`. Synthesize one from a string label when possible, otherwise drop + // the malformed choice so a single bad option cannot crash the widget. + if (typeof choice.label === "string") { + return [{ ...choice, value: choice.label }]; + } + return []; + }); } export function normalizeChoiceSelection(value: unknown, multiple: boolean): string[] { diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index ed4b5c2..7be2b1c 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -90,6 +90,21 @@ test("choices normalize string and object options", () => { ]); }); +test("choices guarantee a string value for object choices", () => { + // string-label fallback when value is missing + assert.deepEqual(normalizeChoices([{ label: "Workers AI", icon: "AI" }]), [ + { value: "Workers AI", label: "Workers AI", icon: "AI" }, + ]); + // malformed choice with no value and non-string label is dropped, valid kept + assert.deepEqual(normalizeChoices([{ icon: "AI" }, { value: "ok", label: "Ok" }]), [ + { value: "ok", label: "Ok" }, + ]); + // every returned choice has a string value + for (const choice of normalizeChoices(["a", { label: "B" }, { value: "c" }])) { + assert.equal(typeof choice.value, "string"); + } +}); + test("multiple choice selections preserve order while removing duplicates", () => { assert.deepEqual(normalizeChoiceSelection(["beta", "alpha", "beta", 42], true), [ "beta", From 85cb829a0b706459dd7621f85815d26639e21d10 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 14:58:33 +0100 Subject: [PATCH 09/27] Fix link field dropping a scalar URL root on first edit A bare string root maps naturally to the link's URL value, so normalizeLinkValue now coerces a non-empty string to { value } instead of {}. The URL is surfaced on load and preserved when editing other subfields. Other non-object roots still normalize to {}. Updates tests. Devana: 20260627T180001Z-P1-link-invalid-root-lost --- .../20260627T180001Z-P1-link-invalid-root-lost.md | 5 +++-- src/admin.tsx | 6 ++++++ tests/transformations.test.mjs | 14 +++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180001Z-P1-link-invalid-root-lost.md b/.devana/20260627T180001Z-P1-link-invalid-root-lost.md index e589b0c..ca16b5a 100644 --- a/.devana/20260627T180001Z-P1-link-invalid-root-lost.md +++ b/.devana/20260627T180001Z-P1-link-invalid-root-lost.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P1 | high | security=no +DEVANA-STATE: fixed | P1 | high | security=no DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost # Link field first edit drops invalid scalar root @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. Unlike object-invalid-root-lost / structure-row-coercion-lost (wontfix, because an array/scalar root has no field mapping in an object editor), a link field has a natural mapping: a bare string root IS the URL value. `normalizeLinkValue` now coerces a non-empty string root to `{ value }`, so the URL is both surfaced in the value input on load and preserved when other subfields are edited (`updateLinkValue({value}, {text})` → `{value, text}`). Other non-object roots (number/array/etc.) still normalize to `{}`. Updated the existing normalization test (string root is no longer dropped; uses number/array/"" for the empty cases) and added a scalar-root-through-edit regression test. Full suite (27 tests) passes. DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost -DEVANA-SUMMARY: open | P1 | high | First link subfield edit replaces an invalid scalar root with a partial object and drops the original value. \ No newline at end of file +DEVANA-SUMMARY: fixed | P1 | high | First link subfield edit replaces an invalid scalar root with a partial object and drops the original value. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 2132831..34f30b1 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -232,6 +232,12 @@ export function moveStructureItem( } export function normalizeLinkValue(value: unknown): LinkValue { + // A bare string root maps naturally to the link's URL value, so preserve and + // surface it instead of discarding it on the first edit. Other non-object + // roots have no field mapping and normalize to an empty link. + if (typeof value === "string") { + return value ? { value } : {}; + } return normalizeObjectValue(value) as LinkValue; } diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 7be2b1c..cc89bda 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -68,7 +68,11 @@ test("structure transformations ignore out-of-range item indexes", () => { }); test("link values normalize invalid inputs and merge partial updates", () => { - assert.deepEqual(normalizeLinkValue("bad"), {}); + assert.deepEqual(normalizeLinkValue(42), {}); + assert.deepEqual(normalizeLinkValue(["bad"]), {}); + assert.deepEqual(normalizeLinkValue(""), {}); + // a bare string root is preserved as the link's URL value + assert.deepEqual(normalizeLinkValue("https://example.com"), { value: "https://example.com" }); assert.deepEqual( updateLinkValue( @@ -83,6 +87,14 @@ test("link values normalize invalid inputs and merge partial updates", () => { }); }); +test("link field preserves a scalar URL root through the first edit", () => { + const data = normalizeLinkValue("https://example.com"); + assert.deepEqual(updateLinkValue(data, { text: "Home" }), { + value: "https://example.com", + text: "Home", + }); +}); + test("choices normalize string and object options", () => { assert.deepEqual(normalizeChoices(["alpha", { value: "beta", label: "Beta" }]), [ { value: "alpha", label: "alpha" }, From b71879f9b6956caef1df1bae58617ee055152d96 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:00:16 +0100 Subject: [PATCH 10/27] Fix choices multiple flag treating string "false" as true ChoicesField used Boolean(options.multiple), so quoted "false" from serialized config enabled multi-select. Derive multiple via shared coerceBoolean (which isBooleanChecked now delegates to) so quoted booleans parse correctly both ways. Adds regression test. Devana: 20260627T180002Z-P2-choices-multiple-string-false --- ...260627T180002Z-P2-choices-multiple-string-false.md | 5 +++-- src/admin.tsx | 8 ++++++-- tests/transformations.test.mjs | 11 +++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.devana/20260627T180002Z-P2-choices-multiple-string-false.md b/.devana/20260627T180002Z-P2-choices-multiple-string-false.md index f7cc51d..aefd8a0 100644 --- a/.devana/20260627T180002Z-P2-choices-multiple-string-false.md +++ b/.devana/20260627T180002Z-P2-choices-multiple-string-false.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false # Choices widget treats string "false" as multiple mode @@ -50,6 +50,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. `ChoicesField` now derives `multiple` via a shared `coerceBoolean(options?.multiple)` instead of `Boolean(...)`, so quoted booleans from serialized config are parsed correctly: `"false"`/`"0"`/`""` → single mode, `"true"`/`"1"`/`true` → multi mode. Note the report's suggested strict `=== true` was not used because it would also break the (equally common) quoted `"true"` case by forcing single mode. The coercion logic is shared with the boolean-subfield checkbox fix: `coerceBoolean` is the implementation and `isBooleanChecked` delegates to it. Added a regression test; typecheck clean; full suite (28 tests) passes. See [[boolean-string-false-checked]]. DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false -DEVANA-SUMMARY: open | P2 | medium | String `"false"` for `options.multiple` enables multi-select mode and array payloads because `Boolean("false")` is true. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | String `"false"` for `options.multiple` enables multi-select mode and array payloads because `Boolean("false")` is true. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 34f30b1..c71f268 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -362,7 +362,7 @@ function choiceLabel(choice: FieldsChoice, i18n: FieldsI18nConfig) { return localizedString(choice.label, i18n, choice.value); } -export function isBooleanChecked(value: unknown): boolean { +export function coerceBoolean(value: unknown): boolean { if (typeof value === "string") { const normalized = value.trim().toLowerCase(); return normalized === "true" || normalized === "1"; @@ -370,6 +370,10 @@ export function isBooleanChecked(value: unknown): boolean { return Boolean(value); } +export function isBooleanChecked(value: unknown): boolean { + return coerceBoolean(value); +} + export function parseNumericInput(value: string, type: "number" | "integer") { if (value.trim() === "") { return undefined; @@ -706,7 +710,7 @@ export function ChoicesField({ const i18n = useFieldI18n(options?.i18n); const choicesList = normalizeChoices(options?.choices ?? options?.options); const legend = label ?? fieldMessage("choices", i18n); - const multiple = Boolean(options?.multiple); + const multiple = coerceBoolean(options?.multiple); const horizontal = options?.orientation === "horizontal"; const selected = new Set(normalizeChoiceSelection(value, multiple)); diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index cc89bda..09e893f 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { addStructureItem, + coerceBoolean, isBooleanChecked, moveStructureItem, normalizeChoices, @@ -146,6 +147,16 @@ test("removing and re-adding a multiple choice moves it to the end", () => { assert.deepEqual(updateChoiceSelection(removed, "beta", true, true), ["alpha", "beta"]); }); +test("coerceBoolean parses quoted booleans for option flags", () => { + assert.equal(coerceBoolean("false"), false); + assert.equal(coerceBoolean("true"), true); + assert.equal(coerceBoolean("0"), false); + assert.equal(coerceBoolean("1"), true); + assert.equal(coerceBoolean(true), true); + assert.equal(coerceBoolean(false), false); + assert.equal(coerceBoolean(undefined), false); +}); + test("boolean subfield checked state ignores truthy string false", () => { assert.equal(isBooleanChecked(true), true); assert.equal(isBooleanChecked("true"), true); From bda4214a219755235548d55ba7b1e00a1163b5fd Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:05:02 +0100 Subject: [PATCH 11/27] Fix numeric subfields rejecting in-progress decimal/negative input Number/integer subfields parsed and wrote back on every keystroke, blocking decimal entry and wiping integers on transient decimals. Add a NumericSubField with a local string draft (text input + inputMode) and a pure numericChangeCommit helper that holds in-progress invalid drafts instead of clearing the value. Adds unit tests. Devana: 20260627T180003Z-P2-numeric-keystroke-intermediate-loss --- ...-P2-numeric-keystroke-intermediate-loss.md | 5 +- src/admin.tsx | 98 ++++++++++++++++++- tests/numeric-input.test.mjs | 18 +++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md b/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md index 3698372..b8a0a41 100644 --- a/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md +++ b/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss # Number and integer subfields reject in-progress numeric input @@ -49,6 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. Number/integer subfields now render via a new `NumericSubField` component that holds a local string draft (the report's suggested fix). The input is `type="text"` with `inputMode` set ("numeric"/"decimal") so the browser cannot sanitize an in-progress draft away — a controlled `type=number` literally cannot display "3." or "-". The pure, exported `numericChangeCommit(raw, type)` decides the action: `clear` on empty, `set` on a complete valid number, and `hold` (no onChange) for in-progress invalid drafts ("-" , and "12.3" on an integer field) so existing values are never wiped. The draft preserves the visible string while the committed value tracks the parsed number, so decimals/negatives can be typed digit-by-digit; on blur the draft re-syncs to the committed value to clean up dangling separators. A `useEffect` reconciles external value changes into the draft without clobbering active typing. Tradeoff: native number spinners are gone (text input), but `parseNumericInput` still enforces integer/number validity. Added unit tests for `numericChangeCommit`; the SSR semantics test (number subfield) still renders with zero Kumo warnings; typecheck clean; full suite (29 tests) passes. DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss -DEVANA-SUMMARY: open | P2 | medium | Per-keystroke numeric parsing blocks decimal and negative entry and can wipe integer values on partial decimals. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Per-keystroke numeric parsing blocks decimal and negative entry and can wipe integer values on partial decimals. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index c71f268..00fbf7d 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -8,6 +8,7 @@ import { type FieldsI18nConfig, type LocalizedString, } from "./i18n"; +import { useEffect, useState } from "react"; import type { CSSProperties, ChangeEvent } from "react"; import type { ChoicesOptions, @@ -391,6 +392,87 @@ export function parseNumericInput(value: string, type: "number" | "integer") { return numericValue; } +export type NumericCommit = + | { type: "set"; value: number } + | { type: "clear" } + | { type: "hold" }; + +/** + * Decide what a numeric subfield should commit for a raw input string. + * + * - `set`: the draft is a complete, valid number — commit it. + * - `clear`: the draft is empty — commit `undefined`. + * - `hold`: the draft is an in-progress/invalid string (e.g. `"3."`, `"-"`, or + * `"12.3"` for an integer) — keep the previously committed value so partial + * typing neither truncates the input nor wipes existing data. + */ +export function numericChangeCommit(raw: string, type: "number" | "integer"): NumericCommit { + if (raw.trim() === "") { + return { type: "clear" }; + } + const parsed = parseNumericInput(raw, type); + return parsed === undefined ? { type: "hold" } : { type: "set", value: parsed }; +} + +function NumericSubField({ + id, + name, + required, + placeholder, + labelledBy, + type, + value, + onChange, +}: { + id: string; + name: string; + required?: boolean; + placeholder?: string; + labelledBy: string; + type: "number" | "integer"; + value: unknown; + onChange: (value: unknown) => void; +}) { + const committed = typeof value === "number" ? value : undefined; + const valueString = committed === undefined ? "" : String(committed); + const [draft, setDraft] = useState(valueString); + + // Reconcile external value changes into the draft. Only resync when the + // committed prop no longer matches what the draft currently represents, so + // in-progress strings ("3.", "-", "12.3") survive while the user types. + useEffect(() => { + if (parseNumericInput(draft, type) !== committed) { + setDraft(valueString); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [committed]); + + return ( + ) => { + const raw = event.currentTarget.value; + setDraft(raw); + const commit = numericChangeCommit(raw, type); + if (commit.type === "set") { + onChange(commit.value); + } else if (commit.type === "clear") { + onChange(undefined); + } + }} + onBlur={() => setDraft(committed === undefined ? "" : String(committed))} + /> + ); +} + function readInputValue( event: ChangeEvent, type: FieldsSubField["type"], @@ -467,14 +549,22 @@ function renderSubField( value={typeof value === "string" ? value : ""} onValueChange={(nextValue) => onChange(String(nextValue))} /> + ) : type === "number" || type === "integer" ? ( + ) : ( )} {suffix ? {suffix} : null} diff --git a/tests/numeric-input.test.mjs b/tests/numeric-input.test.mjs index 59107a9..fb787ae 100644 --- a/tests/numeric-input.test.mjs +++ b/tests/numeric-input.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { parseNumericInput } from "../dist/admin.mjs"; +import { numericChangeCommit, parseNumericInput } from "../dist/admin.mjs"; test("numeric input emits undefined for empty values", () => { assert.equal(parseNumericInput("", "number"), undefined); @@ -31,3 +31,19 @@ test("integer input accepts integers and rejects decimals", () => { assert.equal(parseNumericInput("-9007199254740991", "integer"), Number.MIN_SAFE_INTEGER); assert.equal(parseNumericInput("3.14", "integer"), undefined); }); + +test("numeric commit holds in-progress drafts instead of wiping the value", () => { + // empty clears + assert.deepEqual(numericChangeCommit("", "number"), { type: "clear" }); + assert.deepEqual(numericChangeCommit(" ", "integer"), { type: "clear" }); + // complete numbers commit + assert.deepEqual(numericChangeCommit("3.14", "number"), { type: "set", value: 3.14 }); + assert.deepEqual(numericChangeCommit("-5", "integer"), { type: "set", value: -5 }); + // "3." parses to 3, so it commits; the visible "3." is preserved by the + // component's draft state so the user can continue typing "3.14" + assert.deepEqual(numericChangeCommit("3.", "number"), { type: "set", value: 3 }); + // lone minus while starting a negative number holds (no commit, no wipe) + assert.deepEqual(numericChangeCommit("-", "integer"), { type: "hold" }); + // transient decimal on an integer field holds rather than clearing the value + assert.deepEqual(numericChangeCommit("12.3", "integer"), { type: "hold" }); +}); From df8c7285ab365e0a7ce49d7a3d4e604d99a99bc5 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:07:06 +0100 Subject: [PATCH 12/27] Validate link type/target against documented unions normalizeLinkValue passed through any string type/target, so alien values (javascript, _parent) displayed as defaults yet persisted. Now out-of-union type/target are dropped while value/text and extra keys are preserved, keeping storage consistent with the controls. Adds test. Devana: 20260627T180004Z-P2-link-alien-fields-persist --- ...627T180004Z-P2-link-alien-fields-persist.md | 5 +++-- src/admin.tsx | 18 +++++++++++++++++- tests/transformations.test.mjs | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md index 0655d70..0c96207 100644 --- a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md +++ b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist # Link field preserves invalid type and target values @@ -45,6 +45,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. `normalizeLinkValue` now validates `type` against `["url","email","tel","entry","media"]` and `target` against `["_blank","_self"]`, deleting any value outside the union. This stops stored JSON from diverging from the controls: an alien `type` (e.g. `"javascript"`) is dropped so the select shows its `"url"` default, and an alien `target` (e.g. `"_parent"`) is dropped so the unchecked checkbox matches storage; both clear on the next save. `value`/`text` and any unknown extra keys are preserved (spread, then targeted deletes). Since `LinkField` reads through `normalizeLinkValue` and `updateLinkValue` merges onto that normalized base, the write path is clean too. Added a regression test; typecheck clean; full suite (30 tests) passes. See [[link-invalid-root-lost]]. DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist -DEVANA-SUMMARY: open | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 00fbf7d..f0afb16 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -232,6 +232,9 @@ export function moveStructureItem( return nextItems; } +const LINK_TYPES = ["url", "email", "tel", "entry", "media"] as const; +const LINK_TARGETS = ["_blank", "_self"] as const; + export function normalizeLinkValue(value: unknown): LinkValue { // A bare string root maps naturally to the link's URL value, so preserve and // surface it instead of discarding it on the first edit. Other non-object @@ -239,7 +242,20 @@ export function normalizeLinkValue(value: unknown): LinkValue { if (typeof value === "string") { return value ? { value } : {}; } - return normalizeObjectValue(value) as LinkValue; + // Drop `type`/`target` values outside their documented unions so stored JSON + // cannot diverge from what the controls display (the select/checkbox fall back + // to their defaults) and the alien value clears on the next save. + const next = { ...normalizeObjectValue(value) } as Record; + if (typeof next.type !== "string" || !(LINK_TYPES as readonly string[]).includes(next.type)) { + delete next.type; + } + if ( + typeof next.target !== "string" || + !(LINK_TARGETS as readonly string[]).includes(next.target) + ) { + delete next.target; + } + return next as LinkValue; } export function updateLinkValue(value: unknown, nextValue: Partial): LinkValue { diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 09e893f..85db3cb 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -88,6 +88,21 @@ test("link values normalize invalid inputs and merge partial updates", () => { }); }); +test("link values drop type and target outside the documented unions", () => { + assert.deepEqual(normalizeLinkValue({ type: "javascript", value: "https://x" }), { + value: "https://x", + }); + assert.deepEqual(normalizeLinkValue({ type: "url", value: "https://x", target: "_parent" }), { + type: "url", + value: "https://x", + }); + assert.deepEqual(normalizeLinkValue({ type: "email", value: "a@b.c", target: "_blank" }), { + type: "email", + value: "a@b.c", + target: "_blank", + }); +}); + test("link field preserves a scalar URL root through the first edit", () => { const data = normalizeLinkValue("https://example.com"); assert.deepEqual(updateLinkValue(data, { text: "Home" }), { From 5e5f83b4aa0ff9469049d18e4a1bb7398d53345a Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:08:42 +0100 Subject: [PATCH 13/27] Dedupe duplicate choice values in normalizeChoices Duplicate values share a React key and one selection token, so the cards mirrored each other and could not be toggled independently. Independent selection is unrepresentable (storage is an array of value strings), so later duplicates are dropped (first wins), matching selection-set dedup. Adds test. Devana: 20260627T180005Z-P2-duplicate-choice-value-collapse --- ...005Z-P2-duplicate-choice-value-collapse.md | 5 +-- src/admin.tsx | 33 ++++++++++++------- tests/transformations.test.mjs | 18 ++++++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md b/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md index e3c6b3f..d790fdc 100644 --- a/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md +++ b/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse # Duplicate choice values collapse selection and React keys @@ -55,6 +55,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed via dedup (the report's second suggested remedy). Independent per-row selection is impossible to fix the other way: the persisted value is an array of value strings, so two choices sharing a value are one logical token and `["plan-a"]` cannot encode "Plan A selected but not Plan B". Keying rows by index would still collapse selection at the storage layer. `normalizeChoices` now drops later duplicate values (first occurrence wins), eliminating the React key collision and the mirrored checked state, and matching how `normalizeChoiceSelection` already dedupes the selection set. The missing-value repair from the choice-missing-value-crash fix is preserved. Added a regression test; typecheck clean; full suite (31 tests) passes. See [[choice-missing-value-crash]]. DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse -DEVANA-SUMMARY: open | P2 | medium | Duplicate choice `value`s share React keys and one selection token, so distinct labels cannot be toggled independently. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Duplicate choice `value`s share React keys and one selection token, so distinct labels cannot be toggled independently. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index f0afb16..3338901 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -263,21 +263,30 @@ export function updateLinkValue(value: unknown, nextValue: Partial): } export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] { - return (value ?? []).flatMap((choice) => { + const seen = new Set(); + const result: FieldsChoice[] = []; + for (const choice of value ?? []) { + let normalized: FieldsChoice | undefined; if (typeof choice === "string") { - return [{ value: choice, label: choice }]; + normalized = { value: choice, label: choice }; + } else if (typeof choice.value === "string") { + normalized = choice; + } else if (typeof choice.label === "string") { + // Object choices authored in serialized JSON/YAML can omit the required + // `value`. Synthesize one from a string label when possible, otherwise the + // malformed choice is skipped so a single bad option cannot crash the widget. + normalized = { ...choice, value: choice.label }; } - if (typeof choice.value === "string") { - return [choice]; + // Selection is keyed by `value`, so two choices sharing a value are a single + // logical token that cannot be selected independently. Drop later duplicates + // to avoid React key collisions and mirrored checked state. + if (!normalized || seen.has(normalized.value)) { + continue; } - // Object choices authored in serialized JSON/YAML can omit the required - // `value`. Synthesize one from a string label when possible, otherwise drop - // the malformed choice so a single bad option cannot crash the widget. - if (typeof choice.label === "string") { - return [{ ...choice, value: choice.label }]; - } - return []; - }); + seen.add(normalized.value); + result.push(normalized); + } + return result; } export function normalizeChoiceSelection(value: unknown, multiple: boolean): string[] { diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 85db3cb..bde50c0 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -133,6 +133,24 @@ test("choices guarantee a string value for object choices", () => { } }); +test("choices drop later duplicate values so each card is independent", () => { + assert.deepEqual( + normalizeChoices([ + { value: "plan-a", label: "Plan A" }, + { value: "plan-a", label: "Plan B" }, + { value: "plan-b", label: "Plan B" }, + ]), + [ + { value: "plan-a", label: "Plan A" }, + { value: "plan-b", label: "Plan B" }, + ], + ); + assert.deepEqual(normalizeChoices(["x", "x", "y"]), [ + { value: "x", label: "x" }, + { value: "y", label: "y" }, + ]); +}); + test("multiple choice selections preserve order while removing duplicates", () => { assert.deepEqual(normalizeChoiceSelection(["beta", "alpha", "beta", 42], true), [ "beta", From 18518039582ef4ed41dd898af7f7fcdff0ed9a9d Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:10:21 +0100 Subject: [PATCH 14/27] Document structure min/max semantics (wontfix auto-clamp) Auto-clamping loaded values would silently delete above-max rows or seed spurious below-min rows (dirtying entries on open). min/max are interactive guardrails on Add/Remove; document this in README rather than enforce destructively. Devana: 20260627T180006Z-P2-structure-min-max-bypass --- .devana/20260627T180006Z-P2-structure-min-max-bypass.md | 5 +++-- README.md | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.devana/20260627T180006Z-P2-structure-min-max-bypass.md b/.devana/20260627T180006Z-P2-structure-min-max-bypass.md index f7cb0db..0d25336 100644 --- a/.devana/20260627T180006Z-P2-structure-min-max-bypass.md +++ b/.devana/20260627T180006Z-P2-structure-min-max-bypass.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: wontfix | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass # Structure min and max not enforced on loaded values @@ -45,6 +45,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: wontfix (code) + documented. The suggested auto-clamp is harmful: trimming an above-`max` array silently deletes loaded rows (the same silent data loss the P1 root-coercion findings flag), and padding a below-`min` array seeds empty rows that mark the entry dirty on open with no user action. The current design is the safe one — `min`/`max` gate the interactive controls (Add disabled at `max`, Remove disabled at `min`) while still letting the editor reach compliance (Remove stays enabled above `max`, Add stays enabled below `min`), and externally supplied out-of-bounds arrays are shown rather than mutated. Took the report's second remedy: documented these semantics in README.md (min/max constrain the controls, are not enforced on supplied values). Build/typecheck unaffected (docs only). DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass -DEVANA-SUMMARY: open | P2 | medium | Structure `min`/`max` gate buttons only; loaded or edited arrays outside bounds persist unchanged. \ No newline at end of file +DEVANA-SUMMARY: wontfix | P2 | medium | Structure `min`/`max` gate buttons only; loaded or edited arrays outside bounds persist unchanged. \ No newline at end of file diff --git a/README.md b/README.md index 8482bb9..2c87168 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,13 @@ Structure field: } ``` +The structure widget also accepts `min` and `max` row counts. These constrain +the interactive controls — the Add button is disabled at `max` and the Remove +button is disabled at `min` — guiding editors back within bounds. They are not +enforced on externally supplied values: a stored array outside the limits is +shown as-is rather than silently trimmed (which would drop data) or padded with +empty rows (which would dirty the entry on open). + Choices with horizontal cards: ```json From c715fea0ef866d9cf20d94f52bb0fa8b0e7087cf Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:12:42 +0100 Subject: [PATCH 15/27] Fix structure deadlock when min > max A contradictory min > max disabled both Add and Remove at max, locking the editor below an unreachable floor. Add effectiveStructureBounds to clamp the floor to the ceiling so the field settles at exactly max, and drive both button guards from it. Adds unit test. Devana: 20260627T180007Z-P2-structure-min-gt-max-deadlock --- ...80007Z-P2-structure-min-gt-max-deadlock.md | 5 +++-- src/admin.tsx | 20 +++++++++++++++++-- tests/transformations.test.mjs | 12 +++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md b/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md index 3b203d6..039a270 100644 --- a/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md +++ b/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock # Structure min greater than max deadlocks the editor @@ -47,6 +47,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed via the report's "derive effective bounds" remedy rather than a config-error message (a hard error would block all editing, and a new i18n key carries locale/test surface). Added a pure exported `effectiveStructureBounds(min, max)` that, when `min > max`, clamps the floor to the ceiling (`{min: max, max: max}`) so the editor settles at exactly `max` instead of locking with both Add and Remove disabled below an unreachable floor. `StructureField` now drives both button `disabled` guards from these reconciled bounds. Valid configs (`min <= max`, or only one set) are unchanged. Added a unit test; typecheck clean; full suite (32 tests) passes. Related: [[structure-min-max-bypass]]. DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock -DEVANA-SUMMARY: open | P2 | medium | When `min > max`, structure add and remove can both disable and leave the editor stuck below `min`. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | When `min > max`, structure add and remove can both disable and leave the editor stuck below `min`. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 3338901..7aaf31d 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -185,6 +185,21 @@ export function updateObjectValue(value: unknown, key: string, nextValue: unknow return { ...normalizeObjectValue(value), [key]: nextValue }; } +export function effectiveStructureBounds( + min?: number, + max?: number, +): { min?: number; max?: number } { + const safeMin = typeof min === "number" ? min : undefined; + const safeMax = typeof max === "number" ? max : undefined; + // A contradictory `min > max` config would disable both Add and Remove at + // `max`, locking the editor below an unreachable floor. Clamp the floor to the + // ceiling so the field can settle at exactly `max` instead of deadlocking. + if (safeMin !== undefined && safeMax !== undefined && safeMin > safeMax) { + return { min: safeMax, max: safeMax }; + } + return { min: safeMin, max: safeMax }; +} + export function normalizeStructureValue(value: unknown): JsonRecord[] { return Array.isArray(value) ? value.map((item) => normalizeObjectValue(item)) : []; } @@ -664,6 +679,7 @@ export function StructureField({ const fields = options?.fields ?? []; const itemLabel = localizedString(options?.itemLabel, i18n, fieldMessage("item", i18n)); const sortable = options?.sortable !== false; + const bounds = effectiveStructureBounds(options?.min, options?.max); if (!fields.length) { return

{fieldMessage("structureRequiresFields", i18n)}

; @@ -693,7 +709,7 @@ export function StructureField({ size="sm" variant="secondary-destructive" icon={TrashIcon} - disabled={typeof options?.min === "number" && items.length <= options.min} + disabled={typeof bounds.min === "number" && items.length <= bounds.min} onClick={() => updateItems(removeStructureItem(items, index))} > {fieldMessage("remove", i18n)} @@ -728,7 +744,7 @@ export function StructureField({ size="sm" className={fullWidthButtonClassName} icon={PlusIcon} - disabled={typeof options?.max === "number" && items.length >= options.max} + disabled={typeof bounds.max === "number" && items.length >= bounds.max} onClick={() => updateItems(addStructureItem(items))} > {formatFieldMessage("addItem", i18n, { item: itemLabel })} diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index bde50c0..86da083 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -3,6 +3,7 @@ import { test } from "node:test"; import { addStructureItem, coerceBoolean, + effectiveStructureBounds, isBooleanChecked, moveStructureItem, normalizeChoices, @@ -61,6 +62,17 @@ test("structure updates, adds, removes, and reorders rows immutably", () => { assert.deepEqual(source, [{ label: "A" }, { label: "B" }, { label: "C" }]); }); +test("structure bounds clamp a contradictory min greater than max", () => { + assert.deepEqual(effectiveStructureBounds(5, 2), { min: 2, max: 2 }); + assert.deepEqual(effectiveStructureBounds(1, 3), { min: 1, max: 3 }); + assert.deepEqual(effectiveStructureBounds(undefined, 3), { min: undefined, max: 3 }); + assert.deepEqual(effectiveStructureBounds(2, undefined), { min: 2, max: undefined }); + assert.deepEqual(effectiveStructureBounds(undefined, undefined), { + min: undefined, + max: undefined, + }); +}); + test("structure transformations ignore out-of-range item indexes", () => { const source = [{ label: "A" }]; From 73d5c63bbaca26d9578c65820c25fa984bbb3805 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:15:17 +0100 Subject: [PATCH 16/27] Fall back to options alias when choices array is empty ChoicesField used choices ?? options, so an empty choices:[] shadowed the options alias and showed the misconfiguration message. Fall back when choices is absent or empty. Adds SSR regression test. Devana: 20260627T180008Z-P2-choices-empty-blocks-options --- ...T180008Z-P2-choices-empty-blocks-options.md | 5 +++-- src/admin.tsx | 7 ++++++- test/semantics.test.mjs | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md b/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md index 7c51da2..5bf2bb6 100644 --- a/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md +++ b/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options # Empty choices array blocks options alias fallback @@ -47,6 +47,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed. `ChoicesField` resolved the list with `options?.choices ?? options?.options`, so a non-nullish empty `choices: []` shadowed the `options` alias and forced the misconfiguration message. Changed to `options?.choices?.length ? options.choices : options?.options` (the report's suggested fix), so the alias supplies items whenever `choices` is absent or empty. Behavior is unchanged when `choices` has items or both are absent. Added an SSR regression test asserting the fallback renders the options and not the "misconfigured" message; full suite (33 tests) passes. DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options -DEVANA-SUMMARY: open | P2 | medium | `choices: []` prevents the `options` alias from supplying choice items because `??` does not treat empty arrays as absent. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | `choices: []` prevents the `options` alias from supplying choice items because `??` does not treat empty arrays as absent. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 7aaf31d..b8a0fa1 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -839,7 +839,12 @@ export function ChoicesField({ options, }: FieldWidgetProps) { const i18n = useFieldI18n(options?.i18n); - const choicesList = normalizeChoices(options?.choices ?? options?.options); + // Treat `choices` and `options` as interchangeable sources; fall back to the + // `options` alias when `choices` is absent OR empty (`??` alone lets an empty + // array shadow the alias). + const choicesList = normalizeChoices( + options?.choices?.length ? options.choices : options?.options, + ); const legend = label ?? fieldMessage("choices", i18n); const multiple = coerceBoolean(options?.multiple); const horizontal = options?.orientation === "horizontal"; diff --git a/test/semantics.test.mjs b/test/semantics.test.mjs index 975ddc5..4e51b3c 100644 --- a/test/semantics.test.mjs +++ b/test/semantics.test.mjs @@ -56,6 +56,24 @@ test("link inputs render connected labels without Kumo warnings", () => { assert.match(html, /id="fields-link-target"/); }); +test("empty choices array falls back to the options alias", () => { + const { html } = renderWithoutWarnings( + React.createElement(ChoicesField, { + value: null, + onChange() {}, + id: "choices", + options: { + choices: [], + options: ["alpha", "beta"], + }, + }), + ); + + assert.doesNotMatch(html, /misconfigured/i); + assert.match(html, /alpha/); + assert.match(html, /beta/); +}); + test("choice collections expose semantic groups and unique labelled controls", () => { const { html } = renderWithoutWarnings( React.createElement(ChoicesField, { From c93d54f21b93fe7260b6c9c81c2ee703a1d517c2 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:17:00 +0100 Subject: [PATCH 17/27] Surface numeric select subfield values via string coercion Select subfields rendered blank for any non-string stored value. Add selectSubfieldValue to stringify numbers (legacy numeric JSON) so they match string options and render as selected, mirroring text inputs. Non-scalars still blank. Adds unit test. Devana: 20260627T180009Z-P2-select-subfield-non-string-hidden --- ...7T180009Z-P2-select-subfield-non-string-hidden.md | 5 +++-- src/admin.tsx | 12 +++++++++++- tests/transformations.test.mjs | 11 +++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md index 8384016..3335aaf 100644 --- a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md +++ b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden # Object select subfields hide non-string stored values @@ -47,6 +47,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed (display coercion). The select branch now derives its value via a new exported `selectSubfieldValue(value)` that stringifies a stored number (the realistic legacy numeric-JSON case, e.g. `{tone: 1}` against options `["1","2"]`) so it can match a string option and render as selected, mirroring how text subfields already accept numbers. Non-scalar values (arrays/objects) have no option to match and still render blank, which is the correct display. No mount-time `onChange` is added, consistent with the project's avoidance of spurious dirty state — a genuine re-select still writes a clean string. Note: when the stored value matches no option (e.g. `1` vs `["Calm","Bold"]`), blank remains correct; coercion only helps when the stringified value is an actual option. Added a unit test; typecheck clean; full suite (34 tests) passes. Related: [[single-choice-array-deselected]]. DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden -DEVANA-SUMMARY: open | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index b8a0fa1..0994d13 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -415,6 +415,16 @@ export function isBooleanChecked(value: unknown): boolean { return coerceBoolean(value); } +export function selectSubfieldValue(value: unknown): string { + // Mirror the leniency of text inputs: a stored number (legacy numeric JSON) + // is stringified so it can match a string option value and render as selected, + // instead of always falling back to the blank placeholder. Non-scalar values + // have no option to match and stay blank. + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return ""; +} + export function parseNumericInput(value: string, type: "number" | "integer") { if (value.trim() === "") { return undefined; @@ -586,7 +596,7 @@ function renderSubField( label: choiceLabel(choice, i18n), })), ]} - value={typeof value === "string" ? value : ""} + value={selectSubfieldValue(value)} onValueChange={(nextValue) => onChange(String(nextValue))} /> ) : type === "number" || type === "integer" ? ( diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 86da083..f2f86d0 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -13,6 +13,7 @@ import { normalizeObjectValue, normalizeStructureValue, removeStructureItem, + selectSubfieldValue, updateChoiceSelection, updateLinkValue, updateObjectValue, @@ -202,6 +203,16 @@ test("coerceBoolean parses quoted booleans for option flags", () => { assert.equal(coerceBoolean(undefined), false); }); +test("select subfield value stringifies numbers and blanks non-scalars", () => { + assert.equal(selectSubfieldValue("Calm"), "Calm"); + assert.equal(selectSubfieldValue(1), "1"); + assert.equal(selectSubfieldValue(0), "0"); + assert.equal(selectSubfieldValue(undefined), ""); + assert.equal(selectSubfieldValue(null), ""); + assert.equal(selectSubfieldValue(["Calm"]), ""); + assert.equal(selectSubfieldValue({ value: "Calm" }), ""); +}); + test("boolean subfield checked state ignores truthy string false", () => { assert.equal(isBooleanChecked(true), true); assert.equal(isBooleanChecked("true"), true); From 136cbdae632a09c4e832f4d47af031f9e7079e74 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:19:28 +0100 Subject: [PATCH 18/27] Mark structure-stale-closure-clobber as wontfix Value-based onChange + synchronous controlled parent (EmDash's contract) re-renders between discrete events, so closures recompose from current state. The clobber needs a deferring/batching parent the widget can't detect; a correct fix requires routing all composition (buttons + per-row edits) through a value ref, an invasive refactor for a theoretical edge. Devana: 20260627T180010Z-P2-structure-stale-closure-clobber --- .../20260627T180010Z-P2-structure-stale-closure-clobber.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md b/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md index 08fc4d8..19d05f4 100644 --- a/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md +++ b/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: wontfix | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber # Structure handlers can clobber pending edits from stale render snapshot @@ -49,7 +49,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes -- 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: wontfix. The mechanism is real but only under a non-standard parent. `onChange` here is value-based (`(value) => void`, no functional updater), and EmDash drives field widgets as synchronous controlled inputs, so React re-renders between discrete user events and the handler closures always recompose from current state. The clobber requires a parent that defers/batches the `value` prop across two distinct user actions — an integration this widget cannot reliably detect. A correct fix would have to route ALL composition through a mutable value ref updated on every emit: not just the add/remove/move button handlers but every per-row subfield edit flowing through `renderObjectFields` (whose `onChange` merges onto the render-captured row object). A buttons-only ref would fix the report's exact counterexample (edit row 0, then remove row 1) but still drop sequential same-row subfield edits under a deferring parent, giving false confidence. The full refactor carries regression risk disproportionate to a theoretical, parent-dependent edge, so behavior is left as-is. Recommended pattern if revisited: hold `const itemsRef = useRef(items)` synced to the prop each render, update it inside a single `updateItems`, and compose every mutation (including row edits, basing each row merge on `itemsRef.current[index]`) from the ref. Related but distinct: [[structure-reorder-wrong-row]] (invalid; index-key/focus, not stale composition). DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber -DEVANA-SUMMARY: open | P2 | medium | Structure row handlers compose `onChange` from a render snapshot and can drop a pending edit if a second action runs before props refresh. \ No newline at end of file +DEVANA-SUMMARY: wontfix | P2 | medium | Structure row handlers compose `onChange` from a render snapshot and can drop a pending edit if a second action runs before props refresh. \ No newline at end of file From 25230dcace3a9af24c0fd3056f49553676713d4b Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:21:26 +0100 Subject: [PATCH 19/27] Coerce numeric subfield string/off-type values for display Number/integer subfields now interpret stored values via interpretNumericValue: quoted numeric strings display as numbers (and edits emit a real Number), off-type shapes blank out. Also fixes a regression where the draft refactor blanked stored numeric strings. Adds unit + SSR tests. Devana: 20260627T180011Z-P2-number-subfield-string-persists --- ...011Z-P2-number-subfield-string-persists.md | 6 ++--- src/admin.tsx | 23 ++++++++++++++++++- test/semantics.test.mjs | 14 +++++++++++ tests/numeric-input.test.mjs | 22 +++++++++++++++++- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md index 56fb184..9eaa256 100644 --- a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md +++ b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md @@ -1,5 +1,5 @@ DEVANA-FINDING: v1 -DEVANA-STATE: open | P2 | medium | security=no +DEVANA-STATE: fixed | P2 | medium | security=no DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists # Number subfields round-trip non-number stored values unchanged @@ -50,7 +50,7 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes -- 2026-06-27: open by Devana. Initial report written from static source inspection. +- 2026-06-27: fixed (display/interpretation; intentionally no mount-time write). Numeric subfields now route their committed value through a new exported `interpretNumericValue(value, type)`: quoted numeric strings (common in YAML/JSON, e.g. `{count: "42"}`) are coerced so they display as numbers and any edit emits a real `Number`, while off-type shapes (a non-integer for an `integer` field, non-finite numbers, non-scalars) clear to `undefined` (blank) per the report's suggested remedy. This also fixes a regression introduced by the numeric-keystroke-intermediate-loss refactor, where `NumericSubField` treated only `typeof value === "number"` as committed and so blanked a stored numeric string; a stored "42" now correctly renders "42" again. No mount `onChange` is added — consistent with the project's avoidance of spurious dirty state — so an untouched off-type value is shown per the interpretation but the raw stored value is only rewritten when the user edits the field. Added unit tests for `interpretNumericValue` and an SSR test asserting `{count:"42"}` renders `value="42"`; typecheck clean; full suite (36 tests) passes. See [[numeric-keystroke-intermediate-loss]]. DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists -DEVANA-SUMMARY: open | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. \ No newline at end of file diff --git a/src/admin.tsx b/src/admin.tsx index 0994d13..1adc066 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -442,6 +442,27 @@ export function parseNumericInput(value: string, type: "number" | "integer") { return numericValue; } +/** + * Interpret a stored subfield value as the number it represents for the given + * type, or `undefined` when it is not a valid value. Coerces numeric strings + * (common in quoted YAML/JSON) so they display as numbers, and rejects off-type + * shapes (non-integers for `integer`, non-finite numbers, non-scalars). + */ +export function interpretNumericValue( + value: unknown, + type: "number" | "integer", +): number | undefined { + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefined; + if (type === "integer" && !Number.isInteger(value)) return undefined; + return value; + } + if (typeof value === "string") { + return parseNumericInput(value, type); + } + return undefined; +} + export type NumericCommit = | { type: "set"; value: number } | { type: "clear" } @@ -483,7 +504,7 @@ function NumericSubField({ value: unknown; onChange: (value: unknown) => void; }) { - const committed = typeof value === "number" ? value : undefined; + const committed = interpretNumericValue(value, type); const valueString = committed === undefined ? "" : String(committed); const [draft, setDraft] = useState(valueString); diff --git a/test/semantics.test.mjs b/test/semantics.test.mjs index 4e51b3c..0d88c90 100644 --- a/test/semantics.test.mjs +++ b/test/semantics.test.mjs @@ -39,6 +39,20 @@ test("text-like subfields render connected labels without Kumo warnings", () => assert.match(html, /aria-labelledby="fields-object-count-label"/); }); +test("number subfield displays a quoted numeric string value", () => { + const { html } = renderWithoutWarnings( + React.createElement(ObjectField, { + value: { count: "42" }, + onChange() {}, + options: { + fields: [{ key: "count", label: "Count", type: "number" }], + }, + }), + ); + + assert.match(html, /value="42"/); +}); + test("link inputs render connected labels without Kumo warnings", () => { const { html, warnings } = renderWithoutWarnings( React.createElement(LinkField, { diff --git a/tests/numeric-input.test.mjs b/tests/numeric-input.test.mjs index fb787ae..b3f8dda 100644 --- a/tests/numeric-input.test.mjs +++ b/tests/numeric-input.test.mjs @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { numericChangeCommit, parseNumericInput } from "../dist/admin.mjs"; +import { + interpretNumericValue, + numericChangeCommit, + parseNumericInput, +} from "../dist/admin.mjs"; test("numeric input emits undefined for empty values", () => { assert.equal(parseNumericInput("", "number"), undefined); @@ -32,6 +36,22 @@ test("integer input accepts integers and rejects decimals", () => { assert.equal(parseNumericInput("3.14", "integer"), undefined); }); +test("numeric interpretation coerces strings and rejects off-type values", () => { + // quoted numeric strings display as numbers + assert.equal(interpretNumericValue("42", "number"), 42); + assert.equal(interpretNumericValue("42", "integer"), 42); + assert.equal(interpretNumericValue("3.14", "number"), 3.14); + // already-valid numbers pass through + assert.equal(interpretNumericValue(7, "integer"), 7); + // off-type / invalid shapes clear to undefined + assert.equal(interpretNumericValue("3.14", "integer"), undefined); + assert.equal(interpretNumericValue(3.14, "integer"), undefined); + assert.equal(interpretNumericValue(Infinity, "number"), undefined); + assert.equal(interpretNumericValue("abc", "number"), undefined); + assert.equal(interpretNumericValue(["42"], "number"), undefined); + assert.equal(interpretNumericValue(undefined, "number"), undefined); +}); + test("numeric commit holds in-progress drafts instead of wiping the value", () => { // empty clears assert.deepEqual(numericChangeCommit("", "number"), { type: "clear" }); From 7990216282e8e74216fcaafe8608ef001eca2ecd Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:39:29 +0100 Subject: [PATCH 20/27] Fix link value normalization on load --- ...27T180004Z-P2-link-alien-fields-persist.md | 4 +- src/admin.tsx | 52 ++++++++++++++++++- tests/transformations.test.mjs | 13 +++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md index 0c96207..a2012e9 100644 --- a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md +++ b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md @@ -46,6 +46,8 @@ After working this report, preserve the original finding body. Update line 2 `DE - 2026-06-27: open by Devana. Initial report written from static source inspection. - 2026-06-27: fixed. `normalizeLinkValue` now validates `type` against `["url","email","tel","entry","media"]` and `target` against `["_blank","_self"]`, deleting any value outside the union. This stops stored JSON from diverging from the controls: an alien `type` (e.g. `"javascript"`) is dropped so the select shows its `"url"` default, and an alien `target` (e.g. `"_parent"`) is dropped so the unchecked checkbox matches storage; both clear on the next save. `value`/`text` and any unknown extra keys are preserved (spread, then targeted deletes). Since `LinkField` reads through `normalizeLinkValue` and `updateLinkValue` merges onto that normalized base, the write path is clean too. Added a regression test; typecheck clean; full suite (30 tests) passes. See [[link-invalid-root-lost]]. +- 2026-06-27: reopened. The enum validation fixes the edit path but does not block the original untouched-save case. `LinkField` derives a normalized local `data` snapshot from `normalizeLinkValue(value)`, but it does not emit `onChange` on mount. If a loaded value contains `{ target: "_parent" }` and the editor saves without changing the link field, the parent state can still persist the raw alien target. Evidence checked: `normalizeLinkValue` deletes invalid keys, `LinkField` reads through it, and `update` only runs from field handlers. +- 2026-06-27: fixed. `LinkField` now runs load-time normalization for representable link values whose normalized JSON differs from the raw prop. `shouldNormalizeLinkValue` gates this to non-empty string URL roots and object records, and `useNormalizedOnChange` emits the cleaned `normalizeLinkValue(value)` once. The original untouched-save case is blocked because `{ target: "_parent" }` now triggers `onChange({ ...without target... })` without requiring a link field edit. Added a helper regression test covering alien records, valid records, bare URL strings, and unrepresentable roots. DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist -DEVANA-SUMMARY: fixed | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. diff --git a/src/admin.tsx b/src/admin.tsx index 1adc066..7c2d99a 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -8,7 +8,7 @@ import { type FieldsI18nConfig, type LocalizedString, } from "./i18n"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { CSSProperties, ChangeEvent } from "react"; import type { ChoicesOptions, @@ -32,6 +32,18 @@ type FieldWidgetProps> = { type JsonRecord = Record; +function isJsonRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function jsonValueSignature(value: unknown): string { + return JSON.stringify(value) ?? "undefined"; +} + +function jsonValuesEqual(left: unknown, right: unknown): boolean { + return jsonValueSignature(left) === jsonValueSignature(right); +} + const wrapperStyle = { display: "grid", gap: "0.75rem", @@ -177,8 +189,33 @@ function useFieldI18n(i18n: FieldsI18nConfig | undefined): FieldsI18nConfig { return { ...i18n, locale }; } +function useNormalizedOnChange( + value: unknown, + normalizedValue: unknown, + onChange: (value: unknown) => void, + enabled: boolean, +) { + const lastEmittedSignature = useRef(null); + const rawSignature = jsonValueSignature(value); + const normalizedSignature = jsonValueSignature(normalizedValue); + + useEffect(() => { + if (!enabled || rawSignature === normalizedSignature) { + return; + } + + const emissionSignature = `${rawSignature}->${normalizedSignature}`; + if (lastEmittedSignature.current === emissionSignature) { + return; + } + + lastEmittedSignature.current = emissionSignature; + onChange(normalizedValue); + }, [enabled, normalizedSignature, normalizedValue, onChange, rawSignature]); +} + export function normalizeObjectValue(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; + return isJsonRecord(value) ? value : {}; } export function updateObjectValue(value: unknown, key: string, nextValue: unknown): JsonRecord { @@ -273,6 +310,16 @@ export function normalizeLinkValue(value: unknown): LinkValue { return next as LinkValue; } +export function shouldNormalizeLinkValue(value: unknown): boolean { + if (typeof value !== "string" && !isJsonRecord(value)) { + return false; + } + if (value === "") { + return false; + } + return !jsonValuesEqual(value, normalizeLinkValue(value)); +} + export function updateLinkValue(value: unknown, nextValue: Partial): LinkValue { return { ...normalizeLinkValue(value), ...nextValue }; } @@ -801,6 +848,7 @@ export function LinkField({ }: FieldWidgetProps) { const i18n = useFieldI18n(options?.i18n); const data = normalizeLinkValue(value); + useNormalizedOnChange(value, data, onChange, shouldNormalizeLinkValue(value)); function update(nextValue: Partial) { onChange(updateLinkValue(data, nextValue)); diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index f2f86d0..6e0e2f4 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -14,6 +14,7 @@ import { normalizeStructureValue, removeStructureItem, selectSubfieldValue, + shouldNormalizeLinkValue, updateChoiceSelection, updateLinkValue, updateObjectValue, @@ -116,6 +117,18 @@ test("link values drop type and target outside the documented unions", () => { }); }); +test("link values report when load-time normalization should be emitted", () => { + assert.equal( + shouldNormalizeLinkValue({ type: "javascript", value: "https://x" }), + true, + ); + assert.equal(shouldNormalizeLinkValue({ type: "url", value: "https://x" }), false); + assert.equal(shouldNormalizeLinkValue("https://x"), true); + assert.equal(shouldNormalizeLinkValue(""), false); + assert.equal(shouldNormalizeLinkValue(42), false); + assert.equal(shouldNormalizeLinkValue(["bad"]), false); +}); + test("link field preserves a scalar URL root through the first edit", () => { const data = normalizeLinkValue("https://example.com"); assert.deepEqual(updateLinkValue(data, { text: "Home" }), { From a221ddda8f4528caa2b7c4b701929c1dabeef4dc Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:41:30 +0100 Subject: [PATCH 21/27] Fix select subfield normalization on load --- ...9Z-P2-select-subfield-non-string-hidden.md | 4 +- src/admin.tsx | 73 ++++++++++++++++++- tests/transformations.test.mjs | 30 ++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md index 3335aaf..41865d8 100644 --- a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md +++ b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md @@ -48,6 +48,8 @@ After working this report, preserve the original finding body. Update line 2 `DE - 2026-06-27: open by Devana. Initial report written from static source inspection. - 2026-06-27: fixed (display coercion). The select branch now derives its value via a new exported `selectSubfieldValue(value)` that stringifies a stored number (the realistic legacy numeric-JSON case, e.g. `{tone: 1}` against options `["1","2"]`) so it can match a string option and render as selected, mirroring how text subfields already accept numbers. Non-scalar values (arrays/objects) have no option to match and still render blank, which is the correct display. No mount-time `onChange` is added, consistent with the project's avoidance of spurious dirty state — a genuine re-select still writes a clean string. Note: when the stored value matches no option (e.g. `1` vs `["Calm","Bold"]`), blank remains correct; coercion only helps when the stringified value is an actual option. Added a unit test; typecheck clean; full suite (34 tests) passes. Related: [[single-choice-array-deselected]]. +- 2026-06-27: reopened. The current fix only improves display for numeric values whose string form matches an option. It does not block the report's original counterexample: `{ tone: 1 }` with options `["Calm", "Bold"]` still renders blank and, because there is no mount-time `onChange`, saving without touching the select can leave parent state as `{ tone: 1 }`. Evidence checked: `selectSubfieldValue(1)` returns `"1"`, the select's items remain `"Calm"`/`"Bold"`, and the only write path is `onValueChange`. +- 2026-06-27: fixed. Select subfields now normalize against configured options on load. `normalizeSelectSubfieldValue` preserves a string/numeric value only when it matches a configured option, otherwise it clears to the blank string. `ObjectField` and `StructureField` emit normalized object/row values via `useNormalizedOnChange`, while preserving invalid structure row payloads outside object rows. The original `{ tone: 1 }` with options `["Calm", "Bold"]` now normalizes to `{ tone: "" }` without requiring the user to touch the select. Added helper tests for object and structure load normalization. DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden -DEVANA-SUMMARY: fixed | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. diff --git a/src/admin.tsx b/src/admin.tsx index 7c2d99a..82085a9 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -472,6 +472,65 @@ export function selectSubfieldValue(value: unknown): string { return ""; } +export function normalizeSelectSubfieldValue( + value: unknown, + choices: FieldsChoice[] | string[] | undefined, +): string { + const nextValue = selectSubfieldValue(value); + if (!nextValue) return ""; + return normalizeChoices(choices).some((choice) => choice.value === nextValue) ? nextValue : ""; +} + +export function normalizeSubfieldStoredValue(field: FieldsSubField, value: unknown): unknown { + const type = field.type ?? "text"; + if (type === "select") { + return normalizeSelectSubfieldValue(value, field.options); + } + return value; +} + +export function normalizeObjectSubfieldValues( + value: unknown, + fields: FieldsSubField[], +): JsonRecord { + const data = normalizeObjectValue(value); + let nextData = data; + + for (const field of fields) { + const nextValue = normalizeSubfieldStoredValue(field, data[field.key]); + if (!jsonValuesEqual(data[field.key], nextValue)) { + if (nextData === data) { + nextData = { ...data }; + } + nextData[field.key] = nextValue; + } + } + + return nextData; +} + +export function normalizeStructureSubfieldValues( + value: unknown, + fields: FieldsSubField[], +): unknown[] { + if (!Array.isArray(value)) return []; + return value.map((item) => (isJsonRecord(item) ? normalizeObjectSubfieldValues(item, fields) : item)); +} + +export function shouldNormalizeObjectSubfieldValues( + value: unknown, + fields: FieldsSubField[], +): boolean { + return isJsonRecord(value) && !jsonValuesEqual(value, normalizeObjectSubfieldValues(value, fields)); +} + +export function shouldNormalizeStructureSubfieldValues( + value: unknown, + fields: FieldsSubField[], +): boolean { + return Array.isArray(value) && !jsonValuesEqual(value, normalizeStructureSubfieldValues(value, fields)); +} + export function parseNumericInput(value: string, type: "number" | "integer") { if (value.trim() === "") { return undefined; @@ -664,7 +723,7 @@ function renderSubField( label: choiceLabel(choice, i18n), })), ]} - value={selectSubfieldValue(value)} + value={normalizeSelectSubfieldValue(value, field.options)} onValueChange={(nextValue) => onChange(String(nextValue))} /> ) : type === "number" || type === "integer" ? ( @@ -729,8 +788,9 @@ export function ObjectField({ options, }: FieldWidgetProps) { const i18n = useFieldI18n(options?.i18n); - const data = normalizeObjectValue(value); const fields = options?.fields ?? []; + const data = normalizeObjectSubfieldValues(value, fields); + useNormalizedOnChange(value, data, onChange, shouldNormalizeObjectSubfieldValues(value, fields)); if (!fields.length) { return

{fieldMessage("objectRequiresFields", i18n)}

; @@ -753,8 +813,15 @@ export function StructureField({ options, }: FieldWidgetProps) { const i18n = useFieldI18n(options?.i18n); - const items = normalizeStructureValue(value); const fields = options?.fields ?? []; + const normalizedValue = normalizeStructureSubfieldValues(value, fields); + const items = normalizeStructureValue(normalizedValue); + useNormalizedOnChange( + value, + normalizedValue, + onChange, + shouldNormalizeStructureSubfieldValues(value, fields), + ); const itemLabel = localizedString(options?.itemLabel, i18n, fieldMessage("item", i18n)); const sortable = options?.sortable !== false; const bounds = effectiveStructureBounds(options?.min, options?.max); diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 6e0e2f4..eaaefb5 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -11,10 +11,15 @@ import { normalizeSingleChoice, normalizeLinkValue, normalizeObjectValue, + normalizeObjectSubfieldValues, + normalizeSelectSubfieldValue, normalizeStructureValue, + normalizeStructureSubfieldValues, removeStructureItem, selectSubfieldValue, shouldNormalizeLinkValue, + shouldNormalizeObjectSubfieldValues, + shouldNormalizeStructureSubfieldValues, updateChoiceSelection, updateLinkValue, updateObjectValue, @@ -226,6 +231,31 @@ test("select subfield value stringifies numbers and blanks non-scalars", () => { assert.equal(selectSubfieldValue({ value: "Calm" }), ""); }); +test("select subfield values normalize against configured options", () => { + assert.equal(normalizeSelectSubfieldValue("Calm", ["Calm", "Bold"]), "Calm"); + assert.equal(normalizeSelectSubfieldValue(1, ["1", "2"]), "1"); + assert.equal(normalizeSelectSubfieldValue(1, ["Calm", "Bold"]), ""); + assert.equal(normalizeSelectSubfieldValue(["Calm"], ["Calm"]), ""); +}); + +test("object and structure subfield values normalize select fields on load", () => { + const fields = [{ key: "tone", label: "Tone", type: "select", options: ["Calm", "Bold"] }]; + + assert.deepEqual(normalizeObjectSubfieldValues({ tone: 1, title: "Intro" }, fields), { + tone: "", + title: "Intro", + }); + assert.equal(shouldNormalizeObjectSubfieldValues({ tone: 1 }, fields), true); + assert.equal(shouldNormalizeObjectSubfieldValues(["bad"], fields), false); + + assert.deepEqual(normalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), [ + { tone: "" }, + "bad", + ]); + assert.equal(shouldNormalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), true); + assert.equal(shouldNormalizeStructureSubfieldValues(["bad"], fields), false); +}); + test("boolean subfield checked state ignores truthy string false", () => { assert.equal(isBooleanChecked(true), true); assert.equal(isBooleanChecked("true"), true); From 4c3c21ec884e80eaf67c0590c6402a53aa6511db Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:42:52 +0100 Subject: [PATCH 22/27] Fix numeric subfield normalization on load --- ...011Z-P2-number-subfield-string-persists.md | 4 +++- src/admin.tsx | 3 +++ tests/transformations.test.mjs | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md index 9eaa256..70e05b0 100644 --- a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md +++ b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md @@ -51,6 +51,8 @@ After working this report, preserve the original finding body. Update line 2 `DE ## Status Notes - 2026-06-27: fixed (display/interpretation; intentionally no mount-time write). Numeric subfields now route their committed value through a new exported `interpretNumericValue(value, type)`: quoted numeric strings (common in YAML/JSON, e.g. `{count: "42"}`) are coerced so they display as numbers and any edit emits a real `Number`, while off-type shapes (a non-integer for an `integer` field, non-finite numbers, non-scalars) clear to `undefined` (blank) per the report's suggested remedy. This also fixes a regression introduced by the numeric-keystroke-intermediate-loss refactor, where `NumericSubField` treated only `typeof value === "number"` as committed and so blanked a stored numeric string; a stored "42" now correctly renders "42" again. No mount `onChange` is added — consistent with the project's avoidance of spurious dirty state — so an untouched off-type value is shown per the interpretation but the raw stored value is only rewritten when the user edits the field. Added unit tests for `interpretNumericValue` and an SSR test asserting `{count:"42"}` renders `value="42"`; typecheck clean; full suite (36 tests) passes. See [[numeric-keystroke-intermediate-loss]]. +- 2026-06-27: reopened. The current code interprets numeric strings for display and future edits, but the original untouched-save counterexample is still reachable. `NumericSubField` computes `committed = interpretNumericValue(value, type)` and keeps a local draft, but it only calls `onChange` from input changes. Loading `{ count: "42" }` and saving without editing the field can still leave the parent value as the string `"42"`; an invalid integer value such as `3.14` is blanked for display but likewise is not cleared from parent state without an edit. Evidence checked: `interpretNumericValue`, `NumericSubField` draft setup, `onChange` handler, and blur resync. +- 2026-06-27: fixed. Number and integer subfields now participate in load-time object/structure normalization through `normalizeSubfieldStoredValue`. Quoted numeric strings such as `{ count: "42" }` emit `{ count: 42 }` without requiring an edit, and invalid integer values such as `{ priority: 3.14 }` emit `{ priority: undefined }` through the same path. Structure normalization preserves invalid non-object rows while normalizing valid row objects. Added helper regression tests covering object and structure numeric normalization. DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists -DEVANA-SUMMARY: fixed | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. \ No newline at end of file +DEVANA-SUMMARY: fixed | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. diff --git a/src/admin.tsx b/src/admin.tsx index 82085a9..a5a9ee0 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -486,6 +486,9 @@ export function normalizeSubfieldStoredValue(field: FieldsSubField, value: unkno if (type === "select") { return normalizeSelectSubfieldValue(value, field.options); } + if (type === "number" || type === "integer") { + return interpretNumericValue(value, type); + } return value; } diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index eaaefb5..7cab36c 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -256,6 +256,25 @@ test("object and structure subfield values normalize select fields on load", () assert.equal(shouldNormalizeStructureSubfieldValues(["bad"], fields), false); }); +test("object and structure subfield values normalize numeric fields on load", () => { + const fields = [ + { key: "count", label: "Count", type: "number" }, + { key: "priority", label: "Priority", type: "integer" }, + ]; + + assert.deepEqual(normalizeObjectSubfieldValues({ count: "42", priority: 3.14 }, fields), { + count: 42, + priority: undefined, + }); + assert.equal(shouldNormalizeObjectSubfieldValues({ count: "42" }, fields), true); + + assert.deepEqual(normalizeStructureSubfieldValues([{ count: "42", priority: 3.14 }, "bad"], fields), [ + { count: 42, priority: undefined }, + "bad", + ]); + assert.equal(shouldNormalizeStructureSubfieldValues([{ count: "42" }, "bad"], fields), true); +}); + test("boolean subfield checked state ignores truthy string false", () => { assert.equal(isBooleanChecked(true), true); assert.equal(isBooleanChecked("true"), true); From ef3864888225734860317d57d7adbc7989d64b65 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 15:48:42 +0100 Subject: [PATCH 23/27] Avoid normalizing absent subfield keys --- src/admin.tsx | 3 +++ tests/transformations.test.mjs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/admin.tsx b/src/admin.tsx index a5a9ee0..4ff9e4d 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -500,6 +500,9 @@ export function normalizeObjectSubfieldValues( let nextData = data; for (const field of fields) { + if (!Object.hasOwn(data, field.key)) { + continue; + } const nextValue = normalizeSubfieldStoredValue(field, data[field.key]); if (!jsonValuesEqual(data[field.key], nextValue)) { if (nextData === data) { diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 7cab36c..1a4019e 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -245,14 +245,23 @@ test("object and structure subfield values normalize select fields on load", () tone: "", title: "Intro", }); + assert.deepEqual(normalizeObjectSubfieldValues({ title: "Intro" }, fields), { + title: "Intro", + }); assert.equal(shouldNormalizeObjectSubfieldValues({ tone: 1 }, fields), true); + assert.equal(shouldNormalizeObjectSubfieldValues({ title: "Intro" }, fields), false); assert.equal(shouldNormalizeObjectSubfieldValues(["bad"], fields), false); assert.deepEqual(normalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), [ { tone: "" }, "bad", ]); + assert.deepEqual(normalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), [ + { title: "Intro" }, + "bad", + ]); assert.equal(shouldNormalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), true); + assert.equal(shouldNormalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), false); assert.equal(shouldNormalizeStructureSubfieldValues(["bad"], fields), false); }); @@ -266,13 +275,22 @@ test("object and structure subfield values normalize numeric fields on load", () count: 42, priority: undefined, }); + assert.deepEqual(normalizeObjectSubfieldValues({ title: "Intro" }, fields), { + title: "Intro", + }); assert.equal(shouldNormalizeObjectSubfieldValues({ count: "42" }, fields), true); + assert.equal(shouldNormalizeObjectSubfieldValues({ title: "Intro" }, fields), false); assert.deepEqual(normalizeStructureSubfieldValues([{ count: "42", priority: 3.14 }, "bad"], fields), [ { count: 42, priority: undefined }, "bad", ]); + assert.deepEqual(normalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), [ + { title: "Intro" }, + "bad", + ]); assert.equal(shouldNormalizeStructureSubfieldValues([{ count: "42" }, "bad"], fields), true); + assert.equal(shouldNormalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), false); }); test("boolean subfield checked state ignores truthy string false", () => { From f834b6997821a807f305d630e42d48d7b35ff89a Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 16:12:40 +0100 Subject: [PATCH 24/27] comments --- src/admin-locale.ts | 8 ++++ src/admin.tsx | 86 ++++++++++++++++++---------------- src/i18n.ts | 11 +++++ src/index.ts | 9 ++++ src/schema.ts | 10 ++++ src/types.ts | 11 +++++ tests/numeric-input.test.mjs | 9 ---- tests/transformations.test.mjs | 4 -- 8 files changed, 95 insertions(+), 53 deletions(-) diff --git a/src/admin-locale.ts b/src/admin-locale.ts index 87d4e21..d36246a 100644 --- a/src/admin-locale.ts +++ b/src/admin-locale.ts @@ -1,8 +1,15 @@ +/** + * Admin UI locale from the EmDash `emdash-locale` cookie. + * + * Widgets use this when options omit an explicit locale so labels track the + * signed-in admin language. + */ import { useEffect, useState } from "react"; import { DEFAULT_LOCALE, normalizeLocale } from "./i18n"; const LOCALE_COOKIE_NAME = "emdash-locale"; +/** Reads the current admin locale from `document.cookie`, with SSR-safe fallback. */ export function readAdminLocale(fallback = DEFAULT_LOCALE): string { const normalizedFallback = normalizeLocale(fallback); @@ -23,6 +30,7 @@ export function readAdminLocale(fallback = DEFAULT_LOCALE): string { } } +/** Reactive admin locale that resyncs on focus and a short polling interval. */ export function useAdminLocale(fallback = DEFAULT_LOCALE): string { const [locale, setLocale] = useState(() => readAdminLocale(fallback)); diff --git a/src/admin.tsx b/src/admin.tsx index 4ff9e4d..14fed5b 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -1,3 +1,10 @@ +/** + * React field widgets and pure normalization for structured JSON values. + * + * Widgets coerce persisted JSON into editor-safe shapes on mount and emit + * normalized values through `onChange`; exported helpers support tests and + * custom integrations outside the default widget bundle. + */ import { Button, Input, Radio, Select, Textarea } from "@cloudflare/kumo"; import { ArrowDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; import { useAdminLocale } from "./admin-locale"; @@ -189,6 +196,7 @@ function useFieldI18n(i18n: FieldsI18nConfig | undefined): FieldsI18nConfig { return { ...i18n, locale }; } +/** One-shot load-time emit: pushes normalized JSON upstream when storage diverges. */ function useNormalizedOnChange( value: unknown, normalizedValue: unknown, @@ -214,37 +222,41 @@ function useNormalizedOnChange( }, [enabled, normalizedSignature, normalizedValue, onChange, rawSignature]); } +/** Coerces unknown roots to a plain object; non-objects become `{}`. */ export function normalizeObjectValue(value: unknown): JsonRecord { return isJsonRecord(value) ? value : {}; } +/** Immutable single-key update on a normalized object value. */ export function updateObjectValue(value: unknown, key: string, nextValue: unknown): JsonRecord { return { ...normalizeObjectValue(value), [key]: nextValue }; } +/** Resolves structure min/max; clamps min to max when the config contradicts itself. */ export function effectiveStructureBounds( min?: number, max?: number, ): { min?: number; max?: number } { const safeMin = typeof min === "number" ? min : undefined; const safeMax = typeof max === "number" ? max : undefined; - // A contradictory `min > max` config would disable both Add and Remove at - // `max`, locking the editor below an unreachable floor. Clamp the floor to the - // ceiling so the field can settle at exactly `max` instead of deadlocking. + // min > max would disable both add and remove at max — clamp to a reachable floor. if (safeMin !== undefined && safeMax !== undefined && safeMin > safeMax) { return { min: safeMax, max: safeMax }; } return { min: safeMin, max: safeMax }; } +/** Coerces unknown roots to an array of object rows; non-arrays become `[]`. */ export function normalizeStructureValue(value: unknown): JsonRecord[] { return Array.isArray(value) ? value.map((item) => normalizeObjectValue(item)) : []; } +/** Appends an empty object row to a structure value. */ export function addStructureItem(value: unknown): JsonRecord[] { return [...normalizeStructureValue(value), {}]; } +/** Replaces one structure row; out-of-range indexes return the source unchanged. */ export function updateStructureItem( value: unknown, index: number, @@ -260,10 +272,12 @@ export function updateStructureItem( return nextItems; } +/** Removes one structure row by index. */ export function removeStructureItem(value: unknown, index: number): JsonRecord[] { return normalizeStructureValue(value).filter((_item, itemIndex) => itemIndex !== index); } +/** Reorders one structure row; invalid indexes return the source unchanged. */ export function moveStructureItem( value: unknown, fromIndex: number, @@ -287,17 +301,13 @@ export function moveStructureItem( const LINK_TYPES = ["url", "email", "tel", "entry", "media"] as const; const LINK_TARGETS = ["_blank", "_self"] as const; +/** Coerces link JSON for editing; scalar URL strings map to `{ value }`. */ export function normalizeLinkValue(value: unknown): LinkValue { - // A bare string root maps naturally to the link's URL value, so preserve and - // surface it instead of discarding it on the first edit. Other non-object - // roots have no field mapping and normalize to an empty link. if (typeof value === "string") { return value ? { value } : {}; } - // Drop `type`/`target` values outside their documented unions so stored JSON - // cannot diverge from what the controls display (the select/checkbox fall back - // to their defaults) and the alien value clears on the next save. const next = { ...normalizeObjectValue(value) } as Record; + // Strip type/target outside documented unions so controls and storage stay aligned. if (typeof next.type !== "string" || !(LINK_TYPES as readonly string[]).includes(next.type)) { delete next.type; } @@ -310,6 +320,7 @@ export function normalizeLinkValue(value: unknown): LinkValue { return next as LinkValue; } +/** Whether load-time link normalization would change the stored value. */ export function shouldNormalizeLinkValue(value: unknown): boolean { if (typeof value !== "string" && !isJsonRecord(value)) { return false; @@ -320,10 +331,12 @@ export function shouldNormalizeLinkValue(value: unknown): boolean { return !jsonValuesEqual(value, normalizeLinkValue(value)); } +/** Immutable partial merge on a normalized link value. */ export function updateLinkValue(value: unknown, nextValue: Partial): LinkValue { return { ...normalizeLinkValue(value), ...nextValue }; } +/** Normalizes choice config to `{ value, label }` objects and drops duplicate values. */ export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] { const seen = new Set(); const result: FieldsChoice[] = []; @@ -334,14 +347,9 @@ export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoic } else if (typeof choice.value === "string") { normalized = choice; } else if (typeof choice.label === "string") { - // Object choices authored in serialized JSON/YAML can omit the required - // `value`. Synthesize one from a string label when possible, otherwise the - // malformed choice is skipped so a single bad option cannot crash the widget. + // Serialized choices may omit `value`; synthesize from label or skip malformed rows. normalized = { ...choice, value: choice.label }; } - // Selection is keyed by `value`, so two choices sharing a value are a single - // logical token that cannot be selected independently. Drop later duplicates - // to avoid React key collisions and mirrored checked state. if (!normalized || seen.has(normalized.value)) { continue; } @@ -351,6 +359,7 @@ export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoic return result; } +/** Coerces stored choice values to a deduped string selection for the widget mode. */ export function normalizeChoiceSelection(value: unknown, multiple: boolean): string[] { if (multiple) { if (typeof value === "string") { @@ -371,10 +380,12 @@ export function normalizeChoiceSelection(value: unknown, multiple: boolean): str return []; } +/** First string choice for single-select mode, or `""` when none apply. */ export function normalizeSingleChoice(value: unknown): string { return normalizeChoiceSelection(value, false)[0] ?? ""; } +/** Toggles or replaces the stored choice value for single- or multi-select mode. */ export function updateChoiceSelection( value: unknown, choiceValue: string, @@ -450,6 +461,7 @@ function choiceLabel(choice: FieldsChoice, i18n: FieldsI18nConfig) { return localizedString(choice.label, i18n, choice.value); } +/** Parses quoted `"true"` / `"false"` flags from serialized widget options. */ export function coerceBoolean(value: unknown): boolean { if (typeof value === "string") { const normalized = value.trim().toLowerCase(); @@ -458,20 +470,19 @@ export function coerceBoolean(value: unknown): boolean { return Boolean(value); } +/** Checkbox checked state; treats string `"false"` as unchecked. */ export function isBooleanChecked(value: unknown): boolean { return coerceBoolean(value); } +/** Display string for a select subfield; numbers stringify, non-scalars become `""`. */ export function selectSubfieldValue(value: unknown): string { - // Mirror the leniency of text inputs: a stored number (legacy numeric JSON) - // is stringified so it can match a string option value and render as selected, - // instead of always falling back to the blank placeholder. Non-scalar values - // have no option to match and stay blank. if (typeof value === "string") return value; if (typeof value === "number") return String(value); return ""; } +/** Select subfield value kept only when it matches a configured option. */ export function normalizeSelectSubfieldValue( value: unknown, choices: FieldsChoice[] | string[] | undefined, @@ -481,6 +492,7 @@ export function normalizeSelectSubfieldValue( return normalizeChoices(choices).some((choice) => choice.value === nextValue) ? nextValue : ""; } +/** Load-time coercion for one subfield based on its declared type. */ export function normalizeSubfieldStoredValue(field: FieldsSubField, value: unknown): unknown { const type = field.type ?? "text"; if (type === "select") { @@ -492,6 +504,7 @@ export function normalizeSubfieldStoredValue(field: FieldsSubField, value: unkno return value; } +/** Load-time coercion of configured subfield keys on a single object row. */ export function normalizeObjectSubfieldValues( value: unknown, fields: FieldsSubField[], @@ -515,6 +528,7 @@ export function normalizeObjectSubfieldValues( return nextData; } +/** Load-time subfield coercion across every object row in a structure value. */ export function normalizeStructureSubfieldValues( value: unknown, fields: FieldsSubField[], @@ -523,6 +537,7 @@ export function normalizeStructureSubfieldValues( return value.map((item) => (isJsonRecord(item) ? normalizeObjectSubfieldValues(item, fields) : item)); } +/** Whether object subfield normalization would change the stored value. */ export function shouldNormalizeObjectSubfieldValues( value: unknown, fields: FieldsSubField[], @@ -530,6 +545,7 @@ export function shouldNormalizeObjectSubfieldValues( return isJsonRecord(value) && !jsonValuesEqual(value, normalizeObjectSubfieldValues(value, fields)); } +/** Whether structure subfield normalization would change the stored value. */ export function shouldNormalizeStructureSubfieldValues( value: unknown, fields: FieldsSubField[], @@ -537,6 +553,7 @@ export function shouldNormalizeStructureSubfieldValues( return Array.isArray(value) && !jsonValuesEqual(value, normalizeStructureSubfieldValues(value, fields)); } +/** Parses a complete numeric string; returns `undefined` for empty or invalid input. */ export function parseNumericInput(value: string, type: "number" | "integer") { if (value.trim() === "") { return undefined; @@ -554,12 +571,7 @@ export function parseNumericInput(value: string, type: "number" | "integer") { return numericValue; } -/** - * Interpret a stored subfield value as the number it represents for the given - * type, or `undefined` when it is not a valid value. Coerces numeric strings - * (common in quoted YAML/JSON) so they display as numbers, and rejects off-type - * shapes (non-integers for `integer`, non-finite numbers, non-scalars). - */ +/** Coerces stored subfield values to a finite number matching the subfield type. */ export function interpretNumericValue( value: unknown, type: "number" | "integer", @@ -575,20 +587,13 @@ export function interpretNumericValue( return undefined; } +/** Commit decision for a numeric subfield keystroke: set, clear, or hold prior value. */ export type NumericCommit = | { type: "set"; value: number } | { type: "clear" } | { type: "hold" }; -/** - * Decide what a numeric subfield should commit for a raw input string. - * - * - `set`: the draft is a complete, valid number — commit it. - * - `clear`: the draft is empty — commit `undefined`. - * - `hold`: the draft is an in-progress/invalid string (e.g. `"3."`, `"-"`, or - * `"12.3"` for an integer) — keep the previously committed value so partial - * typing neither truncates the input nor wipes existing data. - */ +/** Maps raw input to a commit action without wiping in-progress decimals or minus signs. */ export function numericChangeCommit(raw: string, type: "number" | "integer"): NumericCommit { if (raw.trim() === "") { return { type: "clear" }; @@ -620,9 +625,7 @@ function NumericSubField({ const valueString = committed === undefined ? "" : String(committed); const [draft, setDraft] = useState(valueString); - // Reconcile external value changes into the draft. Only resync when the - // committed prop no longer matches what the draft currently represents, so - // in-progress strings ("3.", "-", "12.3") survive while the user types. + // Resync draft only when the committed prop diverges — not on every keystroke. useEffect(() => { if (parseNumericInput(draft, type) !== committed) { setDraft(valueString); @@ -787,6 +790,7 @@ function summary( }); } +/** JSON object editor with a fixed subfield layout from `options.fields`. */ export function ObjectField({ value, onChange, @@ -812,6 +816,7 @@ export function ObjectField({ ); } +/** Repeatable JSON object rows with add/remove, optional reorder, and min/max guards. */ export function StructureField({ value, onChange, @@ -913,6 +918,7 @@ export const ObjectFormField = ObjectField; /** @deprecated Use StructureField. */ export const ListField = StructureField; +/** Typed link editor for URL, email, tel, entry, and media targets. */ export function LinkField({ value, onChange, @@ -983,6 +989,7 @@ export function LinkField({ ); } +/** Single- or multi-select choice widget with vertical, horizontal, and card layouts. */ export function ChoicesField({ value, onChange, @@ -991,9 +998,7 @@ export function ChoicesField({ options, }: FieldWidgetProps) { const i18n = useFieldI18n(options?.i18n); - // Treat `choices` and `options` as interchangeable sources; fall back to the - // `options` alias when `choices` is absent OR empty (`??` alone lets an empty - // array shadow the alias). + // Empty `choices` must not shadow the `options` alias (`??` alone would). const choicesList = normalizeChoices( options?.choices?.length ? options.choices : options?.options, ); @@ -1123,6 +1128,7 @@ export function ChoicesField({ ); } +/** Admin widget registry keyed by `fieldsWidgets` identifiers. */ export const fields = { object: ObjectField, structure: StructureField, diff --git a/src/i18n.ts b/src/i18n.ts index 648cfec..2dc95f8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,3 +1,9 @@ +/** + * Localization contracts and default copy for fields admin widgets. + * + * Widget options may carry `FieldsI18nConfig`; otherwise labels resolve through + * locale fallback chains and built-in English defaults. + */ export type LocalizedString = string | Record; export type FieldsMessageKey = @@ -77,6 +83,7 @@ export function normalizeLocale(locale: string | null | undefined): string { return (locale ?? DEFAULT_LOCALE).trim() || DEFAULT_LOCALE; } +/** Ordered locale chain: active locale, configured fallbacks, then default. */ export function localeFallbacks(i18n: FieldsI18nConfig | string | null | undefined): string[] { const config = typeof i18n === "string" ? { locale: i18n } : (i18n ?? {}); const defaultLocale = normalizeLocale(config.defaultLocale ?? DEFAULT_FIELDS_I18N.defaultLocale); @@ -100,6 +107,7 @@ export function localeFallbacks(i18n: FieldsI18nConfig | string | null | undefin return chain; } +/** Resolves a localized string or map using the locale fallback chain. */ export function localizedString( value: LocalizedString | null | undefined, i18n: FieldsI18nConfig | string | null | undefined, @@ -122,6 +130,7 @@ export function localizedString( return first ?? fallback; } +/** Interpolates `{token}` placeholders in a localized string template. */ export function formatLocalizedString( value: LocalizedString, i18n: FieldsI18nConfig | string | null | undefined, @@ -134,6 +143,7 @@ export function formatLocalizedString( }); } +/** Built-in widget copy for a message key, with per-locale overrides. */ export function fieldMessage( key: FieldsMessageKey, i18n: FieldsI18nConfig | string | null | undefined, @@ -154,6 +164,7 @@ export function fieldMessage( return DEFAULT_FIELDS_I18N.messages.en[key] ?? key; } +/** Built-in widget copy with `{token}` interpolation. */ export function formatFieldMessage( key: FieldsMessageKey, i18n: FieldsI18nConfig | string | null | undefined, diff --git a/src/index.ts b/src/index.ts index 988f1d4..cb997bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +/** + * EmDash plugin descriptor and public surface for structured JSON field widgets. + * + * Register `fieldsPlugin` or `createPlugin` so the admin loads object, structure, + * link, and choices editors from `@bnomei/emdash-fields/admin`. + */ import { definePlugin, type PluginDescriptor } from "emdash"; import { fieldMessage, type FieldsI18nConfig } from "./i18n"; import { fieldsWidgets } from "./schema"; @@ -42,6 +48,7 @@ export { localizedString, } from "./i18n"; +/** Plugin wiring: package entrypoints and optional widget i18n overrides. */ export type FieldsDescriptorOptions = { entrypoint?: string; adminEntry?: string; @@ -52,6 +59,7 @@ const PLUGIN_ID = "fields"; const PLUGIN_VERSION = "0.2.0"; const PACKAGE_NAME = "@bnomei/emdash-fields"; +/** Native-format plugin descriptor for EmDash field registration. */ export function fieldsPlugin(options: FieldsDescriptorOptions = {}): PluginDescriptor { const entrypoint = options.entrypoint ?? PACKAGE_NAME; const adminEntry = options.adminEntry ?? `${entrypoint}/admin`; @@ -66,6 +74,7 @@ export function fieldsPlugin(options: FieldsDescriptorOptions = {}): PluginDescr }; } +/** `definePlugin` wrapper that registers the four JSON field widgets for admin. */ export function createPlugin(options: Pick = {}) { return definePlugin({ id: PLUGIN_ID, diff --git a/src/schema.ts b/src/schema.ts index 91f0c02..573dad5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,5 +1,12 @@ +/** + * Widget registry keys and typed option builders for field schema authoring. + * + * `fieldsWidgets` names match admin entry keys; builders satisfy option types when + * defining CMS JSON fields. + */ import type { ChoicesOptions, FieldsSubField, ObjectOptions, StructureOptions } from "./types"; +/** Admin widget identifiers referenced from EmDash field definitions. */ export const fieldsWidgets = { object: "fields:object", structure: "fields:structure", @@ -9,6 +16,7 @@ export const fieldsWidgets = { list: "fields:list", } as const; +/** Builds a typed object widget options object with required `fields`. */ export function objectOptions( fields: FieldsSubField[], options: Omit = {}, @@ -16,6 +24,7 @@ export function objectOptions( return { ...options, fields } satisfies ObjectOptions; } +/** Builds a typed structure widget options object with required `fields`. */ export function structureOptions( fields: FieldsSubField[], options: Omit = {}, @@ -32,6 +41,7 @@ export const objectFormOptions = objectOptions; /** @deprecated Use structureOptions. */ export const listOptions = structureOptions; +/** Identity helper that preserves `ChoicesOptions` typing at schema sites. */ export function choicesOptions(options: ChoicesOptions) { return options; } diff --git a/src/types.ts b/src/types.ts index 953662b..7edca1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,9 @@ +/** + * Option and stored-value types for fields plugin widgets. + * + * Options are schema-safe widget configuration on EmDash JSON fields; values are + * the JSON shapes widgets read from persistence and emit through `onChange`. + */ import type { ReactElement } from "react"; import type { FieldsI18nConfig, LocalizedString } from "./i18n"; @@ -24,6 +30,7 @@ export type FieldsChoice = { icon?: string | ReactElement; }; +/** One keyed input inside an object or structure row. */ export type FieldsSubField = { key: string; label: LocalizedString; @@ -34,12 +41,14 @@ export type FieldsSubField = { options?: FieldsChoice[] | string[]; }; +/** Fixed subfield layout for a single JSON object editor. */ export type ObjectOptions = { fields: FieldsSubField[]; helpText?: LocalizedString; i18n?: FieldsI18nConfig; }; +/** Repeatable object rows with optional min/max bounds and reordering. */ export type StructureOptions = { fields: FieldsSubField[]; itemLabel?: LocalizedString; @@ -66,6 +75,7 @@ export type ObjectFormOptions = ObjectOptions; /** @deprecated Use StructureOptions. */ export type ListOptions = StructureOptions; +/** Persisted link payload: type, href value, label text, and target window. */ export type LinkValue = { type?: "url" | "email" | "tel" | "entry" | "media"; value?: string; @@ -77,6 +87,7 @@ export type LinkOptions = { i18n?: FieldsI18nConfig; }; +/** Single- or multi-select choice cards; `options` aliases `choices` when empty. */ export type ChoicesOptions = { choices?: FieldsChoice[] | string[]; options?: FieldsChoice[] | string[]; diff --git a/tests/numeric-input.test.mjs b/tests/numeric-input.test.mjs index b3f8dda..25495bb 100644 --- a/tests/numeric-input.test.mjs +++ b/tests/numeric-input.test.mjs @@ -37,13 +37,10 @@ test("integer input accepts integers and rejects decimals", () => { }); test("numeric interpretation coerces strings and rejects off-type values", () => { - // quoted numeric strings display as numbers assert.equal(interpretNumericValue("42", "number"), 42); assert.equal(interpretNumericValue("42", "integer"), 42); assert.equal(interpretNumericValue("3.14", "number"), 3.14); - // already-valid numbers pass through assert.equal(interpretNumericValue(7, "integer"), 7); - // off-type / invalid shapes clear to undefined assert.equal(interpretNumericValue("3.14", "integer"), undefined); assert.equal(interpretNumericValue(3.14, "integer"), undefined); assert.equal(interpretNumericValue(Infinity, "number"), undefined); @@ -53,17 +50,11 @@ test("numeric interpretation coerces strings and rejects off-type values", () => }); test("numeric commit holds in-progress drafts instead of wiping the value", () => { - // empty clears assert.deepEqual(numericChangeCommit("", "number"), { type: "clear" }); assert.deepEqual(numericChangeCommit(" ", "integer"), { type: "clear" }); - // complete numbers commit assert.deepEqual(numericChangeCommit("3.14", "number"), { type: "set", value: 3.14 }); assert.deepEqual(numericChangeCommit("-5", "integer"), { type: "set", value: -5 }); - // "3." parses to 3, so it commits; the visible "3." is preserved by the - // component's draft state so the user can continue typing "3.14" assert.deepEqual(numericChangeCommit("3.", "number"), { type: "set", value: 3 }); - // lone minus while starting a negative number holds (no commit, no wipe) assert.deepEqual(numericChangeCommit("-", "integer"), { type: "hold" }); - // transient decimal on an integer field holds rather than clearing the value assert.deepEqual(numericChangeCommit("12.3", "integer"), { type: "hold" }); }); diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 1a4019e..141481b 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -91,7 +91,6 @@ test("link values normalize invalid inputs and merge partial updates", () => { assert.deepEqual(normalizeLinkValue(42), {}); assert.deepEqual(normalizeLinkValue(["bad"]), {}); assert.deepEqual(normalizeLinkValue(""), {}); - // a bare string root is preserved as the link's URL value assert.deepEqual(normalizeLinkValue("https://example.com"), { value: "https://example.com" }); assert.deepEqual( @@ -150,15 +149,12 @@ test("choices normalize string and object options", () => { }); test("choices guarantee a string value for object choices", () => { - // string-label fallback when value is missing assert.deepEqual(normalizeChoices([{ label: "Workers AI", icon: "AI" }]), [ { value: "Workers AI", label: "Workers AI", icon: "AI" }, ]); - // malformed choice with no value and non-string label is dropped, valid kept assert.deepEqual(normalizeChoices([{ icon: "AI" }, { value: "ok", label: "Ok" }]), [ { value: "ok", label: "Ok" }, ]); - // every returned choice has a string value for (const choice of normalizeChoices(["a", { label: "B" }, { value: "c" }])) { assert.equal(typeof choice.value, "string"); } From 35a2d0c6fe9178065b4489e77336b054080c746d Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Sat, 27 Jun 2026 16:13:16 +0100 Subject: [PATCH 25/27] Delete .devana directory --- ...T114006Z-P1-multiple-choice-scalar-lost.md | 56 --------- ...625T114007Z-P1-object-invalid-root-lost.md | 55 -------- ...T114008Z-P1-structure-row-coercion-lost.md | 55 -------- ...114009Z-P2-boolean-string-false-checked.md | 55 -------- ...T114010Z-P2-structure-reorder-wrong-row.md | 55 -------- ...4011Z-P2-single-choice-array-deselected.md | 55 -------- ...114012Z-P2-structure-non-array-empty-ui.md | 55 -------- ...5T120226Z-P3-choice-missing-value-crash.md | 119 ------------------ ...60627T180001Z-P1-link-invalid-root-lost.md | 55 -------- ...80002Z-P2-choices-multiple-string-false.md | 56 --------- ...-P2-numeric-keystroke-intermediate-loss.md | 55 -------- ...27T180004Z-P2-link-alien-fields-persist.md | 53 -------- ...005Z-P2-duplicate-choice-value-collapse.md | 61 --------- ...627T180006Z-P2-structure-min-max-bypass.md | 51 -------- ...80007Z-P2-structure-min-gt-max-deadlock.md | 53 -------- ...180008Z-P2-choices-empty-blocks-options.md | 53 -------- ...9Z-P2-select-subfield-non-string-hidden.md | 55 -------- ...010Z-P2-structure-stale-closure-clobber.md | 55 -------- ...011Z-P2-number-subfield-string-persists.md | 58 --------- 19 files changed, 1110 deletions(-) delete mode 100644 .devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md delete mode 100644 .devana/20260625T114007Z-P1-object-invalid-root-lost.md delete mode 100644 .devana/20260625T114008Z-P1-structure-row-coercion-lost.md delete mode 100644 .devana/20260625T114009Z-P2-boolean-string-false-checked.md delete mode 100644 .devana/20260625T114010Z-P2-structure-reorder-wrong-row.md delete mode 100644 .devana/20260625T114011Z-P2-single-choice-array-deselected.md delete mode 100644 .devana/20260625T114012Z-P2-structure-non-array-empty-ui.md delete mode 100644 .devana/20260625T120226Z-P3-choice-missing-value-crash.md delete mode 100644 .devana/20260627T180001Z-P1-link-invalid-root-lost.md delete mode 100644 .devana/20260627T180002Z-P2-choices-multiple-string-false.md delete mode 100644 .devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md delete mode 100644 .devana/20260627T180004Z-P2-link-alien-fields-persist.md delete mode 100644 .devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md delete mode 100644 .devana/20260627T180006Z-P2-structure-min-max-bypass.md delete mode 100644 .devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md delete mode 100644 .devana/20260627T180008Z-P2-choices-empty-blocks-options.md delete mode 100644 .devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md delete mode 100644 .devana/20260627T180010Z-P2-structure-stale-closure-clobber.md delete mode 100644 .devana/20260627T180011Z-P2-number-subfield-string-persists.md diff --git a/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md b/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md deleted file mode 100644 index f4678f1..0000000 --- a/.devana/20260625T114006Z-P1-multiple-choice-scalar-lost.md +++ /dev/null @@ -1,56 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P1 | high | security=no -DEVANA-KEY: src/admin.tsx:248 | multiple-choice-scalar-lost - -# Multiple-choice widget drops scalar stored value on first toggle - -## Finding - -When `ChoicesField` runs with `multiple: true` but the persisted value is a scalar string (for example `"alpha"`), the first checkbox toggle rebuilds selection from an empty set and emits a new array that omits the previously stored choice. - -## Violated Invariant Or Contract - -Multiple-choice mode should preserve existing string selections when the user adds or removes another choice. A stored scalar in multiple mode is a realistic legacy shape after toggling `multiple` or importing older JSON. - -## Oracle - -`updateChoiceSelection` tests in `tests/transformations.test.mjs` cover array inputs only. `normalizeChoiceSelection` is the seed for every toggle path in `ChoicesField`. - -## Counterexample - -1. Widget options: `{ multiple: true, choices: ["alpha", "beta", "gamma"] }`. -2. Persisted `value: "alpha"`. -3. `normalizeChoiceSelection("alpha", true)` returns `[]` because the value is not an array. -4. User checks `"beta"`. -5. `updateChoiceSelection("alpha", "beta", true, true)` returns `["beta"]`. -6. `"alpha"` is lost from stored JSON without an explicit user action to remove it. - -## Why It Might Matter - -Editors can unknowingly drop an existing selection the first time they interact with a migrated or misconfigured field. Downstream templates that still expect `"alpha"` will read the wrong value after a single click. - -## Proof - -Control-flow trace: - -`ChoicesField` (`multiple=true`) → `normalizeChoiceSelection(value, true)` → `[]` for scalar input → `updateChoiceSelection` seeds `Set([])` → first `onChange` emits only newly checked values. - -## Counterevidence Checked - -Multiple-mode filtering of non-string array entries is intentional and tested. No test covers scalar pre-state in multiple mode. The UI never coerces scalar values to arrays on mount. - -## Suggested Next Step - -Coerce scalar strings to one-element arrays in `normalizeChoiceSelection` when `multiple` is true, or normalize `value` once when `ChoicesField` mounts. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. Confirmed `normalizeChoiceSelection(scalar, true)` returned `[]`, dropping the stored value on first toggle. `normalizeChoiceSelection` now coerces a scalar string to a one-element array in multiple mode, so `updateChoiceSelection` seeds from the existing selection. Added regression test in tests/transformations.test.mjs; full suite (23 tests) passes. - -DEVANA-KEY: src/admin.tsx:248 | multiple-choice-scalar-lost -DEVANA-SUMMARY: fixed | P1 | high | Scalar choice values in multiple mode are discarded on the first checkbox toggle. \ No newline at end of file diff --git a/.devana/20260625T114007Z-P1-object-invalid-root-lost.md b/.devana/20260625T114007Z-P1-object-invalid-root-lost.md deleted file mode 100644 index b102ace..0000000 --- a/.devana/20260625T114007Z-P1-object-invalid-root-lost.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: wontfix | P1 | high | security=no -DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost - -# Object field replaces invalid root value on first edit - -## Finding - -`ObjectField` renders invalid non-object root values as `{}`, but the parent state keeps the original value until the user edits a subfield. The first edit calls `updateObjectValue` against a normalized empty object, so the original array or scalar root is replaced by a partial object. - -## Violated Invariant Or Contract - -Invalid persisted values are normalized for display, but the first mutation should not silently delete the previous root shape without an explicit reset path. - -## Oracle - -`tests/transformations.test.mjs` documents that `normalizeObjectValue(["title"])` becomes `{}`, but does not trace the widget save path. - -## Counterexample - -1. Persisted object-field `value: ["title", "Old"]`. -2. `normalizeObjectValue(value)` returns `{}`; all subfields render empty. -3. User types `"New"` into `title`. -4. `updateObjectValue(["title", "Old"], "title", "New")` normalizes to `{}` and returns `{ title: "New" }`. -5. The original array is gone after one keystroke. - -## Why It Might Matter - -A single subfield edit can destroy recoverable malformed JSON that was still present in storage. Editors may not realize data was dropped because the UI already looked empty. - -## Proof - -Dataflow trace: - -invalid root `value` → `normalizeObjectValue` → `{}` for render → first `onChange` via `updateObjectValue` uses normalized `{}` as base → parent receives new object, original root shape lost. - -## Counterevidence Checked - -Normalization to `{}` for non-objects is intentional for display. Widget does not write back normalized values on mount, so the loss only happens on first edit, not on load alone. - -## Suggested Next Step - -Emit a normalized canonical object on mount when the root shape is invalid, or base updates on the raw parent value after explicit migration. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: wontfix. Dataflow confirmed accurate, but the only data that can be "lost" is a non-object root (array/scalar/null), which an object editor cannot represent — there is no key mapping from an array/scalar into named subfields. Verified that *valid* object roots already preserve every key, including ones absent from the fields config (`updateObjectValue({title,extra}, "title", "New")` keeps `extra`). The loss happens only on an explicit edit, never on load, so untouched malformed values remain intact in storage. The suggested mount-time canonicalization would drop the same unrepresentable data earlier while marking every entry with malformed JSON dirty on open — strictly worse UX. No change is data-preserving, so behavior is left intentional. - -DEVANA-KEY: src/admin.tsx:179 | object-invalid-root-lost -DEVANA-SUMMARY: wontfix | P1 | high | First subfield edit replaces an invalid object root with a partial object and drops the original value. \ No newline at end of file diff --git a/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md b/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md deleted file mode 100644 index e3d3171..0000000 --- a/.devana/20260625T114008Z-P1-structure-row-coercion-lost.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: wontfix | P1 | high | security=no -DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost - -# Structure field drops invalid row payloads on first mutation - -## Finding - -`StructureField` coerces non-object rows to `{}` during render, but the parent array keeps the original row values until any structure mutation runs. The first add, remove, move, or row edit emits the normalized array and permanently discards invalid row payloads such as nested arrays. - -## Violated Invariant Or Contract - -Read-side normalization should not cause silent data loss on the first write unless the user explicitly deletes the row. - -## Oracle - -`tests/transformations.test.mjs` asserts invalid rows normalize to `{}` for display. No test covers parent-state divergence before the first `onChange`. - -## Counterexample - -1. Persisted `value: [{ label: "A" }, ["secret"]]`. -2. `normalizeStructureValue(value)` renders `[{ label: "A" }, {}]`; row 2 looks empty. -3. User clicks Add item. -4. `addStructureItem(items)` uses the normalized in-memory array and `onChange` emits `[{ label: "A" }, {}, {}]`. -5. `["secret"]` is removed from stored JSON without an explicit delete of that payload. - -## Why It Might Matter - -Malformed imported rows can vanish after an unrelated structure action. The editor never saw the hidden payload and cannot restore it from the widget. - -## Proof - -Control-flow trace: - -invalid row in parent `value` → `normalizeStructureValue` → `{}` at render → first `updateItems(...)` / `onChange` emits normalized array only → original non-object row never written back. - -## Counterevidence Checked - -Coercion to `{}` is tested and intentional for rendering. The widget does not auto-normalize parent state on mount, so invalid payloads survive until the first mutation. - -## Suggested Next Step - -Normalize and write back structure values on mount when row shapes are invalid, or preserve raw row data until the user edits that specific row. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: wontfix. Same class as object-invalid-root-lost. Verified `addStructureItem([{label:"A"},["secret"]])` yields `[{label:"A"},{},{}]`, dropping the array row, while object rows keep extra keys (`updateStructureItem` preserves `{label,extra}`). The only data lost is a non-object row, which the per-row object editor cannot represent. The library's documented and tested invariant is "structure values normalize every row to an object" — preserving an array/scalar row would mean persisting mixed-type rows that violate that contract and would still render empty and be uneditable. Loss occurs only on an explicit mutation, never on load. The suggested mount-time writeback drops the same unrepresentable data earlier and marks entries dirty on open. Left intentional. - -DEVANA-KEY: src/admin.tsx:187 | structure-row-coercion-lost -DEVANA-SUMMARY: wontfix | P1 | high | First structure mutation persists normalized rows and drops invalid row payloads that were still in parent state. \ No newline at end of file diff --git a/.devana/20260625T114009Z-P2-boolean-string-false-checked.md b/.devana/20260625T114009Z-P2-boolean-string-false-checked.md deleted file mode 100644 index 479bc99..0000000 --- a/.devana/20260625T114009Z-P2-boolean-string-false-checked.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked - -# Boolean subfield treats string "false" as checked - -## Finding - -Object and structure boolean subfields render `checked={Boolean(value)}` without coercing stored JSON to a real boolean. The string `"false"` is truthy in JavaScript, so the checkbox appears checked while the persisted value remains the string `"false"`. - -## Violated Invariant Or Contract - -README documents boolean subfields as storing a `Boolean`. The UI should reflect boolean semantics for persisted values, not generic truthiness. - -## Oracle - -README stored-value table (`boolean` → `Boolean`). `renderSubField` boolean branch in `src/admin.tsx`. - -## Counterexample - -1. Object subfield `enabled` has persisted value `"false"`. -2. `Boolean("false")` evaluates to `true`. -3. Checkbox renders checked. -4. User saves another subfield without toggling `enabled`. -5. Stored JSON still contains `"false"`, and the UI remains checked. - -## Why It Might Matter - -Imported or hand-edited JSON with string booleans shows the opposite state from what consumers expect. Frontend code comparing against `false` will disagree with the admin UI. - -## Proof - -Counterexample value: - -`{ enabled: "false" }` → `renderSubField` boolean branch → `checked={Boolean("false")}` → checked UI with string payload unchanged. - -## Counterevidence Checked - -No boolean normalization exists in `normalizeObjectValue` or mutators. The field only writes a real boolean after the user toggles the checkbox. Numeric truthy values such as `1` have the same display bug. - -## Suggested Next Step - -Normalize boolean subfield values with strict boolean parsing (`value === true || value === "true"`) before rendering and optionally on load. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. The boolean subfield branch now uses a new exported `isBooleanChecked(value)` helper instead of `Boolean(value)`. Strings are interpreted strictly (`"true"`/`"1"` → checked; `"false"`/`"0"`/`""` → unchecked, case/whitespace-insensitive); non-string values keep `Boolean()` semantics. Added a regression test covering string/number/boolean/null inputs. Full suite (24 tests) passes. - -DEVANA-KEY: src/admin.tsx:404 | boolean-string-false-checked -DEVANA-SUMMARY: fixed | P2 | medium | String "false" in boolean subfields renders as checked because the widget uses Boolean(value). \ No newline at end of file diff --git a/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md b/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md deleted file mode 100644 index 566497c..0000000 --- a/.devana/20260625T114010Z-P2-structure-reorder-wrong-row.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: invalid | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row - -# Structure reorder can edit the wrong row after move or delete - -## Finding - -`StructureField` uses `key={index}` for each row and binds row edits to the row index from the render that created the handler. After reordering or deleting another row, focus can remain on the same DOM position while the item at that index changes, so subsequent typing updates a different row than the one the editor was working on. - -## Violated Invariant Or Contract - -Row identity should follow item content through reorder and removal. Index-keyed rows plus index-scoped `updateStructureItem(items, index, ...)` break that invariant when the list order changes between render and edit. - -## Oracle - -Standard React list-key guidance and controlled-field behavior for sortable editors. - -## Counterexample - -1. Structure value `[{ title: "A" }, { title: "B" }, { title: "C" }]`. -2. Editor focuses the `title` input in row B at index `1`. -3. Editor clicks Up on row B; `moveStructureItem(items, 1, 0)` yields `[B, A, C]`. -4. React reuses the component at `key={1}`, which now displays item A. -5. Focus remains in index `1`'s input; further typing calls `updateStructureItem(items, 1, ...)` against the stale render snapshot and writes into item A instead of B. - -## Why It Might Matter - -Sortable structure lists can silently corrupt row data during a common reorder workflow. The visible values may look plausible because controlled props match the row currently at that index, but the editor's intent was applied to the wrong item. - -## Proof - -State transition mismatch: - -focus on row index `1` → reorder changes item at index `1` → same index key and handler target index `1` → subsequent `onChange` updates the wrong item. - -## Counterevidence Checked - -Sequential edits without reorder keep indices stable and behave correctly. Pure helpers such as `moveStructureItem` are immutable and correct in isolation. The bug requires reorder/remove between focus and the next edit event. - -## Suggested Next Step - -Use stable row keys derived from item identity or an internal row id, and resolve the target row by id instead of render-time index. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: invalid. The data-corruption mechanism (a "stale render snapshot" handler writing to the wrong item) does not occur here. `StructureField` keeps no per-row local state: `items` is recomputed from props on every render, the kumo `Input`/`Textarea` are fully controlled via `value`, and `renderSubField`/`renderObjectFields` are pure. After `moveStructureItem` re-renders the component, `.map` re-runs and every input receives a fresh `onChange` closure bound to the current `items` and `index`, so the input at a given slot always displays AND edits the item currently at that slot — consistent, not corrupting. Counterexample step 5 ("writes into A instead of B") is therefore false: there is no persisted stale closure across renders. Separately, reorder is triggered by clicking the Up/Down `Button`, which moves focus to the button, so the premise "focus remains in the input" cannot arise through the provided UI. Index keys are non-ideal style but harmless with controlled inputs; a stable-id refactor would add a parallel-state-sync surface with no correctness benefit here. - -DEVANA-KEY: src/admin.tsx:520 | structure-reorder-wrong-row -DEVANA-SUMMARY: invalid | P2 | medium | Index-keyed structure rows can apply edits to the wrong item after reorder or delete while focus stays on the same slot. \ No newline at end of file diff --git a/.devana/20260625T114011Z-P2-single-choice-array-deselected.md b/.devana/20260625T114011Z-P2-single-choice-array-deselected.md deleted file mode 100644 index 2b9bb45..0000000 --- a/.devana/20260625T114011Z-P2-single-choice-array-deselected.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected - -# Single-choice widget ignores array stored values - -## Finding - -When `ChoicesField` runs with `multiple: false`, a persisted array value such as `["alpha"]` is treated as unselected. The UI shows no checked choice, but the parent state keeps the array until the user picks a new option. - -## Violated Invariant Or Contract - -Single-choice mode should either coerce a one-element array to its string value or surface the stored selection. Showing an empty selection while the parent still holds an array breaks the widget's read/write contract. - -## Oracle - -`normalizeChoiceSelection` and `ChoicesField` radio rendering (`value={typeof value === "string" ? value : ""}`). Tests cover scalar single-mode values only. - -## Counterexample - -1. Widget options: `{ choices: ["alpha", "beta"], multiple: false }`. -2. Persisted `value: ["alpha"]`. -3. `normalizeChoiceSelection(["alpha"], false)` returns `[]`. -4. `Radio.Group` receives `value=""`; nothing appears selected. -5. User saves without choosing again; parent state can remain `["alpha"]`. - -## Why It Might Matter - -Legacy multiple-choice data or config changes from `multiple: true` to `false` leave the field looking blank while stored JSON still contains an array. Frontend consumers and the admin UI disagree about the current value. - -## Proof - -Contract mismatch: - -stored `string[]` in single mode → `normalizeChoiceSelection` accepts only `string` → empty selection in UI → no mount-time write-back → array persists unchanged. - -## Counterevidence Checked - -Multiple-mode non-string filtering is intentional and tested. The UI never normalizes legacy shapes on mount. Horizontal and vertical choice renderers share the same selection normalization. - -## Suggested Next Step - -When `multiple` is false, coerce a one-element string array to its sole element for display and initial `onChange` normalization. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. A one-element (or multi-element) string array is a representable legacy shape (e.g. after switching `multiple: true → false`), so it is coerced to its first string value. `normalizeChoiceSelection(value, false)` now extracts the first string element from an array, which fixes the horizontal single-choice renderer's `selected` set. The vertical `Radio.Group` derived its value separately (`typeof value === "string" ? value : ""`) and is now fed by a new exported `normalizeSingleChoice(value)` helper so it reflects the coerced selection too. `updateChoiceSelection`'s single-mode deselect path also benefits via the same normalization. Added regression tests. Full suite (25 tests) passes. - -DEVANA-KEY: src/admin.tsx:255 | single-choice-array-deselected -DEVANA-SUMMARY: fixed | P2 | medium | Single-choice fields show no selection for array stored values while the array remains in parent state. \ No newline at end of file diff --git a/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md b/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md deleted file mode 100644 index 4be5968..0000000 --- a/.devana/20260625T114012Z-P2-structure-non-array-empty-ui.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: wontfix | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui - -# Structure field hides non-array persisted values - -## Finding - -When a structure field's persisted value is not an array, `normalizeStructureValue` returns `[]` for rendering and the widget shows zero rows. The parent state keeps the original object or scalar until the user performs a structure action, so valid data can be hidden from editors without any warning. - -## Violated Invariant Or Contract - -Structure widgets should either surface malformed persisted data for correction or normalize it back to the parent on load. Rendering an empty list while storage still holds a non-array value breaks the editor's view of stored content. - -## Oracle - -`normalizeStructureValue` behavior tested in `tests/transformations.test.mjs` for invalid inputs, without a widget save-path test. - -## Counterexample - -1. Persisted structure value `{ "0": { label: "A" }, "1": { label: "B" } }` (object map instead of array). -2. `normalizeStructureValue(value)` returns `[]`. -3. `StructureField` renders no rows and no error beyond an empty list. -4. User saves the entry without adding a row. -5. Parent state can remain the original object map, invisible in admin UI. - -## Why It Might Matter - -Imported JSON with the wrong top-level shape looks like an empty field, so editors may add new rows on top of hidden data or publish content believing the structure is blank. - -## Proof - -Dataflow trace: - -non-array persisted `value` → `normalizeStructureValue` → `[]` at render → no mount-time `onChange` → original non-array value remains in parent state while UI shows emptiness. - -## Counterevidence Checked - -Normalization to `[]` for non-array input is intentional for helper semantics. Add/remove controls operate only on the normalized in-memory array. The bug is the parent/UI divergence, not the helper alone. - -## Suggested Next Step - -Detect non-array structure values on mount and either migrate them into an array shape with an explicit `onChange`, or render a recovery warning with the raw value. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: wontfix. Same class as object-invalid-root-lost and structure-row-coercion-lost. The dataflow is accurate, but the persisted value is left intact on load (no mount-time write), so nothing is lost unless the editor performs an explicit action. The two suggested remedies are both undesirable here: (1) mount-time migration via `onChange` marks every entry with malformed JSON dirty on open and would still need a fragile heuristic to tell an "index map" (`{"0":{...}}`) apart from a normal object, risking mis-migration of legitimate values; (2) a raw-value recovery warning is a new UI/i18n feature, not a correctness fix, and outside the scope of this widget's normalize-for-display contract. Left intentional, consistent with the related root-coercion findings. - -DEVANA-KEY: src/admin.tsx:187 | structure-non-array-empty-ui -DEVANA-SUMMARY: wontfix | P2 | medium | Non-array structure values render as an empty list while the original persisted value remains hidden in parent state. \ No newline at end of file diff --git a/.devana/20260625T120226Z-P3-choice-missing-value-crash.md b/.devana/20260625T120226Z-P3-choice-missing-value-crash.md deleted file mode 100644 index 1f5576d..0000000 --- a/.devana/20260625T120226Z-P3-choice-missing-value-crash.md +++ /dev/null @@ -1,119 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P3 | medium | security=no -DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash - -# Choice object without `value` crashes the whole choices widget - -## Finding - -`normalizeChoices` normalizes *string* choices into `{ value, label }`, but passes -*object* choices through unchanged: - -```ts -export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] { - return (value ?? []).map((choice) => - typeof choice === "string" ? { value: choice, label: choice } : choice, - ); -} -``` - -So a choice object that omits `value` (e.g. `{ label: "X" }`) survives with -`choice.value === undefined`. Downstream, `ChoicesField` renders it through -`choiceInputId(id, choice.value, index)` at `src/admin.tsx:684` (horizontal) and -`src/admin.tsx:737` (vertical multiple), and `choiceInputId` does: - -```ts -function choiceInputId(id: string, value: string, index: number) { - const safeValue = value.replace(/[^a-zA-Z0-9_-]/g, "-") || "choice"; - ... -} -``` - -`undefined.replace(...)` throws `TypeError: Cannot read properties of undefined`, -which propagates out of render and crashes the entire field widget (and any -parent that does not catch it), not just the malformed row. In the single -(non-multiple, non-horizontal) `Radio.Group` branch it does not throw but renders -a `Radio.Item` with `value={undefined}`, producing a non-selectable, broken option. - -## Violated Invariant Or Contract - -`normalizeChoices` is named and used as the normalization boundary for choices: -its return type is `FieldsChoice[]`, and every consumer assumes each element has a -string `value` (used as React `key`, DOM `id` seed, and selection key). The -function enforces this for string inputs but not for object inputs, so the -post-normalization invariant "every choice has a string `value`" does not hold. - -## Oracle - -- Neighboring implementation: the string branch of `normalizeChoices` itself - synthesizes `value`, showing the intended contract is "produce a usable - `value`". The object branch silently breaks that. -- `choiceInputId` (`src/admin.tsx:292`) types its second parameter as `string` - and calls `.replace` on it with no guard — it trusts the normalization step. -- `FieldsChoice` (`src/types.ts:13`) declares `value: string` as required, so all - downstream code assumes it is present. - -## Counterexample - -Schema config authored as serialized JSON/YAML (where TypeScript's required -`value` is not enforced): - -```json -{ - "widget": "fields:choices", - "options": { "choices": [{ "label": "Workers AI", "icon": "AI" }] } -} -``` - -Rendering this widget in the default (multiple) or horizontal layout throws -`TypeError` at `choiceInputId` and the admin field crashes on first paint. - -## Why It Might Matter - -A single mistyped choice (missing `value`) takes down the whole admin field -widget rather than degrading that one option. The README documents authoring -choices in serialized JSON schema, where the TypeScript `value: string` -requirement provides no protection, so this is reachable from ordinary -content-model authoring. Availability/correctness impact on the admin UI. - -## Proof - -Dataflow trace: `options.choices` (object missing `value`) -> -`normalizeChoices` object branch returns it unmodified (`src/admin.tsx:244`) -> -`ChoicesField` maps choices and calls `choiceInputId(id, choice.value, index)` -(`src/admin.tsx:684` / `:737`) -> `value.replace(...)` on `undefined` -(`src/admin.tsx:293`) -> `TypeError` escapes render. - -## Counterevidence Checked - -- `FieldsChoice.value` is typed required, so well-typed TS callers cannot hit - this. Counter: EmDash schema config is commonly serialized JSON/YAML (README - "Examples" / "Choice Icons" sections show JSON choices), where the type guard - does not apply; the string branch's own value-synthesis shows loose input was - anticipated. -- Single (`Radio.Group`) branch does not call `choiceInputId`, so it does not - crash — but it still renders a broken, non-selectable item, so the invariant - violation is real across all three layouts. -- Strongest reason this might be false: it is arguably "garbage-in" config error - rather than a logic defect. It is kept P3 for that reason, but the asymmetry - with the string branch and the hard crash (vs. graceful skip) make it - actionable. - -## Suggested Next Step - -In `normalizeChoices`, drop or repair object choices lacking a string `value` -(e.g. filter them out, or default `value` from `label`), or guard -`choiceInputId` against a non-string `value`. Smallest fix: coerce/guard in -`normalizeChoices` so the post-normalization invariant holds for all branches. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-25: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. Confirmed `normalizeChoices` passed object choices through untouched, so `{label:"X"}` reached `choiceInputId(id, undefined, index)` → `undefined.replace(...)` → TypeError crashing the widget in the horizontal/multiple layouts (and a broken non-selectable item in single mode). `normalizeChoices` now enforces the post-normalization invariant "every choice has a string `value`": object choices with a string value pass through; those missing it synthesize a value from a string `label` (mirroring the string branch); otherwise the malformed choice is dropped via `flatMap` so one bad option degrades gracefully instead of crashing. Added regression tests; typecheck clean; full suite (26 tests) passes. - -DEVANA-KEY: src/admin.tsx:242 | choice-missing-value-crash -DEVANA-SUMMARY: fixed | P3 | medium | A choice object missing `value` survives normalizeChoices and crashes the choices widget at choiceInputId in two of three layouts. diff --git a/.devana/20260627T180001Z-P1-link-invalid-root-lost.md b/.devana/20260627T180001Z-P1-link-invalid-root-lost.md deleted file mode 100644 index ca16b5a..0000000 --- a/.devana/20260627T180001Z-P1-link-invalid-root-lost.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P1 | high | security=no -DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost - -# Link field first edit drops invalid scalar root - -## Finding - -When a link field's persisted value is a non-object root (for example a bare URL string), the widget normalizes it to `{}` for display but merges edits against that empty object instead of the original root. The first subfield edit replaces the stored value with a partial object and silently discards the original payload. - -## Violated Invariant Or Contract - -`updateLinkValue` is exported as a deterministic link transformer (`CHANGELOG.md`, `tests/transformations.test.mjs`). A user edit should extend the current stored value, not replace an invalid root with a smaller object on the first keystroke. - -## Oracle - -`tests/transformations.test.mjs` covers `normalizeLinkValue("bad")` → `{}` and valid merges, but not the widget save path. `LinkField` reads via `normalizeLinkValue(value)` and writes via `updateLinkValue(data, nextValue)` where `data` is the normalized render snapshot. - -## Counterexample - -1. Persisted link field value: `"https://example.com"` (scalar string root). -2. `LinkField` renders with `data = {}`; value/text inputs appear empty. -3. User types link text `"Home"` without touching the value input. -4. `onChange` emits `{ text: "Home" }`. -5. Original `"https://example.com"` is gone after one edit. - -## Why It Might Matter - -Imported or legacy JSON with scalar link roots can lose URLs or other metadata the editor never surfaced, causing silent data loss on the first save after opening the entry. - -## Proof - -Control-flow trace: invalid root `value` → `normalizeLinkValue` → `{}` at render → `update({ text })` calls `updateLinkValue(data, { text })` with normalized `data`, not raw `value` → parent receives `{ text: "Home" }` instead of merged link object. - -Locations: `normalizeLinkValue` (234–236), `updateLinkValue` (238–240), `LinkField` closure `data` and `update` (596–601). - -## Counterevidence Checked - -`normalizeLinkValue("bad")` → `{}` is intentional helper behavior. No mount-time write-back occurs, so data survives until first edit — same class as `object-invalid-root-lost`, but this is a separate `LinkField` code path not covered by that report. - -## Suggested Next Step - -Align `LinkField.update` with `updateLinkValue(value, nextValue)` using the raw prop, or seed an initial normalized object via `onChange` on mount when the root is invalid. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. Unlike object-invalid-root-lost / structure-row-coercion-lost (wontfix, because an array/scalar root has no field mapping in an object editor), a link field has a natural mapping: a bare string root IS the URL value. `normalizeLinkValue` now coerces a non-empty string root to `{ value }`, so the URL is both surfaced in the value input on load and preserved when other subfields are edited (`updateLinkValue({value}, {text})` → `{value, text}`). Other non-object roots (number/array/etc.) still normalize to `{}`. Updated the existing normalization test (string root is no longer dropped; uses number/array/"" for the empty cases) and added a scalar-root-through-edit regression test. Full suite (27 tests) passes. - -DEVANA-KEY: src/admin.tsx:596 | link-invalid-root-lost -DEVANA-SUMMARY: fixed | P1 | high | First link subfield edit replaces an invalid scalar root with a partial object and drops the original value. \ No newline at end of file diff --git a/.devana/20260627T180002Z-P2-choices-multiple-string-false.md b/.devana/20260627T180002Z-P2-choices-multiple-string-false.md deleted file mode 100644 index aefd8a0..0000000 --- a/.devana/20260627T180002Z-P2-choices-multiple-string-false.md +++ /dev/null @@ -1,56 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false - -# Choices widget treats string "false" as multiple mode - -## Finding - -`ChoicesField` derives multi-select mode with `Boolean(options?.multiple)`. In serialized JSON, `"multiple": "false"` is a non-empty string and is therefore truthy in JavaScript. The widget enters checkbox/multi mode and emits array payloads even when the schema author intended single-select. - -## Violated Invariant Or Contract - -`ChoicesOptions.multiple` is a boolean flag (`src/types.ts`). `multiple: false` must select radio/single mode and scalar `onChange` strings. - -## Oracle - -`ChoicesOptions.multiple?: boolean` in `src/types.ts`. `Boolean("false") === true` is standard JavaScript semantics. Distinct from `boolean-string-false-checked`, which affects boolean subfields via `Boolean(value)` at render time. - -## Counterexample - -Schema options: `{ "multiple": "false", "choices": ["alpha", "beta"] }` (string, not boolean). - -1. `ChoicesField` sets `multiple = true`. -2. Widget renders checkboxes instead of radios. -3. User selects `"alpha"`. -4. `onChange(["alpha"])` instead of `onChange("alpha")`. - -## Why It Might Matter - -Hand-edited YAML/JSON configs often quote booleans as strings. Frontend templates expecting a scalar choice string receive an array, breaking conditionals and display logic after a seemingly correct schema fix. - -## Proof - -Contract mismatch: caller supplies `multiple: "false"` (string) → `Boolean(options?.multiple)` → `true` → `updateChoiceSelection(..., true)` → array payloads for the session. - -Location: `ChoicesField` line 669. - -## Counterevidence Checked - -TypeScript types `multiple` as `boolean` only; no runtime coercion in this package. Tests (`test/semantics.test.mjs`) use boolean `multiple: true`. EmDash may coerce options before props reach the widget — not visible in this repo. - -## Suggested Next Step - -Normalize with strict boolean parsing (`options?.multiple === true`) or reject non-boolean `multiple` at the widget boundary. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. `ChoicesField` now derives `multiple` via a shared `coerceBoolean(options?.multiple)` instead of `Boolean(...)`, so quoted booleans from serialized config are parsed correctly: `"false"`/`"0"`/`""` → single mode, `"true"`/`"1"`/`true` → multi mode. Note the report's suggested strict `=== true` was not used because it would also break the (equally common) quoted `"true"` case by forcing single mode. The coercion logic is shared with the boolean-subfield checkbox fix: `coerceBoolean` is the implementation and `isBooleanChecked` delegates to it. Added a regression test; typecheck clean; full suite (28 tests) passes. See [[boolean-string-false-checked]]. - -DEVANA-KEY: src/admin.tsx:669 | choices-multiple-string-false -DEVANA-SUMMARY: fixed | P2 | medium | String `"false"` for `options.multiple` enables multi-select mode and array payloads because `Boolean("false")` is true. \ No newline at end of file diff --git a/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md b/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md deleted file mode 100644 index b8a0a41..0000000 --- a/.devana/20260627T180003Z-P2-numeric-keystroke-intermediate-loss.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss - -# Number and integer subfields reject in-progress numeric input - -## Finding - -Object and structure subfields of type `number` or `integer` parse the full input string on every `change` event via `parseNumericInput` and immediately write the parsed result back as the controlled `value`. Valid intermediate typing states are converted or rejected, blocking decimal entry digit-by-digit and wiping integer fields on partial decimals. A lone minus sign for negative integers is also rejected. - -## Violated Invariant Or Contract - -README documents `number` and `integer` subfields as storing a `Number`, or `undefined` when empty. Editors must allow users to reach valid negative and decimal numbers through normal keyboard entry, not only paste. - -## Oracle - -`tests/numeric-input.test.mjs` validates `parseNumericInput` on finished strings (including `"-9007199254740991"`) but not per-keystroke widget flow. `readInputValue` always calls `parseNumericInput` for `type="number"` inputs (350–361). Controlled `value` at line 383 re-renders from parent state after each parse. - -## Counterexample - -**Decimal on `number` subfield:** User types `3` then `.` to enter `3.14`. `parseNumericInput("3.", "number")` returns `3`; controlled input shows `3`; the decimal separator cannot be entered. - -**Integer wipe:** Stored `count: 12`, user edits toward `13` but transiently produces `"12.3"`. `parseNumericInput("12.3", "integer")` returns `undefined`; `onChange(undefined)` clears the field. - -**Negative integer:** Empty integer field, user types `-` first. `parseNumericInput("-", "integer")` → `Number("-")` is `NaN` → `undefined`; minus is dropped before trailing digits. - -## Why It Might Matter - -Editors cannot reliably enter decimals or negative integers by typing. Existing integer values can be erased by a single mistyped decimal keystroke, causing silent data loss before save. - -## Proof - -Dataflow trace: keystroke → `readInputValue` → `parseNumericInput` → `onChange(parsed)` → controlled `value` re-render removes in-progress string state. - -Locations: `parseNumericInput` (333–347), `readInputValue` (358–359), `renderSubField` `commonProps.value` (383–385). - -## Counterevidence Checked - -Pasting a complete value like `3.14` or `-5` in one event succeeds. `tests/numeric-input.test.mjs` covers finished strings only. Some browsers may not surface invalid partials to `onChange`, but the widget layer always parses on change with no draft-state buffer. - -## Suggested Next Step - -Keep a local string draft for numeric inputs and commit parsed numbers on blur or when `parseNumericInput` matches the full input without truncation. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. Number/integer subfields now render via a new `NumericSubField` component that holds a local string draft (the report's suggested fix). The input is `type="text"` with `inputMode` set ("numeric"/"decimal") so the browser cannot sanitize an in-progress draft away — a controlled `type=number` literally cannot display "3." or "-". The pure, exported `numericChangeCommit(raw, type)` decides the action: `clear` on empty, `set` on a complete valid number, and `hold` (no onChange) for in-progress invalid drafts ("-" , and "12.3" on an integer field) so existing values are never wiped. The draft preserves the visible string while the committed value tracks the parsed number, so decimals/negatives can be typed digit-by-digit; on blur the draft re-syncs to the committed value to clean up dangling separators. A `useEffect` reconciles external value changes into the draft without clobbering active typing. Tradeoff: native number spinners are gone (text input), but `parseNumericInput` still enforces integer/number validity. Added unit tests for `numericChangeCommit`; the SSR semantics test (number subfield) still renders with zero Kumo warnings; typecheck clean; full suite (29 tests) passes. - -DEVANA-KEY: src/admin.tsx:358 | numeric-keystroke-intermediate-loss -DEVANA-SUMMARY: fixed | P2 | medium | Per-keystroke numeric parsing blocks decimal and negative entry and can wipe integer values on partial decimals. \ No newline at end of file diff --git a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md b/.devana/20260627T180004Z-P2-link-alien-fields-persist.md deleted file mode 100644 index a2012e9..0000000 --- a/.devana/20260627T180004Z-P2-link-alien-fields-persist.md +++ /dev/null @@ -1,53 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist - -# Link field preserves invalid type and target values - -## Finding - -`normalizeLinkValue` and `updateLinkValue` pass through any string `type` and `target` without validating against the `LinkValue` union. The UI can show defaults that do not match persisted JSON, and saves without touching those controls leave alien values in stored data. - -## Violated Invariant Or Contract - -`LinkValue.type` is `"url" | "email" | "tel" | "entry" | "media"` and `LinkValue.target` is `"_blank" | "_self"` (`src/types.ts`). Helpers and the widget should normalize or surface invalid enum members. - -## Oracle - -`LinkValue` type definitions in `src/types.ts`. `LinkField` type select uses `value={data.type ?? "url"}` (616) with a fixed item list. Target checkbox uses `checked={data.target === "_blank"}` (648) and only writes `"_blank"` or `"_self"` on toggle. - -## Counterexample - -**Invalid type:** Load `{ type: "javascript", value: "https://example.com" }`. Select shows `"javascript"` with no matching item. User edits value text and saves; `type: "javascript"` persists. - -**Invalid target:** Load `{ type: "url", value: "https://x.test", target: "_parent" }`. Checkbox is unchecked (`!== "_blank"`). User saves without toggling; `target: "_parent"` persists. - -## Why It Might Matter - -Imported JSON with out-of-union `type` or `target` values survives round-trips through the admin UI. Frontend link renderers expecting the documented unions may mis-handle `_parent` or unknown types. - -## Proof - -Read-path pass-through: `normalizeLinkValue` is `normalizeObjectValue(value) as LinkValue` with no enum check (234–236). Write-path shallow merge in `updateLinkValue` (238–240). UI controls do not normalize alien values on mount or on unrelated edits. - -## Counterevidence Checked - -`tests/transformations.test.mjs` tests invalid roots and valid merges only. `onValueChange` cast `as LinkValue["type"]` (617) adds no runtime guard. Normal UI interaction through the fixed select only produces valid types. - -## Suggested Next Step - -Validate and coerce `type`/`target` in `normalizeLinkValue`, or normalize on mount when values fall outside the union. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. `normalizeLinkValue` now validates `type` against `["url","email","tel","entry","media"]` and `target` against `["_blank","_self"]`, deleting any value outside the union. This stops stored JSON from diverging from the controls: an alien `type` (e.g. `"javascript"`) is dropped so the select shows its `"url"` default, and an alien `target` (e.g. `"_parent"`) is dropped so the unchecked checkbox matches storage; both clear on the next save. `value`/`text` and any unknown extra keys are preserved (spread, then targeted deletes). Since `LinkField` reads through `normalizeLinkValue` and `updateLinkValue` merges onto that normalized base, the write path is clean too. Added a regression test; typecheck clean; full suite (30 tests) passes. See [[link-invalid-root-lost]]. -- 2026-06-27: reopened. The enum validation fixes the edit path but does not block the original untouched-save case. `LinkField` derives a normalized local `data` snapshot from `normalizeLinkValue(value)`, but it does not emit `onChange` on mount. If a loaded value contains `{ target: "_parent" }` and the editor saves without changing the link field, the parent state can still persist the raw alien target. Evidence checked: `normalizeLinkValue` deletes invalid keys, `LinkField` reads through it, and `update` only runs from field handlers. -- 2026-06-27: fixed. `LinkField` now runs load-time normalization for representable link values whose normalized JSON differs from the raw prop. `shouldNormalizeLinkValue` gates this to non-empty string URL roots and object records, and `useNormalizedOnChange` emits the cleaned `normalizeLinkValue(value)` once. The original untouched-save case is blocked because `{ target: "_parent" }` now triggers `onChange({ ...without target... })` without requiring a link field edit. Added a helper regression test covering alien records, valid records, bare URL strings, and unrepresentable roots. - -DEVANA-KEY: src/admin.tsx:234 | link-alien-fields-persist -DEVANA-SUMMARY: fixed | P2 | medium | Invalid link `type` and `target` strings pass through normalization and survive save without user correction. diff --git a/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md b/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md deleted file mode 100644 index d790fdc..0000000 --- a/.devana/20260627T180005Z-P2-duplicate-choice-value-collapse.md +++ /dev/null @@ -1,61 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse - -# Duplicate choice values collapse selection and React keys - -## Finding - -`ChoicesField` keys each choice row with `key={choice.value}` and tracks selection with `selected.has(choice.value)`. When two configured choices share the same `value`, React key collisions occur and both rows share one selection token — checking or unchecking one row affects all rows with that value. - -## Violated Invariant Or Contract - -Each rendered choice should be an independent selectable option. Distinct labels with the same `value` must not mirror checked state or collapse into one logical token. - -## Oracle - -`FieldsChoice.value: string` has no uniqueness constraint, but `ChoicesField` uses `value` as both React list key and selection set member. `test/semantics.test.mjs` dedupes DOM ids via index in `choiceInputId`, not selection identity. - -## Counterexample - -```json -{ - "multiple": true, - "choices": [ - { "value": "plan-a", "label": "Plan A" }, - { "value": "plan-a", "label": "Plan B" } - ] -} -``` - -Stored `value: []`. User checks "Plan B" → `onChange(["plan-a"])`. Both cards render checked. User unchecks "Plan A" → `onChange([])`; both unchecked. User cannot independently select Plan A vs Plan B. - -## Why It Might Matter - -Misconfigured or generated schemas with duplicate `value`s produce a broken editor where distinct options cannot be controlled separately, and saved arrays cannot represent per-label selection. - -## Proof - -State trace: duplicate `value` → `key="plan-a"` collision → `Set(["plan-a"])` → `selected.has("plan-a")` true for both rows → single `updateChoiceSelection` token drives all matching rows. - -Locations: horizontal layout (688, 683), vertical multiple (740, 744), `normalizeChoiceSelection` Set dedup (248–252). - -## Counterevidence Checked - -`normalizeChoices` does not enforce unique values. README examples use distinct values but does not forbid duplicates. `choiceInputId` uses index for DOM ids, so id collision is avoided — the bug is selection/key semantics, not id attributes. - -## Suggested Next Step - -Key rows by index (or generated stable ids) and track selection per row, or reject duplicate `value`s at `normalizeChoices` with a visible configuration error. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed via dedup (the report's second suggested remedy). Independent per-row selection is impossible to fix the other way: the persisted value is an array of value strings, so two choices sharing a value are one logical token and `["plan-a"]` cannot encode "Plan A selected but not Plan B". Keying rows by index would still collapse selection at the storage layer. `normalizeChoices` now drops later duplicate values (first occurrence wins), eliminating the React key collision and the mirrored checked state, and matching how `normalizeChoiceSelection` already dedupes the selection set. The missing-value repair from the choice-missing-value-crash fix is preserved. Added a regression test; typecheck clean; full suite (31 tests) passes. See [[choice-missing-value-crash]]. - -DEVANA-KEY: src/admin.tsx:688 | duplicate-choice-value-collapse -DEVANA-SUMMARY: fixed | P2 | medium | Duplicate choice `value`s share React keys and one selection token, so distinct labels cannot be toggled independently. \ No newline at end of file diff --git a/.devana/20260627T180006Z-P2-structure-min-max-bypass.md b/.devana/20260627T180006Z-P2-structure-min-max-bypass.md deleted file mode 100644 index 0d25336..0000000 --- a/.devana/20260627T180006Z-P2-structure-min-max-bypass.md +++ /dev/null @@ -1,51 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: wontfix | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass - -# Structure min and max not enforced on loaded values - -## Finding - -`StructureField` applies `options.min` and `options.max` only to Add/Remove button `disabled` state. Persisted or parent-supplied `value` arrays outside those bounds are rendered and saved as-is. Subfield edits call `onChange` with the full unclamped array. - -## Violated Invariant Or Contract - -`StructureOptions.min` and `max` imply a cardinality floor and ceiling. Remove disabled when `items.length <= min` (537) and add disabled when `items.length >= max` (572) suggest counts should stay within bounds once configured. - -## Oracle - -`StructureOptions.min?: number` and `max?: number` in `src/types.ts`. Button guards reference both limits. `normalizeStructureValue` and structure helpers have no min/max awareness. - -## Counterexample - -**Above max:** `options.max = 2`, `value = [{}, {}, {}]`. UI shows three rows; add is disabled but remove is allowed. User edits a subfield in row 1 and saves → `onChange` emits three rows. - -**Below min:** `options.min = 3`, `value = []`. UI shows zero rows; remove/add guards do not seed rows. User saves without clicking Add → parent remains `[]`. - -## Why It Might Matter - -Imported content or API updates can leave structure fields outside configured limits. The editor presents and persists out-of-bound row counts without normalization or warning. - -## Proof - -Control-flow trace: `items = normalizeStructureValue(value)` (504) with no clamp → handlers call `onChange(nextItems)` directly (513–515) → persisted length unchanged vs `min`/`max`. - -## Counterevidence Checked - -README does not document `min`/`max` semantics explicitly. Limits may be intended as interactive hints only. Widget still disables both add and remove in conflicting configs (see separate `structure-min-gt-max-deadlock` report) rather than ignoring limits entirely. - -## Suggested Next Step - -Clamp or validate `items.length` against `min`/`max` on mount and before each `onChange`, or document that limits apply only to button actions. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: wontfix (code) + documented. The suggested auto-clamp is harmful: trimming an above-`max` array silently deletes loaded rows (the same silent data loss the P1 root-coercion findings flag), and padding a below-`min` array seeds empty rows that mark the entry dirty on open with no user action. The current design is the safe one — `min`/`max` gate the interactive controls (Add disabled at `max`, Remove disabled at `min`) while still letting the editor reach compliance (Remove stays enabled above `max`, Add stays enabled below `min`), and externally supplied out-of-bounds arrays are shown rather than mutated. Took the report's second remedy: documented these semantics in README.md (min/max constrain the controls, are not enforced on supplied values). Build/typecheck unaffected (docs only). - -DEVANA-KEY: src/admin.tsx:504 | structure-min-max-bypass -DEVANA-SUMMARY: wontfix | P2 | medium | Structure `min`/`max` gate buttons only; loaded or edited arrays outside bounds persist unchanged. \ No newline at end of file diff --git a/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md b/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md deleted file mode 100644 index 039a270..0000000 --- a/.devana/20260627T180007Z-P2-structure-min-gt-max-deadlock.md +++ /dev/null @@ -1,53 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock - -# Structure min greater than max deadlocks the editor - -## Finding - -When `options.min` exceeds `options.max`, the structure widget can reach a row count where both Add and Remove are disabled simultaneously. The editor cannot move toward satisfying `min` without violating `max`, and no source path warns about or rejects the misconfiguration. - -## Violated Invariant Or Contract - -When both `min` and `max` are set, the widget should allow some reachable item count within both bounds, or reject invalid configuration. Independent `<= min` / `>= max` checks with no reconciliation create an impossible interactive state. - -## Oracle - -`StructureOptions` exposes both as optional `number` with no `min <= max` validation. Remove guard: `items.length <= options.min` (537). Add guard: `items.length >= options.max` (572). - -## Counterexample - -`options: { min: 5, max: 2, fields: [...] }`, `value: []`. - -1. User adds rows until add disables at length 2 (`2 >= 2`). -2. At length 2, remove is disabled (`2 <= 5`). -3. Editor is stuck at 2 items while `min` requires 5. - -## Why It Might Matter - -A single schema typo (`min: 5, max: 2`) makes the structure field uneditable for cardinality changes, blocking content authors without a clear error message. - -## Proof - -State-transition trace: length 2 with `min: 5, max: 2` → add disabled by max → remove disabled by min → no transition increases or decreases row count. - -## Counterevidence Checked - -Misconfigured bounds may be treated as author error outside widget scope. Source actively disables both actions rather than ignoring bad config, producing a reachable deadlock in the admin UI. - -## Suggested Next Step - -Validate `min <= max` when both are set and show a configuration error, or derive effective bounds with `Math.min`/`Math.max`. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed via the report's "derive effective bounds" remedy rather than a config-error message (a hard error would block all editing, and a new i18n key carries locale/test surface). Added a pure exported `effectiveStructureBounds(min, max)` that, when `min > max`, clamps the floor to the ceiling (`{min: max, max: max}`) so the editor settles at exactly `max` instead of locking with both Add and Remove disabled below an unreachable floor. `StructureField` now drives both button `disabled` guards from these reconciled bounds. Valid configs (`min <= max`, or only one set) are unchanged. Added a unit test; typecheck clean; full suite (32 tests) passes. Related: [[structure-min-max-bypass]]. - -DEVANA-KEY: src/admin.tsx:537 | structure-min-gt-max-deadlock -DEVANA-SUMMARY: fixed | P2 | medium | When `min > max`, structure add and remove can both disable and leave the editor stuck below `min`. \ No newline at end of file diff --git a/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md b/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md deleted file mode 100644 index 5bf2bb6..0000000 --- a/.devana/20260627T180008Z-P2-choices-empty-blocks-options.md +++ /dev/null @@ -1,53 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options - -# Empty choices array blocks options alias fallback - -## Finding - -`ChoicesField` resolves the choice list with `normalizeChoices(options?.choices ?? options?.options)`. Nullish coalescing only falls through when `choices` is `null` or `undefined`, not when it is an empty array. An empty `choices: []` prevents reading `options.options` entirely and renders the misconfiguration message. - -## Violated Invariant Or Contract - -`ChoicesOptions` documents both `choices` and `options` as alternate sources for the choice list (`src/types.ts`). Consumers expect both keys to be interchangeable when one is absent or empty. - -## Oracle - -`ChoicesOptions.choices?: FieldsChoice[] | string[]` and `options?: FieldsChoice[] | string[]` in `src/types.ts`. Line 667 uses `??`, not `||` or length check. - -## Counterexample - -`options = { choices: [], options: ["alpha", "beta"] }`, `value: null`. - -1. `choicesList = normalizeChoices([])` → `[]`. -2. Early return at `!choicesList.length` (673–675) shows `choicesRequiresChoices`. -3. `options.options` is never read; user cannot select a value. - -## Why It Might Matter - -Schema generators that default `choices` to `[]` while populating `options` produce a broken widget with no choices rendered, even though the alternate key carries valid data. - -## Proof - -Dataflow trace: `choices: []` is not nullish → `??` does not evaluate `options` → empty list → early return before rendering controls. - -## Counterevidence Checked - -`choices: []` may mean intentional empty configuration. Types list both keys as peers without explicit fallback semantics; `??` behavior makes `[]` win over `options` by design of the operator. - -## Suggested Next Step - -Fall back when `choices` is nullish or empty: `normalizeChoices(options?.choices?.length ? options.choices : options?.options)`. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed. `ChoicesField` resolved the list with `options?.choices ?? options?.options`, so a non-nullish empty `choices: []` shadowed the `options` alias and forced the misconfiguration message. Changed to `options?.choices?.length ? options.choices : options?.options` (the report's suggested fix), so the alias supplies items whenever `choices` is absent or empty. Behavior is unchanged when `choices` has items or both are absent. Added an SSR regression test asserting the fallback renders the options and not the "misconfigured" message; full suite (33 tests) passes. - -DEVANA-KEY: src/admin.tsx:667 | choices-empty-blocks-options -DEVANA-SUMMARY: fixed | P2 | medium | `choices: []` prevents the `options` alias from supplying choice items because `??` does not treat empty arrays as absent. \ No newline at end of file diff --git a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md b/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md deleted file mode 100644 index 41865d8..0000000 --- a/.devana/20260627T180009Z-P2-select-subfield-non-string-hidden.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden - -# Object select subfields hide non-string stored values - -## Finding - -In `ObjectField` and `StructureField`, select subfields bind `value={typeof value === "string" ? value : ""}`. Persisted non-string values (numbers, arrays, booleans) render as the empty placeholder while parent state remains unchanged until the user explicitly re-selects an option. - -## Violated Invariant Or Contract - -README documents `select` subfields as storing a selected string value. The read path should reflect the persisted selection or normalize alien shapes; showing blank while parent keeps a non-string is a UI/state contract mismatch. - -## Oracle - -README subfield table (`select` → selected string). `renderSubField` select branch (412–425). Distinct from `single-choice-array-deselected`, which affects `ChoicesField`, not object subfield selects. - -## Counterexample - -Object subfield `{ key: "tone", type: "select", options: ["Calm", "Bold"] }`, persisted `{ tone: 1 }` (legacy numeric JSON). - -1. Select receives `value=""`; UI shows blank "Select..." option. -2. User saves without touching tone. -3. Parent state remains `{ tone: 1 }`. - -## Why It Might Matter - -Migrated or imported JSON with wrong-typed select values appears unset in the admin while frontend templates may still read the non-string payload, causing editor/display divergence. - -## Proof - -Dataflow trace: non-string `value[field.key]` → strict `typeof === "string"` guard → `value=""` → no mount-time `onChange` → parent keeps alien type across save. - -## Counterevidence Checked - -Non-string select values may be considered invalid input. No coercion on mount is intentional in similar normalization paths. TypeScript does not enforce stored runtime shapes for subfield values. - -## Suggested Next Step - -Coerce or clear non-string select values on mount, or display a warning when stored type does not match `string`. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: open by Devana. Initial report written from static source inspection. -- 2026-06-27: fixed (display coercion). The select branch now derives its value via a new exported `selectSubfieldValue(value)` that stringifies a stored number (the realistic legacy numeric-JSON case, e.g. `{tone: 1}` against options `["1","2"]`) so it can match a string option and render as selected, mirroring how text subfields already accept numbers. Non-scalar values (arrays/objects) have no option to match and still render blank, which is the correct display. No mount-time `onChange` is added, consistent with the project's avoidance of spurious dirty state — a genuine re-select still writes a clean string. Note: when the stored value matches no option (e.g. `1` vs `["Calm","Bold"]`), blank remains correct; coercion only helps when the stringified value is an actual option. Added a unit test; typecheck clean; full suite (34 tests) passes. Related: [[single-choice-array-deselected]]. -- 2026-06-27: reopened. The current fix only improves display for numeric values whose string form matches an option. It does not block the report's original counterexample: `{ tone: 1 }` with options `["Calm", "Bold"]` still renders blank and, because there is no mount-time `onChange`, saving without touching the select can leave parent state as `{ tone: 1 }`. Evidence checked: `selectSubfieldValue(1)` returns `"1"`, the select's items remain `"Calm"`/`"Bold"`, and the only write path is `onValueChange`. -- 2026-06-27: fixed. Select subfields now normalize against configured options on load. `normalizeSelectSubfieldValue` preserves a string/numeric value only when it matches a configured option, otherwise it clears to the blank string. `ObjectField` and `StructureField` emit normalized object/row values via `useNormalizedOnChange`, while preserving invalid structure row payloads outside object rows. The original `{ tone: 1 }` with options `["Calm", "Bold"]` now normalizes to `{ tone: "" }` without requiring the user to touch the select. Added helper tests for object and structure load normalization. - -DEVANA-KEY: src/admin.tsx:423 | select-subfield-non-string-hidden -DEVANA-SUMMARY: fixed | P2 | medium | Select subfields show empty when stored values are not strings, while parent JSON keeps the non-string payload. diff --git a/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md b/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md deleted file mode 100644 index 19d05f4..0000000 --- a/.devana/20260627T180010Z-P2-structure-stale-closure-clobber.md +++ /dev/null @@ -1,55 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: wontfix | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber - -# Structure handlers can clobber pending edits from stale render snapshot - -## Finding - -`StructureField` handlers close over the `items` array from the render that created them. `updateItems(updateStructureItem(items, index, nextItem))`, `removeStructureItem(items, index)`, and similar calls read that snapshot at event time, not the latest parent `value` prop. A second structure action before the parent prop refreshes can recompose from the old array and drop a pending row edit. - -## Violated Invariant Or Contract - -Each `onChange` from a controlled widget should compose from the current parent-owned value. Writes must not derive payloads from an outdated snapshot after a prior `onChange` already advanced parent state. - -## Oracle - -`updateStructureItem` tests in `tests/transformations.test.mjs` cover pure helpers in isolation, not widget handler composition across back-to-back events. Distinct from `structure-reorder-wrong-row`, which is an index-key/focus issue. - -## Counterexample - -Persisted `value: [{ label: "A" }, { label: "B" }]`. - -1. Render closes handlers over `items = [{ label: "A" }, { label: "B" }]`. -2. User edits row 0 label to `"A2"` → first `onChange` emits `[{ label: "A2" }, { label: "B" }]`. -3. Before parent `value` prop refreshes, user removes row 1 → `removeStructureItem(items, 1)` uses stale snapshot → emits `[{ label: "A" }]`. -4. Parent last-write-wins keeps `[{ label: "A" }]`; the `"A2"` edit is lost. - -## Why It Might Matter - -Fast cross-row edit-and-delete sequences, or parents that debounce or batch updates, can silently lose in-flight edits in persisted JSON. - -## Proof - -Dataflow trace: parent holds updated array after first `onChange` → child handler still reads render-closure `items` → second `onChange` overwrites first with stale-based result. - -Locations: `items` at 504; `updateItems` / row handlers at 525–538, 549, 558, 573; `renderObjectFields` uses render-scoped `item` at 452. - -## Counterevidence Checked - -Synchronous React parents usually re-render before the next discrete event, so many single-step flows stay fresh. `structure-reorder-wrong-row` covers a different mechanism (index keys). No functional updater or `value` re-read at event time exists in source. - -## Suggested Next Step - -Use functional updates that read the latest `value` prop at event time, or latch pending mutations until props catch up. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: wontfix. The mechanism is real but only under a non-standard parent. `onChange` here is value-based (`(value) => void`, no functional updater), and EmDash drives field widgets as synchronous controlled inputs, so React re-renders between discrete user events and the handler closures always recompose from current state. The clobber requires a parent that defers/batches the `value` prop across two distinct user actions — an integration this widget cannot reliably detect. A correct fix would have to route ALL composition through a mutable value ref updated on every emit: not just the add/remove/move button handlers but every per-row subfield edit flowing through `renderObjectFields` (whose `onChange` merges onto the render-captured row object). A buttons-only ref would fix the report's exact counterexample (edit row 0, then remove row 1) but still drop sequential same-row subfield edits under a deferring parent, giving false confidence. The full refactor carries regression risk disproportionate to a theoretical, parent-dependent edge, so behavior is left as-is. Recommended pattern if revisited: hold `const itemsRef = useRef(items)` synced to the prop each render, update it inside a single `updateItems`, and compose every mutation (including row edits, basing each row merge on `itemsRef.current[index]`) from the ref. Related but distinct: [[structure-reorder-wrong-row]] (invalid; index-key/focus, not stale composition). - -DEVANA-KEY: src/admin.tsx:526 | structure-stale-closure-clobber -DEVANA-SUMMARY: wontfix | P2 | medium | Structure row handlers compose `onChange` from a render snapshot and can drop a pending edit if a second action runs before props refresh. \ No newline at end of file diff --git a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md b/.devana/20260627T180011Z-P2-number-subfield-string-persists.md deleted file mode 100644 index 70e05b0..0000000 --- a/.devana/20260627T180011Z-P2-number-subfield-string-persists.md +++ /dev/null @@ -1,58 +0,0 @@ -DEVANA-FINDING: v1 -DEVANA-STATE: fixed | P2 | medium | security=no -DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists - -# Number subfields round-trip non-number stored values unchanged - -## Finding - -`renderSubField` displays number and integer subfields with `value: typeof value === "string" || typeof value === "number" ? value : ""` but only coerces input through `parseNumericInput` on user `change` events. Pre-existing string or non-integer number values are shown and persisted without normalization until the user edits the field. - -## Violated Invariant Or Contract - -README documents `number` and `integer` subfields as storing a `Number`, or `undefined` when empty. Loaded values should be numbers (integers for `integer`), not strings or off-type numbers. - -## Oracle - -README subfield stored-value table (lines 97–98). `parseNumericInput` and `readInputValue` run only on change (350–361), not on mount. - -## Counterexample - -Object subfield `{ key: "count", type: "number" }`, persisted `{ count: "42" }` (string). - -1. Input displays `"42"` because `typeof value === "string"`. -2. User saves without editing count. -3. Parent keeps `count: "42"` (string), not `42` (number). - -For `integer` subfield with `{ priority: 3.14 }`, non-integer number displays and persists until edited. - -## Why It Might Matter - -Imported YAML/JSON often quotes numbers as strings. Frontend templates expecting numeric types receive strings, breaking comparisons and formatting. - -## Proof - -Dataflow trace: alien typed `value[field.key]` → display accepts string → no mount normalization → save without edit preserves wrong type. - -Location: `commonProps.value` (383), `readInputValue`/`parseNumericInput` (350–361). - -## Counterevidence Checked - -README may mean "editor emits Number on edit" rather than "normalize on load". EmDash may validate at another layer — not visible here. `tests/numeric-input.test.mjs` covers `parseNumericInput` only, not load paths. - -## Suggested Next Step - -Normalize number/integer subfield values on mount or when rendering, coercing valid numeric strings and clearing invalid shapes to `undefined`. - -## Agent Handoff - -After working this report, preserve the original finding body. Update line 2 `DEVANA-STATE: ...` and the final `DEVANA-SUMMARY:` status/priority/confidence prefix. Use one of: `open`, `fixed`, `invalid`, `stale`, `duplicate`, `wontfix`. Keep `DEVANA-KEY:` stable unless the same finding moved. Add dated notes below with evidence checked. - -## Status Notes - -- 2026-06-27: fixed (display/interpretation; intentionally no mount-time write). Numeric subfields now route their committed value through a new exported `interpretNumericValue(value, type)`: quoted numeric strings (common in YAML/JSON, e.g. `{count: "42"}`) are coerced so they display as numbers and any edit emits a real `Number`, while off-type shapes (a non-integer for an `integer` field, non-finite numbers, non-scalars) clear to `undefined` (blank) per the report's suggested remedy. This also fixes a regression introduced by the numeric-keystroke-intermediate-loss refactor, where `NumericSubField` treated only `typeof value === "number"` as committed and so blanked a stored numeric string; a stored "42" now correctly renders "42" again. No mount `onChange` is added — consistent with the project's avoidance of spurious dirty state — so an untouched off-type value is shown per the interpretation but the raw stored value is only rewritten when the user edits the field. Added unit tests for `interpretNumericValue` and an SSR test asserting `{count:"42"}` renders `value="42"`; typecheck clean; full suite (36 tests) passes. See [[numeric-keystroke-intermediate-loss]]. -- 2026-06-27: reopened. The current code interprets numeric strings for display and future edits, but the original untouched-save counterexample is still reachable. `NumericSubField` computes `committed = interpretNumericValue(value, type)` and keeps a local draft, but it only calls `onChange` from input changes. Loading `{ count: "42" }` and saving without editing the field can still leave the parent value as the string `"42"`; an invalid integer value such as `3.14` is blanked for display but likewise is not cleared from parent state without an edit. Evidence checked: `interpretNumericValue`, `NumericSubField` draft setup, `onChange` handler, and blur resync. -- 2026-06-27: fixed. Number and integer subfields now participate in load-time object/structure normalization through `normalizeSubfieldStoredValue`. Quoted numeric strings such as `{ count: "42" }` emit `{ count: 42 }` without requiring an edit, and invalid integer values such as `{ priority: 3.14 }` emit `{ priority: undefined }` through the same path. Structure normalization preserves invalid non-object rows while normalizing valid row objects. Added helper regression tests covering object and structure numeric normalization. - -DEVANA-KEY: src/admin.tsx:383 | number-subfield-string-persists -DEVANA-SUMMARY: fixed | P2 | medium | Number and integer subfields display string or non-integer stored values and persist them until the user edits the field. From 517b75de7316cf8a83c1a472c5733f51f8fe09e5 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Mon, 29 Jun 2026 13:22:07 +0100 Subject: [PATCH 26/27] Preserve unknown select subfield values --- src/admin.tsx | 27 +++++++++++++-------------- tests/transformations.test.mjs | 33 ++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/admin.tsx b/src/admin.tsx index 14fed5b..ee565aa 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -482,7 +482,7 @@ export function selectSubfieldValue(value: unknown): string { return ""; } -/** Select subfield value kept only when it matches a configured option. */ +/** Display value for a select subfield, kept only when it matches a configured option. */ export function normalizeSelectSubfieldValue( value: unknown, choices: FieldsChoice[] | string[] | undefined, @@ -496,7 +496,7 @@ export function normalizeSelectSubfieldValue( export function normalizeSubfieldStoredValue(field: FieldsSubField, value: unknown): unknown { const type = field.type ?? "text"; if (type === "select") { - return normalizeSelectSubfieldValue(value, field.options); + return value; } if (type === "number" || type === "integer") { return interpretNumericValue(value, type); @@ -534,7 +534,9 @@ export function normalizeStructureSubfieldValues( fields: FieldsSubField[], ): unknown[] { if (!Array.isArray(value)) return []; - return value.map((item) => (isJsonRecord(item) ? normalizeObjectSubfieldValues(item, fields) : item)); + return value.map((item) => + isJsonRecord(item) ? normalizeObjectSubfieldValues(item, fields) : item, + ); } /** Whether object subfield normalization would change the stored value. */ @@ -542,7 +544,9 @@ export function shouldNormalizeObjectSubfieldValues( value: unknown, fields: FieldsSubField[], ): boolean { - return isJsonRecord(value) && !jsonValuesEqual(value, normalizeObjectSubfieldValues(value, fields)); + return ( + isJsonRecord(value) && !jsonValuesEqual(value, normalizeObjectSubfieldValues(value, fields)) + ); } /** Whether structure subfield normalization would change the stored value. */ @@ -550,7 +554,9 @@ export function shouldNormalizeStructureSubfieldValues( value: unknown, fields: FieldsSubField[], ): boolean { - return Array.isArray(value) && !jsonValuesEqual(value, normalizeStructureSubfieldValues(value, fields)); + return ( + Array.isArray(value) && !jsonValuesEqual(value, normalizeStructureSubfieldValues(value, fields)) + ); } /** Parses a complete numeric string; returns `undefined` for empty or invalid input. */ @@ -588,10 +594,7 @@ export function interpretNumericValue( } /** Commit decision for a numeric subfield keystroke: set, clear, or hold prior value. */ -export type NumericCommit = - | { type: "set"; value: number } - | { type: "clear" } - | { type: "hold" }; +export type NumericCommit = { type: "set"; value: number } | { type: "clear" } | { type: "hold" }; /** Maps raw input to a commit action without wiping in-progress decimals or minus signs. */ export function numericChangeCommit(raw: string, type: "number" | "integer"): NumericCommit { @@ -747,11 +750,7 @@ function renderSubField( onChange={onChange} /> ) : ( - + )} {suffix ? {suffix} : null} diff --git a/tests/transformations.test.mjs b/tests/transformations.test.mjs index 141481b..e57d838 100644 --- a/tests/transformations.test.mjs +++ b/tests/transformations.test.mjs @@ -122,10 +122,7 @@ test("link values drop type and target outside the documented unions", () => { }); test("link values report when load-time normalization should be emitted", () => { - assert.equal( - shouldNormalizeLinkValue({ type: "javascript", value: "https://x" }), - true, - ); + assert.equal(shouldNormalizeLinkValue({ type: "javascript", value: "https://x" }), true); assert.equal(shouldNormalizeLinkValue({ type: "url", value: "https://x" }), false); assert.equal(shouldNormalizeLinkValue("https://x"), true); assert.equal(shouldNormalizeLinkValue(""), false); @@ -230,33 +227,43 @@ test("select subfield value stringifies numbers and blanks non-scalars", () => { test("select subfield values normalize against configured options", () => { assert.equal(normalizeSelectSubfieldValue("Calm", ["Calm", "Bold"]), "Calm"); assert.equal(normalizeSelectSubfieldValue(1, ["1", "2"]), "1"); + assert.equal(normalizeSelectSubfieldValue("Legacy", ["Calm", "Bold"]), ""); assert.equal(normalizeSelectSubfieldValue(1, ["Calm", "Bold"]), ""); assert.equal(normalizeSelectSubfieldValue(["Calm"], ["Calm"]), ""); }); -test("object and structure subfield values normalize select fields on load", () => { +test("object and structure subfield values preserve select fields on load", () => { const fields = [{ key: "tone", label: "Tone", type: "select", options: ["Calm", "Bold"] }]; assert.deepEqual(normalizeObjectSubfieldValues({ tone: 1, title: "Intro" }, fields), { - tone: "", + tone: 1, title: "Intro", }); + assert.deepEqual(normalizeObjectSubfieldValues({ tone: "Legacy" }, fields), { + tone: "Legacy", + }); assert.deepEqual(normalizeObjectSubfieldValues({ title: "Intro" }, fields), { title: "Intro", }); - assert.equal(shouldNormalizeObjectSubfieldValues({ tone: 1 }, fields), true); + assert.equal(shouldNormalizeObjectSubfieldValues({ tone: 1 }, fields), false); + assert.equal(shouldNormalizeObjectSubfieldValues({ tone: "Legacy" }, fields), false); assert.equal(shouldNormalizeObjectSubfieldValues({ title: "Intro" }, fields), false); assert.equal(shouldNormalizeObjectSubfieldValues(["bad"], fields), false); assert.deepEqual(normalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), [ - { tone: "" }, + { tone: 1 }, + "bad", + ]); + assert.deepEqual(normalizeStructureSubfieldValues([{ tone: "Legacy" }, "bad"], fields), [ + { tone: "Legacy" }, "bad", ]); assert.deepEqual(normalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), [ { title: "Intro" }, "bad", ]); - assert.equal(shouldNormalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), true); + assert.equal(shouldNormalizeStructureSubfieldValues([{ tone: 1 }, "bad"], fields), false); + assert.equal(shouldNormalizeStructureSubfieldValues([{ tone: "Legacy" }, "bad"], fields), false); assert.equal(shouldNormalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), false); assert.equal(shouldNormalizeStructureSubfieldValues(["bad"], fields), false); }); @@ -277,10 +284,10 @@ test("object and structure subfield values normalize numeric fields on load", () assert.equal(shouldNormalizeObjectSubfieldValues({ count: "42" }, fields), true); assert.equal(shouldNormalizeObjectSubfieldValues({ title: "Intro" }, fields), false); - assert.deepEqual(normalizeStructureSubfieldValues([{ count: "42", priority: 3.14 }, "bad"], fields), [ - { count: 42, priority: undefined }, - "bad", - ]); + assert.deepEqual( + normalizeStructureSubfieldValues([{ count: "42", priority: 3.14 }, "bad"], fields), + [{ count: 42, priority: undefined }, "bad"], + ); assert.deepEqual(normalizeStructureSubfieldValues([{ title: "Intro" }, "bad"], fields), [ { title: "Intro" }, "bad", From 5e735eb3cbc7f7f733518bb4580695a1e29fad83 Mon Sep 17 00:00:00 2001 From: Bruno Meilick Date: Mon, 29 Jun 2026 13:25:25 +0100 Subject: [PATCH 27/27] Format numeric input test --- tests/numeric-input.test.mjs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/numeric-input.test.mjs b/tests/numeric-input.test.mjs index 25495bb..434373b 100644 --- a/tests/numeric-input.test.mjs +++ b/tests/numeric-input.test.mjs @@ -1,10 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { - interpretNumericValue, - numericChangeCommit, - parseNumericInput, -} from "../dist/admin.mjs"; +import { interpretNumericValue, numericChangeCommit, parseNumericInput } from "../dist/admin.mjs"; test("numeric input emits undefined for empty values", () => { assert.equal(parseNumericInput("", "number"), undefined);