UI cleanup, visual and keyboard navigation

This commit is contained in:
2026-09-11 13:32:07 +02:00
parent df89acd9a8
commit fbf03a9847
13 changed files with 445 additions and 105 deletions

View File

@@ -60,7 +60,7 @@ describe("ambienceBaseFor", () => {
const hues = [musicHue, audiobook.fixed!.gradient[0].h, podcast.fixed!.gradient[0].h];
expect(new Set(hues).size).toBe(3); // all three distinct
expect(musicHue).toBe(210);
expect(audiobook.fixed!.gradient[0].h).toBe(80);
expect(audiobook.fixed!.gradient[0].h).toBe(55);
expect(podcast.fixed!.gradient[0].h).toBe(300);
});

View File

@@ -48,8 +48,18 @@ const resultsFor = (ui: UiState) =>
category: ui.category,
});
const press = (k: string, ui: UiState = initialUiState, over = {}, openAlbumTrackCount = 0) =>
handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount);
// The three root shelves' own category keys for ALBUMS above, in the same
// alphabetical order categoryMatches produces: music has two (Kinderparty, Rolf),
// audiobooks one (Conni), podcasts none.
const ROOT_SHELVES: string[][] = [["Kinderparty", "Rolf"], ["Conni"], []];
const press = (
k: string,
ui: UiState = initialUiState,
over = {},
openAlbumTrackCount = 0,
rootShelves: string[][] = ROOT_SHELVES,
) => handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount, rootShelves);
describe("normalize", () => {
it("strips case, punctuation and diacritics so a child's letters match", () => {
@@ -130,6 +140,57 @@ describe("keyboard", () => {
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
});
it("steps tiles within a row with Ctrl+h/l and rows with Ctrl+j/k, without opening any of them", () => {
// Row 0 (music) has two tiles: Kinderparty at column 0, Rolf at column 1.
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([
{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } },
]);
const onCol1 = { ...initialUiState, selIndex: 1 };
expect(press("h", onCol1, { ctrlKey: true })).toEqual([
{ type: "ui", patch: { shelfRow: 0, selIndex: 0 } },
]);
// Clamped at the row's own end rather than wrapping or spilling into the next row.
expect(press("l", onCol1, { ctrlKey: true })).toEqual([
{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } },
]);
// Dropping a row (Ctrl+j) into audiobooks - one tile - clamps the column down with it.
expect(press("j", onCol1, { ctrlKey: true })).toEqual([
{ type: "ui", patch: { shelfRow: 1, selIndex: 0 } },
]);
// Off the top edge stays put rather than wrapping.
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
{ type: "ui", patch: { shelfRow: 0, selIndex: 0 } },
]);
});
it("matches Ctrl+hjkl's root-shelf stepping with the arrow keys", () => {
expect(press("l", initialUiState, { ctrlKey: true })).toEqual(
press("ArrowRight", initialUiState),
);
expect(press("j", initialUiState, { ctrlKey: true })).toEqual(
press("ArrowDown", initialUiState),
);
const onCol1 = { ...initialUiState, selIndex: 1 };
expect(press("h", onCol1, { ctrlKey: true })).toEqual(press("ArrowLeft", onCol1));
expect(press("k", onCol1, { ctrlKey: true })).toEqual(press("ArrowUp", onCol1));
});
it("opens the focused tile's category with ENTER at the bare root", () => {
const onAudiobooksRow = { ...initialUiState, shelfRow: 1, selIndex: 0 };
expect(press("Enter", onAudiobooksRow)).toEqual([
{ type: "pop", freq: 440 },
{ type: "ui", patch: { group: "audiobooks", category: "Conni", selIndex: 0 } },
]);
});
it("opens the bare group with ENTER on a row with nothing in it", () => {
const onPodcastsRow = { ...initialUiState, shelfRow: 2, selIndex: 0 };
expect(press("Enter", onPodcastsRow)).toEqual([
{ type: "pop", freq: 380 },
{ type: "ui", patch: { group: "podcasts", category: null, selIndex: 0 } },
]);
});
it("controls transport and volume from anywhere with Shift+h/j/k/l/m", () => {
expect(press("H", initialUiState, { shiftKey: true })).toEqual([{ type: "previous" }]);
expect(press("L", initialUiState, { shiftKey: true })).toEqual([{ type: "next" }]);
@@ -159,11 +220,9 @@ describe("keyboard", () => {
});
});
it("jumps into the first group with arrows at the bare root", () => {
expect(press("ArrowRight")).toEqual([
{ type: "pop", freq: 380 },
{ type: "ui", patch: { group: "music", selIndex: 0 } },
]);
it("moves within a row with left/right and between rows with up/down at the bare root, rather than opening one", () => {
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } }]);
expect(press("ArrowDown")).toEqual([{ type: "ui", patch: { shelfRow: 1, selIndex: 0 } }]);
});
it("gives arrows to navigation once inside a group, and to transport while playing", () => {

View File

@@ -12,6 +12,7 @@
import type { Album, TrackAnalysis, TrackCurves, TrackDetail } from "../api/types";
import type { AmbienceTunables } from "./ambienceTunables";
import { groupOf } from "./search";
import { GROUP_HUE } from "./theme";
export interface Oklch {
/** 0..100, percent lightness. */
@@ -42,14 +43,6 @@ export function oklchString(color: Oklch, alpha = 1): string {
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
//: Today's sea, unchanged - the Dolphin Beats identity, and music's baseline hue.
const MUSIC_HUE = 210;
//: Matches the book-paper tint `cardBackground` already uses for audiobooks
//: (`oklch(93% 0.055 88)` in `lib/covers.ts`).
const AUDIOBOOK_HUE = 80;
//: The one unused family, so all three shelves are instantly distinguishable at a glance.
const PODCAST_HUE = 300;
const BASE_CHROMA = 0.07;
const BASE_TOP_LIGHTNESS = 55;
const DEEP_LIGHTNESS = 20;
@@ -73,7 +66,7 @@ const MUSIC_SPAWN_ENERGY_SWING = 2.6;
//: Audiobooks and podcasts: a slow ambient drift, not a field.
const AMBIENT_SPAWN_RATE = 0.5;
const BUBBLE_TINT: Oklch = { l: 90, c: 0.02, h: MUSIC_HUE };
const BUBBLE_TINT: Oklch = { l: 90, c: 0.02, h: GROUP_HUE.music };
//: Tint chroma tracks gradient chroma proportionally, so a result with every scalar
//: `null` (the un-analyzed baseline, or a track the analyzer failed on) computes back
//: to exactly `BUBBLE_TINT.c` rather than a near-miss from an independent constant.
@@ -109,7 +102,7 @@ export function tempoToRise(tempo: number | null): number {
/** Nothing loaded: today's sea, no bubbles. */
export const IDLE_AMBIENCE: Ambience = {
gradient: gradientForHue(MUSIC_HUE, BASE_CHROMA, BASE_TOP_LIGHTNESS),
gradient: gradientForHue(GROUP_HUE.music, BASE_CHROMA, BASE_TOP_LIGHTNESS),
tint: BUBBLE_TINT,
spawnRate: 0,
rise: 1,
@@ -128,8 +121,8 @@ function fixedAmbience(hue: number): {
};
}
const AUDIOBOOK_FIXED = fixedAmbience(AUDIOBOOK_HUE);
const PODCAST_FIXED = fixedAmbience(PODCAST_HUE);
const AUDIOBOOK_FIXED = fixedAmbience(GROUP_HUE.audiobooks);
const PODCAST_FIXED = fixedAmbience(GROUP_HUE.podcasts);
/** The per-track constant part of the picture: group palette (non-music), and
* tempo-derived rise/current-magnitude bases (music). Computed once on track change -
@@ -181,9 +174,9 @@ export function ambienceBaseFor(
}
//: valence 0 -> dark blue-violet, valence 1 -> bright green-teal, centred on
//: MUSIC_HUE - a narrower swing than the pre-refactor 85 degrees, since valence is
//: now a secondary nudge rather than the primary colour driver. Clamped implicitly by
//: its own small amplitude: at any valence 0..1 the result stays well inside a
//: GROUP_HUE.music - a narrower swing than the pre-refactor 85 degrees, since valence
//: is now a secondary nudge rather than the primary colour driver. Clamped implicitly
//: by its own small amplitude: at any valence 0..1 the result stays well inside a
//: water-plausible range, never drifting toward orange.
const VALENCE_HUE_NUDGE = 24;
//: Energy's swing on chroma/lightness - wide, so the effect reads as "noticeable" per
@@ -200,7 +193,7 @@ export function ambienceColorAt(
valence: number,
tunables: AmbienceTunables,
): { gradient: [Oklch, Oklch, Oklch]; tint: Oklch } {
const hue = MUSIC_HUE - VALENCE_HUE_NUDGE * (clamp01(valence) - 0.5);
const hue = GROUP_HUE.music - VALENCE_HUE_NUDGE * (clamp01(valence) - 0.5);
const gain = tunables.energyColorGain;
const energyDelta = clamp01(energy) - 0.5;
const chroma = Math.max(0, BASE_CHROMA + CHROMA_ENERGY_SWING * gain * energyDelta);

View File

@@ -7,6 +7,7 @@
*/
import type { Album, AlbumKind } from "../api/types";
import { GROUP_HUE } from "./theme";
export const isBook = (album: Album): boolean => album.kind === "book";
@@ -39,9 +40,10 @@ export function stripes(album: Album, width: number): string {
/** A book spine: page edges on the right, a darker board on the left. */
export function spine(album: Album): string {
const [primary, secondary] = colours(album);
const hue = GROUP_HUE.audiobooks;
return [
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
" oklch(90% 0.03 88) 97.5% 100%)",
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` +
` oklch(90% 0.03 ${hue}) 97.5% 100%)`,
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
@@ -53,15 +55,20 @@ export function coverBackground(album: Album, stripeWidth: number): string {
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
}
/** The card's own tint: warm paper for books, cool glass for music. */
/** The card's own tint: warm paper for books, cool glass for music. Amber
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
* rather than washed-out. */
export const cardBackground = (album: Album): string =>
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
isBook(album)
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
export const cardShadow = (album: Album): string =>
isBook(album)
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
" 0 6px 18px oklch(15% 0.05 210 / .35)"
: "0 6px 18px oklch(15% 0.05 210 / .35)";
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`
: `0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`;
export const unitLabel = (album: Album, count: number): string =>
isBook(album)

View File

@@ -20,6 +20,10 @@ export interface UiState {
group: Group | null;
category: string | null;
selIndex: number;
/** Which of the three root shelves (music/audiobooks/podcasts) is focused. Only
* meaningful at the bare root screen - `selIndex` doubles up there as "which tile in
* that row", the way it means "which flat position" everywhere else. */
shelfRow: number;
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
* so toggling to the room and back leaves the music side exactly as it was. */
page: "music" | "room";
@@ -41,6 +45,7 @@ export const initialUiState: UiState = {
group: null,
category: null,
selIndex: 0,
shelfRow: 0,
page: "music",
view: "browse",
openAlbumId: null,
@@ -73,7 +78,7 @@ function typeIntoSearch(state: UiState, key: string): Action[] {
: [];
}
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10;
export const SEEK_STEP = 15;
@@ -133,6 +138,27 @@ export function selectionAlbumAt(results: Results, selIndex: number): Album | nu
return album && groupOf(album) !== "podcasts" ? album : null;
}
/** The bare root (three shelves) has no flat selection for `moveSelection` to move -
* `results` is empty there, since each shelf computes its own categories independently.
* It's a genuine two-axis layout instead: three rows (tracked by `shelfRow`), each a
* differently-long row of category tiles (tracked by `selIndex`, doubling up as "which
* tile in the focused row" the way it means "which flat position" everywhere else).
* `rootShelves[row]` is that row's tile keys in display order - the same data
* BrowseView renders - so a vertical move can clamp the column to how long the row it
* lands on actually is, rather than guessing. */
function moveRootShelf(state: UiState, dx: number, dy: number, rootShelves: string[][]): Action[] {
let row = state.shelfRow;
let col = state.selIndex;
if (dy) {
row = Math.max(0, Math.min(GROUP_ORDER.length - 1, row + dy));
col = Math.min(col, Math.max(0, (rootShelves[row]?.length ?? 0) - 1));
}
if (dx) {
col = Math.max(0, Math.min((rootShelves[row]?.length ?? 0) - 1, col + dx));
}
return [{ type: "ui", patch: { shelfRow: row, selIndex: col } }];
}
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
if (!results.total) return [];
const cols = Math.max(1, state.cols);
@@ -193,13 +219,16 @@ export interface KeyEvent {
*
* `openAlbumTrackCount` is the track count of the open album (when `openAlbumId` is
* set) - the one piece of data Ctrl+j/k needs to clamp the track-list highlight that
* `results` doesn't otherwise carry.
* `results` doesn't otherwise carry. `rootShelves` is the equivalent for the bare root
* screen: each of the three rows' category keys, in display order, for `moveRootShelf`
* to clamp against and ENTER to open.
*/
export function handleKey(
event: KeyEvent,
state: UiState,
results: Results,
openAlbumTrackCount = 0,
rootShelves: string[][] = [],
): Action[] {
const { key } = event;
@@ -257,7 +286,25 @@ export function handleKey(
}
}
if (state.view !== "browse" || state.showHelp) return [];
switch (key.toLowerCase()) {
const lower = key.toLowerCase();
if (!"hjkldu".includes(lower)) return [];
if (isRootShelf(state)) {
switch (lower) {
case "h":
return moveRootShelf(state, -1, 0, rootShelves);
case "l":
return moveRootShelf(state, 1, 0, rootShelves);
case "j":
case "d":
return moveRootShelf(state, 0, 1, rootShelves);
case "k":
case "u":
return moveRootShelf(state, 0, -1, rootShelves);
default:
return [];
}
}
switch (lower) {
case "h":
return moveSelection(state, results, -1, 0);
case "l":
@@ -291,27 +338,19 @@ export function handleKey(
case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
if (browsing && isRootShelf(state)) return moveRootShelf(state, 1, 0, rootShelves);
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
case "ArrowLeft":
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
if (browsing && isRootShelf(state)) return moveRootShelf(state, -1, 0, rootShelves);
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
case "ArrowDown":
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, 1, rootShelves);
return browsing
? moveSelection(state, results, 0, 1)
: [{ type: "volume", delta: -VOLUME_STEP }];
case "ArrowUp":
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, -1, rootShelves);
return browsing
? moveSelection(state, results, 0, -1)
: [{ type: "volume", delta: VOLUME_STEP }];
@@ -336,6 +375,15 @@ export function handleKey(
if (state.openAlbumId !== null) {
return [{ type: "play", albumId: state.openAlbumId, trackIndex: state.modalTrackIndex }];
}
if (state.view === "browse" && isRootShelf(state)) {
const row = Math.max(0, Math.min(GROUP_ORDER.length - 1, state.shelfRow));
const group = GROUP_ORDER[row]!;
const category = rootShelves[row]?.[state.selIndex] ?? null;
return [
{ type: "pop", freq: category ? 440 : 380 },
{ type: "ui", patch: { group, category, selIndex: 0 } },
];
}
if (event.shiftKey) {
const album = selectionAlbumAt(results, state.selIndex);
if (album) return [{ type: "ui", patch: { openAlbumId: album.id, modalTrackIndex: 0 } }];

70
web/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,70 @@
/** The browse screen's shelf/row look, in one place: per-group colors and identity,
* and the feature toggles that have gone back and forth while this design was under
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts.
*
* `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf
* header - an icon badge plus label button above each row's tiles. */
import type { Group } from "./search";
// ---------------------------------------------------------------- toggles --
/** Tint each shelf panel and its rows with the group's hue below. */
export const SHOW_ROW_TINT = false;
/** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */
export const SHOW_ROW_TITLES = true;
/** Show the small icon badge (🎵/📖/🎙️) beside the title. Stands alone fine with
* `SHOW_ROW_TITLES` off (an icon-only header), but the original look needs both. */
export const SHOW_ROW_ICONS = true;
/** Vertical gap between shelves on the root browse screen, in px. */
export const ROW_SPACING = 25;
// ------------------------------------------------------------------ colors --
/** Per-group hue (oklch degrees). The play view's ambient water uses the same
* family (`ambienceBaseFor` in `lib/ambience.ts`), so a tinted shelf already hints
* at the mood its albums play into - and so does the book/music card tint in
* `lib/covers.ts`, which reads `GROUP_HUE.audiobooks`/`GROUP_HUE.music` too. */
export const GROUP_HUE: Record<Group, number> = {
music: 210, // today's sea - Dolphin Beats' baseline hue
audiobooks: 55, // warm amber - richer than the old shared hue (80)'s dull brown
podcasts: 300, // the one otherwise-unused family
};
export const GROUP_ICON: Record<Group, string> = {
music: "🎵",
audiobooks: "📖",
podcasts: "🎙️",
};
export const GROUP_LABEL: Record<Group, string> = {
music: "Musik",
audiobooks: "Hörbücher",
podcasts: "Podcasts",
};
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
export function glassTint(hue: number): React.CSSProperties {
if (!SHOW_ROW_TINT) return {};
return {
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
borderColor: `oklch(75% 0.09 ${hue} / .4)`,
};
}
/** One list row's background, tinted with its group's hue and brighter/more opaque
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
* neutral highlight the row used before tinting existed. */
export function rowTint(hue: number, highlighted: boolean): React.CSSProperties {
if (!SHOW_ROW_TINT) {
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
}
return {
background: `oklch(${highlighted ? "62% 0.12" : "42% 0.08"} ${hue} / ${highlighted ? ".34" : ".2"})`,
borderColor: `oklch(78% 0.09 ${hue} / ${highlighted ? ".5" : ".28"})`,
};
}