diff --git a/.changes/value-row-alignment.json b/.changes/value-row-alignment.json new file mode 100644 index 00000000..5d4d1777 --- /dev/null +++ b/.changes/value-row-alignment.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.21.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f16e931..f1d672ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.21.2 — 2026-10-07 + +A fix release (issue #332): a Pool's, a Parameter's and a Register's value and detail rows sit inside the node, as [`docs/node-shell-content-in-vessel.md`](docs/node-shell-content-in-vessel.md) "Follow-up — value and detail rows" describes. + +- **Rows start with the title.** The value, a Pool's capacity, a Parameter's unit and a Register's `= expr` now start where the title text starts, at least 8 px inside the drawn outline at both ends. Before, they started 14 px earlier, and a Pool's value sat on or across its slanted side. +- **Wider only where needed.** A node whose row would not fit grows by the minimum, up to the 260 px maximum; in the three templates that is one node, by under 6 px. A row too long even for the widest node, or one whose widening would bring a title closer to the outline or change a node's height, is cut with a visible "…". +- **Unchanged:** node heights and positions, saved files, digests and simulation results; Source, Drain, Converter, Gate and End rows. + +**No migration.** Three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.21.2`. + ## v0.21.1 — 2026-10-07 A fix release (issue #329): Focus mode dims the connections outside the focus set, as [`docs/large-graph-readability.md`](docs/large-graph-readability.md) §LGR3.1 always said. diff --git a/README.md b/README.md index 931bf5a2..77cf2ee7 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,17 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.21.1 +## Latest — v0.21.2 + +A fix release: values and detail rows sit inside their node. + +- **Pool, Parameter and Register rows start with the title**, at least 8 px inside the + drawn outline; before, a Pool's value sat on its slanted side +- **Wider only where needed**: a node grows only when a row would not fit, up to the + usual maximum, and a row too long even then ends in "…"; positions, heights, files and + results are unchanged + +## v0.21.1 A fix release: Focus mode dims the connections too. @@ -172,21 +182,8 @@ Flow colours beyond the canvas. already shown above them - **On a phone**, the read-only Inspector shows a colour as one line: a dot, its name and hex -## v0.19.0 - -Flow colours: give nodes and connections a colour so a large graph's flows read apart. - -- **The Inspector's Colour section** colours every selected node and connection at once: - five palette colours, recent ones, the colours already in the diagram, a hex value or any - colour from the browser's picker; Default removes it -- **Kept everywhere, changing nothing it computes**: saved with the diagram and in links - and undo, and a colour change never resets the run or a Monte Carlo result -- **Selection stays clear on any colour**: a selected node shows a ring outside its - outline, a selected connection a highlight beneath it -- **Advice, never a block**: a colour that would be hard to see, or that looks like a - colour the canvas uses for focus, warnings or a run, is still applied, with a note - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.18.2 (the +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.19.0 (flow +colours on nodes and connections), v0.18.2 (the guided tour says each step once), v0.18.1 (one keyboard contract for every menu), v0.18.0 (the third-party open-source licenses in the About dialog), v0.17.2 (the Temporary session button drawn like the menu buttons), v0.17.1 (share diff --git a/docs/node-shell-content-in-vessel.md b/docs/node-shell-content-in-vessel.md index a673abcb..c96b362d 100644 --- a/docs/node-shell-content-in-vessel.md +++ b/docs/node-shell-content-in-vessel.md @@ -146,8 +146,58 @@ boundary: chip ≥ 5.5, head / sub ≥ 3.0, value ≥ 2 px (parameter, 118 – 2 bug. (`.nodef__stack` is an unpainted layout box that spans to the fixed body padding, so its corners are no longer the thing measured.) +## Follow-up — value and detail rows (issue #332, v0.21.2) + +The rows under the title (a Pool's value and capacity `≤ N`, a Parameter's +value and unit, a Register's result and `= expr`) used to start at the content +edge: 14 px before the title text, and on the Pool's slanted side on or across +the outline (measured −9.7 px); a Register's value sat inside the keyboard +focus ring. For these three kinds only: + +1. each row starts where the title text starts (the chip plus its gap), along + the stack's PHYSICAL start: the canvas is physically left-to-right in every + language (`docs/localization.md` §L9.2), and a row whose own text resolves + to RTL (`dir="auto"`, an Arabic unit) still starts on the chip's side; +2. each row's glyphs stay at least 8 px (`NODE_RINGS.focus.to` + 2) inside the + fill at the row's own height, at both ends; +3. a node keeps the width it had before when that already holds both; + otherwise only that node widens, by the minimum, up to the 260 px maximum, + and only for a row that is then whole: a row too long even for the widest + node ends in its ellipsis where it is; +4. a title that had 8 px or more keeps at least `max(8, its clearance − 0.5)` + px (`TITLE_SLACK`), one that had less keeps all of it: the outline scales + with the width (`preserveAspectRatio="none"`), so on the Pool's slant every + px of width moves it toward the title; +5. a widening that would unwrap a title (and so change the node's height) does + not happen. Where rule 4 or 5 stops a widening, the row is cut with a + visible ellipsis (`1234567.89` reads `12345…`); only the display is cut. + +`./src/components/nodes/rowFit.ts` (`fitRows`, pure) decides from what +`NodeFrame` measures; `fillSpanAt` (`./silhouette`) reads the fill straight +from the drawn path. The fit is taken in the same pass as the height, when the +rendered strings, the language, the fonts or the height change, never per +animation frame; it reaches the CSS as `--vra-*` custom properties (a physical +`margin-left`, a plain px `max-width`: a `%` inside `min()` is cyclic in the +intrinsic pass and drops the whole `max-width`) and a `min-width`. Unchanged: +node heights, positions, saved coordinates, files, digests and the engine. In +the three templates exactly one node widens (early MMO `r_income`, +5.66 px). +The Source / Drain / Converter mode line, the Gate's rows and two-line Pool +titles are left as they were: their pointed, notched or slanted outlines grow +with the width, so the same rule would widen 37 template nodes by up to 49 px +(a separate follow-up). + ## Regression tests +- `src/components/nodes/rowFit.test.ts` (the fill and the fit, rules 1 – 5 on + a grid of widths) and `e2e/value-row-alignment.spec.ts` / + `value-row-alignment.mobile.spec.ts` (#332) — every Pool / Parameter / + Register row against the drawn outline (Range glyphs, `isPointInFill` at the + row's top, middle and bottom) in a synthetic graph and the three templates, + light, dark, forced colours, `ar` and a phone; states that move nothing; + Source / Drain / Converter untouched; the content digest; a measurement + counter (`__loop.rowFit.count`) that rises only on a frame whose row text + changed. + - `e2e/node-long-label.spec.ts` "content ⊂ vessel — path-aware (isPointInFill)" — for every Parameter / Register, in EN / KO / JA, all four corners of every painted box (`.nodef__chip`, `.nodef__head`, `.nodef__value`, `.nodef__sub`) diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png index d07314d3..022a3bda 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png index 86e3e4e9..e628dfac 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png index 511f006b..98944fcd 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png index f99d9edf..584404d1 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png index 2c82a671..ca673d94 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png index e9ea41ae..feec37e1 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png index 893824b5..8f7c4b63 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png index f753b754..cd1420f5 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png index ecf08347..b0d1dc3d 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png index 5b78af84..969be24c 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png index 99ceb8ff..ef646025 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png index a20e9697..7559a782 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png index 70b91fa9..36a0ead9 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png index 76d2421c..da3e5406 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png index a1750a8d..2f646e3b 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png index cf83590d..cabb904f 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png index ef7fb5da..30e2a7e6 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png index a906ac03..534b188d 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png index d3a26bbc..a017ce0e 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png differ diff --git a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png index a65caf49..e230e03e 100644 Binary files a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png and b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png index a1894c49..32d16f98 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png index 9f211d71..4e8b77a7 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png index 3376eff6..548c75ac 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png index 014ef117..a5047156 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png index 74ba8866..549ccf19 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png index 047c9f83..a1bab3e0 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png differ diff --git a/e2e/support/rowFit.ts b/e2e/support/rowFit.ts new file mode 100644 index 00000000..54d24916 --- /dev/null +++ b/e2e/support/rowFit.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' + +// issue #332 — reading the value / detail rows of the Pool, Parameter and +// Register nodes against their DRAWN outline: the glyphs' own rect (a DOM +// Range, cut at the row's box where its ellipsis cuts the text) and the fill +// edge found with `SVGGeometryElement.isPointInFill` along the row's top, middle +// and bottom. All in CSS px at the current zoom. + +export type RowReading = { + id: string + kind: string + key: 'value' | 'sub' + text: string + /** px from the fill's left edge to the first glyph, the least over the row */ + startClear: number + /** px from the last painted glyph to the fill's right edge, the least */ + endClear: number + /** the row's first glyph minus the title's first glyph */ + vsTitle: number + clipped: boolean +} + +type Bridge = { __loop: Record any }> & { rf: { setViewport: (v: object, o: object) => void } } } + +/** a synthetic graph with every case of the three kinds: 0, two and three + * digits, negative, decimal, long, a long unit, an Arabic unit, invalid */ +export const ROWS_GRAPH = (() => { + const N = (id: string, kind: string, x: number, y: number, data: object) => ({ + id, type: kind, position: { x, y }, data: { kind, label: id, ...data }, + }) + return JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: [ + N('Pool 0', 'pool', 0, 0, { activation: 'passive', initial: 0, mode: 'pullAny' }), + N('Pool 90', 'pool', 220, 0, { activation: 'passive', initial: 90, capacity: 130, mode: 'pullAny' }), + N('Pool neg', 'pool', 440, 0, { activation: 'passive', initial: -12.5, mode: 'pullAny' }), + N('Pool long', 'pool', 660, 0, { activation: 'passive', initial: 1234567.89, capacity: 9999999, mode: 'pullAny' }), + N('Reg 90', 'register', 0, 180, { expr: '90', unit: 'gold' }), + N('Reg 189', 'register', 220, 180, { expr: '189', unit: 'units per day' }), + N('Reg long', 'register', 440, 180, { expr: '123456789.123', unit: 'kg' }), + N('Reg invalid', 'register', 660, 180, { expr: '@missing + 1' }), + N('Param 90', 'parameter', 0, 360, { value: 90, unit: 'gold' }), + N('Param 189', 'parameter', 220, 360, { value: 189, unit: 'items per hour' }), + N('Param ar', 'parameter', 440, 360, { value: 12, unit: 'وحدة في الساعة' }), + N('Param long', 'parameter', 660, 360, { value: 98765432.1 }), + N('Source', 'source', 0, 540, { activation: 'automatic', mode: 'pushAny' }), + N('Drain', 'drain', 220, 540, { activation: 'automatic', mode: 'pullAny' }), + N('Converter', 'converter', 440, 540, { activation: 'automatic', mode: 'pullAny' }), + ], + edges: [], + }) +})() + +/** the canvas at zoom 1 with the graph's top-left in view */ +export const zoomOne = (page: Page) => + page.evaluate(() => (window as unknown as Bridge).__loop.rf.setViewport({ x: 20, y: 20, zoom: 1 }, { duration: 0 })) + +export const readRows = (page: Page): Promise => + page.evaluate(() => { + const out: RowReading[] = [] + const glyphs = (el: Element) => { + const r = document.createRange() + r.selectNodeContents(el) + const q = [...r.getClientRects()].filter((c) => c.width > 0) + return q.length ? { left: Math.min(...q.map((c) => c.left)), right: Math.max(...q.map((c) => c.right)) } : null + } + for (const n of document.querySelectorAll('.react-flow__node')) { + const f = n.querySelector('.nodef') + const kind = f && [...f.classList].find((c) => /^nodef--/.test(c))?.slice(7) + if (!f || !kind || !['pool', 'parameter', 'register'].includes(kind)) continue + const path = f.querySelector('path.nodef__fill')! + const inv = path.ownerSVGElement!.getScreenCTM()!.inverse() + const box = f.getBoundingClientRect() + const z = box.width / f.offsetWidth + const inside = (x: number, y: number) => path.isPointInFill(new DOMPoint(x, y).matrixTransform(inv)) + const edges = (y: number) => { + let l = box.left + let r = box.right + while (l < box.right && !inside(l, y)) l += 0.25 + while (r > box.left && !inside(r, y)) r -= 0.25 + return [l, r] + } + const title = glyphs(f.querySelector('.nodef__title')!)! + for (const key of ['value', 'sub'] as const) { + const el = f.querySelector(`.nodef__${key}`) + if (!el) continue + const g = glyphs(el) + if (!g) continue + const eb = el.getBoundingClientRect() + const clipped = el.scrollWidth > el.clientWidth + 0.5 + const right = clipped ? Math.min(g.right, eb.right) : g.right + let startClear = Infinity + let endClear = Infinity + for (const y of [eb.top + 1, (eb.top + eb.bottom) / 2, eb.bottom - 1]) { + const [l, r] = edges(y) + startClear = Math.min(startClear, (g.left - l) / z) + endClear = Math.min(endClear, (r - right) / z) + } + out.push({ + id: n.dataset.id!, kind, key, text: (el.textContent ?? '').trim(), + startClear: +startClear.toFixed(2), endClear: +endClear.toFixed(2), + vsTitle: +((g.left - title.left) / z).toFixed(2), clipped, + }) + } + } + return out + }) + +/** every row below `clear` px from the outline, or starting before the title */ +export const misfits = (rows: RowReading[], clear = 8) => + rows + .filter((r) => r.startClear < clear - 0.3 || r.endClear < clear - 0.3 || r.vsTitle < -0.5) + .map((r) => `${r.id}.${r.key} "${r.text}" start ${r.startClear} end ${r.endClear} vsTitle ${r.vsTitle}`) + +/** how many row-fit measurements the app has taken */ +export const fitCount = (page: Page): Promise => + page.evaluate(() => (window as unknown as { __loop: { rowFit: { count: () => number } } }).__loop.rowFit.count()) + +/** wait `n` animation frames */ +export const frames = (page: Page, n: number) => + page.evaluate(async (n) => { + for (let i = 0; i < n; i++) await new Promise((r) => requestAnimationFrame(r)) + }, n) diff --git a/e2e/value-row-alignment.mobile.spec.ts b/e2e/value-row-alignment.mobile.spec.ts new file mode 100644 index 00000000..f993d4f3 --- /dev/null +++ b/e2e/value-row-alignment.mobile.spec.ts @@ -0,0 +1,43 @@ +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' +import { fitCount, frames, misfits, readRows, ROWS_GRAPH, zoomOne } from './support/rowFit' + +// issue #332 on the phone (the `mobile` project, 390 × 844, touch): the same +// fit as on the desktop — every Pool / Parameter / Register row starts at the +// title text and keeps 8 px inside the drawn outline, light and dark, and +// panning takes no measurement. + +const load = async (page: Page) => { + await openApp(page) + await resetAll(page) + await importGraph(page, ROWS_GRAPH) + await page.locator('.react-flow__node[data-id="Converter"]').waitFor({ state: 'attached' }) + await page.evaluate(() => document.fonts.ready) + await zoomOne(page) + await frames(page, 2) +} + +test.describe('#332 value and detail rows inside the vessel, phone', () => { + for (const scheme of ['light', 'dark'] as const) { + test(`${scheme}: every row starts at the title and keeps 8 px inside the outline`, async ({ page }) => { + await page.emulateMedia({ colorScheme: scheme }) + await load(page) + const rows = await readRows(page) + expect(rows.length).toBe(21) + expect(misfits(rows)).toEqual([]) + }) + } + + test('panning the canvas takes no measurement', async ({ page }) => { + await load(page) + const n0 = await fitCount(page) + await page.evaluate(() => + (window as unknown as { __loop: { rf: { setViewport: (v: object, o: object) => void } } }).__loop.rf.setViewport( + { x: -300, y: -100, zoom: 1 }, + { duration: 0 }, + ), + ) + await frames(page, 20) + expect(await fitCount(page)).toBe(n0) + }) +}) diff --git a/e2e/value-row-alignment.spec.ts b/e2e/value-row-alignment.spec.ts new file mode 100644 index 00000000..6e2ae654 --- /dev/null +++ b/e2e/value-row-alignment.spec.ts @@ -0,0 +1,202 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' +import { fitCount, frames, misfits, readRows, ROWS_GRAPH, zoomOne } from './support/rowFit' + +// issue #332 — a Pool's value and capacity, a Parameter's value and unit and a +// Register's result and `= expr` start where the title text starts and keep +// 8 px inside the DRAWN outline at both ends (./src/components/nodes/rowFit). +// The fit is taken when the content or the size changes, never per frame. + +type Bridge = { __loop: Record any }> } + +const EXAMPLES = [ + ['coffee-roastery.json', 'cafe_retail_demand_kg'], + ['gacha-banner-zones.json', 'cmp1_hit_rate_free'], + ['mmo-progression.json', 'char_creation'], +] as const +const example = (f: string) => readFileSync(new URL(`../examples/${f}`, import.meta.url), 'utf8') + +/** a Source feeding a Pool by 1 a step, a Register reading the Pool */ +const RUN_GRAPH = JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: [ + { id: 'src', type: 'source', position: { x: 0, y: 0 }, data: { kind: 'source', label: 'In', activation: 'automatic', mode: 'pushAny' } }, + { id: 'p', type: 'pool', position: { x: 240, y: 0 }, data: { kind: 'pool', label: 'Stock', activation: 'passive', initial: 10, mode: 'pullAny' } }, + { id: 'r', type: 'register', position: { x: 240, y: 160 }, data: { kind: 'register', label: 'Twice', expr: '@p * 2' } }, + ], + edges: [ + { id: 'e1', source: 'src', target: 'p', sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1' } }, + ], +}) + +const load = async (page: Page, json: string, waitId: string) => { + await openApp(page) + await resetAll(page) + await importGraph(page, json) + await page.locator(`.react-flow__node[data-id="${waitId}"]`).waitFor() + await page.evaluate(() => document.fonts.ready) + await zoomOne(page) + await frames(page, 2) +} + +/** commit `n` steps at the fastest beat, waiting for each transition */ +const steps = (page: Page, n: number) => + page.evaluate(async (n) => { + const S = (window as unknown as Bridge).__loop.sim + S.getState().setSpeed(120) + for (let i = 0; i < n; i++) { + S.getState().stepOnce() + while (S.getState().transition) await new Promise((r) => requestAnimationFrame(r)) + } + }, n) + +const digest = (page: Page) => + page.evaluate(async () => { + const M = await import('/src/model/revision.ts') + const g = (window as unknown as Bridge).__loop.graph.getState() + return M.digestOfCanonical(M.canonicalContent({ nodes: g.nodes, edges: g.edges })) + }) + +test.describe('#332 value and detail rows inside the vessel', () => { + for (const scheme of ['light', 'dark', 'forced'] as const) { + test(`${scheme}: every row starts at the title and keeps 8 px inside the outline`, async ({ page }) => { + if (scheme === 'forced') await page.emulateMedia({ forcedColors: 'active' }) + else await page.emulateMedia({ colorScheme: scheme }) + await load(page, ROWS_GRAPH, 'Converter') + const rows = await readRows(page) + expect(rows.length).toBe(21) + expect(misfits(rows)).toEqual([]) + // the long Pool value is cut short where the slanted side would reach its title + expect(rows.find((r) => r.id === 'Pool long' && r.key === 'value')!.clipped).toBe(true) + }) + } + + for (const [file, waitId] of EXAMPLES) { + test(`${file}: every row fits after ten steps`, async ({ page }) => { + await load(page, example(file), waitId) + await steps(page, 10) + await frames(page, 30) // past the value bump + expect(misfits(await readRows(page))).toEqual([]) + }) + } + + test('ar: the rows start on the chip side, physically left, Arabic unit included', async ({ page }) => { + await load(page, ROWS_GRAPH, 'Converter') + await page.evaluate(() => (window as unknown as Bridge).__loop.i18n.getState().setLocale('ar')) + await page.waitForFunction(() => document.documentElement.dir === 'rtl') + await page.evaluate(() => document.fonts.ready) + await frames(page, 2) + const rows = await readRows(page) + expect(misfits(rows)).toEqual([]) + const ar = rows.find((r) => r.id === 'Param ar' && r.key === 'sub')! + expect(ar.vsTitle).toBeGreaterThanOrEqual(-0.5) + expect(ar.vsTitle).toBeLessThan(1) + }) + + test('the Source, Drain and Converter mode rows are left as they were', async ({ page }) => { + await load(page, ROWS_GRAPH, 'Converter') + const out = await page.evaluate(() => + ['Source', 'Drain', 'Converter'].map((id) => { + const f = document.querySelector(`.react-flow__node[data-id="${id}"] .nodef`)! + return { id, style: f.getAttribute('style') ?? '', margin: getComputedStyle(f.querySelector('.nodef__sub')!).marginLeft } + }), + ) + for (const o of out) { + expect(o.style).not.toContain('--vra') + expect(o.margin).toBe('0px') + } + }) + + test('selection, keyboard focus, Focus mode and the activity overlay move no row and take no measurement', async ({ page }) => { + await load(page, ROWS_GRAPH, 'Converter') + const before = await readRows(page) + const n0 = await fitCount(page) + await page.locator('.react-flow__node[data-id="Reg 189"]').click() + await page.keyboard.press('Tab') + await page.evaluate(() => { + const l = (window as unknown as Bridge).__loop + l.ui.getState().setFocusMode(true) + l.ui.getState().setActivityOverlay(true) + }) + await page.locator('.react-flow__node[data-id="Param 189"]').hover() + await frames(page, 10) + expect(await fitCount(page)).toBe(n0) + expect(await readRows(page)).toEqual(before) + }) + + test('the fit leaves the graph alone: the content digest does not move', async ({ page }) => { + await openApp(page) + await resetAll(page) + await importGraph(page, example('mmo-progression.json')) + const d0 = await digest(page) + await page.locator('.react-flow__node[data-id="char_creation"]').waitFor() + await zoomOne(page) + await frames(page, 10) + expect(await digest(page)).toBe(d0) + }) + + test('idle, pan and zoom take no measurement', async ({ page }) => { + await load(page, ROWS_GRAPH, 'Converter') + const n0 = await fitCount(page) + await frames(page, 60) + await page.evaluate(() => { + const rf = (window as unknown as { __loop: { rf: { setViewport: (v: object, o: object) => void } } }).__loop.rf + rf.setViewport({ x: -200, y: 40, zoom: 0.5 }, { duration: 0 }) // L1: the sub rows hide, nothing reflows + }) + await frames(page, 10) + await zoomOne(page) + await frames(page, 10) + expect(await fitCount(page)).toBe(n0) + }) + + test('a new number of the same length is read once, on the commit that shows it', async ({ page }) => { + await load(page, RUN_GRAPH, 'r') + const value = (id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__value`).textContent() + expect(await value('p')).toContain('10') + const n0 = await fitCount(page) + await steps(page, 1) + await frames(page, 30) + expect(await value('p')).toContain('11') + expect(await value('r')).toContain('22') + // one read each for the Pool and the Register, none for the frames between + expect(await fitCount(page) - n0).toBe(2) + }) + + test('playback: a measurement only on the frame a row text changes, never per animation frame', async ({ page }) => { + await load(page, RUN_GRAPH, 'r') + const trace = await page.evaluate(async () => { + const l = (window as unknown as { __loop: Record }).__loop + const sig = () => + [...document.querySelectorAll('.nodef--pool, .nodef--parameter, .nodef--register')] + .map((f) => [...f.querySelectorAll('.nodef__value, .nodef__sub')].map((e) => e.textContent).join('|')) + .join('#') + const S = l.sim + S.getState().setSpeed(120) + S.getState().play() + const out: { count: number; sig: string }[] = [] + for (let i = 0; i < 180; i++) { + await new Promise((r) => requestAnimationFrame(r)) + out.push({ count: l.rowFit.count(), sig: sig() }) + } + S.getState().pause() + return out + }) + let reads = 0 + let changes = 0 + for (let i = 1; i < trace.length; i++) { + const read = trace[i].count - trace[i - 1].count + const changed = trace[i].sig !== trace[i - 1].sig + if (changed) changes++ + if (read > 0) { + reads++ + // the read lands on the frame the new text is committed + expect(changed, `frame ${i}: a measurement without a row change`).toBe(true) + } + } + expect(changes).toBeGreaterThan(3) + expect(reads).toBe(changes) + expect(reads).toBeLessThan(trace.length / 4) + }) +}) diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index ef379ddb..6964939f 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -238,7 +238,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) await page.keyboard.press('Escape') await expect(panel).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index 9bcbdab8..be167ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.21.1", + "version": "0.21.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.21.1", + "version": "0.21.2", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index 2cf9bc2f..8ccd9e59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.21.1", + "version": "0.21.2", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/components/nodes/nodes.tsx b/src/components/nodes/nodes.tsx index 47271683..2dfecd9e 100644 --- a/src/components/nodes/nodes.tsx +++ b/src/components/nodes/nodes.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import { Handle, Position, @@ -26,6 +26,7 @@ import type { ContentDir } from '../../i18n/contentDirection' import { useI18n } from '../../i18n/store' import { usePhrasedTitle } from './phraseTitle' import { InsideMask, OutsideMask } from './RingMasks' +import { fitRows, type MeasuredRow, type RowFit, rowFitMeasured } from './rowFit' import type { ConverterData, DrainData, @@ -55,6 +56,138 @@ import { useNodeActivityOpacity } from '../frames/useActivityTint' const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)) +// ── issue #332 — the value / detail rows' fit ───────────────────────────── +// Pool (value, capacity), Register (result, `= expr`) and Parameter (value, +// unit). The Source / Drain / Converter mode row keeps its place: its pointed +// or notched vessel scales with the width, so fitting it there would widen a +// node far more than its row needs (measured in #332, deferred). +const ROW_FIT_KINDS: ReadonlySet = new Set(['pool', 'parameter', 'register']) + +/** `el`'s offset from `frame`, in untransformed CSS px (a value's bump + * animation scales it, which a `getBoundingClientRect` would read) */ +function offsetIn(el: HTMLElement, frame: HTMLElement): { left: number; top: number } { + let left = 0 + let top = 0 + for (let e: HTMLElement | null = el; e && e !== frame; e = e.offsetParent as HTMLElement | null) { + left += e.offsetLeft + top += e.offsetTop + } + return { left, top } +} + +/** a wrapped title's width on one line, from its own font (canvas + * `measureText`): never by laying the title out unwrapped, which would write + * to the DOM and force a layout of the whole canvas per node — a 2,400-node + * import measured 98 s that way */ +let measureCtx: CanvasRenderingContext2D | null | undefined +function oneLineWidth(title: HTMLElement, cs: CSSStyleDeclaration): number | null { + if (measureCtx === undefined) measureCtx = document.createElement('canvas').getContext('2d') + if (!measureCtx) return null + measureCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}` + return measureCtx.measureText(title.textContent ?? '').width +} + +/** the whole laid-out width of `el`'s text, even where its ellipsis cuts it + * short: the text's own rect, divided by `scale` (screen px per CSS px) */ +function textWidth(el: HTMLElement, scale: number): number { + const range = document.createRange() + range.selectNodeContents(el) + return range.getBoundingClientRect().width / scale +} + +/** One measurement pass: the stack's and the title's place, and each row's + * extent, all in CSS px within the box, unrounded and untransformed; then + * ./rowFit decides. Rects are scaled back by the frame's own (the canvas + * zoom); the value row's by its own too, as its bump animation scales it. + * Three style reads per node (frame, title, value): a 2,400-node import + * pays for every one. */ +function measureRowFit(frame: HTMLElement, stack: HTMLElement, kind: NodeKind, h: number): RowFit { + const width = parseFloat(getComputedStyle(frame).width) || frame.offsetWidth + const box = frame.getBoundingClientRect() + const scale = box.width / width || 1 + // `h` is the box height this fit is for, which the box may not be drawn at + // yet (the first render's is the floor): the body centres the stack + // vertically, so the rows will sit half the difference lower + const dy = (h - frame.offsetHeight) / 2 + const stackStart = offsetIn(stack, frame).left + const stackWidth = stack.getBoundingClientRect().width / scale + const title = frame.querySelector('.nodef__title') + const cs = title ? getComputedStyle(title) : null + // the title's start unrounded: the Parameter / Register head's rim is a + // fraction of a px that `offsetLeft` would round away + const tBox = title?.getBoundingClientRect() + const titleLeft = tBox ? (tBox.left - box.left) / scale : stackStart + 14 + const titleTop = (title ? offsetIn(title, frame).top : 0) + dy + const lh = cs ? parseFloat(cs.lineHeight) || 1.2 * parseFloat(cs.fontSize) : 0 + const titleWrapped = title && cs ? title.offsetHeight > 1.5 * lh : false + const titleBoxWidth = tBox ? tBox.width / scale : 0 + const rows: MeasuredRow[] = [] + for (const key of ['value', 'sub'] as const) { + const el = frame.querySelector(`.nodef__${key}`) + if (!el) continue + const at = offsetIn(el, frame) + // the value's bump scales it: its own rect over its used width + const own = key === 'value' ? el.getBoundingClientRect().width / (parseFloat(getComputedStyle(el).width) || el.offsetWidth) : scale + rows.push({ key, top: at.top + dy, bottom: at.top + dy + el.offsetHeight, width: textWidth(el, own || scale) }) + } + return fitRows( + kind, + { + height: h, + width, + stackStart, + padEnd: width - stackStart - stackWidth, + titleStart: titleLeft, + titleWidth: title && cs ? (titleWrapped ? (oneLineWidth(title, cs) ?? titleBoxWidth) : titleBoxWidth) : 0, + titleTop, + titleBottom: titleTop + (title?.offsetHeight ?? 0), + titleWrapped, + titleMax: cs ? parseFloat(cs.maxWidth) || Infinity : Infinity, + }, + rows, + ) +} + +/** a count of the web-font loads that have finished: a face that arrives after + * the first measurement changes a row's text width without resizing a stack + * the fit already holds, so each load re-reads the fit once */ +let fontLoads = 0 +const fontLoadListeners = new Set<() => void>() +const onFontLoad = () => { + fontLoads++ + for (const on of fontLoadListeners) on() +} +const subscribeFontLoads = (on: () => void): (() => void) => { + const fonts = typeof document !== 'undefined' ? document.fonts : undefined + if (!fonts) return () => {} + if (fontLoadListeners.size === 0) fonts.addEventListener('loadingdone', onFontLoad) + fontLoadListeners.add(on) + return () => { + fontLoadListeners.delete(on) + if (fontLoadListeners.size === 0) fonts.removeEventListener('loadingdone', onFontLoad) + } +} +const useFontLoads = (): number => useSyncExternalStore(subscribeFontLoads, () => fontLoads, () => 0) + +/** the box height the stack's rendered content asks for (see NodeFrame) */ +const boxHeightOf = (kind: NodeKind, stack: HTMLElement): number => + clampNodeHeight(kind, stack.offsetHeight + VESSEL_INSET_Y[kind] + 2 * VESSEL_MIN_PAD_Y) + +const sameFit = (a: RowFit | null, b: RowFit): boolean => + a !== null && JSON.stringify(a) === JSON.stringify(b) + +/** the row fit as the frame's CSS custom properties (index.css reads them) */ +function fitStyle(fit: RowFit | null): Record | null { + if (!fit) return null + const s: Record = {} + for (const key of ['value', 'sub'] as const) { + if (fit.start[key] != null) s[`--vra-${key}-start`] = `${fit.start[key]}px` + if (fit.maxWidth[key] != null) s[`--vra-${key}-max`] = `${fit.maxWidth[key]}px` + } + if (fit.minWidth != null) s.minWidth = fit.minWidth + return s +} + function useFiring(id: string): boolean { return useSimStore((s) => s.firedNodeIds.includes(id)) } @@ -204,14 +337,20 @@ function NodeFrame({ // feeds back into the box height. const stackRef = useRef(null) const [boxH, setBoxH] = useState(BASE_NODE_H) + // issue #332 — the rows' fit (./rowFit): where the value and detail rows + // start and how wide the node must be so no row crosses the vessel. Taken + // after the height is known, only when the content, the language, the fonts + // or the height change (below) — never per animation frame. + const [fit, setFit] = useState(null) + // the inputs of the last fit: a fit is read once per change of the rendered + // strings, the language, the fonts or the box height, and never again for + // the same ones (the re-render a fit itself causes reads nothing) + const fitInput = useRef('') useLayoutEffect(() => { const stack = stackRef.current if (!stack) return const read = () => { - const next = clampNodeHeight( - kind, - stack.offsetHeight + VESSEL_INSET_Y[kind] + 2 * VESSEL_MIN_PAD_Y, - ) + const next = boxHeightOf(kind, stack) setBoxH((prev) => (Math.abs(prev - next) > 0.5 ? next : prev)) } read() @@ -219,6 +358,27 @@ function NodeFrame({ ro.observe(stack) return () => ro.disconnect() }, [kind]) + // a content change that keeps the stack's size (a new number of the same + // length, a shorter sub inside a title-wide node) still moves a row's glyph + // extent, so any change of the rendered strings is re-read once, after + // React commits it: one read per change, never per animation frame + const fonts = useFontLoads() + const fitKey = JSON.stringify([value, unit, sub, title, locale, fonts]) + useLayoutEffect(() => { + const frame = frameRef.current + const stack = stackRef.current + if (!frame || !stack || !ROW_FIT_KINDS.has(kind)) return + // the height the content asks for, read in the same pass as the box's + // own (so the fit and the height land in ONE re-render of the node: a + // second one, for 2,400 nodes, cost about 0.7 s) + const h = boxHeightOf(kind, stack) + const input = `${fitKey}|${h}` + if (input === fitInput.current) return + fitInput.current = input + rowFitMeasured() + const next = measureRowFit(frame, stack, kind, h) + setFit((prev) => (sameFit(prev, next) ? prev : next)) + }, [fitKey, boxH, kind]) // No explicit `updateNodeInternals` call here: React Flow's own internal // per-node ResizeObserver already keeps `node.measured` (and the handle // bounds edge routing reads) in sync with this wrapper's real DOM size, @@ -282,8 +442,12 @@ function NodeFrame({ data-invalid={invalid ? '' : undefined} data-accent={accent} style={ - grown || accent - ? { ...(grown ? { height: boxH } : null), ...(accent ? { ['--node-accent' as string]: accent } : null) } + grown || accent || fit + ? { + ...(grown ? { height: boxH } : null), + ...(accent ? { ['--node-accent' as string]: accent } : null), + ...fitStyle(fit), + } : undefined } onMouseEnter={() => setHovered(true)} diff --git a/src/components/nodes/rowFit.test.ts b/src/components/nodes/rowFit.test.ts new file mode 100644 index 00000000..515e87b6 --- /dev/null +++ b/src/components/nodes/rowFit.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest' +import type { NodeKind } from '../../model/types' +import { fitRows, type FrameGeometry, headRim, type MeasuredRow, NODE_MAX_W, NODE_MIN_W, ROW_CLEAR, TITLE_SLACK } from './rowFit' +import { fillSpanAt, NODE_RINGS } from './silhouette' + +// issue #332 — the value / detail row fit of a Pool, a Parameter, a Register. + +describe('fillSpanAt', () => { + it('reads the fill straight from the drawn path', () => { + // pool at 58: the left side runs (24, 13) → (8, 46), the right (96, 13) → (112, 46) + expect(fillSpanAt('pool', 58, 29.5)).toEqual([16, 104]) + // register at 64 (the historic cut): its ends bulge to x6 / x118 at mid-height + const [l, r] = fillSpanAt('register', 64, 32)! + expect(l).toBeCloseTo(6, 1) + expect(r).toBeCloseTo(118, 1) + }) + + it('leaves the Parameter tab out: text keeps clear of the body edge x8', () => { + expect(fillSpanAt('parameter', 70, 35)![0]).toBe(8) + }) + + it('is null above or below the vessel', () => { + expect(fillSpanAt('pool', 58, 2)).toBeNull() + expect(fillSpanAt('register', 86, 80)).toBeNull() + }) + + it('clamps the height to the kind range, like the drawn path', () => { + expect(fillSpanAt('pool', 20, 29.5)).toEqual(fillSpanAt('pool', 56, 29.5)) + expect(fillSpanAt('pool', 400, 29.5)).toEqual(fillSpanAt('pool', 132, 29.5)) + }) +}) + +// geometry close to what NodeFrame measures in the shipped layout (index.css): +// Pool body padding 12 / 12, title one line at y 10 … 26; Parameter / Register +// padding 15 / 15, the head carrying the width-scaled rim +const pool = (width: number, over: Partial = {}): FrameGeometry => ({ + height: 74, width, stackStart: 12, padEnd: 12, titleStart: 26, titleWidth: 60, + titleTop: 10, titleBottom: 26, titleWrapped: false, titleMax: 135, ...over, +}) +const capsule = (kind: 'parameter' | 'register', width: number, over: Partial = {}): FrameGeometry => ({ + height: 86, width, stackStart: 15, padEnd: 15, titleStart: 29 + headRim(kind, width), titleWidth: 70, + titleTop: 16, titleBottom: 32, titleWrapped: false, titleMax: 135, ...over, +}) +const POOL_ROWS = (value: number, sub = 40): MeasuredRow[] => [ + { key: 'value', top: 27, bottom: 48, width: value }, + { key: 'sub', top: 49, bottom: 64, width: sub }, +] +const CAPSULE_ROWS = (value: number, sub = 40): MeasuredRow[] => [ + { key: 'value', top: 33, bottom: 54, width: value }, + { key: 'sub', top: 55, bottom: 70, width: sub }, +] + +/** the extreme fill edges across a row, in CSS px at width `w` */ +function edges(kind: NodeKind, h: number, r: MeasuredRow, w: number): [number, number] { + let l = -Infinity + let rt = Infinity + for (let k = 0; k <= 4; k++) { + const s = fillSpanAt(kind, h, r.top + ((r.bottom - r.top) * k) / 4) + if (!s) continue + l = Math.max(l, s[0]) + rt = Math.min(rt, s[1]) + } + return [(l * w) / 120, (rt * w) / 120] +} + +describe('fitRows', () => { + it('keeps ROW_CLEAR at the inner focus ring plus 2', () => { + expect(ROW_CLEAR).toBe(NODE_RINGS.focus.to + 2) + expect(ROW_CLEAR).toBe(8) + }) + + it('starts each row at the title text, and keeps the size when it already fits', () => { + // a one-line Pool (58 px tall, value only) and a Register + const p = fitRows('pool', pool(118, { height: 58 }), POOL_ROWS(20).slice(0, 1)) + expect(p).toEqual({ width: 118, start: { value: 14 }, minWidth: null, maxWidth: {} }) + const r = fitRows('register', capsule('register', 118), CAPSULE_ROWS(20, 30)) + expect(r).toEqual({ width: 118, start: { value: 14, sub: 14 }, minWidth: null, maxWidth: {} }) + }) + + it('moves a row past the title start where the slant needs it', () => { + // a capacity row makes the Pool 74 px tall, so its left side leans in + // further toward the top: at the value row's top the fill starts at + // x ≈ 19.4, and 8 px clear of it is past the title's 26 + const fit = fitRows('pool', pool(118), POOL_ROWS(20)) + expect(fit.start.value! + 12).toBeGreaterThan(26) + expect(fit.start.sub).toBe(14) + }) + + it('widens a Register by the minimum that holds the row whole', () => { + const rows = CAPSULE_ROWS(100) + const fit = fitRows('register', capsule('register', 137), rows) + expect(fit.minWidth).not.toBeNull() + const w = fit.minWidth! + const at = (width: number) => fitRows('register', capsule('register', width), rows) + // at the width it settled on, the row is whole and 8 px clear at its end + const end = 15 + at(w).start.value! + 100 + expect(end).toBeLessThanOrEqual(edges('register', 86, rows[0], w)[1] - ROW_CLEAR + 0.01) + expect(at(w).maxWidth.value).toBeUndefined() + // half a px narrower, it would not be + const s = 15 + at(w - 0.5).start.value! + expect(Math.min(edges('register', 86, rows[0], w - 0.5)[1] - ROW_CLEAR, w - 0.5 - 15 - 1) - s).toBeLessThan(100) + }) + + it('never widens for a row too long even for the widest node: it is cut where it is', () => { + const rows = CAPSULE_ROWS(30, 400) + const fit = fitRows('register', capsule('register', 260), rows) + expect(fit.minWidth).toBe(NODE_MAX_W) + expect(fit.maxWidth.sub).toBeGreaterThan(0) + expect(fit.maxWidth.sub!).toBeLessThan(400) + // the shorter value row still fits whole + expect(fit.maxWidth.value).toBeUndefined() + }) + + it('never makes a Pool title worse: the slanted side would move toward it', () => { + // a 108 px value (`1234567.89`) set the shipped width (12 + 108 + 12); + // aligned at the title it would need ~185 px, where the title would cross + const fit = fitRows('pool', pool(132), POOL_ROWS(108)) + expect(fit.minWidth).toBe(132) + expect(fit.maxWidth.value).toBeLessThan(108) + }) + + it('lets a title that had 8 px or more lose at most TITLE_SLACK, never below 8', () => { + // a Parameter whose 78 px unit (`items per hour`) needs a wider node: below + // ~129 px the head's rim is 0, so the body edge (x8) creeps toward the title + const g = capsule('parameter', 118) + const rows = CAPSULE_ROWS(30, 78.5) + const fit = fitRows('parameter', g, rows) + const titleEdge = Math.max(...[16, 20, 24, 28, 32].map((y) => fillSpanAt('parameter', 86, y)![0])) + const clear = (w: number) => g.titleStart + headRim('parameter', w) - headRim('parameter', 118) - (titleEdge * w) / 120 + expect(fit.minWidth).toBeGreaterThan(118) + expect(clear(118)).toBeGreaterThan(ROW_CLEAR) + expect(clear(fit.width)).toBeGreaterThanOrEqual(Math.max(ROW_CLEAR, clear(118) - TITLE_SLACK) - 0.01) + expect(TITLE_SLACK).toBe(0.5) + }) + + it('never changes the height: a title wrapped for want of room keeps its width', () => { + // the 110 px `= expr` set the shipped width: 15 + 110 + 15 + const rows = CAPSULE_ROWS(30, 110) + const free = fitRows('register', capsule('register', 140), rows) + expect(free.minWidth).toBeGreaterThan(140) + // a 100 px title wraps at 140 (93 px of room); it would unwrap from ~149 + const wrapped = fitRows('register', capsule('register', 140, { titleWrapped: true, titleWidth: 100, titleBottom: 48 }), rows) + const w = wrapped.minWidth! + expect(w).toBeGreaterThanOrEqual(140) + expect(w).toBeLessThan(free.minWidth!) + expect(w - 30 - 2 * headRim('register', w) - 14).toBeLessThan(100) + expect(wrapped.maxWidth.sub).toBeLessThan(110) + }) + + it('still widens a node whose title wraps at its own max width', () => { + // a 220 px title wraps at its 135 px max width: widening cannot unwrap it + const rows = CAPSULE_ROWS(30, 200) + const g = capsule('register', 200, { titleWrapped: true, titleWidth: 220, titleBottom: 48 }) + expect(fitRows('register', g, rows).minWidth).toBeGreaterThan(200) + }) + + // the contract over a grid: every row it returns starts at or after the + // title, keeps ROW_CLEAR inside the outline at both ends, and the node never + // leaves the CSS width range + it.each([ + ['pool', [118, 140, 200, 260]], + ['parameter', [118, 140, 200, 260]], + ['register', [118, 140, 200, 260]], + ] as const)('%s rows stay inside the outline at every width', (kind, widths) => { + for (const width of widths) { + for (const value of [10, 60, 120, 240, 500]) { + for (const sub of [20, 90, 300]) { + const g = kind === 'pool' ? pool(width) : capsule(kind, width) + const rows = kind === 'pool' ? POOL_ROWS(value, sub) : CAPSULE_ROWS(value, sub) + const fit = fitRows(kind, g, rows) + const w = fit.width + if (fit.minWidth != null) expect(fit.minWidth).toBe(w) + expect(w).toBeGreaterThanOrEqual(NODE_MIN_W) + expect(w).toBeLessThanOrEqual(NODE_MAX_W) + const titleAt = g.titleStart + headRim(kind, w) - headRim(kind, width) + for (const r of rows) { + const [l, rt] = edges(kind, g.height, r, w) + const s = g.stackStart + fit.start[r.key]! + const e = s + Math.min(r.width, fit.maxWidth[r.key] ?? Infinity) + expect(s).toBeGreaterThanOrEqual(Math.min(titleAt, NODE_MAX_W) - 0.01) + expect(s - l).toBeGreaterThanOrEqual(ROW_CLEAR - 0.01) + expect(rt - e).toBeGreaterThanOrEqual(ROW_CLEAR - 0.01) + } + } + } + } + }) +}) diff --git a/src/components/nodes/rowFit.ts b/src/components/nodes/rowFit.ts new file mode 100644 index 00000000..ad78e02a --- /dev/null +++ b/src/components/nodes/rowFit.ts @@ -0,0 +1,195 @@ +// issue #332 — where a Pool's, a Parameter's and a Register's value and detail +// rows start, and how wide the node must be, so that no row crosses the drawn +// vessel. +// +// The contract (Option C plus minimal widening, for those three kinds only): +// 1. a value row and a detail row start where the title text starts (after +// the kind chip and its gap), along the stack's PHYSICAL start: the canvas +// is physically left-to-right in every language (docs/localization.md +// §L9.2), and a row whose own text resolves to RTL (`dir="auto"` on an +// Arabic unit) must still start on the chip's side; +// 2. every row's glyphs stay at least `ROW_CLEAR` px inside the silhouette at +// the row's own height, both ends (the 6 px focus ring plus 2); +// 3. a node keeps the width it had before this fix when that already +// satisfies both; otherwise only that node widens, by the minimum, up to +// the 260 px maximum — and only for a row that is then whole: a row too +// long even for the widest node is cut where it is; +// 4. a widening never makes the title's clearance worse: the vessel scales +// with the width (`preserveAspectRatio="none"`), so on the Pool's slanted +// side every px of width moves the outline toward the title. A title +// that had 8 px or more keeps at least `max(8, its clearance − 0.5)` +// (`TITLE_SLACK`, the measurement's own tolerance); one that had less +// keeps every px of it. Nor does it +// change the node's height: a title that wraps for want of room (not at +// its own max width) would unwrap as the node widens, so it keeps its +// width; +// 5. where the room ends (the maximum, or the width the title allows) a row +// is cut short (its ellipsis) before it would cross, and the node keeps +// the width it settled on. +// +// Pure: it reads only the measurements `NodeFrame` takes when the content or +// the size changes (never per frame) and the silhouette geometry, so it can be +// unit-tested. + +import type { NodeKind } from '../../model/types' +import { fillSpanAt, NODE_RINGS } from './silhouette' + +/** how many row-fit measurements `NodeFrame` has taken (a dev-bridge counter, + * so the e2e can assert none is taken per animation frame) */ +let measures = 0 +export const rowFitMeasured = (): void => { + measures++ +} +export const rowFitMeasureCount = (): number => measures + +/** px between a row's glyphs and the silhouette: the inner focus ring's far + * edge (`NODE_RINGS.focus.to`, 6) plus 2. */ +export const ROW_CLEAR = NODE_RINGS.focus.to + 2 + +/** px a title that had `ROW_CLEAR` or more may lose to a widening (rule 4) */ +export const TITLE_SLACK = 0.5 + +/** the node's CSS width range (`.nodef` min-width / max-width in index.css) */ +export const NODE_MIN_W = 118 +export const NODE_MAX_W = 260 + +/** the `margin-inline` rim the Parameter / Register head carries (index.css: + * `clamp(0px, calc(11.6667% - 11.5px), 17px)` of the stack, which is the box + * width minus the body's 15 + 15 px padding). 0 for every other kind. */ +export const headRim = (kind: NodeKind, width: number): number => + kind === 'parameter' || kind === 'register' + ? Math.max(0, Math.min(0.116667 * (width - 30) - 11.5, 17)) + : 0 + +/** the inline margins a row carried before #332, as they count in the node's + * intrinsic width (a % margin counts as 0 there): the Register value's 1 px, + * the Parameter value's `max(4px, 8% − 13px)`. The before width is rebuilt + * from them, so a node whose rows already fit keeps its size. */ +const shippedMargins = (kind: NodeKind, key: MeasuredRow['key']): number => + key !== 'value' ? 0 : kind === 'register' ? 2 : kind === 'parameter' ? 8 : 0 + +/** the margin a row still carries at its END (the left one is the fit's own) */ +const endMargin = (kind: NodeKind, key: MeasuredRow['key']): number => + key !== 'value' ? 0 : kind === 'register' ? 1 : kind === 'parameter' ? 4 : 0 + +/** One row as `NodeFrame` measured it: its vertical extent and its whole + * text's width (even while an ellipsis cuts it short), in CSS px within the + * node box. */ +export type MeasuredRow = { key: 'value' | 'sub'; top: number; bottom: number; width: number } + +/** The node's geometry around its rows, in CSS px within the box at its + * current `width`. */ +export type FrameGeometry = { + /** the node's box height (the silhouette's viewBox height) */ + height: number + /** the node's current CSS width */ + width: number + /** the stack's start, from the box's left edge (the body's start padding) */ + stackStart: number + /** the body's end padding */ + padEnd: number + /** the title text's start, from the box's left edge */ + titleStart: number + /** the title text's width on one line (its max-content width) */ + titleWidth: number + /** the title's vertical extent */ + titleTop: number + titleBottom: number + /** does the title run to more than one line, and its own max width */ + titleWrapped: boolean + titleMax: number +} + +export type RowFit = { + /** the width the fit is for: the node's width once it is applied */ + width: number + /** each row's start, in CSS px from the stack's start (its `margin-left`) */ + start: Partial> + /** the node's minimum width, or null when its own content sets it */ + minWidth: number | null + /** a row's max width where the room ends before the row does */ + maxWidth: Partial> +} + +// Layout units (1/64 px). A row aligned to the title takes the title's own +// start, to the nearest unit, so it shares the title's sub-pixel phase; a +// clearance is only ever rounded up, and a room down. Never a coarser step: a +// half-px ceil turned a title start measured at 29.00001 into 29.5 on one +// machine and 29 on another (CI), moving the row's glyphs by half a pixel. +const UNIT = 64 +const EPS = 1e-6 +const nearest = (v: number) => Math.round(v * UNIT) / UNIT +const up = (v: number) => Math.ceil(v * UNIT - EPS) / UNIT +const down = (v: number) => Math.floor(v * UNIT + EPS) / UNIT +const clampW = (w: number) => Math.max(NODE_MIN_W, Math.min(w, NODE_MAX_W)) + +/** the extreme fill edges across `[top, bottom]`, in viewBox x */ +function edgesOver(kind: NodeKind, height: number, top: number, bottom: number): [number, number] { + let l = -Infinity + let r = Infinity + for (let k = 0; k <= 4; k++) { + const s = fillSpanAt(kind, height, top + ((bottom - top) * k) / 4) + if (!s) continue + l = Math.max(l, s[0]) + r = Math.min(r, s[1]) + } + return [l, r] +} + +export function fitRows(kind: NodeKind, g: FrameGeometry, rows: MeasuredRow[]): RowFit { + const { height, width, stackStart, padEnd, titleStart } = g + // the title text (and with it every aligned row) moves with the Parameter / + // Register rim as the width changes + const titleAt = (w: number) => titleStart + headRim(kind, w) - headRim(kind, width) + const titleEdge = edgesOver(kind, height, g.titleTop, g.titleBottom)[0] + const titleClear = (w: number) => titleAt(w) - (titleEdge * w) / 120 + const rowEdges = rows.map((r) => edgesOver(kind, height, r.top, r.bottom)) + const startAt = (i: number, w: number) => Math.max(nearest(titleAt(w)), up((rowEdges[i][0] * w) / 120 + ROW_CLEAR)) + // a row's room at width `w`: inside the outline by `ROW_CLEAR`, and inside + // the box's end padding (a row that reached into it would widen the node) + const roomAt = (i: number, w: number) => + Math.min((rowEdges[i][1] * w) / 120 - ROW_CLEAR, w - padEnd - endMargin(kind, rows[i].key)) - startAt(i, w) + + // the width before this fix: the head, or a row at its old place + // (a title counts at its one-line width, up to its own max width) + const head = titleAt(0) - stackStart - headRim(kind, 0) + Math.min(g.titleWidth, g.titleMax) + const before = clampW( + stackStart + Math.max(head, ...rows.map((r) => r.width + shippedMargins(kind, r.key))) + padEnd, + ) + // rule 4: the widest the title allows + const was = titleClear(before) + const floor = was >= ROW_CLEAR ? Math.max(ROW_CLEAR, was - TITLE_SLACK) : was + let cap = NODE_MAX_W + while (cap > before && titleClear(cap) < floor - 0.01) cap -= 0.5 + cap = Math.max(cap, before) + // …and the title's line count: a title that wraps although one line would + // fit its max width wraps for want of room, and unwraps where the room the + // node gives it reaches its one-line width + const chipGap = titleStart - headRim(kind, width) - stackStart + const titleRoom = (w: number) => w - stackStart - padEnd - 2 * headRim(kind, w) - chipGap + if (g.titleWrapped && g.titleWidth <= g.titleMax + 0.5) { + while (cap > before && titleRoom(cap) >= g.titleWidth - 0.01) cap -= 0.5 + cap = Math.max(cap, before) + } + // rule 3: the narrowest width that holds every row whole — every row that + // the widest allowed node can hold whole: one that would still be cut short + // there gains nothing whole from a wider node, so it is cut where it is + const whole = (i: number, w: number) => rows[i].width <= roomAt(i, w) + 0.01 + const widening = rows.map((_, i) => i).filter((i) => whole(i, cap)) + const fits = (w: number) => widening.every((i) => whole(i, w)) + let need = NODE_MIN_W + while (need < cap && !fits(need)) need += 0.5 + const used = Math.max(before, Math.min(need, cap)) + + const start: RowFit['start'] = {} + const maxWidth: RowFit['maxWidth'] = {} + rows.forEach((r, i) => { + start[r.key] = +(startAt(i, used) - stackStart).toFixed(4) + const room = roomAt(i, used) + if (room < r.width - 0.01) maxWidth[r.key] = Math.max(0, down(room)) + }) + // a row cut short no longer holds the node at its width, so the width is + // held explicitly then too + const cut = Object.keys(maxWidth).length > 0 + return { width: +used.toFixed(4), start, minWidth: used > before + 0.01 || cut ? +used.toFixed(4) : null, maxWidth } +} diff --git a/src/components/nodes/silhouette.ts b/src/components/nodes/silhouette.ts index 6b1984d0..ba8a86c8 100644 --- a/src/components/nodes/silhouette.ts +++ b/src/components/nodes/silhouette.ts @@ -146,6 +146,68 @@ export function silhouettePath(kind: NodeKind, h = BASE_NODE_H): string { } } +/** issue #332 — where the drawn vessel's FILL is along a horizontal line at + * `y` (CSS px from the box top, = viewBox y), as `[left, right]` in viewBox x + * units (0 … 120; multiply by `width / 120` for CSS px, the SVG is + * `preserveAspectRatio="none"`). `null` when `y` misses the shape. Computed + * from `silhouettePath` itself (its `M H V L Q Z` commands, the quadratic + * corners flattened), so it cannot drift from what is drawn. The Parameter's + * left tab (`x1 … 8`) is left out: it is a port-side marker, not room for text, + * so the body's edge `x8` is the edge text must keep clear of. */ +export function fillSpanAt(kind: NodeKind, h: number, y: number): [number, number] | null { + const H = Math.max(BASE_NODE_H, Math.min(h, MAX_NODE_H[kind])) + const key = `${kind}|${H}` + let segs = segmentCache.get(key) + if (!segs) segmentCache.set(key, (segs = pathSegments(silhouettePath(kind, H)))) + const xs: number[] = [] + for (const [a, b] of segs) { + if ((a[1] <= y && b[1] >= y) || (b[1] <= y && a[1] >= y)) { + if (a[1] === b[1]) xs.push(a[0], b[0]) + else xs.push(a[0] + ((y - a[1]) / (b[1] - a[1])) * (b[0] - a[0])) + } + } + if (xs.length < 2) return null + const left = kind === 'parameter' ? Math.max(8, Math.min(...xs)) : Math.min(...xs) + return [left, Math.max(...xs)] +} + +type Pt = [number, number] +/** each (kind, height)'s flattened path, parsed once: a row fit samples it + * about fifteen times per node */ +const segmentCache = new Map() +/** The straight segments of one of this module's paths, quadratics flattened. */ +function pathSegments(d: string): [Pt, Pt][] { + const tok = d.match(/[MHVLQZ]|-?\d+(?:\.\d+)?/g) ?? [] + const segs: [Pt, Pt][] = [] + let i = 0 + let cur: Pt = [0, 0] + let start: Pt = [0, 0] + const num = () => Number(tok[i++]) + while (i < tok.length) { + const c = tok[i++] + if (c === 'M') { cur = [num(), num()]; start = cur } + else if (c === 'H') { const p: Pt = [num(), cur[1]]; segs.push([cur, p]); cur = p } + else if (c === 'V') { const p: Pt = [cur[0], num()]; segs.push([cur, p]); cur = p } + else if (c === 'L') { const p: Pt = [num(), num()]; segs.push([cur, p]); cur = p } + else if (c === 'Q') { + const q: Pt = [num(), num()] + const p: Pt = [num(), num()] + let prev = cur + for (let k = 1; k <= 16; k++) { + const t = k / 16 + const pt: Pt = [ + (1 - t) * (1 - t) * cur[0] + 2 * (1 - t) * t * q[0] + t * t * p[0], + (1 - t) * (1 - t) * cur[1] + 2 * (1 - t) * t * q[1] + t * t * p[1], + ] + segs.push([prev, pt]) + prev = pt + } + cur = p + } else if (c === 'Z') { segs.push([cur, start]); cur = start } + } + return segs +} + /** Clamp a content height to this kind's silhouette range: never below the base * height, never above the per-kind ceiling. The caller decides *whether* to * grow at all (only when the title has actually wrapped). */ diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index d6575c24..21689787 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -205,15 +205,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('it copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) expect(Object.keys(IT).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1033, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1255) + expect(per).toEqual({ catalog: 1036, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1258) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 69339ea2..0843244c 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -595,6 +595,9 @@ const ui = { 'whatsNew.v0211.dim': 'عند تشغيل التركيز، تخفت الآن الاتصالات خارج منطقة التركيز كما تخفت العقد هناك، فيبرز الجوار الذي تركّز عليه.', 'whatsNew.v0211.kept': 'يبقى التحديد والتحذيرات والعلامات المتحركة لمحاكاة قيد التشغيل ظاهرة بكامل وضوحها.', 'whatsNew.v0211.contrast': 'في التباين العالي لا يُفتَّح لون الاتصال الخافت، بل يُميَّز بخط منقّط رفيع بدلًا من ذلك.', + 'whatsNew.v0212.aligned': 'في العقد التي تعرض قيمة، تبدأ القيمة والسطر الذي تحتها الآن من حيث يبدأ العنوان، وتبقى داخل حدود العقدة بهامش كافٍ.', + 'whatsNew.v0212.wider': 'تتسع العقدة قليلًا إذا لم تتسع للقيمة أو للسطر، ويُختصر السطر الأطول من أعرض عقدة بعلامة «…».', + 'whatsNew.v0212.same': 'يتغير العرض فقط: القيم ومواضع العقد ونتائج المحاكاة تبقى كما هي.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 84a59a64..a8eecef0 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -630,6 +630,9 @@ const ui = { 'whatsNew.v0211.dim': 'Mit eingeschaltetem Fokus werden jetzt auch die Verbindungen außerhalb des fokussierten Bereichs abgeschwächt, wie die Knoten dort, sodass die fokussierte Umgebung hervortritt.', 'whatsNew.v0211.kept': 'Auswahl, Warnungen und die beweglichen Markierungen einer laufenden Simulation bleiben voll sichtbar.', 'whatsNew.v0211.contrast': 'Bei hohem Kontrast wird eine abgeschwächte Verbindung nicht blasser gezeichnet, sondern durch eine feine gepunktete Linie markiert.', + 'whatsNew.v0212.aligned': 'In Knoten mit einem Wert beginnen der Wert und die Zeile darunter jetzt auf Höhe des Titels, gut innerhalb des Knotenumrisses.', + 'whatsNew.v0212.wider': 'Ein Knoten, in den der Wert oder die Zeile nicht passt, wird etwas breiter; eine Zeile, die selbst in den breitesten Knoten nicht passt, endet mit „…“.', + 'whatsNew.v0212.same': 'Nur die Darstellung ändert sich: gespeicherte Werte, Knotenpositionen und Simulationsergebnisse bleiben gleich.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 9014ca02..320bf145 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -539,6 +539,9 @@ const ui = { 'whatsNew.v0211.dim': 'With Focus on, the connections outside the focused area now fade like the nodes there, so the focused neighbourhood stands out.', 'whatsNew.v0211.kept': 'Selection, warnings and the moving markers of a running simulation still show at full strength.', 'whatsNew.v0211.contrast': 'In high contrast, a faded connection is not made paler: a fine dotted line marks it instead.', + 'whatsNew.v0212.aligned': 'In nodes that show a value, the value and the line under it now start where the title starts, well inside the node’s outline.', + 'whatsNew.v0212.wider': 'A node whose value or line would not fit grows a little wider; a line too long even for the widest node is cut short with “…”.', + 'whatsNew.v0212.same': 'Only the display changes: stored values, node positions and simulation results stay the same.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index 1bf769d2..3bb523f5 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -553,6 +553,9 @@ const ui = { 'whatsNew.v0211.dim': 'Con el enfoque activado, las conexiones fuera de la zona enfocada ahora se atenúan como los nodos de esa zona, de modo que el entorno enfocado destaca.', 'whatsNew.v0211.kept': 'La selección, los avisos y las marcas en movimiento de una simulación en curso se siguen viendo con toda su intensidad.', 'whatsNew.v0211.contrast': 'En contraste alto, una conexión atenuada no se aclara: la marca una línea de puntos fina.', + 'whatsNew.v0212.aligned': 'En los nodos que muestran un valor, el valor y la línea de debajo empiezan ahora a la altura del título, bien dentro del contorno del nodo.', + 'whatsNew.v0212.wider': 'Un nodo en el que no cabe el valor o la línea se ensancha un poco; una línea demasiado larga incluso para el nodo más ancho se abrevia con «…».', + 'whatsNew.v0212.same': 'Solo cambia la presentación: los valores guardados, la posición de los nodos y los resultados de la simulación no cambian.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index 7a62071b..1706826a 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -567,6 +567,9 @@ const ui = { 'whatsNew.v0211.dim': 'Con el enfoque activado, las conexiones fuera de la zona enfocada ahora se atenúan como los nodos de esa zona, de modo que el entorno enfocado destaca.', 'whatsNew.v0211.kept': 'La selección, los avisos y las marcas en movimiento de una simulación en curso se siguen viendo con toda su intensidad.', 'whatsNew.v0211.contrast': 'En contraste alto, una conexión atenuada no se aclara: la marca una línea de puntos fina.', + 'whatsNew.v0212.aligned': 'En los nodos que muestran un valor, el valor y la línea de debajo empiezan ahora a la altura del título, bien dentro del contorno del nodo.', + 'whatsNew.v0212.wider': 'Un nodo en el que no cabe el valor o la línea se ensancha un poco; una línea demasiado larga incluso para el nodo más ancho se abrevia con «…».', + 'whatsNew.v0212.same': 'Solo cambia la presentación: los valores guardados, la posición de los nodos y los resultados de la simulación no cambian.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index 43a7278a..9053348f 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -624,6 +624,9 @@ const ui = { 'whatsNew.v0211.dim': 'Avec le Focus activé, les connexions hors de la zone ciblée s’estompent désormais comme les nœuds qui s’y trouvent, ce qui fait ressortir le voisinage ciblé.', 'whatsNew.v0211.kept': 'La sélection, les avertissements et les repères mobiles d’une simulation en cours restent pleinement visibles.', 'whatsNew.v0211.contrast': 'En contraste élevé, une connexion estompée n’est pas pâlie : de fins pointillés la signalent à la place.', + 'whatsNew.v0212.aligned': 'Dans les nœuds qui affichent une valeur, la valeur et la ligne en dessous commencent désormais au niveau du titre, bien à l’intérieur du contour du nœud.', + 'whatsNew.v0212.wider': 'Un nœud dont la valeur ou la ligne ne tient pas s’élargit un peu ; une ligne trop longue même pour le nœud le plus large est abrégée par « … ».', + 'whatsNew.v0212.same': 'Seul l’affichage change : les valeurs enregistrées, la position des nœuds et les résultats de simulation restent identiques.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 1d9eb4fa..6e69efc8 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -569,6 +569,9 @@ const ui = { 'whatsNew.v0211.dim': 'Con la messa a fuoco attiva, anche le connessioni fuori dall’area a fuoco ora si attenuano come i nodi che vi si trovano, così l’area a fuoco risalta.', 'whatsNew.v0211.kept': 'La selezione, gli avvisi e i segni in movimento di una simulazione in corso restano ben visibili.', 'whatsNew.v0211.contrast': 'In contrasto elevato una connessione attenuata non viene schiarita: la segnala invece una linea punteggiata sottile.', + 'whatsNew.v0212.aligned': 'Nei nodi che mostrano un valore, il valore e la riga sottostante ora iniziano all’altezza del titolo, ben dentro il contorno del nodo.', + 'whatsNew.v0212.wider': 'Un nodo in cui il valore o la riga non entrano si allarga un poco; una riga troppo lunga anche per il nodo più largo viene accorciata con «…».', + 'whatsNew.v0212.same': 'Cambia solo la visualizzazione: i valori salvati, la posizione dei nodi e i risultati della simulazione restano invariati.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index ce0b967e..402c7958 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -574,6 +574,9 @@ const ui = { 'whatsNew.v0211.dim': 'フォーカスをオンにすると、フォーカス範囲外の接続もそこのノードと同じように淡く表示され、注目している周辺が際立ちます。', 'whatsNew.v0211.kept': '選択、警告、実行中のシミュレーションで動く印は、これまでどおりはっきり表示されます。', 'whatsNew.v0211.contrast': '高コントラストでは、淡くなった接続を薄くせず、代わりに細い点線で示します。', + 'whatsNew.v0212.aligned': '値を表示するノードでは、値とその下の行がタイトルと同じ位置から始まり、ノードの輪郭の内側に十分収まるようになりました。', + 'whatsNew.v0212.wider': '値や下の行が収まらないノードは少しだけ幅が広がります。最も広いノードにも収まらない行は「…」で省略されます。', + 'whatsNew.v0212.same': '変わるのは表示だけです。保存された値、ノードの位置、シミュレーション結果は変わりません。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 5ae309cc..77dc64fc 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -542,6 +542,9 @@ const ui = { 'whatsNew.v0211.dim': 'Focus를 켜면 집중 영역 밖의 연결선도 그곳의 노드처럼 흐려져, 집중한 주변이 더 잘 보입니다.', 'whatsNew.v0211.kept': '선택, 경고, 실행 중인 시뮬레이션의 움직이는 표시는 그대로 선명하게 보입니다.', 'whatsNew.v0211.contrast': '고대비에서는 흐려진 연결선을 옅게 만들지 않고, 대신 가는 점선으로 표시합니다.', + 'whatsNew.v0212.aligned': '값을 보여 주는 노드에서 값과 그 아래 줄이 이제 제목과 같은 위치에서 시작해 노드 윤곽 안쪽에 넉넉히 들어옵니다.', + 'whatsNew.v0212.wider': '값이나 아래 줄이 들어가지 않는 노드는 조금 넓어지고, 가장 넓은 노드에도 다 들어가지 않는 줄은 “…”로 줄여 표시합니다.', + 'whatsNew.v0212.same': '바뀌는 것은 표시뿐입니다. 저장된 값, 노드 위치, 시뮬레이션 결과는 그대로입니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 64729684..24a92d33 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -550,6 +550,9 @@ const ui = { 'whatsNew.v0211.dim': 'Met Focus aan worden de verbindingen buiten het gefocuste gebied nu ook gedimd, net als de knooppunten daar, zodat de gefocuste omgeving opvalt.', 'whatsNew.v0211.kept': 'Selectie, waarschuwingen en de bewegende markeringen van een lopende simulatie blijven volledig zichtbaar.', 'whatsNew.v0211.contrast': 'Bij hoog contrast wordt een gedimde verbinding niet lichter getekend, maar met een fijne stippellijn gemarkeerd.', + 'whatsNew.v0212.aligned': 'In knooppunten met een waarde beginnen de waarde en de regel eronder nu op de hoogte van de titel, ruim binnen de omtrek van het knooppunt.', + 'whatsNew.v0212.wider': 'Een knooppunt waarin de waarde of de regel niet past, wordt iets breder; een regel die zelfs in het breedste knooppunt niet past, eindigt op „…”.', + 'whatsNew.v0212.same': 'Alleen de weergave verandert: opgeslagen waarden, de posities van knooppunten en simulatieresultaten blijven gelijk.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 5f9d6e00..09b0f86d 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -555,6 +555,9 @@ const ui = { 'whatsNew.v0211.dim': 'Com o Foco ativado, as conexões fora da área em foco agora ficam esmaecidas como os nós dessa área, destacando a vizinhança em foco.', 'whatsNew.v0211.kept': 'A seleção, os avisos e as marcas em movimento durante a simulação continuam bem visíveis.', 'whatsNew.v0211.contrast': 'Em alto contraste, uma conexão esmaecida não fica mais clara: uma linha pontilhada fina a indica.', + 'whatsNew.v0212.aligned': 'Nos nós que mostram um valor, o valor e a linha abaixo dele começam agora na altura do título, bem dentro do contorno do nó.', + 'whatsNew.v0212.wider': 'Um nó em que o valor ou a linha não cabe fica um pouco mais largo; uma linha que não cabe nem no nó mais largo termina em “…”.', + 'whatsNew.v0212.same': 'Só a apresentação muda: os valores, as posições dos nós e os resultados da simulação continuam iguais.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index 23943221..7b8953d5 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -569,6 +569,9 @@ const ui = { 'whatsNew.v0211.dim': 'Com o Foco ativado, as ligações fora da área em foco passam a ficar esbatidas como os nós dessa área, destacando a vizinhança em foco.', 'whatsNew.v0211.kept': 'A seleção, os avisos e as marcas em movimento durante a simulação continuam bem visíveis.', 'whatsNew.v0211.contrast': 'Em alto contraste, uma ligação esbatida não fica mais clara: uma linha pontilhada fina indica-a.', + 'whatsNew.v0212.aligned': 'Nos nós que mostram um valor, o valor e a linha abaixo dele começam agora na altura do título, bem dentro do contorno do nó.', + 'whatsNew.v0212.wider': 'Um nó em que o valor ou a linha não cabe fica um pouco mais largo; uma linha que não cabe nem no nó mais largo termina em “…”.', + 'whatsNew.v0212.same': 'Só a apresentação muda: os valores, as posições dos nós e os resultados da simulação continuam iguais.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index bb18ed6e..38a17bff 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -623,6 +623,9 @@ const ui = { 'whatsNew.v0211.dim': 'При включённом фокусе связи вне области фокуса теперь тоже приглушаются, как и узлы там, и окружение в фокусе выделяется.', 'whatsNew.v0211.kept': 'Выделение, предупреждения и движущиеся отметки идущей симуляции по-прежнему видны полностью.', 'whatsNew.v0211.contrast': 'При высокой контрастности приглушённая связь не становится бледнее: её отмечает тонкая пунктирная линия.', + 'whatsNew.v0212.aligned': 'В узлах, показывающих значение, само значение и строка под ним теперь начинаются на уровне заголовка, с запасом внутри контура узла.', + 'whatsNew.v0212.wider': 'Узел, в который не помещается значение или строка, становится чуть шире; строка, которая не помещается даже в самый широкий узел, обрезается многоточием «…».', + 'whatsNew.v0212.same': 'Меняется только отображение: значения, положение узлов и результаты симуляции остаются прежними.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index 730e1fb4..2512f7ab 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -615,6 +615,9 @@ const ui = { 'whatsNew.v0211.dim': 'เมื่อเปิดโฟกัส เส้นเชื่อมที่อยู่นอกบริเวณที่โฟกัสจะจางลงเช่นเดียวกับโหนดในบริเวณนั้น ทำให้บริเวณที่โฟกัสเด่นขึ้น', 'whatsNew.v0211.kept': 'การเลือก คำเตือน และเครื่องหมายเคลื่อนไหวของการจำลองที่กำลังทำงานยังคงแสดงเต็มที่', 'whatsNew.v0211.contrast': 'ในโหมดคอนทราสต์สูง เส้นเชื่อมที่จางลงจะไม่ถูกทำให้ซีดลง แต่จะแสดงด้วยเส้นประจุดบาง ๆ แทน', + 'whatsNew.v0212.aligned': 'ในโหนดที่แสดงค่า ค่าและบรรทัดด้านล่างจะเริ่มที่ตำแหน่งเดียวกับชื่อ และอยู่ภายในเส้นขอบของโหนดโดยมีที่ว่างเหลือพอ', + 'whatsNew.v0212.wider': 'โหนดที่ใส่ค่าหรือบรรทัดไม่พอจะกว้างขึ้นเล็กน้อย ส่วนบรรทัดที่ยาวเกินแม้แต่โหนดที่กว้างที่สุดจะถูกตัดด้วย “…”', + 'whatsNew.v0212.same': 'เปลี่ยนเฉพาะการแสดงผล ค่า ตำแหน่งโหนด และผลการจำลองยังคงเหมือนเดิม', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 68df891e..2b23d5d4 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -609,6 +609,9 @@ const ui = { 'whatsNew.v0211.dim': 'Odak açıkken odak alanının dışındaki bağlantılar da artık oradaki düğümler gibi soluklaşır; böylece odaklanılan çevre öne çıkar.', 'whatsNew.v0211.kept': 'Seçim, uyarılar ve çalışan bir simülasyonun hareket eden işaretleri tam belirginlikte kalır.', 'whatsNew.v0211.contrast': 'Yüksek karşıtlıkta soluklaşan bir bağlantı daha açık çizilmez; bunun yerine ince noktalı bir çizgiyle belirtilir.', + 'whatsNew.v0212.aligned': 'Değer gösteren düğümlerde değer ve altındaki satır artık başlıkla aynı hizada başlar ve düğümün dış çizgisinin rahatça içinde kalır.', + 'whatsNew.v0212.wider': 'Değerin veya satırın sığmadığı bir düğüm biraz genişler; en geniş düğüme bile sığmayan bir satır “…” ile kısaltılır.', + 'whatsNew.v0212.same': 'Yalnızca görünüm değişir: değerler, düğüm konumları ve simülasyon sonuçları aynı kalır.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 3876543a..264de95a 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -555,6 +555,9 @@ const ui = { 'whatsNew.v0211.dim': 'Khi bật Tiêu điểm, các liên kết nằm ngoài vùng tiêu điểm giờ cũng mờ đi như các nút ở đó, nhờ vậy vùng đang được tập trung nổi bật hơn.', 'whatsNew.v0211.kept': 'Lựa chọn, cảnh báo và các dấu chuyển động của một mô phỏng đang chạy vẫn hiển thị rõ ràng.', 'whatsNew.v0211.contrast': 'Ở chế độ tương phản cao, liên kết bị mờ không bị làm nhạt đi mà được đánh dấu bằng một đường chấm mảnh.', + 'whatsNew.v0212.aligned': 'Ở các nút hiển thị giá trị, giá trị và dòng bên dưới giờ bắt đầu thẳng hàng với tiêu đề, nằm gọn trong đường viền của nút.', + 'whatsNew.v0212.wider': 'Nút không đủ chỗ cho giá trị hoặc dòng sẽ rộng ra một chút; dòng quá dài ngay cả với nút rộng nhất sẽ được rút gọn bằng “…”.', + 'whatsNew.v0212.same': 'Chỉ cách hiển thị thay đổi: giá trị, vị trí nút và kết quả mô phỏng vẫn giữ nguyên.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index a69d0c07..f7a947e2 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -547,6 +547,9 @@ const ui = { 'whatsNew.v0211.dim': '开启聚焦后,聚焦范围以外的连线也会像那里的节点一样变淡,聚焦的邻近区域更加醒目。', 'whatsNew.v0211.kept': '选择、警告和运行中模拟的移动标记仍以完整强度显示。', 'whatsNew.v0211.contrast': '在高对比度下,变淡的连线不会变浅,而是改以细点线标示。', + 'whatsNew.v0212.aligned': '在显示数值的节点中,数值及其下方的一行现在从标题开始的位置起排,完全位于节点轮廓之内。', + 'whatsNew.v0212.wider': '放不下数值或下方一行的节点会稍微变宽;即使最宽的节点也放不下的行会以“…”截断。', + 'whatsNew.v0212.same': '只改变显示:已存储的数值、节点位置和模拟结果保持不变。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index e5cb4387..25e1c8ba 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -573,6 +573,9 @@ const ui = { 'whatsNew.v0211.dim': '開啟聚焦後,聚焦範圍以外的連線也會像那裡的節點一樣變淡,聚焦的鄰近區域更加醒目。', 'whatsNew.v0211.kept': '選取、警告和執行中模擬的移動標記仍以完整強度顯示。', 'whatsNew.v0211.contrast': '在高對比度下,變淡的連線不會變淺,而是改以細點線標示。', + 'whatsNew.v0212.aligned': '在顯示數值的節點中,數值及其下方的一行現在從標題開始的位置起排,完全位於節點輪廓之內。', + 'whatsNew.v0212.wider': '放不下數值或下方一行的節點會稍微變寬;即使最寬的節點也放不下的行會以「…」截斷。', + 'whatsNew.v0212.same': '只改變顯示:已儲存的數值、節點位置和模擬結果保持不變。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index e1428716..1d567e28 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -261,15 +261,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('nl copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) expect(Object.keys(NL).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1033, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1255) + expect(per).toEqual({ catalog: 1036, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1258) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index 1fdbb392..2411dcc8 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // ---------------------------------------------------------------- shape it('has exactly the same key set as pt-BR', () => { expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { @@ -144,7 +144,8 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // not `conexões`; the other two read the same in both. // Focus dimming (0.21.1) moved it from 275 to 277: two of its three // release-note lines - `ligações` / `ligação esbatida` not `conexões` / - // `conexão esmaecida`; the third reads the same in both. + // `conexão esmaecida`; the third reads the same in both. Value rows + // (0.21.2) left it at 277: all three of its lines read the same in both. expect(DELTA).toHaveLength(277) // and it is a real audit, not a rewrite — most of the catalog agrees. // diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index b4c9e362..bfb1f6ac 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => { // ------------------------------------------------------------------ shape it('has exactly the base key set', () => { expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) }) // ----------------------------------------------------------------- script diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index d4bfd48b..7e70af42 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => { describe('th copy — the first no-space script', () => { it('has exactly the base key set', () => { expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1033) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1036) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 4446149a..6be2d008 100644 --- a/src/i18n/trCopy.test.ts +++ b/src/i18n/trCopy.test.ts @@ -30,7 +30,7 @@ const KEYS = Object.keys(EN) describe('tr copy — the first Turkish catalog', () => { it('has exactly the base key set', () => { expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1033) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index 0f472146..962661b0 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => { describe('vi copy — the first locale English hides inside', () => { it('has exactly the base key set', () => { expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1033) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines) + expect(KEYS).toHaveLength(1036) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1033) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1036) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) diff --git a/src/index.css b/src/index.css index b5e7a494..4e85af9f 100644 --- a/src/index.css +++ b/src/index.css @@ -2030,6 +2030,43 @@ a.sheet__row { margin-inline: max(4px, calc(8% - 13px)); /* 19 px to ~243 px, then up to 20.3 px at 260 */ } +/* issue #332 — the value row and the detail rows of a Pool (its capacity), a + Parameter (its unit) and a Register (its `= expr`) used to start at the + content edge: 14 px before the title text, and on the Pool's slanted side on + top of the outline. + `NodeFrame` now measures each row once per resize / content change and hands + its start (and, at the maximum width, its max width) in as custom + properties, computed by `./components/nodes/rowFit`: the title text's start, + and never closer than 8 px to the silhouette at the row's own height. The + offset is the PHYSICAL `margin-left`: the canvas is physically left-to-right + in every language (docs/localization.md §L9.2), and a row whose own text + resolves to RTL (an Arabic unit, `dir="auto"`) must still start on the + chip's side, which `margin-inline-start` would put on its right. A fixed px + margin counts in the node's intrinsic width, so a row that sets the width + widens its node by exactly what it needs, up to the 260 px cap; there the + row ends in its ellipsis before the outline. The fit's max width is a plain + px length, never inside a `min()` with a %: a % there is cyclic in the + intrinsic pass, which drops the whole `max-width`, so a cut-short row would + still widen its node by its full text. `flex-start` keeps each row its + own text's width, so the measurement reads the text, not the stack. The + Source / Drain / Converter mode row and the Gate's centred rows are left as + they were. The fallbacks are the first frame only: the measurement runs + before the first paint. */ +:is(.nodef--pool, .nodef--parameter, .nodef--register) :is(.nodef__value, .nodef__sub) { + align-self: flex-start; +} +:is(.nodef--pool, .nodef--parameter, .nodef--register) .nodef__value { + margin-left: var(--vra-value-start, 14px); + max-width: var(--vra-value-max, calc(100% - var(--vra-value-start, 14px))); +} +:is(.nodef--pool, .nodef--parameter, .nodef--register) .nodef__sub { + margin-left: var(--vra-sub-start, 14px); + max-width: var(--vra-sub-max, calc(100% - var(--vra-sub-start, 14px))); +} +.nodef--register .nodef__sub { + margin-left: var(--vra-sub-start, clamp(0px, calc(11.6667% - 11.5px), 17px)); +} + .nodef__head { display: flex; align-items: center; diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index cd820be4..c96918fc 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,15 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // issue #332 - a Pool's, a Parameter's and a Register's value and detail + // rows start at the title and stay inside the drawn outline; the date is + // the day it is deployed, set right before the merge + id: 'release:0.21.2', + version: '0.21.2', + date: '2026-10-07', + items: ['whatsNew.v0212.aligned', 'whatsNew.v0212.wider', 'whatsNew.v0212.same'], + }, { // issue #329 - Focus mode dims the connections outside the focus set (an // inline opacity beat the rule); the date is the day it is deployed, set diff --git a/src/startApp.tsx b/src/startApp.tsx index 28ae29b2..26f51635 100644 --- a/src/startApp.tsx +++ b/src/startApp.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' +import { rowFitMeasureCount } from './components/nodes/rowFit' import { directionOf, initI18n, useI18n } from './i18n' import * as share from './model/share' import { flushAutosave, useGraphStore } from './store/graphStore' @@ -88,6 +89,9 @@ export async function startApp(): Promise { return currentRouteMap(g.nodes, g.edges).get(id) ?? null }, }, + // issue #332 — how many node row-fit measurements have run, so the e2e + // can assert none runs per animation frame + rowFit: { count: rowFitMeasureCount }, share, shareLink, // issue #297 — which door the port is open on, for the session specs