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/focus-edge-dimming.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "user-facing", "releaseNoteId": "release:0.21.1" }
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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.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.

- **Connections fade with the nodes.** A connection outside the focus set is drawn at 0.26, its path and its arrowhead. Before, every connection path carried an inline `opacity` that beat the dimming rule, so only the nodes were dimmed.
- **Focus wins over an activator.** An unsatisfied activator connection stays at 0.5 inside the focus set and goes to 0.26 outside it.
- **High contrast keeps its own tell.** Under forced colours a dimmed connection is not faded; its fine `1 5` dash marks it, as before.
- **Unchanged:** labels, the selection highlight, nodes, the playback tokens and cues, files, digests and simulation results.

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

## 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.
Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,16 @@ 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.0
## Latest — v0.21.1

A fix release: Focus mode dims the connections too.

- **Connections outside the focus set fade** like the nodes there, path and arrowhead,
whatever their activator says; before, only the nodes were dimmed
- **What stays at full strength is unchanged**: selection, warnings and the moving markers
of a running simulation; under high contrast a faded connection is a fine dotted line

## v0.21.0

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

Expand Down Expand Up @@ -177,16 +186,8 @@ Flow colours: give nodes and connections a colour so a large graph's flows read
- **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

## v0.18.2

A fix release: the guided tour says each step once.

- **Each step is announced once**: step 1 as the tour opens, every later step as Next or
Back reaches it, with its number, title and text
- **Focus stays on Next or Back** while the steps change, and ending the tour returns it
to the Help button (More on a phone)

See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.18.1 (one
See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, 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
links compressed with the browser's own Compression Streams), v0.17.0
Expand Down
9 changes: 9 additions & 0 deletions docs/large-graph-readability.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ expression, type dot) are **hidden** while dimmed — they are the noise focus i
removing; the in-node value / capacity bar stays. §LGR2.3's required set is
exempt and stays full-strength.

