From be3aef709d5bcdd4f4b5bae482152ce233fc2ace Mon Sep 17 00:00:00 2001 From: Eugene Chernyshov Date: Sun, 19 Jul 2026 00:05:03 +0300 Subject: [PATCH 1/2] FR-132 v2.8: harden the Examples picker for host-supplied corpora MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported regression: the docs-site embed's Examples picker showed all 163 cases "duplicated and uncategorised". Root cause: FR-132's tier/rule joins happen only inside buildExampleCorpus, which a host-supplied host.examples override bypasses — the docs site hand-maps the engine corpus (dropping rule/tier), so everything fell into one "Reference · other" optgroup, and the doc-first-sentence labels are not unique (the pinned corpus collides in three rule groups even when categorised), so distinct cases rendered identical labels. SPEC-first amendment of FR-132 (v2.8, no new IDs): - Colliding labels within a group are disambiguated with " — ". - A corpus with no tier/rule membership at all renders as a flat, ungrouped list — no fabricated "other" header. - The corpus derivation is exported for embedders. Implementation: - examples.ts: split buildExampleCorpusFromDocs(docs) out of buildExampleCorpus — takes the EditorDocs shape, isomorphic to the engine get_all_docs() payload, so hosts derive instead of hand-mapping. - panels.tsx: groupExamples returns a label:null group for group-less corpora (rendered without optgroup chrome; partially categorised corpora keep "Reference · other"); disambiguatedLabels suffixes per group. - editor-react + editor-element re-export buildExampleCorpus / buildExampleCorpusFromDocs (+ EditorDocs/EditorMetadata types). Tests red-first (examples-picker.test.tsx): flat degradation, mixed-corpus "other" retention, collision suffix, per-group disambiguation scoping, from-docs parity. Traceability FR-132 row updated in the same change. Gates green (traceability, parity 23/29/34, snapshot); live-verified in the Pyodide reference host: 163 options, 22 groups, 0 duplicate values, 0 remaining within-group label collisions. Follow-up (docs-site repo): swap toExampleCases for the exported buildExampleCorpusFromDocs once a new editor tarball is released, restoring tiers/rule groups in the embedded picker. Refs: FR-132, FR-009 Slice: fix-examples-picker-host-corpus Co-Authored-By: Claude Fable 5 --- docs/SPEC.md | 23 +++++- docs/current-state.md | 26 ++++++- docs/traceability.md | 2 +- packages/editor-element/src/index.ts | 6 ++ packages/editor-react/src/index.ts | 6 ++ packages/editor-ui/src/components/panels.tsx | 55 +++++++++++--- packages/editor-ui/src/index.ts | 4 +- packages/editor-ui/src/session/examples.ts | 47 +++++++----- .../editor-ui/test/examples-picker.test.tsx | 74 +++++++++++++++++-- 9 files changed, 202 insertions(+), 41 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 6cd5213..ecd9b7a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,6 +1,20 @@ # SPEC.md — Transon Visual Template Editor -> **Version:** 2.7 · **Status:** Pre-implementation baseline · **Last updated:** 2026-07-18 +> **Version:** 2.8 · **Status:** Pre-implementation baseline · **Last updated:** 2026-07-18 + +> **v2.8 — FR-132 presentation hardening for host-supplied corpora.** Amends **FR-132** (no new +> IDs): (1) **label disambiguation** — when two entries in the same group would render the same +> doc-sentence label, each colliding label is suffixed with the unique case name (the engine corpus +> guarantees unique *names*, not unique doc first sentences — the pinned corpus already collides in +> three rule groups); (2) **graceful degradation** — a corpus in which **no** entry carries any +> tier or rule membership is presented as a single flat, ungrouped list, with no fabricated +> "other" group header. Motivating regression: a host-supplied `examples` override built without +> the engine reference lists (the docs-site embed's hand-mapped corpus) rendered all 163 cases +> under one "Reference · other" group with colliding labels — read as "duplicated and +> uncategorised". The tier/rule derivation itself is unchanged and now reachable by embedders: +> the corpus builder is re-exported from the public surfaces +> (`@transon/editor-react`, `@transon/editor-element`) so hosts can derive `ExampleCase[]` from an +> engine docs payload instead of hand-mapping. > **v2.7 — total-membership codec + engine floor (RFC-008 slices 2–3, ratified OQs).** Adds > **§7.19 (FR-142)**: a session-init **codec engine-floor check** with a persistent, non-blocking @@ -422,7 +436,12 @@ itself (FR-121, AC-036). [`metadata-contract.md`](metadata-contract.md) §2.7) — never from a hand-maintained editor-side list (AD-012) — and are presentation-only: they do not alter corpus contents, selection semantics (FR-009, AC-018), or a host-supplied `examples` override, which is - presented through the same mechanical derivation. + presented through the same mechanical derivation. When two entries in the same group would + render the same label, each colliding label shall be **disambiguated** by appending the unique + case name (doc first sentences are not unique; case names are). A corpus in which no entry + carries any tier or rule membership — e.g. a host-supplied override built without the engine + reference lists — shall be presented as a single **flat, ungrouped** list; the picker shall not + fabricate an "other" group header for a corpus that has no groups at all. - **FR-010** The editor shall be usable as an embeddable component (§7.14). - **FR-011** The editor shall expose events/callbacks so an embedding application can observe template changes, validation results, and execution results. diff --git a/docs/current-state.md b/docs/current-state.md index b0402bf..c11c91b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -8,14 +8,36 @@ | | | |---|---| -| Repo HEAD | `fb2a4ed` — Merge pull request #16 from transon-org/rfc-008-generator-shrink | -| Branch | `fix-variant-face-collision` | +| Repo HEAD | `21921e2` — Merge pull request #18 from transon-org/fix-hermetic-mount | +| Branch | `fix-examples-picker-host-corpus` | | Engine pin | transon `v0.2.0` @ `58391ecc49bd` (see [metadata-snapshot.md](metadata-snapshot.md)) | | Metadata snapshot | committed ([metadata-snapshot.json](metadata-snapshot.json)) | ## Last action +_**FR-132 PICKER HARDENING FOR HOST CORPORA (2026-07-19, branch `fix-examples-picker-host-corpus`, +SPEC v2.8, uncommitted):** user-reported "examples duplicated and uncategorised" — root cause: +the FR-132 tiered picker derives `tier`/`rule` only inside `buildExampleCorpus`, which a +host-supplied `host.examples` override bypasses; the docs-site embed +(transon-org.github.io `EditorView.tsx` → `toExampleCases`) hand-maps the engine corpus dropping +the joins, so all 163 cases rendered under one "Reference · other" optgroup with colliding +doc-sentence labels (doc first sentences are NOT unique — the pinned corpus collides in 3 rule +groups even when categorised). SPEC-first amendment of **FR-132** (v2.8, no new IDs): (1) +per-group label disambiguation — colliding labels suffixed " — "; (2) a corpus with NO +tier/rule membership renders flat (no fabricated "other" header); (3) corpus builder exported for +embedders. Implementation: `buildExampleCorpusFromDocs(docs)` split out of `buildExampleCorpus` +(examples.ts; takes the `get_all_docs()`-isomorphic `EditorDocs` shape), `groupExamples` returns +`label: null` for group-less corpora + `disambiguatedLabels` per group (panels.tsx), value +re-exports from `@transon/editor-react` + `@transon/editor-element` (+ `EditorDocs`/ +`EditorMetadata` types). Tests red-first in examples-picker.test.tsx (flat degradation, mixed +corpus keeps "other", collision suffix, per-group scoping, from-docs parity); traceability FR-132 +row updated. All gates green (traceability, parity 23/29/34, snapshot); editor-ui 206 / react 5 / +element 12 pass; **live-verified** (Pyodide reference host): 163 options, 22 groups, 0 dup values, +0 remaining within-group label collisions, console clean. **Next:** commit + PR; then the +docs-site follow-up (swap `toExampleCases` for the exported `buildExampleCorpusFromDocs` to +restore tiers/groups in the embedded picker — needs a new editor release/tarball first)._ + _**NFR-052 NETWORK-HERMETIC MOUNT (2026-07-18, branch `fix-hermetic-mount`):** main's `agentic-checks/tests` went red on the last two merge pushes with 8 unhandled `fetch failed` rejections — diagnosed to Blockly's DEFAULT media path: `mountBlockly` passed no `media`/`sounds` diff --git a/docs/traceability.md b/docs/traceability.md index f07c15a..86c0529 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -218,7 +218,7 @@ dedicated tests: | FR-125 | Palette block definitions are valid/loadable Zelos | headless palette-load gate | [x] `packages/editor-blockly/test/palette-load.test.ts` | | FR-127 / NFR-048 | Presentation/category/colour from metadata or projection data, single committed source | presentation source-scan + completeness check; AC-037 synthetic-rule test | [x] `harness/scripts/check_presentation.py` (+ `--selftest`; source-scan + completeness + FR-130 curation checks) + `packages/editor-core/test/presentation.test.ts`; synthetic rule from data: `test/engine-node-adapter/test/codec/ac037-presentation.test.ts`; data: `packages/editor-core/src/codec/presentation.json` | | FR-130 | Curated dropdown menus for constant parameters (display-only; every metadata token accepted + round-trips verbatim, §13.6) | presentation curation data + `check_presentation.py` curation check (+ `--selftest`); `field_transon_dropdown` runtime field; palette projection (`field_transon_dropdown` widget, curated/identity menu + full accept domain) | [x] data: `packages/editor-core/src/codec/presentation.json` (`dropdownMenus`) + `packages/editor-core/test/presentation.test.ts`; projection: `packages/editor-core/src/codec/codegen.ts` (`enrichForPalette`/`menuFor`, `G_palette` `P_ARG`) + `test/engine-node-adapter/test/codec/palette.test.ts`; runtime field: `packages/editor-blockly/src/runtime.ts` (`FieldTransonDropdown`) + `packages/editor-blockly/test/dropdown.test.ts`; round-trip corpus alias cases: `test/engine-node-adapter/test/codec/corpus.ts` (`expr-value-alias-op-lt`, `expr-values-alias-op-and`) via `roundtrip.test.ts` | -| FR-132 | Tiered/grouped Examples picker with doc-sentence labels (curated tiers first in engine reference-list order, reference grouped by owning rule; case name stays the selection value; mechanical over engine corpus data, AD-012) | corpus tier-ordering + panel optgroup/label/selection tests | [x] ordering: `buildExampleCorpus` curated-first (`packages/editor-ui/src/session/examples.ts`); grouping + labels + unchanged selection semantics + host-override flow-through: `groupExamples`/`exampleLabel` (`packages/editor-ui/src/components/panels.tsx`) — `packages/editor-ui/test/examples-picker.test.tsx` | +| FR-132 | Tiered/grouped Examples picker with doc-sentence labels (curated tiers first in engine reference-list order, reference grouped by owning rule; case name stays the selection value; mechanical over engine corpus data, AD-012). SPEC v2.8: per-group label disambiguation (colliding labels suffixed with the case name) + flat, group-less rendering for a corpus with no tier/rule membership; corpus builder exported for embedders | corpus tier-ordering + panel optgroup/label/selection tests; disambiguation + flat-degradation + `buildExampleCorpusFromDocs` parity tests | [x] ordering + embedder seam: `buildExampleCorpus`/`buildExampleCorpusFromDocs` (`packages/editor-ui/src/session/examples.ts`, re-exported from `@transon/editor-react` + `@transon/editor-element`); grouping + labels + unchanged selection semantics + v2.8 degradation/disambiguation: `groupExamples`/`exampleLabel`/`disambiguatedLabels` (`packages/editor-ui/src/components/panels.tsx`) — `packages/editor-ui/test/examples-picker.test.tsx` | | FR-135 | Autorun mode (`autorun`): re-execute on every accepted template/input change, debounced per NFR-027; Output panel live; respects engine-ready + valid-input gates | autorun-path test (run fires on template + input change, debounced, and NOT when engine not-ready) | [x] **RFC-005 Part 2 A1.** Store-subscription in `createEditorController` re-executes (shared `runExecution`, debounced per NFR-027) when `template_json`/`sample_input_json` change; `executeTemplate` no-ops when gated so not-ready never runs — `packages/editor-ui/src/session/controller.ts` (`autorun?` option); threaded via `TransonEditorProps` (React prop) + `` (`packages/editor-element/src/element.ts`). Tests: `packages/editor-ui/test/autorun.test.tsx` (template change, input change, off, not-ready gate, debounce coalescing) | | FR-136 | Hide individual toolbar actions (`hideToolbarActions`): hidden = not rendered, distinct from read-only (FR-107) disable | toolbar-visibility test (hidden actions absent from DOM; unhidden unaffected; independent of readOnly) | [x] **RFC-005 Part 2 A2.** `hideToolbarActions?: ToolbarActionId[]` on `EditorControllerOptions` (`packages/editor-ui/src/session/controller.ts`) → `Toolbar` omits each named action (`packages/editor-ui/src/components/panels.tsx`, `hidden` set) → passed from `TransonEditor` (both shell modes) + `` (space/comma list, `packages/editor-element/src/element.ts`). Tests: `packages/editor-ui/test/toolbar-visibility.test.tsx` (default all shown, hide-all, subset, independent of readOnly) | | FR-137 | Optional host-provided leading toolbar action (`onBack` + label): rendered first; editor invokes the host callback and performs no navigation (AD-008) | leading-action test (renders first only when provided; click calls it; absent otherwise) | [x] **RFC-005 Part 2 A3.** `onBack?()` + `backLabel?` on `EditorControllerOptions`; `Toolbar` renders the leading button as the first child only when `onBack` is set and calls it on click (`packages/editor-ui/src/components/panels.tsx`); threaded from `TransonEditor` (both modes) + `` (opts in; onBack re-emits a `back` DOM event, `packages/editor-element/src/element.ts`). Tests: `packages/editor-ui/test/toolbar-back.test.tsx` (absent by default, first-child + label, click invokes, default label) | diff --git a/packages/editor-element/src/index.ts b/packages/editor-element/src/index.ts index 980e491..27b10db 100644 --- a/packages/editor-element/src/index.ts +++ b/packages/editor-element/src/index.ts @@ -24,3 +24,9 @@ export type { TransonTheme, ToolboxCategoryConfig, } from '@transon/editor-ui'; + +// Example-corpus derivation (FR-132, SPEC v2.8): hosts overriding `host.examples` should derive +// their corpus from the engine docs payload here — a hand-mapped corpus drops the `rule`/`tier` +// joins and the picker degrades to a flat, group-less list. +export { buildExampleCorpus, buildExampleCorpusFromDocs } from '@transon/editor-ui'; +export type { EditorDocs, EditorMetadata } from '@transon/editor-core'; diff --git a/packages/editor-react/src/index.ts b/packages/editor-react/src/index.ts index 155ba5d..0d79254 100644 --- a/packages/editor-react/src/index.ts +++ b/packages/editor-react/src/index.ts @@ -22,6 +22,12 @@ export type { ToolbarActionId, } from '@transon/editor-ui'; +// Example-corpus derivation (FR-132, SPEC v2.8): hosts overriding `host.examples` should derive +// their corpus from the engine docs payload here — a hand-mapped corpus drops the `rule`/`tier` +// joins and the picker degrades to a flat, group-less list. +export { buildExampleCorpus, buildExampleCorpusFromDocs } from '@transon/editor-ui'; +export type { EditorDocs, EditorMetadata } from '@transon/editor-core'; + // Engine-port types (AD-008): a host implements `EngineProvider` to supply the runtime; the // validation/execution result shapes and `Json` type it returns are part of that contract. export type { diff --git a/packages/editor-ui/src/components/panels.tsx b/packages/editor-ui/src/components/panels.tsx index ad6fa53..35c6e02 100644 --- a/packages/editor-ui/src/components/panels.tsx +++ b/packages/editor-ui/src/components/panels.tsx @@ -171,10 +171,11 @@ function exampleLabel(ex: ExampleCase): string { * curated-first from `buildExampleCorpus`, which resolves `tier` from the engine * `worked_examples`/`recipes` name-reference lists — never from tag conventions, contract §2.7), * then reference examples grouped by owning rule (alphabetical, rule-less cases last). Mechanical - * over the engine-emitted case data (AD-012) — host-supplied corpora flow through the same - * derivation. + * over the case data (AD-012). A corpus in which NO entry carries any tier or rule membership — + * e.g. a host `examples` override built without the engine reference lists — renders as one flat + * `label: null` group (SPEC v2.8): no fabricated "other" header over a corpus that has no groups. */ -function groupExamples(examples: ExampleCase[]): Array<{ label: string; entries: ExampleCase[] }> { +function groupExamples(examples: ExampleCase[]): Array<{ label: string | null; entries: ExampleCase[] }> { const worked: ExampleCase[] = []; const recipes: ExampleCase[] = []; const byRule = new Map(); @@ -189,6 +190,9 @@ function groupExamples(examples: ExampleCase[]): Array<{ label: string; entries: } } const rules = [...byRule.keys()].filter((r) => r !== '').sort(); + if (!worked.length && !recipes.length && !rules.length) { + return examples.length ? [{ label: null, entries: examples }] : []; + } if (byRule.has('')) rules.push(''); return [ { label: 'Worked examples', entries: worked }, @@ -200,6 +204,26 @@ function groupExamples(examples: ExampleCase[]): Array<{ label: string; entries: ].filter((group) => group.entries.length > 0); } +/** + * Display labels for one group's entries, keyed by case name (FR-132, SPEC v2.8): the doc-sentence + * label, suffixed with the unique case name when two entries in the group would otherwise render + * identically (the engine guarantees unique names, not unique doc first sentences). Scoped per + * group — the same sentence under two different rules stays bare. + */ +function disambiguatedLabels(entries: ExampleCase[]): Map { + const counts = new Map(); + for (const ex of entries) { + const label = exampleLabel(ex); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + const out = new Map(); + for (const ex of entries) { + const label = exampleLabel(ex); + out.set(ex.name, (counts.get(label) ?? 0) > 1 ? `${label} — ${ex.name}` : label); + } + return out; +} + /** * Examples picker (§12.8, FR-009/099, AC-018). Loads a documentation example's template + sample * input + expected output. Hidden when no corpus is available. "Reset Example" reloads the selected @@ -234,15 +258,22 @@ export function ExamplesPanel({ - {groupExamples(examples).map((group) => ( - - {group.entries.map((ex) => ( - - ))} - - ))} + {groupExamples(examples).map((group) => { + const labels = disambiguatedLabels(group.entries); + const options = group.entries.map((ex) => ( + + )); + // Group-less corpus (SPEC v2.8): flat options, no optgroup chrome. + return group.label === null ? ( + options + ) : ( + + {options} + + ); + })} {selected ? (