From 6c051e90abe4da938ea8bc253436bfafd2466901 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 20:33:11 +0000 Subject: [PATCH 1/3] List the panel controls that set each routing block (t-143) Model D's blocks name their controls, and routingProblems holds every heard control to a block so a card can say what drives it. Co-Authored-By: Claude Opus 5.5 --- lib/app/synths/contract.ts | 5 ++++ lib/app/synths/lib/routing.ts | 14 +++++++++++ lib/app/synths/routing/model-d.ts | 30 +++++++++++++++++++++++ tests/unit/lib/app/synths/routing.test.ts | 30 ++++++++++++++++++++++- 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/lib/app/synths/contract.ts b/lib/app/synths/contract.ts index 8639d30f6..ce3e14210 100644 --- a/lib/app/synths/contract.ts +++ b/lib/app/synths/contract.ts @@ -1262,6 +1262,11 @@ export interface RoutingBlock { description: string; /** What the hover card lists: `{ label: 'Slope', value: '24 dB per octave' }`. Facts from the spec only. */ specs?: { label: string; value: string }[]; + /** + * The panel controls that set this part, in the definition's ids, in the order the card lists them. Every control + * the sound hears is listed on some block; its jacks come from `SynthRouting.jacks`. + */ + controls?: string[]; /** * Makes no signal of its own, only passes on what reaches it (a mixer, the mod bus): live only while a live * edge or cable feeds it. Absent = it makes signal (an oscillator, a contour), and is live on any path out. diff --git a/lib/app/synths/lib/routing.ts b/lib/app/synths/lib/routing.ts index 6ecf6a03a..be94c3cc0 100644 --- a/lib/app/synths/lib/routing.ts +++ b/lib/app/synths/lib/routing.ts @@ -248,6 +248,20 @@ export function routingProblems(def: SynthDef, routing: SynthRouting): string[] } for (const j of def.jacks) if (!(j.id in routing.jacks)) problems.push(`jack ${j.id} belongs to no block`); + // Every control the sound hears sets some block, so a block's card can name what drives it. + const listed = new Set(); + for (const b of routing.blocks) { + const own = new Set(); + for (const id of b.controls ?? []) { + if (!controls[id]) problems.push(`block ${b.id}: no control ${id}`); + else if (controls[id].ui) problems.push(`block ${b.id}: ${id} is a panel mode, not heard`); + if (own.has(id)) problems.push(`block ${b.id}: ${id} is listed twice`); + own.add(id); + listed.add(id); + } + } + for (const c of def.controls) + if (!c.ui && !listed.has(c.id)) problems.push(`control ${c.id} sets no block`); // Every block has a way to the output, with every switch allowed and a cable from any output jack to any input. const blockOf = (dir: 'in' | 'out') => diff --git a/lib/app/synths/routing/model-d.ts b/lib/app/synths/routing/model-d.ts index ad1ffc788..06319b3e6 100644 --- a/lib/app/synths/routing/model-d.ts +++ b/lib/app/synths/routing/model-d.ts @@ -36,6 +36,7 @@ export const routing: SynthRouting = { id: 'pitch_bus', name: 'Master pitch', module: 'glide', + controls: ['ctl.tune', 'ctl.glide'], description: 'Adds up keyboard pitch, pitch bend, the OSC 1V/OCT input and TUNE, applies GLIDE, and sets the oscillators’ pitch.', }, @@ -43,6 +44,7 @@ export const routing: SynthRouting = { id: 'mod_bus', name: 'Modulation mix', module: 'mod', + controls: ['mod.srcA', 'mod.srcB', 'mod.mix', 'mod.depth', 'mod.toOsc', 'mod.toFilter'], passes: true, description: 'Crossfades source A (Oscillator 3 or the filter contour) with source B (noise or the LFO), scales the result by MOD DEPTH and the mod wheel, and sends it to oscillator pitch, filter cutoff or both.', @@ -51,6 +53,7 @@ export const routing: SynthRouting = { id: 'lfo', name: 'LFO', module: 'lfo', + controls: ['lfo.rate', 'lfo.shape'], meter: 'lfo', description: 'A dedicated low-frequency oscillator. Both shapes are always on their jacks; the switch picks which one feeds the mod bus.', @@ -63,6 +66,7 @@ export const routing: SynthRouting = { id: 'osc1', name: 'Oscillator 1', module: 'osc', + controls: ['osc1.range', 'osc1.wave'], meter: 'osc1', description: 'The reference oscillator: no fine-tune knob, its pitch set by RANGE and the master TUNE.', @@ -78,6 +82,7 @@ export const routing: SynthRouting = { id: 'osc2', name: 'Oscillator 2', module: 'osc', + controls: ['osc2.range', 'osc2.freq', 'osc2.wave'], meter: 'osc2', description: 'An oscillator with a detune knob of about seven semitones either way.', specs: [ @@ -92,6 +97,7 @@ export const routing: SynthRouting = { id: 'osc3', name: 'Oscillator 3', module: 'osc', + controls: ['osc3.range', 'osc3.freq', 'osc3.wave', 'osc3.kbd'], meter: 'osc3', description: 'An oscillator that doubles as a modulation source. Off keyboard control it runs free, usually at LO as a second LFO.', @@ -107,6 +113,7 @@ export const routing: SynthRouting = { id: 'noise', name: 'Noise', module: 'mixer', + controls: ['noise.colour'], symbol: 'noise', description: 'White or pink noise: a sound source in the mixer and source B of the mod bus.', specs: [{ label: 'Colours', value: 'White, pink' }], @@ -124,6 +131,18 @@ export const routing: SynthRouting = { id: 'mixer', name: 'Mixer', module: 'mixer', + controls: [ + 'mix.osc1On', + 'mix.osc1', + 'mix.osc2On', + 'mix.osc2', + 'mix.osc3On', + 'mix.osc3', + 'mix.extOn', + 'mix.ext', + 'mix.noiseOn', + 'mix.noise', + ], passes: true, description: 'Five channels, each with a level knob and an on/off switch, into the filter.', specs: [{ label: 'Channels', value: 'Oscillators 1–3, external input, noise' }], @@ -132,6 +151,14 @@ export const routing: SynthRouting = { id: 'filter', name: 'Ladder filter', module: 'filter', + controls: [ + 'filter.cutoff', + 'filter.emphasis', + 'filter.contour', + 'filter.mode', + 'filter.kbd1', + 'filter.kbd2', + ], description: 'A transistor ladder filter with EMPHASIS (resonance), low-pass or high-pass.', specs: [ { label: 'Slope', value: '24 dB per octave (4 poles)' }, @@ -142,6 +169,7 @@ export const routing: SynthRouting = { id: 'filter_contour', name: 'Filter contour', module: 'env', + controls: ['fenv.attack', 'fenv.decay', 'fenv.sustain', 'env.filterDecay'], meter: 'env1', description: 'An attack, decay, sustain envelope for the cutoff. Release is either immediate or the decay time, set by FILTER DECAY.', @@ -151,6 +179,7 @@ export const routing: SynthRouting = { id: 'loudness_contour', name: 'Loudness contour', module: 'env', + controls: ['aenv.attack', 'aenv.decay', 'aenv.sustain', 'env.loudDecay'], meter: 'env2', description: 'An attack, decay, sustain envelope for the amplifier. Release is either immediate or the decay time, set by LOUD DECAY.', @@ -169,6 +198,7 @@ export const routing: SynthRouting = { id: 'output', name: 'Output', module: 'out', + controls: ['out.volume', 'out.mainOn', 'out.phones', 'out.a440'], description: 'Master volume, the MAIN OUT switch, the headphone amplifier and the A-440 tone.', }, diff --git a/tests/unit/lib/app/synths/routing.test.ts b/tests/unit/lib/app/synths/routing.test.ts index ffdef685e..1483b321b 100644 --- a/tests/unit/lib/app/synths/routing.test.ts +++ b/tests/unit/lib/app/synths/routing.test.ts @@ -2,7 +2,7 @@ * Every registered routing against its definition and its spec. * * Against the definition (`routingProblems`): every control, value and jack it names exists, a normal is broken only - * by an input that does not sum, every jack belongs to a block, and every block has a way to the output. A renamed + * by an input that does not sum, every jack belongs to a block, every control the sound hears sets one, and every block has a way to the output. A renamed * control or jack therefore fails here rather than leaving part of the diagram dark for good. * * Against the spec it was projected from (`.context/app/synth-data//.synth.json`): every module and @@ -197,4 +197,32 @@ describe('routingProblems', () => { ].map((b) => `block ${b} has no way to output`), ]); }); + it('refuses a control the definition does not have, a panel mode, and a heard control on no block', () => { + const block = (r: SynthRouting, id: string) => { + const b = r.blocks.find((x) => x.id === id); + if (!b) throw new Error(`no block ${id}`); + return b; + }; + expect( + edit((r) => { + const filter = block(r, 'filter'); + filter.controls = (filter.controls ?? []).filter((c) => c !== 'filter.emphasis'); + filter.controls.push('filter.resonance', 'filter.cutoff'); + block(r, 'lfo').controls?.push('view.mode'); + }) + ).toEqual([ + 'block lfo: no control view.mode', + 'block filter: no control filter.resonance', + 'block filter: filter.cutoff is listed twice', + 'control filter.emphasis sets no block', + ]); + // A panel mode changes what the panel shows, never the sound, so no block lists it. + const moded = { + ...def, + controls: def.controls.map((c) => (c.id === 'out.a440' ? { ...c, ui: true } : c)), + }; + expect(routingProblems(moded, base)).toEqual([ + 'block output: out.a440 is a panel mode, not heard', + ]); + }); }); From c0fca22ca299512898bd99a7091fa7b4df3a10d4 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 20:47:30 +0000 Subject: [PATCH 2/3] Show a block's card on the routing view, and find its controls on the panel (t-143) Hovering or focusing a block shows its card; a click, tap, Enter or Space keeps it and moves focus in. Choosing a control or jack finds it on the panel and flips back through the page's flip, so a control already found still turns it over. Co-Authored-By: Claude Opus 5.5 --- .context/app/adding-a-synth.md | 5 +- .context/app/synth-page.md | 7 + .context/app/synths.md | 8 +- components/app/synth/routing-card.tsx | 112 ++++++++++ components/app/synth/routing-view.tsx | 114 +++++++++- components/app/synth/synth-page.tsx | 4 + components/app/synth/synth.css | 143 +++++++++++++ .../app/synth/routing-view.test.tsx | 197 +++++++++++++++++- .../app/synth/synth-page-sheets.test.tsx | 6 +- .../components/app/synth/synth-page.test.tsx | 29 ++- 10 files changed, 608 insertions(+), 17 deletions(-) create mode 100644 components/app/synth/routing-card.tsx diff --git a/.context/app/adding-a-synth.md b/.context/app/adding-a-synth.md index fbc1e604b..83e6f27dd 100644 --- a/.context/app/adding-a-synth.md +++ b/.context/app/adding-a-synth.md @@ -41,8 +41,9 @@ them. `goldens.test.ts` then holds the definition to them. Write Write `lib/app/synths/routing/.ts` from the spec's `modules` and `connections`, in the definition's control and jack ids, and add it to `SYNTH_ROUTINGS` (`routing/index.ts`). Start from `routing/model-d.ts`; the rules are in -[`synths.md`](./synths.md#signal-routing). `routing.test.ts` fails until every id it names exists in the definition -and every spec module and connection is drawn or `omitted` with a reason. +[`synths.md`](./synths.md#signal-routing). `routing.test.ts` fails until every id it names exists in the definition, +every control the sound hears is in some block's `controls`, and every spec module and connection is drawn or +`omitted` with a reason. ## 2. Brand tags diff --git a/.context/app/synth-page.md b/.context/app/synth-page.md index bb9f177c6..9b141d31b 100644 --- a/.context/app/synth-page.md +++ b/.context/app/synth-page.md @@ -271,6 +271,13 @@ BeatBreaker's drummer player is the precedent (`components/app/studio/player/` t `.kys-panel-flip`, nested in the instrument's): only the snapshots turn, so nothing that holds the panel takes a transform; with reduced motion, or no view transitions, it swaps. Under `lg` the diagram keeps a readable size (`min-width: 900px`) and scrolls sideways in its own scroller. + Each block is a button for its card (`routing-card.tsx`, t-143): what it is (its designator and name), whether + the patch uses it now, its description and specs, and the controls and jacks that set it. Hover or keyboard + focus shows it while the pointer or focus stays (crossing into the card keeps it); a click, a tap, Enter or + Space keeps it open and moves focus into it, and a kept card stays while the pointer passes other blocks. + Escape returns focus to the block; a press elsewhere closes it. Choosing a control or jack finds it on the + panel (`findStore`) and turns the panel face up, through the page's flip rather than the find, so a control + already found turns it over too. A Radix popover, not portalled, so it comes into full screen with the panel. - **The player's buttons carry their own tooltip** (`PlayerTip`, `player-tip.tsx`): the button's name and a line on what it does, on hover or focus, in place of the browser's slow `title`. It is a Radix popup that is not portalled, so it works full screen. diff --git a/.context/app/synths.md b/.context/app/synths.md index c2290e99b..76af0bc1f 100644 --- a/.context/app/synths.md +++ b/.context/app/synths.md @@ -207,8 +207,9 @@ How the parts are wired behind the panel, for the routing view: one **`SynthRout `lib/app/synths/routing/.ts`, registered in `routing/index.ts` (`SYNTH_ROUTINGS`). It sits beside the definition, not on it. A synth with no routing has no routing view. -- **Blocks** are the parts (`osc1`, `mixer`, `filter`), each with a `module` for its colour, a `description` - and the `specs` its hover card lists; `passes: true` marks one that makes no signal of its own (a mixer, the +- **Blocks** are the parts (`osc1`, `mixer`, `filter`), each with a `module` for its colour, a `description`, + the `specs` its card lists, and the `controls` that set it (the definition's ids, in the card's order; the + card takes its jacks from `jacks`). Every control but a `ui` one is on some block; one may be on two. `passes: true` marks one that makes no signal of its own (a mixer, the mod bus). `symbol` names its schematic symbol where the module does not say (`noise`, `input`, `midi`), and `meter` the engine meter that shows it working (`gate`, `env1`, `env2`, `lfo`, `osc1`–`osc4`), for the view's glow; check which envelope the definition's `toEngine()` gives each contour (Model D: `env1` filter, `env2` @@ -246,7 +247,8 @@ definition, not on it. A synth with no routing has no routing view. - **`tests/unit/lib/app/synths/routing.test.ts`** holds every registered routing to its definition (`routingProblems`: every control, value and jack exists, a normal is broken only by a jack that is not `add` and by one edge at most, every jack has a block, every block has a way to the output through edges or - some cable, every `passes` block has an edge or input jack into it) and to its spec (every module and + some cable, every `passes` block has an edge or input jack into it, every heard control sets some block and no + block names a `ui` one) and to its spec (every module and connection is drawn, or listed in `omitted` with the reason). Model D's is the worked example. diff --git a/components/app/synth/routing-card.tsx b/components/app/synth/routing-card.tsx new file mode 100644 index 000000000..32ad6e904 --- /dev/null +++ b/components/app/synth/routing-card.tsx @@ -0,0 +1,112 @@ +'use client'; + +/** + * A block's card on the routing view (`f-routing-view` t-143): what the part is, its specs, whether the patch uses + * it now, and the panel controls and jacks that set it. Choosing a control or a jack hands it to `onShow`, which turns + * the panel face up again with it found, so the diagram and the panel stay one instrument. + * + * Only the card's face: `RoutingView` opens it by a block, on hover, keyboard focus or a tap, and places it. + */ + +import { moduleOf } from '@/lib/app/synths/lib/modules'; +import { jackName } from '@/lib/app/synths/lib/explain'; +import { controlMap, displayName, jackMap } from '@/lib/app/synths/lib/patch'; +import type { RoutingBlock, SynthDef, SynthRouting } from '@/lib/app/synths/contract'; + +/** A control or a jack to find on the panel. */ +export interface RoutingShowTarget { + kind: 'control' | 'jack'; + id: string; +} + +export function RoutingCard({ + def, + routing, + block, + tag, + live, + onShow, +}: { + def: SynthDef; + routing: SynthRouting; + block: RoutingBlock; + /** The designator its box carries (VCO, VCF, EG). */ + tag: string; + /** Whether the patch routes through it now (`routingState()`). */ + live: boolean; + /** Finds a control or jack on the panel. Absent: they are listed, but not offered. */ + onShow?: (target: RoutingShowTarget) => void; +}) { + const controls = controlMap(def); + const jacks = jackMap(def); + const setBy = (block.controls ?? []).flatMap((id) => controls[id] ?? []); + const plugs = Object.entries(routing.jacks).flatMap(([id, b]) => + b === block.id && jacks[id] ? [jacks[id]] : [] + ); + const item = (kind: RoutingShowTarget['kind'], id: string, name: string, note?: string) => ( +
  • + {onShow ? ( + + ) : ( + + {name} + {note && {note}} + + )} +
  • + ); + + return ( +
    +

    +

    +

    + {live ? 'On the path the patch uses now' : 'Not on the path the patch uses now'} +

    +

    {block.description}

    + {block.specs && block.specs.length > 0 && ( +
    + {block.specs.map((s) => ( +
    +
    {s.label}
    +
    {s.value}
    +
    + ))} +
    + )} + {setBy.length > 0 && ( +
    +

    Set by

    +
      + {setBy.map((c) => item('control', c.id, displayName(def, c)))} +
    +
    + )} + {plugs.length > 0 && ( +
    +

    Jacks

    +
      + {plugs.map((j) => item('jack', j.id, jackName(j), j.dir === 'in' ? 'in' : 'out'))} +
    +
    + )} + {setBy.length === 0 && plugs.length === 0 && ( +

    Nothing on the panel sets it.

    + )} + {onShow && (setBy.length > 0 || plugs.length > 0) && ( +

    Choose one to see it on the panel.

    + )} +
    + ); +} diff --git a/components/app/synth/routing-view.tsx b/components/app/synth/routing-view.tsx index 1ceb096fd..083f9d7fa 100644 --- a/components/app/synth/routing-view.tsx +++ b/components/app/synth/routing-view.tsx @@ -17,11 +17,16 @@ * the engine's meters and output level) lights the lamps and runs along the lines, and fades as the sound does; the * LFO, and an oscillator slow enough to see, pulse with their waves. That * is written straight to a CSS variable (`--a`) once a frame, never through React state. + * + * Each block is a button for its card (`RoutingCard`, t-143): hovering it, or reaching it with the keyboard, shows the + * card beside it while the pointer or focus stays; a click, a tap, Enter or Space keeps it open and moves focus into + * it, so its controls and jacks can be chosen. Escape puts it away and returns focus to the block. */ -import { useEffect, useId, useLayoutEffect, useMemo, useRef } from 'react'; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import * as Popover from '@radix-ui/react-popover'; import { z } from 'zod'; -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from 'react'; import { moduleOf } from '@/lib/app/synths/lib/modules'; import { routingState } from '@/lib/app/synths/lib/routing'; import { @@ -33,6 +38,7 @@ import { } from '@/lib/app/synths/lib/routing-activity'; import { BLOCK_H, BLOCK_W, layoutRouting } from '@/lib/app/synths/lib/routing-layout'; import { meterStore } from '@/components/app/synth/stores'; +import { RoutingCard, type RoutingShowTarget } from '@/components/app/synth/routing-card'; import type { CableLike } from '@/lib/app/synths/lib/patch'; import type { ControlValues, @@ -54,6 +60,9 @@ interface Parts { cables: Map; } +/** How long a card shown on hover waits after the pointer leaves, so it can cross the gap into the card. */ +const LEAVE_MS = 160; + /** The symbol cell at a block's left, and the symbol's size in it. */ const CELL = 38; const GLYPH = 22; @@ -196,6 +205,7 @@ export function RoutingView({ cables, wheel, source = null, + onShow, }: { def: SynthDef; routing: SynthRouting; @@ -205,10 +215,52 @@ export function RoutingView({ wheel: number; /** Where the output's level comes from. Absent: the audio path never lights, but the keyboard and contours do. */ source?: RoutingSource | null; + /** Turns the panel face up with a control or jack found, from a block's card. */ + onShow?: (target: RoutingShowTarget) => void; }) { const titleId = useId(); const descId = useId(); const svgRef = useRef(null); + // The open card: which block, and whether it was asked for (a click, a tap, Enter) and so stays, or is only shown + // while the pointer or focus is on the block. + const [card, setCard] = useState<{ id: string; kept: boolean } | null>(null); + const anchorRef = useRef(null); + const cardRef = useRef(null); + const leaveRef = useRef | null>(null); + const stay = () => { + if (leaveRef.current) clearTimeout(leaveRef.current); + leaveRef.current = null; + }; + useEffect(() => stay, []); + const blockEl = (id: string) => + [...(svgRef.current?.querySelectorAll('[data-block]') ?? [])].find( + (el) => el.getAttribute('data-block') === id + ) ?? null; + const show = (id: string, kept: boolean) => { + stay(); + // A kept card stays put while the pointer or focus passes over other blocks. + if (card?.kept && !kept) return; + anchorRef.current = blockEl(id); + setCard({ id, kept }); + }; + // A press or focus on a block is the block's to handle, not a reason to put the card away: it opens its own. + const onBlock = (t: EventTarget | null) => + t instanceof Element && !!svgRef.current?.contains(t) && !!t.closest('[data-block]'); + const leave = () => { + stay(); + leaveRef.current = setTimeout(() => setCard((was) => (was?.kept ? was : null)), LEAVE_MS); + }; + const close = (refocus: boolean) => { + stay(); + const el = card && blockEl(card.id); + setCard(null); + if (refocus && el instanceof SVGElement) el.focus(); + }; + // A kept card takes focus, so its controls are next for the keyboard and a screen reader reads it. + const keptId = card?.kept ? card.id : null; + useEffect(() => { + if (keptId) cardRef.current?.focus(); + }, [keptId]); const state = useMemo( () => routingState(def, routing, { values, cables, wheel }), [def, routing, values, cables, wheel] @@ -319,6 +371,8 @@ export function RoutingView({ return () => cancelAnimationFrame(frame); }, []); + const cardId = `${titleId}-card`; + const open = card ? byId.get(card.id) : undefined; const lit = routing.blocks.filter((b) => state.blocks[b.id]).map((b) => b.name); const dark = routing.blocks.filter((b) => !state.blocks[b.id]).map((b) => b.name); const arrow = `${titleId}-arrow`; @@ -335,7 +389,7 @@ export function RoutingView({ viewBox={`0 0 ${layout.width} ${layout.height}`} width={layout.width} height={layout.height} - role="img" + role="group" aria-labelledby={titleId} aria-describedby={descId} className="kys-routing-svg" @@ -391,8 +445,23 @@ export function RoutingView({ className={`kys-route-block ${live ? 'is-live' : ''}`} style={{ color: moduleOf(b.module).color }} transform={`translate(${at.x} ${at.y})`} + role="button" + tabIndex={0} + aria-label={`${b.name}, ${live ? 'in use' : 'not in use'}`} + aria-haspopup="dialog" + aria-expanded={card?.id === b.id} + aria-controls={card?.id === b.id ? cardId : undefined} + onPointerEnter={(e: PointerEvent) => e.pointerType === 'mouse' && show(b.id, false)} + onPointerLeave={(e: PointerEvent) => e.pointerType === 'mouse' && leave()} + onFocus={(e) => e.currentTarget.matches(':focus-visible') && show(b.id, false)} + onBlur={(e) => !cardRef.current?.contains(e.relatedTarget) && leave()} + onClick={() => show(b.id, true)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + show(b.id, true); + }} > - {b.description} + {/* The card, by its block. Not portalled, so it comes with the panel into full screen. */} + !o && close(false)} modal={false}> + + {open && ( + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => { + e.preventDefault(); + close(true); + }} + onPointerDownOutside={(e) => onBlock(e.target) && e.preventDefault()} + onFocusOutside={(e) => onBlock(e.target) && e.preventDefault()} + onPointerEnter={(e) => e.pointerType === 'mouse' && stay()} + onPointerLeave={(e) => e.pointerType === 'mouse' && leave()} + > + + + + )} +
    • diff --git a/components/app/synth/synth-page.tsx b/components/app/synth/synth-page.tsx index 7cf5277aa..df1c7c2bc 100644 --- a/components/app/synth/synth-page.tsx +++ b/components/app/synth/synth-page.tsx @@ -2255,6 +2255,10 @@ function Synth({ cables={sess.cables} wheel={wheel} source={engine} + onShow={(t) => { + findStore.set(t); + flipPanel(false); + }} /> diff --git a/components/app/synth/synth.css b/components/app/synth/synth.css index c679363a3..54d38202c 100644 --- a/components/app/synth/synth.css +++ b/components/app/synth/synth.css @@ -3007,3 +3007,146 @@ svg .kys-hit:focus-visible { .kys-routing-key .kys-routing-note { flex-basis: 100%; } + +/* A block is a button for its card (t-143): a pointer over it, and a ring in ink while it has the keyboard or its + card is open. */ +.kys-route-block { + cursor: pointer; + outline: none; +} +.kys-route-block:focus-visible .kys-route-halo, +.kys-route-block[aria-expanded='true'] .kys-route-halo { + opacity: 1; + stroke: var(--rt-ink); + stroke-opacity: 0.55; + stroke-width: 2; +} +.kys-route-block:not(.is-live):is(:focus-visible, [aria-expanded='true']) { + opacity: 0.7; +} + +/* The block's card, in the page's raised surface as the player's tips are, the block's colour on its lamp. */ +.kys-routing-card { + z-index: 70; + width: min(300px, calc(100vw - 32px)); + max-height: min(70vh, 460px); + overflow-y: auto; + padding: 10px 12px 11px; + border-radius: 10px; + color: var(--kys-text); + background: var(--kys-raised); + box-shadow: + 0 0 0 1px rgb(var(--kys-wash) / 0.1), + 0 12px 32px -10px var(--kys-shadow); + animation: kys-fade 0.12s ease-out; + outline: none; +} +.kys-routing-card:focus-visible { + box-shadow: + 0 0 0 2px var(--kys-accent), + 0 12px 32px -10px var(--kys-shadow); +} +.kys-routing-card-arrow { + fill: var(--kys-raised); +} +.kys-routing-card-body { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12.5px; + line-height: 1.4; +} +.kys-routing-card-head { + display: flex; + align-items: center; + gap: 7px; +} +.kys-routing-card-lamp { + width: 8px; + height: 8px; + flex: none; + border-radius: 50%; + background: currentColor; +} +.kys-routing-card-tag { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10.5px; + letter-spacing: 0.06em; + color: var(--kys-muted); +} +.kys-routing-card-name { + font-size: 13.5px; + font-weight: 600; + color: var(--kys-text); +} +.kys-routing-card-state { + font-size: 11.5px; + color: var(--kys-muted); +} +.kys-routing-card-state.is-live { + color: var(--kys-text); +} +.kys-routing-card-desc { + color: var(--kys-text); +} +.kys-routing-card-specs { + display: grid; + gap: 2px; + margin: 0; +} +.kys-routing-card-specs div { + display: grid; + grid-template-columns: minmax(0, 5.5em) minmax(0, 1fr); + gap: 8px; +} +.kys-routing-card-specs dt { + color: var(--kys-muted); +} +.kys-routing-card-specs dd { + margin: 0; + color: var(--kys-text); +} +.kys-routing-card-sub { + margin-top: 2px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--kys-muted); +} +.kys-routing-card-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 3px; +} +.kys-routing-card-item { + display: inline-flex; + align-items: baseline; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + border: 1px solid var(--kys-line); + background: var(--kys-ground); + font-size: 12px; + font-weight: 600; + color: var(--kys-text); +} +button.kys-routing-card-item:hover { + border-color: var(--kys-muted); +} +button.kys-routing-card-item:focus-visible { + outline: 2px solid var(--kys-accent); + outline-offset: 1px; +} +.kys-routing-card-note { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10px; + font-weight: 400; + color: var(--kys-muted); +} +.kys-routing-card-none, +.kys-routing-card-hint { + font-size: 11.5px; + color: var(--kys-muted); +} diff --git a/tests/unit/components/app/synth/routing-view.test.tsx b/tests/unit/components/app/synth/routing-view.test.tsx index e70c02f77..e7867780c 100644 --- a/tests/unit/components/app/synth/routing-view.test.tsx +++ b/tests/unit/components/app/synth/routing-view.test.tsx @@ -2,17 +2,20 @@ /** * The routing view over Model D's real routing: what the patch uses is lit and the rest dimmed, and a mixer level, a - * switch or a cable changes which. + * switch or a cable changes which. Each block opens its card (t-143) by hover, keyboard or tap, and the card's + * controls and jacks are handed back to be found on the panel. * * @see components/app/synth/routing-view.tsx */ -import { afterEach, describe, expect, it } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { RoutingView } from '@/components/app/synth/routing-view'; import { getSynthDef } from '@/lib/app/synths/defs'; import { routing } from '@/lib/app/synths/routing/model-d'; import { meterStore } from '@/components/app/synth/stores'; +import { controlMap, displayName } from '@/lib/app/synths/lib/patch'; import type { ControlValues, SynthRouting } from '@/lib/app/synths/contract'; const found = getSynthDef('model-d'); @@ -37,7 +40,7 @@ const edge = (id: string) => lit(`[data-edge="${id}"]`); describe('RoutingView', () => { it('names the diagram, and says what is in use in words', () => { view(); - const img = screen.getByRole('img', { name: 'Model D signal routing' }); + const img = screen.getByRole('group', { name: 'Model D signal routing' }); expect(img.querySelector('desc')?.textContent).toMatch( /^In use now: .*Oscillator 1.*Not in use: .*Oscillator 2/ ); @@ -120,7 +123,7 @@ describe('RoutingView', () => { it('is never drawn bigger than its own size', () => { view(); - const svg = screen.getByRole('img', { name: 'Model D signal routing' }); + const svg = screen.getByRole('group', { name: 'Model D signal routing' }); expect(svg.getAttribute('style')).toContain(`--routing-w: ${svg.getAttribute('width')}px`); }); @@ -247,3 +250,187 @@ describe('RoutingView', () => { }); }); }); + +describe('a block’s card', () => { + const blockButton = (name: string) => + screen.getByRole('button', { name: new RegExp(`^${name},`) }); + const card = (name: string) => screen.queryByRole('dialog', { name }); + const cardBody = (name: string) => { + const c = card(name); + if (!c) throw new Error(`no card for ${name}`); + return within(c); + }; + const DESIGNATOR: Record = { + midi: 'MIDI', + pitch_bus: 'CV', + mod_bus: 'MOD', + lfo: 'LFO', + osc1: 'VCO', + osc2: 'VCO', + osc3: 'VCO', + noise: 'NOISE', + ext_in: 'EXT', + mixer: 'MIX', + filter: 'VCF', + filter_contour: 'EG', + loudness_contour: 'EG', + vca: 'VCA', + output: 'OUT', + }; + + it('shows every block’s type, description and the controls that set it, on a tap', () => { + view(); + const controls = controlMap(def); + expect(Object.keys(DESIGNATOR)).toEqual(routing.blocks.map((b) => b.id)); + for (const b of routing.blocks) { + fireEvent.click(blockButton(b.name)); + const c = cardBody(b.name); + expect(c.getByText(DESIGNATOR[b.id], { selector: '.kys-routing-card-tag' })).toBeTruthy(); + expect(c.getByText(b.description)).toBeTruthy(); + for (const id of b.controls ?? []) { + const ctl = controls[id]; + if (!ctl) throw new Error(`no control ${id}`); + expect(c.getByText(displayName(def, ctl))).toBeTruthy(); + } + for (const s of b.specs ?? []) expect(c.getByText(s.value)).toBeTruthy(); + } + // Model D's blocks between them name every control on the panel. + const named = new Set(routing.blocks.flatMap((b) => b.controls ?? [])); + expect(def.controls.filter((c) => !named.has(c.id)).map((c) => c.id)).toEqual([]); + }); + + it('lists a block’s jacks, and says so when nothing on the panel sets it', () => { + view(); + fireEvent.click(blockButton('LFO')); + const lfo = cardBody('LFO'); + for (const name of ['LFO CV', 'LFO (triangle)', 'LFO (square)']) + expect(lfo.getByText(name)).toBeTruthy(); + fireEvent.click(blockButton('MIDI / USB')); + expect(cardBody('MIDI / USB').getByText('Nothing on the panel sets it.')).toBeTruthy(); + }); + + it('says whether the block is on the path the patch uses now', () => { + view(); + fireEvent.click(blockButton('Oscillator 1')); + expect(cardBody('Oscillator 1').getByText('On the path the patch uses now')).toBeTruthy(); + fireEvent.click(blockButton('Oscillator 2')); + expect(cardBody('Oscillator 2').getByText('Not on the path the patch uses now')).toBeTruthy(); + expect(blockButton('Oscillator 2')).toHaveAccessibleName('Oscillator 2, not in use'); + }); + + it('hands a chosen control or jack back to be found on the panel', () => { + const onShow = vi.fn(); + render( + + ); + fireEvent.click(blockButton('Ladder filter')); + fireEvent.click(cardBody('Ladder filter').getByRole('button', { name: 'EMPHASIS' })); + expect(onShow).toHaveBeenLastCalledWith({ kind: 'control', id: 'filter.emphasis' }); + fireEvent.click(cardBody('Ladder filter').getByRole('button', { name: /CUT CV/ })); + expect(onShow).toHaveBeenLastCalledWith({ kind: 'jack', id: 'j.cutCv' }); + }); + + it('lists the controls without offering them when nothing can find them', () => { + view(); + fireEvent.click(blockButton('Ladder filter')); + const c = cardBody('Ladder filter'); + expect(c.getByText('EMPHASIS')).toBeTruthy(); + expect(c.queryByRole('button')).toBeNull(); + }); + + it('is reached by the keyboard: Enter opens it with focus inside, Tab reaches its controls, Escape returns', async () => { + const user = userEvent.setup(); + const onShow = vi.fn(); + render( + + ); + const filter = blockButton('Ladder filter'); + expect(filter).toHaveAttribute('tabindex', '0'); + act(() => filter.focus()); + await user.keyboard('{Enter}'); + const dialog = card('Ladder filter'); + expect(dialog).not.toBeNull(); + expect(filter).toHaveAttribute('aria-expanded', 'true'); + await waitFor(() => expect(dialog).toHaveFocus()); + await user.tab(); + expect(within(dialog as HTMLElement).getAllByRole('button')[0]).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onShow).toHaveBeenCalledWith({ kind: 'control', id: 'filter.cutoff' }); + await user.keyboard('{Escape}'); + expect(card('Ladder filter')).toBeNull(); + expect(filter).toHaveFocus(); + expect(filter).toHaveAttribute('aria-expanded', 'false'); + // Space opens it too. + await user.keyboard(' '); + expect(card('Ladder filter')).not.toBeNull(); + }); + + it('opens on a tap of another block in place of the one open, and closes on a tap away', async () => { + view(); + fireEvent.pointerEnter(blockButton('Mixer'), { pointerType: 'touch' }); + expect(card('Mixer')).toBeNull(); // a touch has no hover + fireEvent.click(blockButton('Mixer')); + expect(card('Mixer')).not.toBeNull(); + fireEvent.pointerDown(blockButton('Noise')); + fireEvent.click(blockButton('Noise')); + expect(card('Mixer')).toBeNull(); + expect(card('Noise')).not.toBeNull(); + await userEvent + .setup() + .pointer({ keys: '[MouseLeft]', target: screen.getByRole('list', { name: 'Key' }) }); + expect(card('Noise')).toBeNull(); + }); + + describe('on hover', () => { + afterEach(() => vi.useRealTimers()); + + it('shows while the pointer is on the block or the card, and goes when it leaves both', () => { + vi.useFakeTimers(); + view(); + const lfo = blockButton('LFO'); + fireEvent.pointerEnter(lfo, { pointerType: 'mouse' }); + const dialog = card('LFO'); + expect(dialog).not.toBeNull(); + expect(dialog).not.toHaveFocus(); // a hover does not take focus + // Across the gap into the card, it stays. + fireEvent.pointerLeave(lfo, { pointerType: 'mouse' }); + fireEvent.pointerEnter(dialog as HTMLElement, { pointerType: 'mouse' }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(card('LFO')).not.toBeNull(); + fireEvent.pointerLeave(dialog as HTMLElement, { pointerType: 'mouse' }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(card('LFO')).toBeNull(); + }); + + it('keeps a card opened by a click while the pointer passes over other blocks', () => { + vi.useFakeTimers(); + view(); + fireEvent.click(blockButton('LFO')); + fireEvent.pointerLeave(blockButton('LFO'), { pointerType: 'mouse' }); + fireEvent.pointerEnter(blockButton('Mixer'), { pointerType: 'mouse' }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(card('LFO')).not.toBeNull(); + expect(card('Mixer')).toBeNull(); + }); + }); +}); diff --git a/tests/unit/components/app/synth/synth-page-sheets.test.tsx b/tests/unit/components/app/synth/synth-page-sheets.test.tsx index a0356d707..1a03efa3d 100644 --- a/tests/unit/components/app/synth/synth-page-sheets.test.tsx +++ b/tests/unit/components/app/synth/synth-page-sheets.test.tsx @@ -301,9 +301,11 @@ describe('below lg', () => { phone(); page(); fireEvent.click(screen.getByRole('button', { name: 'Signal routing' })); - expect(screen.getByRole('img', { name: 'Model D signal routing' })).toBeTruthy(); + expect(screen.getByRole('group', { name: 'Model D signal routing' })).toBeTruthy(); fireEvent.click(within(bar()).getByRole('button', { name: 'Inspector' })); - expect(screen.queryByRole('img', { name: 'Model D signal routing', hidden: true })).toBeNull(); + expect( + screen.queryByRole('group', { name: 'Model D signal routing', hidden: true }) + ).toBeNull(); }); it('puts the four regions behind a bar, each opening as its own sheet, and keeps About on the page', () => { diff --git a/tests/unit/components/app/synth/synth-page.test.tsx b/tests/unit/components/app/synth/synth-page.test.tsx index b316e9996..93e5d9770 100644 --- a/tests/unit/components/app/synth/synth-page.test.tsx +++ b/tests/unit/components/app/synth/synth-page.test.tsx @@ -2030,7 +2030,7 @@ describe('SynthPage patch edge cases', () => { describe('SynthPage signal routing', () => { const toggle = () => button('Signal routing'); - const diagram = () => screen.queryByRole('img', { name: 'Model D signal routing' }); + const diagram = () => screen.queryByRole('group', { name: 'Model D signal routing' }); const scroller = () => screen.getByTestId('panel-frame').closest('.kys-panel-scroller'); const lit = (sel: string) => document.querySelector(sel)?.getAttribute('data-live'); @@ -2098,6 +2098,33 @@ describe('SynthPage signal routing', () => { expect(diagram()).not.toBeNull(); }); + it('turns face up with a control chosen on a block’s card found, even one already found', () => { + page(); + const away = () => scroller()?.classList.contains('is-away'); + const choose = (block: RegExp, name: string | RegExp) => { + fireEvent.click(screen.getByRole('button', { name: block })); + const card = screen.getByRole('dialog'); + fireEvent.click(within(card).getByRole('button', { name })); + }; + fireEvent.click(toggle()); + choose(/^Ladder filter,/, 'EMPHASIS'); + expect(findStore.get()).toEqual({ kind: 'control', id: 'filter.emphasis' }); + expect(diagram()).toBeNull(); + expect(away()).toBe(false); + // The panel spotlights it. + expect(document.querySelector('.kys-pulse')).not.toBeNull(); + // The same control again, with its find still up: it turns face up all the same. + fireEvent.click(toggle()); + expect(diagram()).not.toBeNull(); + choose(/^Ladder filter,/, 'EMPHASIS'); + expect(diagram()).toBeNull(); + // A jack too. + fireEvent.click(toggle()); + choose(/^LFO,/, /^LFO CV/); + expect(findStore.get()).toEqual({ kind: 'jack', id: 'j.lfoCv' }); + expect(diagram()).toBeNull(); + }); + it('shows no toggle for a synth without a routing', () => { const real = vi.mocked(getSynthRouting).getMockImplementation(); vi.mocked(getSynthRouting).mockImplementation(() => undefined); From 9f7c758e2deaa54c0ea02d5f72d61922451f4951 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 23:07:05 +0000 Subject: [PATCH 3/3] Fix review findings on the routing card (t-143) A find from a card now turns the panel over through the flip: the page marks it seen, so its check no longer turns the view off first with no transition. Focus goes to the Signal routing switch as the card goes. A second press of a block puts its kept card away; keyboard focus moving to another block takes the card there; a focus-opened card comes back when the pointer leaves; and the card takes focus without scrolling. Co-Authored-By: Claude Opus 5.5 --- .context/app/synth-page.md | 10 +++-- components/app/synth/routing-card.tsx | 44 ++++++++++--------- components/app/synth/routing-view.tsx | 35 ++++++++++----- components/app/synth/synth-page.tsx | 26 ++++++++--- .../app/synth/routing-view.test.tsx | 21 +++++++++ .../components/app/synth/synth-page.test.tsx | 2 + 6 files changed, 96 insertions(+), 42 deletions(-) diff --git a/.context/app/synth-page.md b/.context/app/synth-page.md index 9b141d31b..ac136e678 100644 --- a/.context/app/synth-page.md +++ b/.context/app/synth-page.md @@ -274,10 +274,12 @@ BeatBreaker's drummer player is the precedent (`components/app/studio/player/` t Each block is a button for its card (`routing-card.tsx`, t-143): what it is (its designator and name), whether the patch uses it now, its description and specs, and the controls and jacks that set it. Hover or keyboard focus shows it while the pointer or focus stays (crossing into the card keeps it); a click, a tap, Enter or - Space keeps it open and moves focus into it, and a kept card stays while the pointer passes other blocks. - Escape returns focus to the block; a press elsewhere closes it. Choosing a control or jack finds it on the - panel (`findStore`) and turns the panel face up, through the page's flip rather than the find, so a control - already found turns it over too. A Radix popover, not portalled, so it comes into full screen with the panel. + Space keeps it open and moves focus into it, and a kept card stays while the pointer passes other blocks + (keyboard focus moving to another block takes it there). A second press of its block, or Escape, puts it away + and returns focus to the block; a press elsewhere closes it. Choosing a control or jack finds it on the panel + (`findStore`) and turns the panel face up, through the page's flip rather than the find (the page marks the find + seen, so its check does not turn the view off first), so a control already found turns it over too; focus + goes to the Signal routing switch. A Radix popover, not portalled, so it comes into full screen with the panel. - **The player's buttons carry their own tooltip** (`PlayerTip`, `player-tip.tsx`): the button's name and a line on what it does, on hover or focus, in place of the browser's slow `title`. It is a Radix popup that is not portalled, so it works full screen. diff --git a/components/app/synth/routing-card.tsx b/components/app/synth/routing-card.tsx index 32ad6e904..e0635e153 100644 --- a/components/app/synth/routing-card.tsx +++ b/components/app/synth/routing-card.tsx @@ -43,26 +43,30 @@ export function RoutingCard({ const plugs = Object.entries(routing.jacks).flatMap(([id, b]) => b === block.id && jacks[id] ? [jacks[id]] : [] ); - const item = (kind: RoutingShowTarget['kind'], id: string, name: string, note?: string) => ( -
    • - {onShow ? ( - - ) : ( - - {name} - {note && {note}} - - )} -
    • - ); + const item = (kind: RoutingShowTarget['kind'], id: string, name: string, note?: string) => { + const label = ( + <> + {name} + {note && {note}} + + ); + return ( +
    • + {onShow ? ( + + ) : ( + {label} + )} +
    • + ); + }; return (
      diff --git a/components/app/synth/routing-view.tsx b/components/app/synth/routing-view.tsx index 083f9d7fa..981e32bc2 100644 --- a/components/app/synth/routing-view.tsx +++ b/components/app/synth/routing-view.tsx @@ -233,13 +233,18 @@ export function RoutingView({ }; useEffect(() => stay, []); const blockEl = (id: string) => - [...(svgRef.current?.querySelectorAll('[data-block]') ?? [])].find( - (el) => el.getAttribute('data-block') === id - ) ?? null; - const show = (id: string, kept: boolean) => { + svgRef.current?.querySelector(`[data-block="${CSS.escape(id)}"]`) ?? null; + // The block keyboard focus is on, if any: its card is the one to come back to when the pointer leaves. + const focusedBlock = () => { + const el = document.activeElement; + return el instanceof Element && svgRef.current?.contains(el) && el.matches(':focus-visible') + ? el.getAttribute('data-block') + : null; + }; + const show = (id: string, kept: boolean, byFocus = false) => { stay(); - // A kept card stays put while the pointer or focus passes over other blocks. - if (card?.kept && !kept) return; + // A kept card stays put while the pointer passes over other blocks; focus moving to another block takes it there. + if (card?.kept && !kept && (!byFocus || card.id === id)) return; anchorRef.current = blockEl(id); setCard({ id, kept }); }; @@ -248,7 +253,11 @@ export function RoutingView({ t instanceof Element && !!svgRef.current?.contains(t) && !!t.closest('[data-block]'); const leave = () => { stay(); - leaveRef.current = setTimeout(() => setCard((was) => (was?.kept ? was : null)), LEAVE_MS); + leaveRef.current = setTimeout(() => { + const focused = focusedBlock(); + if (focused) anchorRef.current = blockEl(focused); + setCard((was) => (was?.kept ? was : focused ? { id: focused, kept: false } : null)); + }, LEAVE_MS); }; const close = (refocus: boolean) => { stay(); @@ -256,10 +265,12 @@ export function RoutingView({ setCard(null); if (refocus && el instanceof SVGElement) el.focus(); }; + // A press keeps the block's card open, or puts it away if it already is. + const press = (id: string) => (card?.kept && card.id === id ? close(true) : show(id, true)); // A kept card takes focus, so its controls are next for the keyboard and a screen reader reads it. const keptId = card?.kept ? card.id : null; useEffect(() => { - if (keptId) cardRef.current?.focus(); + if (keptId) cardRef.current?.focus({ preventScroll: true }); }, [keptId]); const state = useMemo( () => routingState(def, routing, { values, cables, wheel }), @@ -453,13 +464,15 @@ export function RoutingView({ aria-controls={card?.id === b.id ? cardId : undefined} onPointerEnter={(e: PointerEvent) => e.pointerType === 'mouse' && show(b.id, false)} onPointerLeave={(e: PointerEvent) => e.pointerType === 'mouse' && leave()} - onFocus={(e) => e.currentTarget.matches(':focus-visible') && show(b.id, false)} + onFocus={(e) => + e.currentTarget.matches(':focus-visible') && show(b.id, false, true) + } onBlur={(e) => !cardRef.current?.contains(e.relatedTarget) && leave()} - onClick={() => show(b.id, true)} + onClick={() => press(b.id)} onKeyDown={(e: KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); - show(b.id, true); + press(b.id); }} > + f === null ? null : f.kind === 'areas' ? `areas:${f.ids.join(',')}` : `${f.kind}:${f.id}`; + function freshSession(def: SynthDef, preset: CatalogueSound | undefined): Session { return { presetId: preset?.id ?? null, step: null, soundId: null, ...presetState(def, preset) }; } @@ -631,9 +637,7 @@ function Synth({ // step, the tour, Explain sections or the Inspector coming on. Turning one off leaves the view be, and so does Undo // (it lights what it changes, which the view follows). Dim unused and Sensitive are left out: they point at nothing, // and they come from stored settings that can land after the view is open. - const finding = useStore(findStore, (f) => - f === null ? null : f.kind === 'areas' ? `areas:${f.ids.join(',')}` : `${f.kind}:${f.id}` - ); + const finding = useStore(findStore, findKey); const inspecting = card === 'inspector' || sheet === 'inspector'; const panelWork = [finding, step, tourOn, areasOn, inspecting]; const [seenWork, setSeenWork] = useState(panelWork); @@ -642,6 +646,16 @@ function Synth({ if (routingOn && panelWork.some((v, i) => v !== seenWork[i] && v !== false && v !== null)) setRoutingOn(false); } + // A find from a block's card turns the panel over through the flip: the store's update renders at once, so it is + // marked seen here, or the check above would turn the view off first, with no transition. + const routingToggleRef = useRef(null); + const showFromRouting = (t: RoutingShowTarget) => { + setSeenWork((w) => [findKey(t), ...w.slice(1)]); + findStore.set(t); + flipPanel(false); + // The card goes with the view: focus waits on the switch that turns it over again. + routingToggleRef.current?.focus({ preventScroll: true }); + }; const [cardSide, setCardSide] = useStoredPref(STORAGE_KEYS.cardSide, CardSideSchema, 'right'); const [soundsOpen, setSoundsOpen] = useState(false); // The Sound Design drawer (from `lg` up, with a pointer): up from a press on its tab until the next, so it stays @@ -2151,6 +2165,7 @@ function Synth({ {routing && (
      diff --git a/tests/unit/components/app/synth/routing-view.test.tsx b/tests/unit/components/app/synth/routing-view.test.tsx index e7867780c..0c21e8fd8 100644 --- a/tests/unit/components/app/synth/routing-view.test.tsx +++ b/tests/unit/components/app/synth/routing-view.test.tsx @@ -379,6 +379,27 @@ describe('a block’s card', () => { expect(card('Ladder filter')).not.toBeNull(); }); + it('closes a kept card on a second press of its block, and follows focus to another block', async () => { + const user = userEvent.setup(); + view(); + const filter = blockButton('Ladder filter'); + act(() => filter.focus()); + await user.keyboard('{Enter}'); + expect(filter).toHaveAttribute('aria-expanded', 'true'); + // Back from the card to its own block, it stays kept, and a press puts it away. + act(() => filter.focus()); + expect(card('Ladder filter')).not.toBeNull(); + await user.keyboard('{Enter}'); + expect(card('Ladder filter')).toBeNull(); + expect(filter).toHaveAttribute('aria-expanded', 'false'); + // Kept again, then focus moves to another block: the card goes with it. + await user.keyboard('{Enter}'); + const mixer = blockButton('Mixer'); + act(() => mixer.focus()); + expect(card('Ladder filter')).toBeNull(); + expect(mixer).toHaveAttribute('aria-expanded', 'true'); + }); + it('opens on a tap of another block in place of the one open, and closes on a tap away', async () => { view(); fireEvent.pointerEnter(blockButton('Mixer'), { pointerType: 'touch' }); diff --git a/tests/unit/components/app/synth/synth-page.test.tsx b/tests/unit/components/app/synth/synth-page.test.tsx index 93e5d9770..56d61c03a 100644 --- a/tests/unit/components/app/synth/synth-page.test.tsx +++ b/tests/unit/components/app/synth/synth-page.test.tsx @@ -2111,6 +2111,8 @@ describe('SynthPage signal routing', () => { expect(findStore.get()).toEqual({ kind: 'control', id: 'filter.emphasis' }); expect(diagram()).toBeNull(); expect(away()).toBe(false); + // The card went with the view: focus is on the switch that turns it over again. + expect(toggle()).toHaveFocus(); // The panel spotlights it. expect(document.querySelector('.kys-pulse')).not.toBeNull(); // The same control again, with its find still up: it turns face up all the same.