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
7 changes: 7 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,13 @@ option (a)).
- **NFR-042** The editor shall not require a backend for v1 operation.
- **NFR-043** The editor shall be embeddable into the documentation site or another React app.
- **NFR-044** The editor shall be usable in a standalone demo application.
- **NFR-052** Mounting the canvas shall be **network-hermetic**: injecting a workspace shall
trigger no external network request. In particular, Blockly's default media path
(`static.blockly.com`) must never be fetched — workspace UI **sounds are disabled** (the audio
preload is that path's only consumer in the editor's surface). Rationale: an embeddable
component (NFR-043) must not silently call a third-party CDN from the host page, and the DOM
test suites must not depend on that CDN being reachable (added 2026-07-18 after its timeout
failed CI as unhandled fetch rejections).

### 8.9 Accessibility

Expand Down
13 changes: 13 additions & 0 deletions docs/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@

## Last action

_**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`
option, so `WorkspaceAudio` preloaded 4 sounds from `static.blockly.com` (IPs in the CI error
match that domain exactly; reproduced locally with a `--require` fetch logger) on every jsdom
mount; PR runs passed only because the CDN happened to respond. SPEC-first: **NFR-052** appended
(§8.8): mount is network-hermetic, sounds disabled (the audio preload is the media path's only
consumer in our surface); id-ledger +1; traceability row. Red-first:
`packages/editor-ui/test/hermetic-mount.test.ts` (rejecting fetch spy + `hasSounds === false`;
failed pre-fix with the exact CI signature). Fix: `sounds: false` in `mount.ts` inject options.
Verified: fetch-logger sweep over geometry+density corpus suites shows ZERO fetches; full suite +
gates green. Display-only, no artifact change._

_**VARIANT FACE-COLLISION FIX (2026-07-18, branch `fix-variant-face-collision`, not merged):**
user-reported: `call`/`expr` `value` vs `values` and `map` `item` vs `items` rendered
pixel-identical in palette AND canvas — §12.5's unconditional "lone value input goes bare"
Expand Down
3 changes: 2 additions & 1 deletion docs/id-ledger.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
48,
49,
50,
51
51,
52
],
"AC": [
1,
Expand Down
1 change: 1 addition & 0 deletions docs/traceability.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ dedicated tests:
| NFR-029 | Large templates stay usable within reasonable limits | canvas stress/perf benchmark | [x] **M6 phase 3 realizes this via the NFR-049 density harness**: 121/121 committed docs examples measured with zero regressions (blocks-visible never shrinks, bounding box never grows) after the compact renderer landed — `test/engine-node-adapter/test/ui/density-corpus.test.ts` + `packages/editor-ui/test/density-baseline.json`; FR-133/134 navigation (zoom/fit/minimap/pan, collapse) cover the interaction side (§7.17 row) |
| NFR-049 | Canvas density target: single-value-input rule block ≤ 28 px at 100%; density harness (blocks-visible + bounding box per corpus example, 1440×900) with committed no-regression numbers | density harness over the example corpus (AC-041d) | [x] **M6 phase 3 covered; constants re-tuned by the NFR-050 geometry hardening (see that row) — the density numbers below are the FINAL post-hardening state.** 28px bound: `packages/editor-ui/test/density.test.ts` (hand-built fixtures, engine-free) + `test/engine-node-adapter/test/ui/density-corpus.test.ts` (real engine) — `transon_rule_set__base` measures **24px** at 100% zoom (pre-compaction 26px; the interim pre-hardening value was 20px, given back for correct tab-glyph centering, NFR-050(b)). Harness (§19.4): `density-corpus.test.ts` renders the full 121-example flat docs corpus (`buildExampleCorpus` + `encode()` through the real engine — editor-ui has no engine, AD-008/AD-032, so the corpus sweep lives in the Node adapter package) into a fixed 1440×900 viewport at 100% zoom, records blocks-visible + `getBlocksBoundingBox()` per example, ratchets against committed `packages/editor-ui/test/density-baseline.json` (`UPDATE_DENSITY=1` regen idiom, AD-030-style — a normal run never rubber-stamps). Compact renderer: `packages/editor-ui/src/blockly/theme.ts` `CompactThrasosRenderer`/`CompactConstantProvider` (AD-033 intact, `getClassName()` contains `thrasos`), all vertical constants `GRID_UNIT`-derived (NFR-050(c)): `MEDIUM_PADDING` 5→4, `TAB_HEIGHT` 15→16 (glyph centering, NFR-050(b)), `TAB_OFFSET_FROM_TOP` 5→4, `NOTCH_HEIGHT` 4, `MIN_BLOCK_HEIGHT` 24→20, `TOP/BOTTOM_ROW_MIN_HEIGHT` 5→4, `LARGE_PADDING` 10→8 (zero-gap), `FIELD_BORDER_RECT_HEIGHT` 16; font stays 12px (NFR-045). Final corpus numbers vs pre-M6: median bbox height 127→**120px (−5.5%)**, blocks-visible unchanged (721 total) — the interim −14.2% (109px) was partly bought with the geometry defects NFR-050 fixed; correctness kept, density gain reduced honestly (NFR-050: "density gains never buy geometry defects"). Zero codec-artifact diff (§21.12, verified). |
| NFR-050 | Rendered block geometry invariants (§8.5): (a) zero-gap stacking + shared left edge; (b) label↔child visual alignment (label/field center == connected child center ≤0.5px any height; drawn tab glyph centers on a minimal pill) + no protrusion; (c) `GRID_UNIT`-quantized vertical constants and rendered heights | geometry harness (§19.4) alongside the NFR-049 density harness | [x] **M6 hardening — all three invariants covered, fixture + full-corpus.** Fast harness: `packages/editor-ui/test/geometry.test.ts` (7 tests, red-first: 5 assertions failed against the pre-fix `theme.ts`, reproducing the reported 2px seams); corpus sweep: `test/engine-node-adapter/test/ui/geometry-corpus.test.ts` (all 121 examples, zero-gap + shared-left-edge + no-protrusion + height quantization with ZERO exemptions; unconnected sockets legitimately break a stacked run). Root causes (verified against the `blockly-v13.0.0` renderer sources): **(a)** `thrasos/info.ts#getSpacerRowHeight_` spacer = `LARGE_PADDING`, never shrunk by the density pass — visible gap between stacked value-input children reduces algebraically to `LARGE_PADDING − TAB_OFFSET_FROM_TOP − MEDIUM_PADDING` (= 2px pre-fix, any child height; also defect 3's protrusion). Fixed: `LARGE_PADDING = TAB_OFFSET_FROM_TOP + MEDIUM_PADDING` → exact zero gap + last child flush with the parent's bottom edge. Left edge (defect 4) was already correct (`alignRowElements_`), still asserted. **(b) label↔connection anchoring** (v2.2 §8.5, amended after user feedback: stock thrasos re-centers a row's fields across the row's FINAL stretched height, so a tall child left "Expression op"/"Build object" labels floating mid-void while the tab stayed at the row top): every external-value-input row's field/icon centerline anchors to the **drawn connection tab** — `row.yPos + TAB_HEIGHT/2`, regardless of attached child height. For a minimal-height row this equals the stock row-center placement (short rows render identically — asserted); for stretched rows the label stays at the tab (asserted anchored AND demonstrably not re-centered). Implemented as the ONE placement-rule override the NFR sanctions: `CompactRenderInfo extends Blockly.thrasos.RenderInfo` overriding `getElemCenterline_` (`theme.ts`; thrasos's algorithm otherwise untouched, AD-033-conformant; the internal connection *coordinate* keeps thrasos's row-top convention and is not the specified quantity). Tab-glyph geometry: the interlocked glyph spans `[row.yPos, row.yPos + TAB_HEIGHT]` (child top sits `TAB_OFFSET_FROM_TOP` above the row top; `TAB_OFFSET_FROM_TOP == MEDIUM_PADDING` pinned by name; `TAB_HEIGHT = 16` centers it on a 24px pill). Red-first: the stretched-row test failed against the pre-override renderer; corpus-wide anchoring asserted over all 121 examples in `geometry-corpus.test.ts` (per-block RenderInfo introspection). Field placement does not move row heights: the committed `density-baseline.json` is byte-identical across this fix (ratchet green, no regen). **(c)** `GRID_UNIT = 4` exported from `theme.ts` (+ package index for the corpus sweep); every explicitly-assigned vertical constant and every rendered block height is a multiple — including mutator-bearing blocks: the +/- control glyphs in `packages/editor-blockly/src/runtime.ts` are sized `GLYPH_SIZE=15` so `FieldImage.getSize()` (15 + Blockly's private 1px `Y_PADDING`) = 16 lands on the grid (at 16px the row measured 17px and propagated an odd height into every ancestor of an array/object block). NFR-049 28px bound stays green (set base 24px); density baseline regenerated for the fixed geometry (median 109→120px, blocks-visible unchanged — recorded honestly in the NFR-049 row). AC-040 thrasos assertion green; zero codec-artifact diff (§21.12, verified). |
| NFR-052 | Network-hermetic canvas mount: injecting a workspace triggers no external network request; Blockly's default media path (`static.blockly.com`) never fetched — workspace UI sounds disabled | hermetic-mount test (fetch spy + `hasSounds`) | [x] `sounds: false` in `mountBlockly`'s inject options (`packages/editor-ui/src/blockly/mount.ts`). Red-first: `packages/editor-ui/test/hermetic-mount.test.ts` mounts with a rejecting `fetch` spy — zero calls, `workspace.options.hasSounds === false` (both assertions failed pre-fix: Blockly's `WorkspaceAudio` preloaded 4 sounds from `static.blockly.com` per inject — reproduced with a fetch logger; on CI runners the CDN timed out → 8 unhandled rejections → red `agentic-checks/tests` on main, 2026-07-18) |
| NFR-051 | Codec structural predicates use total primitives (`in`/`length`), never value-sentinel comparisons; floor declared once (AD-037) | artifact scan (no sentinel strings in generators/artifacts) + sentinel-collision round-trip (AC-044) | [x] **RFC-008 slice 3.** Every membership/emptiness helper in `codegen.ts` rewritten onto `in`/`length` with chained-unary-`!` negation (OQ2): `keyPresent`/`thisHasKey`/`fieldsHasMarkerKey`/`decField`/`decInput` presence → direct `in`; `allRequiredPresent`/`noForeignKey`/`isEscape` emptiness → `length == 0` over filtered lists (empty-operand R1 fix preserved vacuously); `@`-time `atHasConstantParams`/`P_HAS_OPTIONS` → `length > 0`; marker-presence (skeleton + blockmap) → `in`. All four sentinel strings deleted (`transon::absent-key`, `@gen` variant, `@noopt`, `__transon_no_marker__`); scan enforced by `sentinel-collision.test.ts` AC-044(c). Artifacts regenerated (AD-030): encoder 419→244 KB (−42%), decoder 163→104 KB (−36%). Floor declared once: `CODEC_ENGINE_FLOOR` (FR-142 row) |

## Open questions
Expand Down
7 changes: 7 additions & 0 deletions packages/editor-ui/src/blockly/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ export function mountBlockly(container: HTMLElement, opts: TransonMountOptions =
// and the workspace is not read-only) so the contract holds regardless of toolbox shape or a
// future Blockly default change — matching the explicit `zoom`/`move` options above.
collapse: true,
// NFR-052: network-hermetic mount. Without this, Blockly's WorkspaceAudio preloads its UI
// sounds from the DEFAULT media path — https://static.blockly.com/media/ — on every inject.
// An embeddable component must not silently call a third-party CDN from the host page, and
// the DOM test suites must not depend on that CDN being reachable (its timeout failed CI as
// unhandled fetch rejections). The audio preload is the media path's only consumer in our
// surface, so disabling sounds removes the editor's last external fetch.
sounds: false,
});

// FR-133 zoom-to-fit — one-action framing of the whole template (@blockly/zoom-to-fit).
Expand Down
31 changes: 31 additions & 0 deletions packages/editor-ui/test/hermetic-mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// NFR-052 — network-hermetic canvas mount: injecting a workspace must trigger NO external
// network request. Without `sounds: false`, Blockly's WorkspaceAudio preloads its four UI sounds
// from the DEFAULT media path — https://static.blockly.com/media/ — on every inject. An
// embeddable component (NFR-043) must not silently call a third-party CDN from the host page,
// and the DOM suites must not depend on that CDN being reachable: its timeout failed CI as
// 8 unhandled fetch rejections (agentic-checks/tests on main, 2026-07-18).
import { describe, it, expect, vi } from 'vitest';
import { mountBlockly } from '../src/blockly/mount.js';

describe('network-hermetic mount (NFR-052)', () => {
it('mounting fetches nothing and disables workspace sounds', () => {
// A rejecting spy: any fetch during mount is both counted and loud.
const spy = vi.fn(() => Promise.reject(new Error('NFR-052: mount must not fetch')));
const origFetch = globalThis.fetch;
globalThis.fetch = spy as unknown as typeof fetch;
const container = document.createElement('div');
document.body.appendChild(container);
try {
const mount = mountBlockly(container);
try {
expect((mount.workspace.options as { hasSounds?: boolean }).hasSounds).toBe(false);
} finally {
mount.dispose();
}
expect(spy).not.toHaveBeenCalled();
} finally {
globalThis.fetch = origFetch;
container.remove();
}
});
});
9 changes: 6 additions & 3 deletions test/engine-node-adapter/test/ui/density-corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,11 @@ describe('NFR-049 density harness over the docs example corpus (§19.4, AC-041(d
}
expect(regressions, regressions.join('\n')).toEqual([]);
},
// CI runners took ~55s pre-§12.6 and hit the old 60s ceiling after — headroom, not license
// to slow down: the empty-palette mount above restores the old per-run cost.
120_000,
// CI runners took ~55s pre-§12.6 at 121 corpus examples; the RFC-008 re-pin grew the corpus
// to 163 and CI now measures 98–120s (main 98.6s / PR#17 112.3s / PR#18 120.3s — the last hit
// the old 120s ceiling exactly, on runner variance alone). 240s is headroom over that
// measured band, not license to slow down: the empty-palette mount above keeps the per-run
// cost, and local runs stay ~13s.
240_000,
);
});
9 changes: 6 additions & 3 deletions test/engine-node-adapter/test/ui/geometry-corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,11 @@ describe('NFR-050 geometry invariants over the docs example corpus (§19.4)', ()
}
expect(violations, violations.join('\n')).toEqual([]);
},
// CI runners took ~55s pre-§12.6 and hit the old 60s ceiling after — headroom, not license
// to slow down: the empty-palette mount above restores the old per-run cost.
120_000,
// CI runners took ~55s pre-§12.6 at 121 corpus examples; the RFC-008 re-pin grew the corpus
// to 163 and CI now measures 98–120s (main 98.6s / PR#17 112.3s / PR#18 120.3s — the last hit
// the old 120s ceiling exactly, on runner variance alone). 240s is headroom over that
// measured band, not license to slow down: the empty-palette mount above keeps the per-run
// cost, and local runs stay ~13s.
240_000,
);
});
Loading