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..ac136e678 100644 --- a/.context/app/synth-page.md +++ b/.context/app/synth-page.md @@ -271,6 +271,15 @@ 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 + (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/.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..e0635e153 --- /dev/null +++ b/components/app/synth/routing-card.tsx @@ -0,0 +1,116 @@ +'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) => { + const label = ( + <> + {name} + {note && {note}} + + ); + return ( +
  • + {onShow ? ( + + ) : ( + {label} + )} +
  • + ); + }; + + 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..981e32bc2 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,63 @@ 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?.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 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 }); + }; + // 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(() => { + 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(); + const el = card && blockEl(card.id); + 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({ preventScroll: true }); + }, [keptId]); const state = useMemo( () => routingState(def, routing, { values, cables, wheel }), [def, routing, values, cables, wheel] @@ -319,6 +382,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 +400,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 +456,25 @@ 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, true) + } + onBlur={(e) => !cardRef.current?.contains(e.relatedTarget) && leave()} + onClick={() => press(b.id)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + press(b.id); + }} > - {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..43af4b03a 100644 --- a/components/app/synth/synth-page.tsx +++ b/components/app/synth/synth-page.tsx @@ -124,6 +124,7 @@ import { import { PlayerTip, PlayerTips } from '@/components/app/synth/player-tip'; import { LayerToggle, PlayerSettings } from '@/components/app/synth/player-tools'; import { RoutingView } from '@/components/app/synth/routing-view'; +import type { RoutingShowTarget } from '@/components/app/synth/routing-card'; import { getSynthRouting } from '@/lib/app/synths/routing'; import { TutorDrawer } from '@/components/app/synth/drawers'; import { LessonCard, type CardId } from '@/components/app/synth/lesson-card'; @@ -185,6 +186,7 @@ import { pendingStore, useStore, } from '@/components/app/synth/stores'; +import type { FindTarget } from '@/components/app/synth/stores'; import { SynthPicker } from '@/components/app/synth/synth-picker'; import { Tour } from '@/components/app/synth/tour'; import type { TutorDesign } from '@/components/app/tutor/apply'; @@ -304,6 +306,10 @@ const UNNAMED_COPY = 'Untitled patch'; const TUTOR_PATTERN_NAME = 'Tutor’s pattern'; /** A panel with the synth's first sound loaded and no lesson step: how a synth opens the first time. */ +/** A find as one comparable value, so the page can tell a new one from the one it has seen. */ +const findKey = (f: FindTarget | null) => + 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/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/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/components/app/synth/routing-view.test.tsx b/tests/unit/components/app/synth/routing-view.test.tsx index e70c02f77..0c21e8fd8 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,208 @@ 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('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' }); + 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..56d61c03a 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,35 @@ 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 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. + 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); 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', + ]); + }); });