**A connection outside the focus set** is drawn at 0.26, its path and its
arrowhead. This holds whatever its activator says: an unsatisfied activator
connection is 0.5 inside the focus set and 0.26 outside it, Focus winning.
Under forced colours a dimmed connection is not faded (opacity 1); the sparse
`1 5` dash is its tell (§LGR9). Issue #329: until v0.21.1 an inline `opacity`
on every connection path beat this rule, so connections were never dimmed; the
activator's 0.5 is now the `.edge-activator-off` class. The playback cues on a
dimmed connection keep their strength (their Focus policy is #330).

**A selected node is never de-emphasised.** Selection outranks focus: whatever
the focus set says, a node the user has selected renders at full strength, with
its badges. The focus calculation itself is unchanged and still applies to every
Expand Down
170 changes: 170 additions & 0 deletions e2e/focus-edge-dimming.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { readFileSync } from 'node:fs'
import type { Page } from '@playwright/test'
import { expect, importGraph, openApp, resetAll, test } from './support/loop'

// issue #329 — Focus mode dims the connections outside the focus set
// (docs/large-graph-readability.md §LGR3.1): the path and its arrowhead at 0.26.
// An unsatisfied activator connection is 0.5 inside the focus set and 0.26
// outside it (Focus wins). Under forced colours a dimmed connection is not
// faded: opacity 1 and the sparse `1 5` dash are its tell. These read the
// DRAWN opacity: the class alone was asserted before, and an inline `opacity`
// on every path beat the stylesheet rule unseen.

const STATE = readFileSync(new URL('../examples/state-verification.json', import.meta.url), 'utf8')
const EQUILIBRIUM = readFileSync(new URL('../examples/equilibrium.json', import.meta.url), 'utf8')

type Loop = {
__loop: Record<string, { getState: () => any }> & { rf: { fitView: (o: object) => void } }
}
type Drawn = { out: boolean; activatorOff: boolean; opacity: string; dash: string; inline: string; marker: string | null }

/** commit `n` steps at the fastest beat, waiting for each transition to settle */
const steps = (page: Page, n: number) =>
page.evaluate(async (n) => {
const S = (window as unknown as Loop).__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)

/** select a node by clicking it, centred first so no panel covers it */
const clickNode = async (page: Page, nodeId: string) => {
await page.evaluate((id) => (window as unknown as Loop).__loop.rf.fitView({ nodes: [{ id }], duration: 0, maxZoom: 1, padding: 1.5 }), nodeId)
await page.locator(`.react-flow__node[data-id="${nodeId}"]`).click()
}

const focusOn = async (page: Page, nodeId: string) => {
await page.evaluate(() => (window as unknown as Loop).__loop.ui.getState().setFocusMode(true))
await clickNode(page, nodeId)
await expect(page.locator('.react-flow__edge.lgr-deemph').first()).toBeAttached()
}

/** per connection: in or out of the focus set, its activator verdict, and the drawn path */
const drawn = (page: Page): Promise<Record<string, Drawn>> =>
page.evaluate(() =>
Object.fromEntries(
[...document.querySelectorAll('.react-flow__edge')].map((e) => {
const p = e.querySelector('path.react-flow__edge-path') as SVGPathElement
const c = getComputedStyle(p)
return [
(e as HTMLElement).dataset.id,
{
out: e.classList.contains('lgr-deemph'),
activatorOff: p.classList.contains('edge-activator-off'),
opacity: c.opacity,
dash: c.strokeDasharray,
inline: p.style.opacity,
marker: p.getAttribute('marker-end'),
},
]
}),
),
)

const openStateGraph = async (page: Page) => {
await openApp(page)
await resetAll(page)
await importGraph(page, STATE)
await page.locator('.react-flow__node[data-id="gauge_b"]').waitFor()
// three steps: `a_ga_pd` is an unsatisfied activator, `a_gb_pd` a satisfied one
await steps(page, 3)
}

test.describe('#329 Focus mode dims the connections outside the focus set', () => {
for (const scheme of ['light', 'dark'] as const) {
test(`${scheme}: outside 0.26 (Focus wins over an unsatisfied activator's 0.5), inside 1 or 0.5, with no inline opacity`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme })
await openStateGraph(page)

const off = await drawn(page)
expect(off.a_ga_pd).toMatchObject({ out: false, activatorOff: true, opacity: '0.5' })
expect(off.a_gb_pd).toMatchObject({ out: false, activatorOff: false, opacity: '1' })
for (const [id, e] of Object.entries(off)) {
expect(e.inline, `${id}: no inline opacity left to beat the stylesheet`).toBe('')
if (!e.activatorOff) expect(e.opacity, `${id}: Focus off, full strength`).toBe('1')
}

// gauge_a in focus: its unsatisfied activator inside (0.5), the satisfied one outside (0.26)
await focusOn(page, 'gauge_a')
const a = await drawn(page)
expect(a.a_ga_pd).toMatchObject({ out: false, activatorOff: true, opacity: '0.5' })
expect(a.a_gb_pd).toMatchObject({ out: true, activatorOff: false, opacity: '0.26' })
expect(a.e_gsrc_ga).toMatchObject({ out: false, opacity: '1' })

// gauge_b in focus: the unsatisfied activator outside — Focus's 0.26 wins over its 0.5
await clickNode(page, 'gauge_b')
await expect.poll(async () => (await drawn(page)).a_ga_pd.out).toBe(true)
const b = await drawn(page)
expect(b.a_ga_pd).toMatchObject({ out: true, activatorOff: true, opacity: '0.26' })
expect(b.a_gb_pd).toMatchObject({ out: false, activatorOff: false, opacity: '1' })
for (const [id, e] of Object.entries(b)) {
if (e.out) expect(e.opacity, `${id}: outside the focus set`).toBe('0.26')
// the arrowhead is the path's own marker, so it is painted at the path's opacity
expect(e.marker, `${id}: the arrowhead is the path's marker`).toMatch(/^url\(#/)
}
})
}

test.describe('forced colours', () => {
test.use({ contextOptions: { forcedColors: 'active' } })
test('a dimmed connection is not faded: opacity 1 and the `1 5` dash, an unsatisfied activator included', async ({ page }) => {
await openStateGraph(page)
await focusOn(page, 'gauge_a')
const a = await drawn(page)
// inside the focus set an unsatisfied activator keeps its 0.5, as before
expect(a.a_ga_pd).toMatchObject({ out: false, activatorOff: true, opacity: '0.5' })
await clickNode(page, 'gauge_b')
await expect.poll(async () => (await drawn(page)).a_ga_pd.out).toBe(true)
const b = await drawn(page)
expect(b.a_ga_pd).toMatchObject({ out: true, activatorOff: true, opacity: '1', dash: '1px, 5px' })
for (const [id, e] of Object.entries(b)) {
if (!e.out) continue
expect(e.opacity, `${id}: not faded under forced colours`).toBe('1')
expect(e.dash, `${id}: the sparse dash is the tell`).toBe('1px, 5px')
}
})
})

test('during a played step the dimmed path is 0.26 and the playback token keeps its strength and colour', async ({ page }) => {
await openApp(page)
await resetAll(page)
await importGraph(page, EQUILIBRIUM)
await page.locator('.react-flow__node[data-id="tpl-gate"]').waitFor()
await focusOn(page, 'tpl-src')
await steps(page, 1)
await page.evaluate(() => {
const s = (window as unknown as Loop).__loop.sim.getState()
s.setSpeed(2400)
s.stepOnce()
})
await page.waitForFunction(() => {
const t = (window as unknown as Loop).__loop.sim.getState().transition
return t && t.tau > 0.5
}, null, { polling: 'raf' })
await page.evaluate(() => (window as unknown as Loop).__loop.sim.getState().pause())
const rows = await page.evaluate(() =>
[...document.querySelectorAll('.react-flow__edge')]
.filter((e) => e.querySelector('.pb-move'))
.map((e) => ({
id: (e as HTMLElement).dataset.id,
out: e.classList.contains('lgr-deemph'),
path: getComputedStyle(e.querySelector('path.react-flow__edge-path')!).opacity,
token: getComputedStyle(e.querySelector('.pb-move')!).opacity,
bead: getComputedStyle(e.querySelector('.flow-bead')!).fill,
})),
)
const inside = rows.filter((r) => !r.out)
const outside = rows.filter((r) => r.out)
expect(inside.length, 'a token on an in-focus connection').toBeGreaterThan(0)
expect(outside.length, 'a token on a dimmed connection').toBeGreaterThan(0)
for (const r of inside) expect(r.path, r.id).toBe('1')
for (const r of outside) expect(r.path, r.id).toBe('0.26')
// the playback cues are unchanged by this fix (#330 decides their Focus policy)
for (const r of rows) {
expect(r.token, `${r.id}: the token is not dimmed by this fix`).toBe('1')
expect(r.bead, `${r.id}: the token keeps the run colour`).toBe(inside[0].bead)
}
})
})
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.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.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)
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.21.0",
"version": "0.21.1",
"type": "module",
"engines": {
"node": ">=22.12.0"
Expand Down
6 changes: 5 additions & 1 deletion src/components/edges/LoopEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,13 +386,17 @@ function LoopEdge({
isState ? 'edge-state' : 'edge-resource',
route ? `route-${route.routeClass}${route.invalidWaypoint ? ' route-invalid' : ''}` : '',
activityOp > 0 ? 'lgr-active-tint' : '',
// issue #329 — an unsatisfied activator's 0.5 is a class, not an
// inline `opacity`: an inline value beat Focus mode's 0.26
// (`.react-flow__edge.lgr-deemph .react-flow__edge-path`), so a
// connection outside the focus set was never dimmed
activatorOn === false ? 'edge-activator-off' : '',
]
.filter(Boolean)
.join(' ')}
style={{
stroke: baseStroke,
strokeWidth: selected ? 2 : activatorOn === true ? 1.8 : isState ? 1 : 1.5,
opacity: activatorOn === false ? 0.5 : 1,
...(activityOp > 0 ? { ['--lgr-activity' as string]: activityOp } : null),
}}
/>
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/itCopy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,15 @@ const IDENTICAL_TO_EN: ReadonlyArray<readonly [string, string]> = [
// ------------------------------------------------------------------ shape
describe('it copy — the surfaces exist and are complete', () => {
it('has exactly the base key set', () => {
expect(KEYS).toHaveLength(1030) // 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)
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(Object.keys(IT).sort()).toEqual([...KEYS].sort())
})

it('covers all four runtime surfaces, at the measured sizes', () => {
const per: Record<string, number> = {}
for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1
expect(per).toEqual({ catalog: 1030, template: 196, frame: 7, module: 19 })
expect(RUNTIME).toHaveLength(1252)
expect(per).toEqual({ catalog: 1033, template: 196, frame: 7, module: 19 })
expect(RUNTIME).toHaveLength(1255)
})

it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ar/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,9 @@ const ui = {
'whatsNew.v0210.compact': 'أصبحت العقد أكثر إحكامًا: العقدة ذات السطر الواحد أقصر وأضيق قليلًا، فيتسع على الشاشة جزء أكبر من المخطط الكبير.',
'whatsNew.v0210.grows': 'العقدة التي تحتاج إلى مساحة ما زالت تكبر: تحتفظ العناوين ذات السطرين والصفوف الإضافية بارتفاعها، وتلتف العناوين الطويلة كما من قبل.',
'whatsNew.v0210.same': 'لا تتغير مواضع العقد ولا الملفات المحفوظة ولا نتائج المحاكاة؛ وأصبحت الاتصالات تتصل بكل عقدة في موضع أعلى قليلًا.',
'whatsNew.v0211.dim': 'عند تشغيل التركيز، تخفت الآن الاتصالات خارج منطقة التركيز كما تخفت العقد هناك، فيبرز الجوار الذي تركّز عليه.',
'whatsNew.v0211.kept': 'يبقى التحديد والتحذيرات والعلامات المتحركة لمحاكاة قيد التشغيل ظاهرة بكامل وضوحها.',
'whatsNew.v0211.contrast': 'في التباين العالي لا يُفتَّح لون الاتصال الخافت، بل يُميَّز بخط منقّط رفيع بدلًا من ذلك.',
} as const

export default ui
3 changes: 3 additions & 0 deletions src/i18n/locales/de/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,9 @@ const ui = {
'whatsNew.v0210.compact': 'Knoten sind kompakter: Ein einzeiliger Knoten ist niedriger und etwas schmaler, sodass mehr von einem großen Graphen auf den Bildschirm passt.',
'whatsNew.v0210.grows': 'Ein Knoten, der Platz braucht, wächst weiterhin: Zweizeilige Titel und zusätzliche Zeilen behalten ihre Höhe, und lange Titel brechen wie bisher um.',
'whatsNew.v0210.same': 'Knotenpositionen, gespeicherte Dateien und Simulationsergebnisse bleiben unverändert; Verbindungen setzen jetzt etwas höher am Knoten an.',
'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.',
} satisfies Record<UiKey, string>

export default ui
Loading
Loading