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/compact-nodes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "user-facing", "releaseNoteId": "release:0.21.0" }
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.0 — 2026-10-07

Compact nodes (issue #325, the last of three parts): more of a large graph fits in view. The contract is [`docs/flow-colour-and-compact-nodes.md`](docs/flow-colour-and-compact-nodes.md) FC-7.

- **Shorter, a little narrower.** A one-line node is 56 px tall instead of 64 (a one-line Pool 58, which its title and value need), with 12 px of side padding instead of 16 (Source and Drain 22 instead of 26 at their pointed end, Gate 26 instead of 30). Of the 386 nodes in the bundled templates and a sample of every kind, 235 got shorter.
- **Content still decides.** 56 is a floor, not a forced height: a two-line title, a capacity row, every Parameter and every Register keep the height their content needs, the title's maximum width is unchanged so long titles wrap where they did, and no outline is squeezed or reversed at any height.
- **Nothing a file holds changes.** Node positions, saved diagrams, share links, digests and simulation results are the same; connections attach 3 to 4 px higher and orthogonal routes are recomputed. The ports and their 18 px hit area keep their size, and the selection, focus, invalid, run and Focus-mode marks keep their order.

**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.0`.

## v0.20.0 — 2026-10-07

Flow colours beyond the canvas (issue #325, the second of three parts). The contract is [`docs/flow-colour-and-compact-nodes.md`](docs/flow-colour-and-compact-nodes.md) FC-5 and FC-6.
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,18 @@ Additional feature-specific design documents (localization, mobile, module
system, large-graph readability, simulation playback, edge routing, data
import, …) live under [`docs/`](docs/).

## Latest — v0.20.0
## Latest — v0.21.0

Compact nodes: more of a large graph fits in view.

- **Shorter, a little narrower**: a one-line node is 56 px tall instead of 64, with less
side padding, so more of a big graph fits at the same zoom
- **Content still decides**: two-line titles, capacity rows, Parameters and Registers keep
the height they need, and long titles wrap as before
- **Nothing in a file changes**: positions, saved diagrams and simulation results stay the
same; connections attach a few pixels higher

## v0.20.0

Flow colours beyond the canvas.

Expand Down Expand Up @@ -175,18 +186,8 @@ A fix release: the guided tour says each step once.
- **Focus stays on Next or Back** while the steps change, and ending the tour returns it
to the Help button (More on a phone)

## v0.18.1

A fix release: the menus answer the keyboard the same way.

- **Every menu button** opens at its first item from the keyboard, moves with the arrows,
Home and End, and closes with Escape or Tab, back to its button
- **Settings and `⋯`** are disclosures you Tab through; Theme is a choice of one
- **On a phone**, a sheet takes focus when it opens, and Escape in a sheet opened from
More goes back to More; the run bar and the update bar stay usable

See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.18.0 (the
third-party open-source licenses in the About dialog), v0.17.2 (the
See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, 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
links compressed with the browser's own Compression Streams), v0.17.0
(password-protected share links), v0.16.0 (the storage gate, temporary sessions and the
Expand Down
9 changes: 6 additions & 3 deletions docs/flow-colour-and-compact-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The selected node differs from a resting one only by a darker stroke 0.5 px heav
| parameter | 19 | 162 / 179 / 179 | — | 86 |
| register | 16 | 127 / 260 / 260 | 86 | 102 |

CSS: min-width 118 (Gate 134), max-width 260, base height 64 (`BASE_NODE_H`); body padding `6px 16px`, Source right 26, Drain left 26, Gate `8px 30px`, End right 24, Parameter and Register inline 15. Title 14 px / 600 / line-height 1.15, max-width 135, wraps to two lines; value 18 px mono; sub 12 px; title-to-value gap 1 px; chip-to-title gap 6 px. The height is measured in JS from the content and clamped per kind; the width is CSS. Ports: an 8 x 8 resource circle and a 7 x 7 state diamond, both with an 18 px hit area (`::before`, inset -5 px). Levels of detail: L2 at zoom 0.8 and above, L1 from 0.45 (no sub line), L0 below (no text, outline 2 px, kind dot).
CSS (before PR 3; see FC-7 for the compact values): min-width 118 (Gate 134), max-width 260, base height 64 (`BASE_NODE_H`); body padding `6px 16px`, Source right 26, Drain left 26, Gate `8px 30px`, End right 24, Parameter and Register inline 15. Title 14 px / 600 / line-height 1.15, max-width 135, wraps to two lines; value 18 px mono; sub 12 px; title-to-value gap 1 px; chip-to-title gap 6 px. The height is measured in JS from the content and clamped per kind; the width is CSS. Ports: an 8 x 8 resource circle and a 7 x 7 state diamond, both with an 18 px hit area (`::before`, inset -5 px). Levels of detail: L2 at zoom 0.8 and above, L1 from 0.45 (no sub line), L0 below (no text, outline 2 px, kind dot).

## FC-2. Data

Expand Down Expand Up @@ -210,8 +210,11 @@ The gacha zones take the colour their frames already carry. Uncoloured: every Pa

## FC-7. Compact nodes (PR 3)

- A typical one-line flow node is 64 px high and needs about 32–38 px of it for its content. First estimate, to confirm with before / after shots against the Slice 0 set: base height 64 → 56 (-12.5 %), inline padding 16 → 12 px (Source and Drain 26 → 22, Gate 30 → 26), title max-width unchanged so long titles still wrap in every language.
- The visible port may shrink; its 18 px hit area does not. No saved coordinate moves; edges may reroute, and the MMO graph is checked for it (`routeMap` assumes 130 x 64 when a size is missing; auto frames use a 150 x 40 canonical size).
- The base height is **56** (it was 64): `BASE_NODE_H` in `src/components/nodes/silhouette.ts` and `.nodef { height }`. It is a floor, not a forced height: the box is still measured from its content (`stack + VESSEL_INSET_Y[kind] + 2 x VESSEL_MIN_PAD_Y`, clamped to `[BASE_NODE_H, MAX_NODE_H[kind]]`), so content that needs more grows past it as before. Measured on 386 nodes (a three-node-per-kind graph, Coffee, Gacha, MMO, MMO in German and Korean; the formula matched every measured height at 64 and at 56): Source, Drain, Converter, Gate and End sit at 56; a one-line Pool needs 58 (title and value, its 12 px inset, 4 px clear above and below) and settles there, so the Pool is not squeezed to 56; a two-line title, a capacity row and every Parameter (70 to 86) and Register (86 to 102) keep their content height. 235 of the 386 nodes got shorter.
- Inline padding 16 -> 12 px (Source right and Drain left 26 -> 22, Gate `8px 30px` -> `8px 26px`); the End's right 24, the Parameter's and the Register's width-scaled 15, the title max-width 135 and the min and max widths (118, Gate 134, 260) are unchanged, so long titles still wrap where they did. A content-sized node is up to 8 px narrower (4 for the End).
- Every silhouette is drawn by its height function from 56 up; at 64 and above each returns the path it always did (at exactly 64, the historic path byte for byte). Below 64 the End's and the Register's rounded caps meet at mid-height instead of inserting a straight run, so no outline reverses at any height (`silhouette.test.ts` walks every height of every kind).
- The visible ports (8 x 8, 7 x 7) and their 18 px hit area are unchanged. Ports sit at half the box height, so edges attach 3 to 4 px higher and orthogonal routes are recomputed; no saved coordinate moves, nothing is written to a file, and digests and simulation are unchanged (a node's size never reaches a document).
- `routeMap`'s size for a node not yet measured is 130 x `BASE_NODE_H`, so a one-line node routes the same before and after it is measured. Auto frames keep their 150 x 40 canonical footprint (results independent of rendered size, §AF8); a frame is that footprint's box plus 24 px, so a 56 / 58 px node now has 8 / 6 px to spare at the bottom instead of none.

## FC-8. Tests that hold this contract

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.
10 changes: 7 additions & 3 deletions e2e/edge-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,14 +445,18 @@ test.describe('edge routing — Slice 1', () => {
hit: hit.getAttribute('d'),
markerEnd: vis.getAttribute('marker-end') ?? vis.style.markerEnd ?? '',
labelTransform: label?.style.transform ?? '',
mid: route ? [Math.round(route.mid.x), Math.round(route.mid.y)] : null,
mid: route ? [route.mid.x, route.mid.y] : null,
}
})
expect(consumers.hit).toBe(consumers.d) // BaseEdge draws both from one path
expect(consumers.markerEnd).toMatch(/url\(#/) // the renderer-owned direction marker
// the label anchor is the route's arc-length midpoint
expect(consumers.labelTransform).toContain(`${consumers.mid![0]}px`)
expect(consumers.labelTransform).toContain(`${consumers.mid![1]}px`)
// compared unrounded: a port can sit on a half pixel (a 58 px compact Pool
// puts it at 29, #325 PR 3), and the label is placed at the exact midpoint
const at = /translate\(([-0-9.]+)px,\s*([-0-9.]+)px\)\s*$/.exec(consumers.labelTransform)
expect(at, `label transform ${consumers.labelTransform}`).not.toBeNull()
expect(Number(at![1])).toBeCloseTo(consumers.mid![0], 6)
expect(Number(at![2])).toBeCloseTo(consumers.mid![1], 6)

await test.step('running sim: the playback token walks the orthogonal d; a selection change does not restart it', async () => {
// prime one step (tokenless) so `gold` holds a unit and the drain edge `e_gd`
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.
40 changes: 40 additions & 0 deletions e2e/gacha-banner-zones.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,46 @@ test.describe('3-zone gacha banner comparison Template', () => {
}
})

test('the Premium Pickup `+1` labels of e_pickup_34 and e_pickup_36 keep clear of each other with compact nodes, in EN/KO/JA', async ({
page,
}) => {
// #325 PR 3 (compact nodes), layout round 7 of
// scripts/gen-gacha-banner-zones-example.ts — with 56 / 58 px nodes both
// edges ran through the one band between the first two flow rows and their
// `+1` labels overlapped in ko. `e_pickup_36` now leaves its bottom port
// downward through one waypoint into the band under the second row. This
// pins the pair the broader every-label test above caught.
await openApp(page)
await resetAll(page)
await pickDesktopTemplate(page, EN_NAME)
await page.setViewportSize({ width: 1600, height: 900 })
const waypoints = await page.evaluate(
() =>
(window as unknown as { __loop: { graph: { getState: () => { edges: { id: string; data?: { waypoints?: unknown } }[] } } } })
.__loop.graph.getState()
.edges.find((e) => e.id === 'e_pickup_36')?.data?.waypoints,
)
expect(waypoints).toEqual([{ x: 3200, y: 650 }])
for (const locale of ['en', 'ko', 'ja'] as const) {
await setLocale(page, locale)
await page.evaluate(() =>
(window as unknown as { __loop: { rf: { setViewport: (v: object) => void } } }).__loop.rf.setViewport({ x: 800 - 3200, y: 450 - 600, zoom: 1 }),
)
const boxes = await page.evaluate(() =>
['e_pickup_34', 'e_pickup_36'].map((id) => {
const el = document.querySelector(`.edge-label[data-edge-id="${id}"]`) as HTMLElement | null
const r = el?.getBoundingClientRect()
return r ? { id, text: (el!.textContent || '').trim(), x: r.x, y: r.y, w: r.width, h: r.height } : null
}),
)
expect(boxes.every((b) => b !== null && b.w > 0 && b.h > 0), `${locale}: both labels render`).toBe(true)
const [a, b] = boxes as { id: string; text: string; x: number; y: number; w: number; h: number }[]
expect([a.text, b.text]).toEqual(['+1', '+1'])
const overlap = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
expect(overlap, `${locale}: ${a.id} ${JSON.stringify(a)} overlaps ${b.id} ${JSON.stringify(b)}`).toBe(false)
}
})

test('comparison frame fully contains every comparison-row node, in EN/KO/JA, at both reviewed viewports', async ({
page,
}) => {
Expand Down
Binary file modified e2e/i18n-visual.spec.ts-snapshots/ko-mobile-app-chromium-win32.png
16 changes: 10 additions & 6 deletions e2e/node-long-label.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ test.describe('§MML1 — long node labels stay inside the box', () => {
await page.waitForTimeout(250) // let the ResizeObserver grow-pass settle
})

test('a short label is unchanged — one line, 64 px box', async ({ page }) => {
// docs/flow-colour-and-compact-nodes.md FC-7 (#325 PR 3) — the compact floor
// is 56, and a one-line Pool settles at the 58 its title and value need
test('a short label stays one line — a one-line Pool is the compact 58 px box', async ({ page }) => {
const b = await box(page, 'short')
expect(b.titleLines).toBe(1)
expect(b.boxH).toBe(64)
expect(b.viewBox).toBe('0 0 120 64')
expect(b.boxH).toBe(58)
expect(b.viewBox).toBe('0 0 120 58')
})

test('an official-length label wraps to ≤ 2 lines and the box grows to fit', async ({ page }) => {
Expand Down Expand Up @@ -427,7 +429,9 @@ test.describe('content ⊂ vessel — path-aware (isPointInFill)', () => {
})
}

test('a Source / plain Pool are unchanged — still the 64 px base box', async ({ page }) => {
// FC-7 (#325 PR 3): a Source sits at the 56 px compact floor, a plain one-line
// Pool at the 58 its content needs
test('a Source / plain Pool take the compact box — 56 / 58 px', async ({ page }) => {
await openApp(page)
await resetAll(page)
await importGraph(page, SHELL)
Expand All @@ -436,7 +440,7 @@ test.describe('content ⊂ vessel — path-aware (isPointInFill)', () => {
src: (document.querySelector('.react-flow__node[data-id="src"] .nodef') as HTMLElement).offsetHeight,
poolPlain: (document.querySelector('.react-flow__node[data-id="poolPlain"] .nodef') as HTMLElement).offsetHeight,
}))
expect(h.src).toBe(64)
expect(h.poolPlain).toBe(64)
expect(h.src).toBe(56)
expect(h.poolPlain).toBe(58)
})
})
2 changes: 1 addition & 1 deletion e2e/whats-new.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.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)
Expand Down
8 changes: 7 additions & 1 deletion examples/gacha-banner-zones.json
Original file line number Diff line number Diff line change
Expand Up @@ -2027,7 +2027,13 @@
"expr": "+1",
"timing": "afterPull",
"when": "source-fired",
"route": "orthogonal"
"route": "orthogonal",
"waypoints": [
{
"x": 3200,
"y": 650
}
]
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "loop-studio",
"private": true,
"version": "0.20.0",
"version": "0.21.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
Expand Down
9 changes: 9 additions & 0 deletions scripts/gen-gacha-banner-zones-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,16 @@ const recommendedRunConfig: RecommendedRunConfig = {
// same point. A short, explicit waypoint per edge — opposite sides of that
// shared point — separates them; still purely cosmetic routing data, never
// an engine change.
// Layout round 7 (#325 PR 3, compact nodes, Lumi 2026-10-07) — with the nodes
// 56 / 58 px tall instead of 64, `e_pickup_34` (SR hit -> Pulls made) and
// `e_pickup_36` (Pickup hit -> SSR count) both ran their horizontal leg through
// the one 38 px band between the first two flow rows, 14 px apart, and their
// two `+1` labels overlapped in ko. The band is too narrow to hold both labels
// clear of each other AND of the rows, so `e_pickup_36` alone now leaves its
// bottom port DOWNWARD into the free band under the second row (y 632..670)
// through one waypoint; its label lands there, clear of every label and node.
const WAYPOINTS: Record<string, { x: number; y: number }[]> = {
e_pickup_36: [{ x: 3200, y: 650 }],
e_standard_12: [{ x: 1200, y: 550 }],
e_standard_13: [{ x: 1320, y: 550 }],
e_pickup_7: [{ x: 2680, y: 600 }],
Expand Down
14 changes: 8 additions & 6 deletions src/components/nodes/nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ import { useNodeActivityOpacity } from '../frames/useActivityTint'
// ── N1 "Vessel" silhouettes ──────────────────────────────────────────────
// The outer shape carries the node's role. Type colour is used only on a small
// chip, never to fill the silhouette. Selection and firing are separate cues.
// The viewBox is `0 0 120 h` — `h` is the measured body height (>= 64 once a
// title wraps to two lines). `./silhouette` regenerates each of the seven paths
// The viewBox is `0 0 120 h` — `h` is the measured body height, floored at
// `BASE_NODE_H` (56, the compact floor of docs/flow-colour-and-compact-nodes.md
// FC-7) and grown by the content. `./silhouette` regenerates each of the paths
// for `h`, keeping stroke / radius / notch fixed (docs/mmo-multilingual-layout.md
// §MML1b); at h = 64 it returns the historic path verbatim.

Expand Down Expand Up @@ -190,14 +191,15 @@ function NodeFrame({

// docs/mmo-multilingual-layout.md §MML1b + docs/node-shell-content-in-vessel.md —
// the box height is driven by the RENDERED content, so the stack always sits
// inside the DRAWN vessel (not the 64px bounding box): the vessel path insets
// inside the DRAWN vessel (not the bounding box): the vessel path insets
// its own top/bottom caps (`VESSEL_INSET_Y`) and a `parameter` / `register`
// capsule is only ~40px tall inside the 64px box, so a title + value + `= expr`
// capsule is 24px shorter than its box, so a title + value + `= expr`
// stack spilled past the outline at both ends. Measure `.nodef__stack`'s real
// height, add the vessel inset + a min clear gap top and bottom, clamp to the
// kind's silhouette range, and redraw the vessel + handles at that height.
// `clampNodeHeight` floors at `BASE_NODE_H`, so a short node (Source / Drain /
// Gate, a plain Pool) is byte-identical to before. The ResizeObserver settles
// `clampNodeHeight` floors at `BASE_NODE_H` (56), so a short node (Source /
// Drain / Gate / Converter / End) sits at the floor and a one-line Pool at the
// 58 its title and value need. The ResizeObserver settles
// in one pass: the SVG is `position: absolute`, so a viewBox change never
// feeds back into the box height.
const stackRef = useRef<HTMLDivElement>(null)
Expand Down
Loading
Loading