tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit 926c6dee60247284686049cb4810897cab870265
parent da4bd3a11d60191f57b0f569bde26c6586330c8c
Author: James Eversole <james@eversole.co>
Date:   Tue, 20 Oct 2026 20:10:51 -0500

Inspect compiled definitions by clicking their names

Replace the arrow gutter with dotted-underlined declaration-name targets, reclaiming mobile source width. Track current compiled scope, wrapped fragments, scrolling, IME and active-card state without replacing the native textarea.

Provide Edit name and touch-hold selection with native focus/undo; retain short-tap inspection, keyboard navigation, drag selection, context menus and explicit IO execution. Keep stale or unavailable names inert.

Update guides and add trusted mouse/touch/keyboard checks, Unicode-offset and wrapped-name regressions. Retain IO, import, download, recovery and theme controls. Document longer bounded Zig IDE validation and optional phase diagnostics.

Validated with generated JS parity, nix flake check, 134 Node tests, Chromium/Firefox on both runtimes, mobile/desktop layout, native typing/undo and selection, IO/recovery, imports, lesson examples and cache checks. Physical Safari selection handles remain unverified.

Diffstat:
Mext/demo-site/README.md | 33+++++++++++++++++++++++++++------
Mext/demo-site/dist/definition-inspector.js | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mext/demo-site/index.html | 6+++---
Mext/demo-site/reference-input/getting-started.md | 14+++++++++-----
Mext/demo-site/src/definition-inspector.ts | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mext/demo-site/style.css | 17+++++++++++------
Mext/demo-site/test/backend-browser.mjs | 12+++++++++---
Mext/demo-site/test/backend-server.mjs | 3++-
Mext/demo-site/test/basic-examples-probe.mjs | 2+-
Aext/demo-site/test/definition-name-inputs.mjs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/definition-names-probe.mjs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/test/ide-probe.mjs | 5+++++
Mext/demo-site/test/inline-io-probe.mjs | 1+
Mext/demo-site/test/site.test.mjs | 7+++++++
Mext/demo-site/test/syntax-probe.mjs | 21++++++++++++---------
15 files changed, 504 insertions(+), 92 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -9,7 +9,7 @@ articles. **Names and values** loads on arrival, including when no selection fra present; loading verifies a package but never executes it or downloads a compiler kit. Haskell is the initial runtime. -The editor is source-first. **▶** beside a definition opens its compiled value +The editor is source-first. Clicking an underlined definition name opens its compiled value inside the editor; write applications as source definitions rather than in a separate evaluator. Auto recognizes supported IO envelopes before trying value interpretations. IO cards offer an explicit **Run IO**, inline terminal/input, @@ -423,7 +423,7 @@ Other inspection/execution is disabled while the session is busy, including whil Stop must precede replacement of an active attempt. Hard Stop or a fatal WASM failure loses that session's definitions, but preserves source. **Compile now** or another edit rebuilds them; Stop does not automatically restart work. A stopped -observation requires another ▶ click after rebuilding. Published sessions offer +observation requires another click on its definition name after rebuilding. Published sessions offer **Reload package**, which retries the same selection without a full page reload; a failed first load must not turn an editable demo into a source-free package. Changing backend rebuilds a draft from its source. Sessions remain active while @@ -443,7 +443,7 @@ link. Copy important source elsewhere before leaving. ### Source bindings, results, and IO There is no page expression prompt, guided-argument editor, or pure/IO switch. -Write an application as a source binding, then inspect it with ▶. Explicit imports +Write an application as a source binding, then click its underlined name to inspect it. Explicit imports and local definitions form that binding's environment. No temporary bindings are generated by the library picker. The runtime-level `run`/`runExpression` helpers and typed literal validation remain for conformance/embedding; they are not an @@ -668,7 +668,7 @@ program. ## Sessions and cancellation The catalogue and selected package load on arrival. Hash selection links load -the exact pinned package/export. ▶ starts inspection; only Run IO starts host IO. +the exact pinned package/export. Clicking a definition name starts inspection; only Run IO starts host IO. The first source edit queues compilation and loads its compiler on demand. Explicitly selecting the compiler package simply loads that library. Work continues while using Settings or scrolling through the page. Source remains editable during compilation; active evaluation/IO locks editing @@ -1387,6 +1387,15 @@ TEST_IDE=1 ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs n # Use backend-firefox.mjs and nixpkgs#firefox for Firefox. ``` +Use `TEST_DEFINITION_NAMES=1` for declaration-name inspection, editing/focus, +Unicode offsets, invalidation, IME, and wrapped/scrolled hit rectangles. Chromium +also sends trusted mouse/touch and Tab/Enter/Space/Escape events, verifies native +rename/undo and cross-name drag selection, touch-hold selection and panning +cancellation at 390/1440 pixels. `TEST_SCREENSHOT_PREFIX` saves closed/open card +screenshots at those widths. Firefox runs the shared compiled-definition and +DOM/geometry checks; it does not run the CDP input checks. Physical mobile Safari +keyboard/selection-handle behavior is not covered by these automated checks. + The focused inline IO suite can be run with `TEST_INLINE_IO=1` in that harness. It includes source-free IO, private entries, envelope-only recognition of a program with a divergent action, explicit Run, CPU-bound cancellation, closing @@ -1398,11 +1407,23 @@ The full Zig adapter corpus (the default harness probe with `TEST_BACKEND=zig-wasm`, without an opt-in UI-suite flag) also recompiles all current and explicitly retained historical demo sources. For that longer run, set `TEST_CDP_TIMEOUT_MS=300000` and use `run-bounded.py --seconds 330` around -Node. The default CDP deadline remains 180 seconds for focused groups. +Node. Use that longer budget for `TEST_IDE=1 TEST_BACKEND=zig-wasm` as well: +its repeated full-module compilation and expansion checks can exceed the default +180-second deadline. `TEST_DIAGNOSTICS=1` logs test phases and read-only page status +snapshots every 30 seconds; it is off by default, including idle-soak checks. ### Inline definition inspection and library discovery -The source gutter's **▶** toggles an anchored compiled-definition card. Opening +Underlined top-level declaration names toggle an anchored compiled-definition card. +There is no inspection gutter or reserved horizontal column. Names are interactive +only for the current successfully compiled scope (public roots for published +packages). References and local binders stay ordinary editable text. Tab then +Enter/Space also opens the card. **Edit name** closes it and selects the identifier +in the native textarea for renaming; read-only source disables that action. +A touch/pen hold selects the identifier in the native editor without inspecting +it (also available through **Edit name**); selection drags and native context +menus do not trigger inspection. IME composition hides the hit areas. Wrapped identifiers have separate hit rectangles +per fragment, avoiding adjacent expression text. Opening starts with **Auto**, checking the supported IO envelope, then preferring readable text, a natural number, a decoded list of text/naturals, a Boolean, bytes, then tricu tree source. The status names the chosen interpretation (for example **Auto → Text**); this is a display heuristic, diff --git a/ext/demo-site/dist/definition-inspector.js b/ext/demo-site/dist/definition-inspector.js @@ -15,9 +15,9 @@ export { valueText as inspectionText } from './codecs.js'; export function definitionInspector(input, request, io) { const wrapper = input.parentElement; wrapper.classList.add('has-definition-inspector'); - const gutter = document.createElement('div'); - gutter.className = 'definition-gutter'; - wrapper.append(gutter); + const targets = document.createElement('div'); + targets.className = 'definition-targets'; + wrapper.append(targets); const card = document.createElement('section'); card.className = 'definition-card'; card.hidden = true; @@ -48,8 +48,13 @@ export function definitionInspector(input, request, io) { const copy = document.createElement('button'), download = document.createElement('button'); copy.textContent = 'Copy'; download.textContent = 'Download'; + const edit = document.createElement('button'); + edit.type = 'button'; + edit.textContent = 'Edit name'; actions.append(copy, download); - card.append(actions); + card.append(actions, edit); + edit.className = 'edit-definition-name'; + edit.hidden = true; const ioPanel = document.createElement('section'); ioPanel.className = 'inline-io'; ioPanel.hidden = true; @@ -133,29 +138,135 @@ export function definitionInspector(input, request, io) { let pending, last = '', generation = 0, wasReady = false, scheduled = false; const buttons = new Map(); const declarationIndices = new Map(); - const forcedColors = matchMedia('(forced-colors: active)'); - forcedColors.addEventListener('change', position); + const fragments = new Map(); + function selectName(name) { + if (busy || input.disabled || wrapper.classList.contains('composing')) + return; + const site = definitionSites(input.value).find(site => site.name === name); + if (!site) + return; + hide(); + input.focus({ preventScroll: true }); + input.setSelectionRange(site.start, site.start + site.name.length); + input.dispatchEvent(new Event('select')); + } + function makeTarget(name) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = name; + button.dataset.definition = name; + button.setAttribute('aria-label', `Inspect compiled definition ${name}`); + button.setAttribute('aria-controls', card.id); + button.setAttribute('aria-expanded', String(selected === name)); + let gesture; + let hold; + const cancelHold = () => { clearTimeout(hold); hold = undefined; }; + button.onpointerdown = event => { + cancelHold(); + const current = { x: event.clientX, y: event.clientY, time: performance.now(), cancelled: false, moved: false, touch: event.pointerType === 'touch' || event.pointerType === 'pen' }; + gesture = current; + if (current.touch) + hold = setTimeout(() => { + if (gesture === current && !current.cancelled) { + current.cancelled = true; + if (button.isConnected && !button.disabled && !button.hidden) + selectName(name); + } + }, 500); + }; + button.onpointermove = event => { + if (gesture && Math.hypot(event.clientX - gesture.x, event.clientY - gesture.y) > 8) { + gesture.cancelled = gesture.moved = true; + cancelHold(); + } + }; + button.onpointerup = () => { + cancelHold(); + // Repeat selection in the release's user gesture: mobile keyboard focus + // must not depend solely on a timer callback's transient activation. + if (gesture?.touch && !gesture.moved && performance.now() - gesture.time >= 500 && button.isConnected && !button.disabled && !button.hidden) { + gesture.cancelled = true; + selectName(name); + } + }; + // Touch generates a compatibility mousedown on release. After a hold it + // must not steal focus back from the textarea selection we just created. + button.onmousedown = event => { if (gesture?.touch && gesture.cancelled && !gesture.moved) + event.preventDefault(); }; + button.onpointercancel = () => { if (gesture) + gesture.cancelled = true; cancelHold(); }; + button.oncontextmenu = () => { + cancelHold(); + if (gesture) { + if (gesture.touch && !gesture.moved && button.isConnected && !button.disabled && !button.hidden) + selectName(name); + gesture.cancelled = true; + } + }; + button.onclick = event => { + cancelHold(); + const selection = document.getSelection(); + if (event.detail && (gesture && (gesture.cancelled || performance.now() - gesture.time > 500) || + selection && !selection.isCollapsed && (button.contains(selection.anchorNode) || button.contains(selection.focusNode)))) + return; + if (ready && !busy && !wrapper.classList.contains('composing')) { + choose(name); + if (!event.detail && !card.hidden) + result.focus({ preventScroll: true }); + } + }; + targets.append(button); + return button; + } function position() { const mirror = wrapper.querySelector('.syntax-viewport pre'); if (!mirror) return; const declarations = [...mirror.querySelectorAll('.syntax-definition')]; - const rect = wrapper.getBoundingClientRect(); - for (const [name, button] of buttons) { + const rect = targets.getBoundingClientRect(); + const style = getComputedStyle(input), visibleHeight = input.clientHeight; + const height = Math.max(1, Number.parseFloat(style.lineHeight) - 2), font = style.font; + // Read text geometry before writing overlay styles (no per-name layout flush). + const geometry = new Map([...buttons.keys()].map(name => { const span = declarations[declarationIndices.get(name) ?? -1]; - button.textContent = forcedColors.matches ? `▶ ${name}` : '▶'; - // Offscreen controls have [hidden], so reveal before measuring height. - button.hidden = false; - // Inline spans report the glyph box, not the line box. Center the - // actual control on the first text fragment (also handles wrapped names). - const text = span?.getClientRects()[0]; - const top = text ? text.top + text.height / 2 - rect.top - button.offsetHeight / 2 : -100; - button.style.top = `${top}px`; - button.hidden = !forcedColors.matches && (top < 0 || top + 20 > input.clientHeight); + return [name, span ? [...span.getClientRects()] : []]; + })); + for (const [name, primary] of buttons) { + const boxes = geometry.get(name); + const group = fragments.get(name); + // A wrapped identifier has separate hit areas, never a large bounding + // rectangle that also intercepts the expression beside its last line. + while (group.length < boxes.length) { + const button = makeTarget(name); + group[group.length - 1].after(button); + group.push(button); + } + let tabStop = false; + for (let i = 0; i < group.length; i++) { + const button = group[i], text = boxes[i]; + button.disabled = primary.disabled; + button.title = primary.title; + button.setAttribute('aria-expanded', String(selected === name && !card.hidden)); + const top = text ? text.top + text.height / 2 - rect.top - height / 2 : -100; + button.hidden = (primary.disabled && !(ready && selected === name && !card.hidden)) || !text || top < 0 || top + height > visibleHeight; + button.tabIndex = !button.hidden && !button.disabled && !tabStop ? 0 : -1; + if (button.tabIndex === 0) + tabStop = true; + if (text) { + button.style.top = `${top}px`; + button.style.left = `${text.left - rect.left}px`; + button.style.width = `${text.width}px`; + button.style.height = `${height}px`; + button.style.font = font; + } + } } + edit.hidden = input.hidden || !selected || !buttons.has(selected); + edit.disabled = busy || input.disabled || input.readOnly; + edit.title = input.readOnly ? 'This source is read-only.' : 'Select the declaration name in the editor.'; card.style.maxHeight = `${input.hidden ? Math.min(540, innerHeight * .75) : Math.min(input.clientHeight - 16, innerHeight * .75, ioDetected ? 540 : 260)}px`; if (selected && !card.hidden && !input.hidden) { - const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + 26; + const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + height + 4; card.style.top = `${Math.max(8, Math.min(top, input.clientHeight - card.offsetHeight - 8))}px`; } } @@ -209,15 +320,18 @@ export function definitionInspector(input, request, io) { const name = selected; selected = undefined; card.hidden = true; - for (const button of buttons.values()) + for (const button of targets.querySelectorAll('button')) button.setAttribute('aria-expanded', 'false'); - buttons.get(name ?? '')?.focus(); + const target = fragments.get(name ?? '')?.find(button => !button.hidden && !button.disabled); + (target ?? (busy ? document.getElementById('stop') : undefined))?.focus({ preventScroll: true }); } close.onclick = hide; card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); hide(); } }); + edit.onclick = () => { if (selected && !input.readOnly) + selectName(selected); }; format.onchange = () => { suspended = false; last = ''; schedule(); }; function update(current, working, publishedNames) { ready = current; @@ -236,22 +350,16 @@ export function definitionInspector(input, request, io) { ioPanel.hidden = true; clear(); source = input.value; - gutter.replaceChildren(); + targets.replaceChildren(); buttons.clear(); + fragments.clear(); declarationIndices.clear(); const declarations = tokenize(source).filter(token => token.kind === 'definition'); for (const site of definitionSites(source)) { declarationIndices.set(site.name, declarations.findIndex(token => token.start === site.start)); - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = '▶'; - button.setAttribute('aria-label', `Inspect compiled definition ${site.name}`); - button.title = `Inspect ${site.name}`; - button.setAttribute('aria-controls', card.id); - button.setAttribute('aria-expanded', String(selected === site.name)); - button.onclick = () => choose(site.name); - gutter.append(button); + const button = makeTarget(site.name); buttons.set(site.name, button); + fragments.set(site.name, [button]); } if (selected && !buttons.has(selected)) { selected = undefined; @@ -266,7 +374,7 @@ export function definitionInspector(input, request, io) { card.hidden = true; result.textContent = ''; } - gutter.hidden = input.hidden; + targets.hidden = input.hidden; for (const [name, button] of buttons) { const available = !publishedNames || publishedNames.includes(name); button.disabled = !ready || busy || !available; @@ -284,7 +392,7 @@ export function definitionInspector(input, request, io) { ioPanel.hidden = true; clear(); if (selected) { - status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click ▶ to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; + status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click the definition name to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; result.textContent = ''; } } @@ -333,7 +441,7 @@ export function definitionInspector(input, request, io) { // Stop is an explicit suspension, not an invitation to re-observe a divergent // binding automatically as soon as the same source is compiled again. stopped(message, suspend = false) { suspended = suspend; ioRunning = false; inputForm.hidden = true; next.disabled = true; stop.disabled = true; status.textContent = message; }, - reset() { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of buttons.values()) + reset() { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of targets.querySelectorAll('button')) button.setAttribute('aria-expanded', 'false'); }, update, reply(message) { diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -50,7 +50,7 @@ <div class="editor-main" data-inspector-background> <section class="source-panel" aria-labelledby="source-title"> <div class="source-heading"><h2 id="source-title">Source</h2><span id="source-label">tricu / stable</span></div> - <p id="playground-intro" class="hint" hidden><strong>Try the Playground:</strong> click ▶ beside a definition to inspect its compiled tree and choose an interpretation. Write calls as source definitions. IO programs offer an explicit <strong>Run IO</strong> button inside their card.</p> + <p id="playground-intro" class="hint" hidden><strong>Try the Playground:</strong> click an underlined definition name to inspect its compiled tree and choose an interpretation. Write calls as source definitions. IO programs offer an explicit <strong>Run IO</strong> button inside their card.</p> <p id="authorship">Published package</p><p id="source-availability" class="hint" hidden></p> <label class="sr-only" for="source-editor">tricu source (up to 16,384 characters)</label> <textarea id="source-editor" rows="18" maxlength="32768" spellcheck="false" autocapitalize="off" autocorrect="off" hidden></textarea> @@ -58,13 +58,13 @@ <section id="package-definitions" hidden><label for="package-definition-search">Search stored definitions</label><input id="package-definition-search" type="search"><div id="package-definition-list"></div><div id="package-definition-view"></div></section> <p id="operation-diagnostic" class="error" role="status" hidden></p> <p id="compile-diagnostic" class="error" role="status" hidden></p><p id="compile-activity" class="hint" role="status" hidden></p> - <div class="source-actions"><button id="reload-package" hidden>Reload package</button><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. ▶ inspects; Run IO executes.</span></div> + <div class="source-actions"><button id="reload-package" hidden>Reload package</button><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Underlined names inspect; Run IO executes.</span></div> </section> </div> <aside id="settings-panel" aria-labelledby="settings-title" hidden> <div class="inspector-heading"><h2 id="settings-title">Editor settings</h2><button id="settings-close" aria-label="Close settings">Close</button></div> <section><h3>Execution</h3> - <p class="hint">▶ inspects a compiled definition. Recognized IO programs offer an explicit Run IO button. Stop discards the active runtime.</p> + <p class="hint">Click an underlined definition name to inspect. Recognized IO programs offer an explicit Run IO button. Stop discards the active runtime.</p> <button id="reset">Clear inspection</button><button id="share" disabled>Copy published selection link</button> </section> <section id="package-selection"><h3>Published package</h3> diff --git a/ext/demo-site/reference-input/getting-started.md b/ext/demo-site/reference-input/getting-started.md @@ -22,7 +22,7 @@ identity x = x answer = identity 42 ``` -After a pause, the status becomes **Compiled**. Click **▶** beside `answer`; +After a pause, the status becomes **Compiled**. Click the underlined `answer` name; **Auto → Natural number** displays `42`. To explore a different application, write another definition in Source and inspect it. There is no separate expression panel. Source edits compile automatically. **Stop** discards ongoing work; edit again or @@ -35,7 +35,7 @@ definitions are inspectable too. Every native module needs at least one export; ## Inspect a compiled definition -Click **▶** beside a top-level definition to open its tree inside the source +Click an underlined top-level definition name to open its tree inside the source editor. **Auto** tries readable text, a natural number, a list of text/naturals, a Boolean, bytes, then tree source, and labels its choice (for example **Auto → Text**). This is a display preference, not an inferred type. Empty trees @@ -49,13 +49,17 @@ No arguments are applied. Opening a card never executes IO. Auto first checks for the supported IO envelope, as described below. Open inspections refresh after successful compilation, preserving a manual interpretation. -Clicking ▶ again closes the card; reopening starts with Auto. Editing, failed +**Edit name** closes the card and selects the identifier for renaming. Tab then +Enter/Space also opens a highlighted name. Ordinary references and local variables +remain text; uncompiled names have no clickable underline. + +Clicking the name again closes the card; reopening starts with Auto. Editing, failed compilation, and Stop clear the old tree. Published packages expose only their public definitions; private bindings become inspectable after editing compiles the source locally. Reading/decoding trees can require reduction and can diverge; **Stop** terminates the owning runtime. Inline text previews show at most 16,384 characters; **Copy** and **Download** in the card include the complete output. -After Stop, recompile/reload and click ▶ to retry; recompilation does not +After Stop, recompile/reload and click the definition name to retry; recompilation does not automatically restart a cancelled observation. ## Find and import libraries @@ -112,7 +116,7 @@ to a handler. Library protocols matter as much as function names. ## Run IO inside a definition card -Select the conversation demo and click ▶ beside `main`. A tree with the +Select the conversation demo and click the underlined `main` name. A tree with the `"tricuIO"` sentinel and supported version 1 envelope opens an **IO program** card. Recognition examines the envelope only, not its action or continuations. It can still require reduction or diverge; Stop stays available. diff --git a/ext/demo-site/src/definition-inspector.ts b/ext/demo-site/src/definition-inspector.ts @@ -21,7 +21,7 @@ export { valueText as inspectionText } from './codecs.js'; export function definitionInspector(input: HTMLTextAreaElement, request: (id: number, name: string, output: InspectionFormat) => void, io: IOControls) { const wrapper = input.parentElement!; wrapper.classList.add('has-definition-inspector'); - const gutter = document.createElement('div'); gutter.className = 'definition-gutter'; wrapper.append(gutter); + const targets = document.createElement('div'); targets.className = 'definition-targets'; wrapper.append(targets); const card = document.createElement('section'); card.className = 'definition-card'; card.hidden = true; card.id = 'inline-definition'; card.setAttribute('aria-label', 'Compiled definition'); const title = document.createElement('strong'), format = document.createElement('select'); @@ -35,7 +35,8 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu card.append(result, status, heading); wrapper.append(card); const actions = document.createElement('div'); actions.className = 'definition-actions'; const copy = document.createElement('button'), download = document.createElement('button'); - copy.textContent = 'Copy'; download.textContent = 'Download'; actions.append(copy, download); card.append(actions); + copy.textContent = 'Copy'; download.textContent = 'Download'; const edit = document.createElement('button'); edit.type = 'button'; edit.textContent = 'Edit name'; + actions.append(copy, download); card.append(actions, edit); edit.className = 'edit-definition-name'; edit.hidden = true; const ioPanel = document.createElement('section'); ioPanel.className = 'inline-io'; ioPanel.hidden = true; ioPanel.innerHTML = `<div class="inline-io-actions"><button type="button" id="run-io">Run IO</button><button type="button" id="stop-io" disabled>Stop</button></div> <p class="hint">Recognized tricuIO v1 envelope. Only Run IO executes actions.</p> @@ -88,26 +89,106 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu let pending: number | undefined, last = '', generation = 0, wasReady = false, scheduled = false; const buttons = new Map<string, HTMLButtonElement>(); const declarationIndices = new Map<string, number>(); - const forcedColors = matchMedia('(forced-colors: active)'); - forcedColors.addEventListener('change', position); + const fragments = new Map<string, HTMLButtonElement[]>(); + function selectName(name: string): void { + if (busy || input.disabled || wrapper.classList.contains('composing')) return; + const site = definitionSites(input.value).find(site => site.name === name); + if (!site) return; + hide(); input.focus({ preventScroll: true }); + input.setSelectionRange(site.start, site.start + site.name.length); + input.dispatchEvent(new Event('select')); + } + function makeTarget(name: string): HTMLButtonElement { + const button = document.createElement('button'); button.type = 'button'; button.textContent = name; + button.dataset.definition = name; + button.setAttribute('aria-label', `Inspect compiled definition ${name}`); + button.setAttribute('aria-controls', card.id); button.setAttribute('aria-expanded', String(selected === name)); + // Short taps inspect. A touch hold selects the real textarea identifier; + // buttons do not acquire native editable-word selection on their own. + // Never cancel pointerdown or the context menu, and cancel holds on drags. + type Gesture = { x: number; y: number; time: number; cancelled: boolean; moved: boolean; touch: boolean }; + let gesture: Gesture | undefined; + let hold: ReturnType<typeof setTimeout> | undefined; + const cancelHold = () => { clearTimeout(hold); hold = undefined; }; + button.onpointerdown = event => { + cancelHold(); + const current: Gesture = { x: event.clientX, y: event.clientY, time: performance.now(), cancelled: false, moved: false, touch: event.pointerType === 'touch' || event.pointerType === 'pen' }; + gesture = current; + if (current.touch) hold = setTimeout(() => { + if (gesture === current && !current.cancelled) { current.cancelled = true; if (button.isConnected && !button.disabled && !button.hidden) selectName(name); } + }, 500); + }; + button.onpointermove = event => { + if (gesture && Math.hypot(event.clientX - gesture.x, event.clientY - gesture.y) > 8) { + gesture.cancelled = gesture.moved = true; cancelHold(); + } + }; + button.onpointerup = () => { + cancelHold(); + // Repeat selection in the release's user gesture: mobile keyboard focus + // must not depend solely on a timer callback's transient activation. + if (gesture?.touch && !gesture.moved && performance.now() - gesture.time >= 500 && button.isConnected && !button.disabled && !button.hidden) { + gesture.cancelled = true; selectName(name); + } + }; + // Touch generates a compatibility mousedown on release. After a hold it + // must not steal focus back from the textarea selection we just created. + button.onmousedown = event => { if (gesture?.touch && gesture.cancelled && !gesture.moved) event.preventDefault(); }; + button.onpointercancel = () => { if (gesture) gesture.cancelled = true; cancelHold(); }; + button.oncontextmenu = () => { + cancelHold(); + if (gesture) { if (gesture.touch && !gesture.moved && button.isConnected && !button.disabled && !button.hidden) selectName(name); gesture.cancelled = true; } + }; + button.onclick = event => { + cancelHold(); + const selection = document.getSelection(); + if (event.detail && (gesture && (gesture.cancelled || performance.now() - gesture.time > 500) || + selection && !selection.isCollapsed && (button.contains(selection.anchorNode) || button.contains(selection.focusNode)))) return; + if (ready && !busy && !wrapper.classList.contains('composing')) { + choose(name); + if (!event.detail && !card.hidden) result.focus({ preventScroll: true }); + } + }; + targets.append(button); return button; + } function position(): void { const mirror = wrapper.querySelector('.syntax-viewport pre'); if (!mirror) return; const declarations = [...mirror.querySelectorAll<HTMLElement>('.syntax-definition')]; - const rect = wrapper.getBoundingClientRect(); - for (const [name, button] of buttons) { + const rect = targets.getBoundingClientRect(); + const style = getComputedStyle(input), visibleHeight = input.clientHeight; + const height = Math.max(1, Number.parseFloat(style.lineHeight) - 2), font = style.font; + // Read text geometry before writing overlay styles (no per-name layout flush). + const geometry = new Map([...buttons.keys()].map(name => { const span = declarations[declarationIndices.get(name) ?? -1]; - button.textContent = forcedColors.matches ? `▶ ${name}` : '▶'; - // Offscreen controls have [hidden], so reveal before measuring height. - button.hidden = false; - // Inline spans report the glyph box, not the line box. Center the - // actual control on the first text fragment (also handles wrapped names). - const text = span?.getClientRects()[0]; - const top = text ? text.top + text.height / 2 - rect.top - button.offsetHeight / 2 : -100; - button.style.top = `${top}px`; button.hidden = !forcedColors.matches && (top < 0 || top + 20 > input.clientHeight); + return [name, span ? [...span.getClientRects()] : []] as const; + })); + for (const [name, primary] of buttons) { + const boxes = geometry.get(name)!; + const group = fragments.get(name)!; + // A wrapped identifier has separate hit areas, never a large bounding + // rectangle that also intercepts the expression beside its last line. + while (group.length < boxes.length) { const button = makeTarget(name); group[group.length - 1]!.after(button); group.push(button); } + let tabStop = false; + for (let i = 0; i < group.length; i++) { + const button = group[i]!, text = boxes[i]; + button.disabled = primary.disabled; button.title = primary.title; + button.setAttribute('aria-expanded', String(selected === name && !card.hidden)); + const top = text ? text.top + text.height / 2 - rect.top - height / 2 : -100; + button.hidden = (primary.disabled && !(ready && selected === name && !card.hidden)) || !text || top < 0 || top + height > visibleHeight; + button.tabIndex = !button.hidden && !button.disabled && !tabStop ? 0 : -1; + if (button.tabIndex === 0) tabStop = true; + if (text) { + button.style.top = `${top}px`; button.style.left = `${text.left - rect.left}px`; + button.style.width = `${text.width}px`; button.style.height = `${height}px`; + button.style.font = font; + } + } } + edit.hidden = input.hidden || !selected || !buttons.has(selected); edit.disabled = busy || input.disabled || input.readOnly; + edit.title = input.readOnly ? 'This source is read-only.' : 'Select the declaration name in the editor.'; card.style.maxHeight = `${input.hidden ? Math.min(540, innerHeight * .75) : Math.min(input.clientHeight - 16, innerHeight * .75, ioDetected ? 540 : 260)}px`; if (selected && !card.hidden && !input.hidden) { - const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + 26; + const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + height + 4; card.style.top = `${Math.max(8, Math.min(top, input.clientHeight - card.offsetHeight - 8))}px`; } } @@ -130,11 +211,13 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu function hide(): void { if (ioRunning) io.stop(); const name = selected; selected = undefined; card.hidden = true; - for (const button of buttons.values()) button.setAttribute('aria-expanded', 'false'); - buttons.get(name ?? '')?.focus(); + for (const button of targets.querySelectorAll('button')) button.setAttribute('aria-expanded', 'false'); + const target = fragments.get(name ?? '')?.find(button => !button.hidden && !button.disabled); + (target ?? (busy ? document.getElementById('stop') : undefined))?.focus({ preventScroll: true }); } close.onclick = hide; card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); hide(); } }); + edit.onclick = () => { if (selected && !input.readOnly) selectName(selected); }; format.onchange = () => { suspended = false; last = ''; schedule(); }; function update(current: boolean, working: boolean, publishedNames?: string[]): void { ready = current; busy = working; @@ -143,21 +226,18 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu wasReady = ready; if (source !== input.value) { ioDetected = false; ioPanel.hidden = true; clear(); - source = input.value; gutter.replaceChildren(); buttons.clear(); declarationIndices.clear(); + source = input.value; targets.replaceChildren(); buttons.clear(); fragments.clear(); declarationIndices.clear(); const declarations = tokenize(source).filter(token => token.kind === 'definition'); for (const site of definitionSites(source)) { declarationIndices.set(site.name, declarations.findIndex(token => token.start === site.start)); - const button = document.createElement('button'); button.type = 'button'; button.textContent = '▶'; - button.setAttribute('aria-label', `Inspect compiled definition ${site.name}`); button.title = `Inspect ${site.name}`; - button.setAttribute('aria-controls', card.id); button.setAttribute('aria-expanded', String(selected === site.name)); button.onclick = () => choose(site.name); - gutter.append(button); buttons.set(site.name, button); + const button = makeTarget(site.name); buttons.set(site.name, button); fragments.set(site.name, [button]); } if (selected && !buttons.has(selected)) { selected = undefined; card.hidden = true; result.textContent = ''; } } if (selected && publishedNames && !publishedNames.includes(selected)) { selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; } - gutter.hidden = input.hidden; + targets.hidden = input.hidden; for (const [name, button] of buttons) { const available = !publishedNames || publishedNames.includes(name); button.disabled = !ready || busy || !available; @@ -168,7 +248,7 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu copy.disabled = download.disabled = busy || !saved; if (!ready) { pending = undefined; last = ''; ioDetected = false; ioPanel.hidden = true; clear(); - if (selected) { status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click ▶ to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; result.textContent = ''; } + if (selected) { status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click the definition name to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; result.textContent = ''; } } position(); schedule(); } @@ -189,7 +269,7 @@ export function definitionInspector(input: HTMLTextAreaElement, request: (id: nu // Stop is an explicit suspension, not an invitation to re-observe a divergent // binding automatically as soon as the same source is compiled again. stopped(message: string, suspend = false): void { suspended = suspend; ioRunning = false; inputForm.hidden = true; next.disabled = true; stop.disabled = true; status.textContent = message; }, - reset(): void { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of buttons.values()) button.setAttribute('aria-expanded', 'false'); }, + reset(): void { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of targets.querySelectorAll('button')) button.setAttribute('aria-expanded', 'false'); }, update, reply(message: InspectionReply): boolean { if (message.id !== pending) return false; diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -51,11 +51,14 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} #theme-toggle:hover{background:var(--panel)} -#source-editor.syntax-input{padding-left:48px} -.definition-gutter{position:absolute;inset:0 auto 0 0;width:38px;overflow:hidden;pointer-events:none;z-index:2} -.definition-gutter button{position:absolute;left:7px;width:24px;height:20px;min-height:0;display:grid;place-items:center;line-height:1;padding:0;border:0;background:transparent;color:#bfe2ba;font-size:13px;pointer-events:auto} -.definition-gutter button:hover,.definition-gutter button[aria-expanded="true"]{background:#385045;color:#fff} -.definition-card{position:absolute;left:40px;right:12px;z-index:3;max-height:260px;overflow:auto;margin:0;padding:12px;border:1px solid #587362;border-radius:8px;background:#21352b;color:#e3edde;min-width:0} +.definition-targets{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:2} +.definition-targets button{position:absolute;min-height:0;min-width:0;margin:0;padding:0;border:0;border-bottom:1px dotted var(--syn-definition);border-radius:0;background:transparent;color:transparent;-webkit-text-fill-color:transparent;white-space:nowrap;overflow:hidden;pointer-events:auto;user-select:text;-webkit-user-select:text;touch-action:manipulation;forced-color-adjust:none} +.definition-targets button:hover:not(:disabled),.definition-targets button[aria-expanded="true"]{background:#8bd3ad24;border-bottom:1px solid var(--syn-definition)} +.definition-targets button:disabled{pointer-events:none;opacity:1} +.definition-targets button:focus-visible{outline:2px solid #bfe2ba;outline-offset:2px} +.definition-targets button::selection{background:#799ea066;color:#e3edde;-webkit-text-fill-color:#e3edde} +.syntax-editor.composing .definition-targets{display:none} +.definition-card{position:absolute;left:8px;right:8px;z-index:3;max-height:260px;overflow:auto;margin:0;padding:12px;border:1px solid #587362;border-radius:8px;background:#21352b;color:#e3edde;min-width:0} .definition-card-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:8px;border-top:1px solid #385045} .definition-card-controls strong{flex:1;min-width:0;overflow-wrap:anywhere;font-size:12px;font-weight:500;color:#b1c1b7} .definition-card select,.definition-card button{background:transparent;color:#b1c1b7;border-color:#385045;max-width:100%;font-size:12px;min-height:32px;padding:4px 6px} @@ -67,7 +70,7 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} #import-exports{display:flex;gap:6px;flex-wrap:wrap}#import-exports button{font:inherit;font-size:13px;padding:5px 8px} #import-results h3{margin-bottom:0}#try-library,#copy-qualified{margin:8px 8px 8px 0} -@media(forced-colors:active){.definition-gutter{position:static;width:auto;display:flex;flex-wrap:wrap;gap:8px;padding:8px;pointer-events:auto}.definition-gutter button{position:static;width:auto;height:auto;border:1px solid ButtonText;color:ButtonText}.definition-card{background:Canvas;color:CanvasText;border-color:CanvasText}.definition-card .hint,.definition-card button,.definition-card select{color:CanvasText;background:Canvas}} +@media(forced-colors:active){.has-definition-inspector .syntax-viewport{display:block;visibility:hidden}.definition-targets{--syn-definition:ButtonText}.definition-targets button{border-color:ButtonText}.definition-targets button:hover:not(:disabled),.definition-targets button[aria-expanded="true"]{background:transparent}.definition-targets button::selection{background:Highlight;color:HighlightText;-webkit-text-fill-color:HighlightText}.definition-targets button:focus-visible{outline-color:Highlight}.definition-card{background:Canvas;color:CanvasText;border-color:CanvasText}.definition-card .hint,.definition-card button,.definition-card select{color:CanvasText;background:Canvas}} /* Definition cards own both inspection and explicitly authorized IO. */ .definition-card .definition-actions,.inline-io-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} @@ -84,3 +87,5 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} #package-definitions label{color:inherit}#operation-diagnostic{color:#ffc5b9} /* Cards share the source's dark surface even when the surrounding page is light. */ .definition-card{color-scheme:dark;--green:#8bd3ad} + +.definition-card .edit-definition-name{display:inline-block;width:auto;height:auto;padding:4px 10px;margin-top:8px;font-size:12px;border:1px solid #587362} diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -8,7 +8,7 @@ import {resolve} from 'node:path'; import {startSiteServer} from './backend-server.mjs'; const {server,url,control,marks}=await startSiteServer(process.argv[2]); const profile = await mkdtemp(resolve(tmpdir(), 'tricu-demo-chromium-')); -let browser, socket, stopMemory; +let browser, socket, stopMemory, diagnostics; const requestTimeout=Number(process.env.TEST_CDP_TIMEOUT_MS??180000); assert(Number.isSafeInteger(requestTimeout)&&requestTimeout>=1000&&requestTimeout<=900000); let next = 0; const pending = new Map(); @@ -57,7 +57,12 @@ try { }; for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));} if(process.env.TEST_SCHEME){assert(['light','dark'].includes(process.env.TEST_SCHEME));await evaluate(`(()=>{if(document.documentElement.dataset.theme!==${JSON.stringify(process.env.TEST_SCHEME)})document.getElementById('theme-toggle').click();})()`);} - const report=await evaluate(`import(${JSON.stringify(process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + if(process.env.TEST_DIAGNOSTICS)diagnostics=setInterval(()=>{ + console.log('Test phases:',JSON.stringify(marks.slice(-4))); + void evaluate("({status:document.getElementById('status')?.textContent,activity:document.getElementById('compile-activity')?.textContent,card:document.querySelector('.definition-card [role=status]')?.textContent})").then(state=>console.log('Page:',JSON.stringify(state))).catch(()=>{}); + },30000); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_DEFINITION_NAMES?'/definition-names-probe.mjs':process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + if(process.env.TEST_DEFINITION_NAMES)await (await import('./definition-name-inputs.mjs')).definitionNameInputs({send,evaluate,report,prefix:process.env.TEST_SCREENSHOT_PREFIX}); if(process.env.TEST_INLINE_IO&&process.env.TEST_SCREENSHOT_PREFIX){ await evaluate("document.getElementById('run-io').click()"); for(let i=0;!await evaluate("document.getElementById('status').textContent==='Waiting for input'");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} @@ -128,7 +133,7 @@ try { } if(process.env.TEST_IDE){ await send('Emulation.setEmulatedMedia',{features:[{name:'forced-colors',value:'active'}]}); - assert.equal(await evaluate("getComputedStyle(document.querySelector('.syntax-viewport')).display==='none'&&getComputedStyle(document.getElementById('source-editor')).webkitTextFillColor!=='rgba(0, 0, 0, 0)'"),true,'High contrast retains readable native text'); + assert.equal(await evaluate("getComputedStyle(document.querySelector('.syntax-viewport')).visibility==='hidden'&&getComputedStyle(document.getElementById('source-editor')).webkitTextFillColor!=='rgba(0, 0, 0, 0)'"),true,'High contrast retains readable native text'); await send('Emulation.setEmulatedMedia',{features:[]}); const beforeEdit=await evaluate("(()=>{const e=document.getElementById('source-editor');e.focus();e.setSelectionRange(e.value.length,e.value.length);return e.value;})()"); await send('Input.insertText',{text:' -- native undo'}); @@ -158,6 +163,7 @@ try { if(report.parity)console.log(JSON.stringify(report.parity)); console.log('PASS actual site Chromium: '+report.checks.join(' / ')); } finally { + clearInterval(diagnostics); if(stopMemory)await stopMemory(marks); for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); } pending.clear(); socket?.close(); diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -32,6 +32,7 @@ export async function startSiteServer(directory,autoRun=false) { res.end('ok');return; } if(path==='/source-probe-helpers.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./source-probe-helpers.mjs',import.meta.url)));return;} + if(path==='/definition-names-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./definition-names-probe.mjs',import.meta.url)));return;} if(path==='/inline-io-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./inline-io-probe.mjs',import.meta.url)));return;} if(path==='/asset-cache-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./asset-cache-probe.mjs',import.meta.url)));return;} if(path==='/zig-negative.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(fixtures()));return;} @@ -81,7 +82,7 @@ export async function startSiteServer(directory,autoRun=false) { if(moduleCorrupt&&path==='/_arboricx/bundle/hash/'+moduleCorrupt){bytes=Buffer.from(bytes);bytes[0]^=1;} if(process.env.TEST_COLD_LOAD&&path==='/nested/site/index.html')bytes=Buffer.from(bytes.toString().replace('<head>',`<head><script>new MutationObserver((_,observer)=>{if(document.getElementById('status')?.textContent==='Ready'){globalThis.__readyMs=performance.now();observer.disconnect();}}).observe(document.documentElement,{childList:true,subtree:true,characterData:true});</script>`)); if(autoRun&&path==='/nested/site/index.html'&&!new URL(req.url,'http://localhost').searchParams.has('noProbe'))bytes=Buffer.from(bytes.toString()+`<script type="module"> - try{const m=await import(${JSON.stringify(process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} + try{const m=await import(${JSON.stringify(process.env.TEST_DEFINITION_NAMES?'/definition-names-probe.mjs':process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} catch(e){await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:false,error:String(e)+'\\n'+(e.stack||'')})});} </script>`); if(process.env.TEST_ASSET_CACHE && ['/nested/site/index.html','/nested/site/assets.json'].includes(path))res.setHeader('Cache-Control','no-cache'); diff --git a/ext/demo-site/test/basic-examples-probe.mjs b/ext/demo-site/test/basic-examples-probe.mjs @@ -18,7 +18,7 @@ export async function run(control,backend='haskell-wasm'){ const base=new URL('../',document.querySelector('script[type=module][src]').src); const releases=(await(await fetch(new URL('packages.json',base))).json()).releases; assert(![...el('program-picker').options].some(o=>o.value.startsWith('package:')),'Libraries are discovered in the explorer, not a giant selector list'); - assert(!el('playground-intro').hidden&&el('playground-intro').textContent.includes('▶'),'First lesson explains inspection and evaluation'); + assert(!el('playground-intro').hidden&&el('playground-intro').textContent.includes('definition name'),'First lesson explains inspection and evaluation'); assert(el('add-import').textContent==='Explore libraries…','Prominent library explorer'); const select=async name=>{change('program-picker','demo:'+name);await status('Ready');}; const evaluate=async(expression,output,expected)=>assert(await evaluateSource(expression,output)===expected,expression+' expected '+expected); diff --git a/ext/demo-site/test/definition-name-inputs.mjs b/ext/demo-site/test/definition-name-inputs.mjs @@ -0,0 +1,88 @@ +// Trusted Chromium input, complementary to the cross-browser DOM/geometry probe. +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +export async function definitionNameInputs({send,evaluate,report,prefix}){ + const target=`document.querySelector('[data-definition="answer"]')`; + const closed=`document.querySelector('.definition-card').hidden`; + const wait=async(expression,label)=>{for(let i=0;i<800;i++){if(await evaluate(expression))return;await pause(25);}throw Error('Definition input timeout: '+label+' / '+JSON.stringify(await evaluate(`({active:document.activeElement.outerHTML.slice(0,300),hidden:document.querySelector('.definition-card').hidden,result:document.querySelector('.definition-card pre').textContent,status:document.getElementById('status').textContent})`)));}; + const point=async selector=>evaluate(`(()=>{const e=${selector};e.scrollIntoView({block:'center',behavior:'instant'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`); + const mouse=async selector=>{ + const {x,y}=await point(selector); + await send('Input.dispatchMouseEvent',{type:'mousePressed',x,y,button:'left',clickCount:1}); + await send('Input.dispatchMouseEvent',{type:'mouseReleased',x,y,button:'left',clickCount:1}); + }; + const key=async(key,code,windowsVirtualKeyCode,modifiers=0)=>{ + await send('Input.dispatchKeyEvent',{type:'keyDown',key,code,windowsVirtualKeyCode,modifiers,...(key==='Enter'?{text:'\r',unmodifiedText:'\r'}:key===' '?{text:' ',unmodifiedText:' '}: {})}); + await send('Input.dispatchKeyEvent',{type:'keyUp',key,code,windowsVirtualKeyCode,modifiers}); + }; + const close=async()=>{ + if(!await evaluate(closed))await mouse(`document.querySelector('[aria-label="Close definition inspection"]')`); + await wait(closed,'close'); + }; + const inspect=async()=>wait(`!${closed}&&!document.querySelector('.definition-card select').disabled&&document.querySelector('.definition-card pre').textContent==='42'`,'actual value'); + for(const width of [390,1440]){ + await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); + await evaluate("new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))"); + await mouse(target);await inspect(); + assert(await evaluate("document.activeElement.id!=='source-editor'"),'Inspection does not focus the textarea/open its keyboard'); + const expected=await evaluate("document.getElementById('source-editor').value"); + await mouse(`document.querySelector('.edit-definition-name')`); + assert(await evaluate("document.activeElement.id==='source-editor'&&document.getElementById('source-editor').value.slice(document.getElementById('source-editor').selectionStart,document.getElementById('source-editor').selectionEnd)==='answer'"),'Trusted Edit name focuses and selects the native identifier'); + await send('Input.insertText',{text:'renamed'}); + assert.equal(await evaluate("document.getElementById('source-editor').value"),expected.replace('answer =','renamed ='),'Native typing replaces only the declaration name'); + await key('z','KeyZ',90,2); + assert.equal(await evaluate("document.getElementById('source-editor').value"),expected,'Rename has native undo'); + await wait("document.getElementById('status').textContent==='Compiled'",'undo compile'); + await evaluate(`document.getElementById('source-editor').focus({preventScroll:true})`);await key('Tab','Tab',9); + assert(await evaluate(`document.activeElement===${target}`),'Tab reaches the declaration name'); + await key('Enter','Enter',13);await inspect(); + assert(await evaluate("document.activeElement===document.querySelector('.definition-card pre')"),'Keyboard inspection focuses the result'); + await key('Escape','Escape',27); + assert(await evaluate(`${closed}&&document.activeElement===${target}`),'Escape returns keyboard focus to the definition name / '+JSON.stringify(await evaluate(`({active:document.activeElement.outerHTML.slice(0,250),hidden:${closed},target:${target}.outerHTML,scroll:document.getElementById('source-editor').scrollTop,status:document.getElementById('status').textContent})`))); + await key(' ','Space',32);await inspect();await close(); + // Clicking the RHS remains normal caret placement, not inspection. + const rhs=await evaluate("(()=>{const s=[...document.querySelectorAll('.syntax-viewport .syntax-definition')].find(s=>s.textContent==='answer').getBoundingClientRect();return {x:s.right+parseFloat(getComputedStyle(document.getElementById('source-editor')).fontSize)*1.5,y:s.y+s.height/2};})()"); + await send('Input.dispatchMouseEvent',{type:'mousePressed',...rhs,button:'left',clickCount:1}); + await send('Input.dispatchMouseEvent',{type:'mouseReleased',...rhs,button:'left',clickCount:1}); + assert(await evaluate(`${closed}&&document.activeElement.id==='source-editor'`),'RHS click edits normally'); + const p=await point(target); + await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{...p,radiusX:2,radiusY:2}]}); + await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await inspect(); + assert(await evaluate("document.activeElement.id!=='source-editor'"),'Touch inspection leaves the editing keyboard closed'); + await close(); + + const hold=await point(target); + await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{...hold,radiusX:2,radiusY:2}]}); + await pause(750);await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await pause(100); + assert(await evaluate(closed),'Real touch hold does not inspect'); + assert(await evaluate("document.activeElement.id==='source-editor'&&document.getElementById('source-editor').value.slice(document.getElementById('source-editor').selectionStart,document.getElementById('source-editor').selectionEnd)==='answer'"),'Touch hold selects the identifier in the native editor / '+JSON.stringify(await evaluate(`({active:document.activeElement.outerHTML.slice(0,200),start:document.getElementById('source-editor').selectionStart,end:document.getElementById('source-editor').selectionEnd,target:${target}.outerHTML})`))); + await evaluate('document.getSelection().removeAllRanges()'); + await evaluate(`document.getElementById('source-editor').setSelectionRange(0,0);${target}.focus({preventScroll:true})`); + const pan=await point(target); + await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[pan]}); + await send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:pan.x,y:pan.y+40}]}); + await pause(550);await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); + assert(await evaluate(`${closed}&&document.getElementById('source-editor').selectionStart===0&&document.getElementById('source-editor').selectionEnd===0`),'Touch panning cancels the hold without selecting or inspecting'); + // Start in ordinary text and drag across a clickable name. The textarea, + // not the overlay, must own the native selection throughout the gesture. + const drag=await evaluate("(()=>{const w=document.getElementById('source-editor').closest('.syntax-editor'),a=w.querySelector('.syntax-directive').getBoundingClientRect(),b=w.querySelector('[data-definition=identity]').getBoundingClientRect();return {from:{x:a.left+2,y:a.top+a.height/2},to:{x:b.left+b.width/2,y:b.top+b.height/2}};})()"); + await send('Input.dispatchMouseEvent',{type:'mousePressed',...drag.from,button:'left',clickCount:1}); + await send('Input.dispatchMouseEvent',{type:'mouseMoved',...drag.to,button:'left',buttons:1}); + await send('Input.dispatchMouseEvent',{type:'mouseReleased',...drag.to,button:'left',clickCount:1}); + assert(await evaluate(`${closed}&&document.getElementById('source-editor').value.slice(document.getElementById('source-editor').selectionStart,document.getElementById('source-editor').selectionEnd).includes('answer = 42')`),'Native drag selection crosses name decorations without inspecting'); + // A native range selection remains possible in the underlying textarea. + await evaluate("(()=>{const e=document.getElementById('source-editor');e.focus();e.setSelectionRange(e.value.indexOf('identity x'),e.value.indexOf('identity x')+14);e.dispatchEvent(new Event('select'));})()"); + assert(await evaluate("document.getElementById('source-editor').selectionEnd>document.getElementById('source-editor').selectionStart"),'Native source selections survive name decoration'); + if(prefix){ + await evaluate("document.activeElement.blur();window.scrollTo({top:0,behavior:'instant'})"); + const capture=async suffix=>{ + const {cssContentSize:r}=await send('Page.getLayoutMetrics'); + const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}}); + await writeFile(prefix+'-'+suffix+'-'+width+'.png',Buffer.from(shot.data,'base64')); + }; + await capture('names');await mouse(target);await inspect();await capture('card');await close(); + } + } + report.checks.push('trusted mouse/touch name activation without editing keyboard; native Edit name typing/undo; Enter/Space/Escape focus; RHS caret placement; touch-hold name selection; panning cancellation; native cross-name drag selection at 390/1440px'); +} diff --git a/ext/demo-site/test/definition-names-probe.mjs b/ext/demo-site/test/definition-names-probe.mjs @@ -0,0 +1,83 @@ +// Actual compiled definitions and shipped editor controls; no evaluator substitute. +import {el,input,change,status,wait,inspect,card,result,pause} from './source-probe-helpers.mjs'; +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +export async function run(control,backend='haskell-wasm'){ + await status('Ready'); + if(el('runtime-backend').value!==backend){change('runtime-backend',backend);await status('Ready');} + document.querySelector('[data-compiler-example=identity]').click(); + const long='aVeryLongDefinitionName'+ 'Extended'.repeat(9); + const source=`-- λ🙂 positions stay native UTF-16 offsets +!export answer +answer = 42 +identity x = x +${long} = 9 +text = "fake = 1" +outer x = let + local = x + in local +select [] = 0 +select xs = 1 +-- comment = 1`; + const editor=el('source-editor'),wrapper=editor.closest('.syntax-editor'); + input('source-editor',source);await status('Compiled');editor.scrollTop=0;editor.dispatchEvent(new Event('scroll')); + await pause(0); + const target=name=>wrapper.querySelector(`[data-definition="${name}"]`); + assert(!wrapper.querySelector('.definition-gutter'),'No gutter'); + assert(parseFloat(getComputedStyle(editor).paddingLeft)<=20,'No reserved horizontal inspection column'); + const names=[...new Set([...wrapper.querySelectorAll('[data-definition]')].map(button=>button.dataset.definition))]; + assert(JSON.stringify(names)===JSON.stringify(['answer','identity',long,'text','outer','select']),'Only unique top-level declaration heads are interactive'); + assert(!target('local')&&!target('x')&&!target('fake')&&!target('comment'),'No local variables, references, comments or string lookalikes'); + assert(!target('answer').hidden&&!target('answer').disabled&&target('answer').tabIndex===0,'Compiled names are keyboard accessible'); + assert(await inspect('answer')==='42','Name opens the actual retained definition'); + assert(target('answer').getAttribute('aria-expanded')==='true','Open name highlighted'); + card().querySelector('.edit-definition-name').click(); + const offset=source.indexOf('answer ='); + assert(card().hidden&&document.activeElement===editor&&editor.selectionStart===offset&&editor.selectionEnd===offset+6,'Edit name selects exactly the declaration, not its export or references'); + assert(editor.value===source,'Opening/closing and Edit name do not mutate source'); + // Selection/hold/drag must not accidentally inspect. These DOM cases complement + // the harness's trusted mouse, touch and keyboard input. + const button=target('answer'); + const down=()=>button.dispatchEvent(new PointerEvent('pointerdown',{clientX:10,clientY:10,pointerId:1,bubbles:true})); + const click=()=>button.dispatchEvent(new MouseEvent('click',{detail:1,bubbles:true})); + down();await pause(550);click();assert(card().hidden,'Holding is not a tap'); + down();button.dispatchEvent(new PointerEvent('pointermove',{clientX:30,clientY:10,pointerId:1,bubbles:true}));click();assert(card().hidden,'Dragging is not a tap'); + down();const menu=new MouseEvent('contextmenu',{bubbles:true,cancelable:true});button.dispatchEvent(menu);click();assert(!menu.defaultPrevented&&card().hidden,'Native context menu is not intercepted'); + down();document.getSelection().selectAllChildren(button);click();assert(card().hidden,'Selecting the identifier does not inspect');document.getSelection().removeAllRanges(); + editor.dispatchEvent(new CompositionEvent('compositionstart')); + assert(getComputedStyle(wrapper.querySelector('.definition-targets')).display==='none','IME has no overlapping inspection targets'); + editor.dispatchEvent(new CompositionEvent('compositionend')); + input('source-editor',source.replaceAll('answer','renamed')); + assert([...wrapper.querySelectorAll('[data-definition]')].every(button=>button.disabled||button.hidden),'Dirty names lose clickability immediately'); + await status('Compiled');assert(await inspect('renamed')==='42','Renamed definition resolves from the new compiled revision'); + input('source-editor','!export renamed\nrenamed = missing');await status('Compilation failed'); + assert([...wrapper.querySelectorAll('[data-definition]')].every(button=>button.disabled&&button.hidden),'Failed compilation leaves no clickable underline'); + input('source-editor',source);await status('Compiled');editor.scrollTop=0;editor.dispatchEvent(new Event('scroll'));await pause(0); + // Long declaration names wrap without stealing the expression's hit area. + const mirror=wrapper.querySelector('.syntax-viewport pre'); + const span=[...mirror.querySelectorAll('.syntax-definition')].find(span=>span.textContent===long); + const boxes=[...span.getClientRects()]; + const targets=[...wrapper.querySelectorAll(`[data-definition="${long}"]`)]; + assert(targets.length===boxes.length,'Every wrapped fragment has its own target'); + for(let i=0;i<boxes.length;i++){ + const rect=targets[i].getBoundingClientRect(); + assert(Math.abs(rect.left-boxes[i].left)<1&&Math.abs(rect.width-boxes[i].width)<1,'No hit area extends over adjacent expression text'); + } + assert(targets.filter(button=>button.tabIndex===0).length===1,'Wrapped definition has one tab stop'); + if(boxes.length>1){ + editor.style.height='240px';await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + editor.scrollTop=span.getClientRects()[0].top-editor.getBoundingClientRect().top+parseFloat(getComputedStyle(editor).lineHeight); + editor.dispatchEvent(new Event('scroll')); + const visible=[...wrapper.querySelectorAll(`[data-definition="${long}"]`)].filter(button=>!button.hidden); + assert(visible.length>0&&visible[0].tabIndex===0,'A partially scrolled wrapped name retains a visible keyboard stop'); + visible[0].click();await pause(0);await wait(()=>!card().querySelector('select').disabled&&result()==='9','wrapped observation'); + card().querySelector('[aria-label="Close definition inspection"]').click(); + assert(document.activeElement===visible[0],'Close returns focus to a visible fragment, not its offscreen first line'); + editor.style.height='';editor.scrollTop=0;editor.dispatchEvent(new Event('scroll')); + await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + } + assert(await inspect(long)==='9','Wrapped private definition is inspectable'); + card().querySelector('[aria-label="Close definition inspection"]').click(); + assert(document.activeElement===target(long),'Closing returns focus to the name'); + editor.scrollTop=0;editor.dispatchEvent(new Event('scroll')); + return {userAgent:navigator.userAgent,checks:['underlined definition names without a gutter; exact wrapped hit areas; unique heads only; retained/private inspection; Edit name and focus; dirty/error invalidation; native context menus; hold/drag/selection suppression; IME; one keyboard stop per name']}; +} diff --git a/ext/demo-site/test/ide-probe.mjs b/ext/demo-site/test/ide-probe.mjs @@ -2,6 +2,7 @@ import {syntaxProbe} from './syntax-probe.mjs'; import {el,input,change,status,wait,inspect,result,description,card,evaluateSource} from './source-probe-helpers.mjs'; const assert=(condition,message)=>{if(!condition)throw Error(message);}; export async function run(control,backend='haskell-wasm'){ + const phase=name=>fetch(control+'/phase',{method:'POST',body:JSON.stringify({name:'ide: '+name})}); await status('Ready');await syntaxProbe();await status('Ready'); assert([...el('runtime-backend').options].map(o=>o.value).join(',')==='haskell-wasm,zig-wasm','Only production runtimes'); for(const path of ['worker.js','compiler-worker.js','conformance/worker.js','conformance/compiler-worker.js'])assert((await fetch(new URL('./'+path,document.querySelector('script[type=module][src]').src))).status===404,'JS conformance Workers not shipped'); @@ -16,6 +17,7 @@ export async function run(control,backend='haskell-wasm'){ const createURL=URL.createObjectURL,click=HTMLAnchorElement.prototype.click; URL.createObjectURL=value=>{blob=value;return createURL(value);};HTMLAnchorElement.prototype.click=function(){download=this.download;}; try{ + await phase('complete text download'); await evaluateSource(JSON.stringify('a'.repeat(600)),'tricu');assert(result().length===16384&&description().includes('Preview: first 16,384'),'Bounded preview is explicit'); card().querySelector('.definition-actions button').click();await wait(()=>copied!==undefined,'complete copy');assert(copied.length>16384&&copied.startsWith(result()),'Copy has complete tree source'); card().querySelectorAll('.definition-actions button')[1].click();assert(download==='probeResult.tri'&&await blob.text()===copied,'Download matches complete copy'); @@ -26,14 +28,17 @@ export async function run(control,backend='haskell-wasm'){ const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await blob.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');assert(digest===expectedHash&&download===expectedHash+'.arboricx','Immutable authenticated bundle bytes'); }finally{URL.createObjectURL=createURL;HTMLAnchorElement.prototype.click=click;if(clipboard)Object.defineProperty(navigator,'clipboard',clipboard);else delete navigator.clipboard;} el('reload-package').click();await status('Ready'); + await phase('shared DAG'); const bindings=['x0 = t t t'];for(let i=1;i<=28;i++)bindings.push(`x${i} = t x${i-1} x${i-1}`); const expression='let\n '+bindings.join('\n ')+'\n in x28'; assert((await evaluateSource(expression,'dag')).length<20000,'DAG preserves exponential sharing'); + await phase('expanded tree refusal'); await inspect('probeResult','tricu');assert(/Expanded tree exceeds 32 MiB/.test(description()),'Refuse oversized expansion before constructing output'); assert([...card().querySelectorAll('.definition-actions button')].every(button=>button.disabled),'Partial result never downloadable'); assert(await evaluateSource('identity 52')==='52','Rendering error preserves definitions'); input('bundle-hash','not-a-hash');el('program-hash').requestSubmit();assert(el('status').textContent==='Stopped'&&card().hidden,'Invalid replacement cannot retain old output'); change('program-picker','demo:language-reference');await status('Ready'); + await phase('live revisions and IO'); const live=await(await import('/live-ux-probe.mjs')).run(control,backend); change('program-picker','demo:language-reference');await status('Ready'); return {userAgent:navigator.userAgent,checks:[...live.checks,'source-first workspace; native settings; t/triangle/DAG/ternary; complete inline Copy/Download; expansion refusal; non-evaluating stored export; immutable bundle download']}; diff --git a/ext/demo-site/test/inline-io-probe.mjs b/ext/demo-site/test/inline-io-probe.mjs @@ -51,6 +51,7 @@ export async function run(control,backend='haskell-wasm'){ el('run-io').click();await status('Finished');assert(el('terminal').textContent==='Explicit output','Rerun clears old terminal');button('write').click(); await open('io');el('io-return-format').value='string';el('step-mode').checked=true;el('run-io').click();await status('Paused before action'); el('next').click();await status('Waiting for input'); + assert(!button('io').hidden&&button('io').disabled&&button('io').getAttribute('aria-expanded')==='true'&&getComputedStyle(button('io')).pointerEvents==='none','Active IO name stays highlighted but cannot start another observation'); input('line-input','a'.repeat(257));el('input-form').requestSubmit();assert(!el('input-form').hidden,'Invalid input remains retryable'); input('line-input','λ雪🙂');el('input-form').requestSubmit();await status('Finished');assert(view().textContent==='λ雪🙂','Returned IO value uses selected interpretation'); el('step-mode').checked=false;el('run-io').click();await status('Waiting for input');el('stop-io').click(); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -96,3 +96,10 @@ test('Ecosystem describes the live content store serving this Playground',async( assert.match(html,/verifies their hashes before using them/); assert.doesNotMatch(html,/same language used for the examples can serve its own packages/); }); + +// Durable user-facing contract; runtime and gesture semantics are browser-tested. +test('inspection instructions use declaration names rather than an arrow gutter',async()=>{ + for(const file of ['index.html','README.md','reference-input/getting-started.md'])assert.doesNotMatch(await read(file),/▶/,file); + const css=await read('style.css');assert.doesNotMatch(css,/definition-gutter|padding-left:48px/); + assert.match(await read('src/definition-inspector.ts'),/Edit name/); +}); diff --git a/ext/demo-site/test/syntax-probe.mjs b/ext/demo-site/test/syntax-probe.mjs @@ -10,29 +10,32 @@ export async function syntaxProbe(){ assert(Math.abs(sourceMirror.scrollHeight-source.scrollHeight)<=2,'Source mirror wrapping matches native editor: '+JSON.stringify({viewport:innerWidth,input:source.scrollHeight,mirror:sourceMirror.scrollHeight,width:source.clientWidth,mirrorWidth:sourceMirror.style.width,lineHeight:getComputedStyle(source).lineHeight})); // Real geometry catches inherited global button sizing and glyph/line-box // differences. The IDE harness runs this at 320, 390 and 1440 pixels. - const checkGutter=()=>{ + const checkTargets=()=>{ const definitions=[...sourceMirror.querySelectorAll('.syntax-definition')]; let visible=0;const controls=[]; - for(const button of source.closest('.syntax-editor').querySelectorAll('.definition-gutter button')){ - if(button.hidden)continue; + for(const button of source.closest('.syntax-editor').querySelectorAll('.definition-targets button')){ + if(button.hidden||button.disabled)continue; const name=button.getAttribute('aria-label').replace('Inspect compiled definition ',''); - const text=definitions.find(span=>span.textContent===name)?.getClientRects()[0]; - assert(text,'Gutter control has declaration text: '+name); - const control=button.getBoundingClientRect();controls.push(control); + const texts=[...definitions.find(span=>span.textContent===name)?.getClientRects()??[]]; + const control=button.getBoundingClientRect(); + const text=texts.find(text=>Math.abs(text.left-control.left)<1&&Math.abs(text.width-control.width)<1); + assert(text,'Name target has declaration text: '+name); + controls.push(control); + assert(Math.abs(control.width-text.width)<=1,'Name target has precisely the identifier width'); assert(control.height<=parseFloat(getComputedStyle(source).lineHeight)-2,'Definition control leaves space between adjacent lines'); assert(Math.abs(control.top+control.height/2-text.top-text.height/2)<=1,'Definition control centered on '+name+' at '+innerWidth+'px: '+JSON.stringify({controlTop:control.top,controlHeight:control.height,textTop:text.top,textHeight:text.height})); visible++; } controls.sort((a,b)=>a.top-b.top); - for(let i=1;i<controls.length;i++)assert(controls[i].top>=controls[i-1].bottom+2,'Sequential definition controls do not overlap'); + for(let i=1;i<controls.length;i++)assert(controls[i].top>=controls[i-1].bottom+1,'Sequential name targets do not overlap'); assert(visible>0,'Visible definition controls tested'); }; const oldScroll=source.scrollTop; const firstDefinition=sourceMirror.querySelector('.syntax-definition').getClientRects()[0]; source.scrollTop=Math.max(0,oldScroll+firstDefinition.top-source.getBoundingClientRect().top-48);source.dispatchEvent(new Event('scroll')); - checkGutter(); + checkTargets(); source.scrollTop=Math.min(source.scrollTop+96,source.scrollHeight-source.clientHeight);source.dispatchEvent(new Event('scroll')); - checkGutter(); + checkTargets(); source.scrollTop=oldScroll;source.dispatchEvent(new Event('scroll')); assert(document.querySelector('#source .syntax-definition'),'Read-only source gets the same tokens'); assert(wrapper.querySelector('[aria-hidden=true]')&&input.tagName==='TEXTAREA','Decorative mirror leaves native accessible textarea intact');