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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/value-row-alignment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "user-facing", "releaseNoteId": "release:0.21.2" }
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 13 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/node-shell-content-in-vessel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 124 additions & 0 deletions e2e/support/rowFit.ts
Original file line number Diff line number Diff line change
@@ -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<string, { getState: () => 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<RowReading[]> =>
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<HTMLElement>('.react-flow__node')) {
const f = n.querySelector<HTMLElement>('.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<SVGPathElement>('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<HTMLElement>(`.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<number> =>
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)
43 changes: 43 additions & 0 deletions e2e/value-row-alignment.mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading