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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .context/app/adding-a-synth.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ them. `goldens.test.ts` then holds the definition to them. Write

Write `lib/app/synths/routing/<id>.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

Expand Down
9 changes: 9 additions & 0 deletions .context/app/synth-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions .context/app/synths.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ How the parts are wired behind the panel, for the routing view: one **`SynthRout
`lib/app/synths/routing/<id>.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`
Expand Down Expand Up @@ -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.
Expand Down
116 changes: 116 additions & 0 deletions components/app/synth/routing-card.tsx
Original file line number Diff line number Diff line change
@@ -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 && <span className="kys-routing-card-note">{note}</span>}
</>
);
return (
<li key={id}>
{onShow ? (
<button
type="button"
className="kys-routing-card-item"
title="Show it on the panel"
onClick={() => onShow({ kind, id })}
>
{label}
</button>
) : (
<span className="kys-routing-card-item">{label}</span>
)}
</li>
);
};

return (
<div className="kys-routing-card-body" style={{ color: moduleOf(block.module).color }}>
<p className="kys-routing-card-head">
<span className="kys-routing-card-lamp" aria-hidden="true" />
<span className="kys-routing-card-tag">{tag}</span>
<span className="kys-routing-card-name">{block.name}</span>
</p>
<p className={`kys-routing-card-state ${live ? 'is-live' : ''}`}>
{live ? 'On the path the patch uses now' : 'Not on the path the patch uses now'}
</p>
<p className="kys-routing-card-desc">{block.description}</p>
{block.specs && block.specs.length > 0 && (
<dl className="kys-routing-card-specs">
{block.specs.map((s) => (
<div key={s.label}>
<dt>{s.label}</dt>
<dd>{s.value}</dd>
</div>
))}
</dl>
)}
{setBy.length > 0 && (
<section aria-label="Set by these controls">
<h3 className="kys-routing-card-sub">Set by</h3>
<ul className="kys-routing-card-list">
{setBy.map((c) => item('control', c.id, displayName(def, c)))}
</ul>
</section>
)}
{plugs.length > 0 && (
<section aria-label="Its jacks">
<h3 className="kys-routing-card-sub">Jacks</h3>
<ul className="kys-routing-card-list">
{plugs.map((j) => item('jack', j.id, jackName(j), j.dir === 'in' ? 'in' : 'out'))}
</ul>
</section>
)}
{setBy.length === 0 && plugs.length === 0 && (
<p className="kys-routing-card-none">Nothing on the panel sets it.</p>
)}
{onShow && (setBy.length > 0 || plugs.length > 0) && (
<p className="kys-routing-card-hint">Choose one to see it on the panel.</p>
)}
</div>
);
}
127 changes: 123 additions & 4 deletions components/app/synth/routing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -54,6 +60,9 @@ interface Parts {
cables: Map<string | null, SVGGElement>;
}

/** 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;
Expand Down Expand Up @@ -196,6 +205,7 @@ export function RoutingView({
cables,
wheel,
source = null,
onShow,
}: {
def: SynthDef;
routing: SynthRouting;
Expand All @@ -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<SVGSVGElement>(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<Element | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const leaveRef = useRef<ReturnType<typeof setTimeout> | 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]
Expand Down Expand Up @@ -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`;
Expand All @@ -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"
Expand Down Expand Up @@ -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);
}}
>
<title>{b.description}</title>
<rect
x={-3.5}
y={-3.5}
Expand Down Expand Up @@ -455,6 +537,43 @@ export function RoutingView({
);
})}
</svg>
{/* The card, by its block. Not portalled, so it comes with the panel into full screen. */}
<Popover.Root open={!!open} onOpenChange={(o) => !o && close(false)} modal={false}>
<Popover.Anchor virtualRef={anchorRef} />
{open && (
<Popover.Content
ref={cardRef}
id={cardId}
aria-label={open.name}
tabIndex={-1}
side="bottom"
align="center"
sideOffset={8}
collisionPadding={12}
className="kys-routing-card"
onOpenAutoFocus={(e) => 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()}
>
<RoutingCard
def={def}
routing={routing}
block={open}
tag={TAG[symbolOf(open)]}
live={state.blocks[open.id]}
onShow={onShow}
/>
<Popover.Arrow className="kys-routing-card-arrow" width={12} height={6} />
</Popover.Content>
)}
</Popover.Root>
</div>
<ul aria-label="Key" className="kys-routing-key">
<li>
Expand Down
Loading
Loading