Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 24 additions & 2 deletions docs/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,36 @@
<!-- BEGIN generated: at-a-glance · python harness/scripts/update_memory.py --state -->
| | |
|---|---|
| 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)) |
<!-- END generated: at-a-glance -->

## 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 " — <case name>"; (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`
Expand Down
2 changes: 1 addition & 1 deletion docs/traceability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) + `<transon-editor autorun>` (`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) + `<transon-editor hide-actions="…">` (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) + `<transon-editor back-label="…">` (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) |
Expand Down
6 changes: 6 additions & 0 deletions packages/editor-element/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions packages/editor-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 44 additions & 12 deletions packages/editor-ui/src/components/panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ExampleCase[]>();
Expand All @@ -189,6 +190,10 @@ function groupExamples(examples: ExampleCase[]): Array<{ label: string; entries:
}
}
const rules = [...byRule.keys()].filter((r) => r !== '').sort();
if (!worked.length && !recipes.length && !rules.length) {
// The panel renders nothing for an empty corpus, so `examples` is non-empty here.
return [{ label: null, entries: examples }];
}
if (byRule.has('')) rules.push('');
return [
{ label: 'Worked examples', entries: worked },
Expand All @@ -200,6 +205,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<string, string> {
const counts = new Map<string, number>();
for (const ex of entries) {
const label = exampleLabel(ex);
counts.set(label, (counts.get(label) ?? 0) + 1);
}
const out = new Map<string, string>();
for (const ex of entries) {
const label = exampleLabel(ex);
out.set(ex.name, counts.get(label)! > 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
Expand Down Expand Up @@ -234,15 +259,22 @@ export function ExamplesPanel({
<option value="" disabled>
Choose an example…
</option>
{groupExamples(examples).map((group) => (
<optgroup key={group.label} label={group.label}>
{group.entries.map((ex) => (
<option key={ex.name} value={ex.name} title={ex.name}>
{exampleLabel(ex)}
</option>
))}
</optgroup>
))}
{groupExamples(examples).map((group) => {
const labels = disambiguatedLabels(group.entries);
const options = group.entries.map((ex) => (
<option key={ex.name} value={ex.name} title={ex.name}>
{labels.get(ex.name)}
</option>
));
// Group-less corpus (SPEC v2.8): flat options, no optgroup chrome.
return group.label === null ? (
options
) : (
<optgroup key={group.label} label={group.label}>
{options}
</optgroup>
);
})}
</select>
{selected ? (
<button type="button" data-testid="btn-reset-example" onClick={onReset}>
Expand Down
4 changes: 2 additions & 2 deletions packages/editor-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export type { ExecuteOptions } from './session/execute.js';
export { tryReverse } from './session/reverse.js';
export type { ReverseOutcome } from './session/reverse.js';

// ---- example corpus (M5 D2, FR-079) ----
export { buildExampleCorpus } from './session/examples.js';
// ---- example corpus (M5 D2, FR-079; FR-132 embedder seam) ----
export { buildExampleCorpus, buildExampleCorpusFromDocs } from './session/examples.js';

// ---- host boundary + controller (D2) ----
export type { TransonEditorHost, ExampleCase, TransonTheme } from './session/host.js';
Expand Down
Loading
Loading