Web frontend: hide app title and row titles, regroup help sheet, drop Ctrl+d/u

- New SHOW_APP_TITLE switch (off): the mascot + title header goes, the status pill
  stays on a slim row of its own.
- SHOW_ROW_TITLES off; with only icons, the shelf icon moves into a column left of
  the tiles so an icon-only shelf is no taller than one without.
- Help sheet regrouped into sections in a compact two-column grid, and reachable
  from a small keyboard button (not on phones or the typing page).
- Ctrl+d/Ctrl+u half-page jumps removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 21:53:06 +02:00
parent 5af5c54c62
commit 6981890be0
7 changed files with 180 additions and 106 deletions

View File

@@ -663,6 +663,31 @@ export function App() {
)} )}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />} {PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
or the typing page (TippenApp has its own help and owns every key there). */}
{!phone && ui.page !== "typing" && !ui.showHelp && (
<button
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
aria-label="Tastenkürzel"
style={{
position: "absolute",
left: PERF_ENABLED && !perfOpen ? 48 : 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⌨
</button>
)}
{(!state || library.loading) && <Splash error={library.error} />} {(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && ( {debugDynamicUI && debugSnapshot && (

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SHOW_APP_TITLE } from "../lib/theme";
interface Props { interface Props {
title?: string; title?: string;
mascot?: string; mascot?: string;
@@ -15,6 +17,25 @@ export function AppHeader({
status, status,
compact = false, compact = false,
}: Props) { }: Props) {
if (!SHOW_APP_TITLE) {
// Still clear the translucent standalone-mode status bar, and keep the status
// pill - it is the only place an offline connection shows on these screens.
return (
<div
style={{
display: "flex",
justifyContent: "center",
padding: status
? `calc(${compact ? 8 : 10}px + env(safe-area-inset-top)) 16px 0`
: "env(safe-area-inset-top) 0 0",
flex: "none",
}}
>
{status}
</div>
);
}
const mascotSize = compact ? 44 : 64; const mascotSize = compact ? 44 : 64;
return ( return (
<div <div

View File

@@ -225,15 +225,20 @@ function BrowseViewImpl({
? "4px solid var(--paper)" ? "4px solid var(--paper)"
: undefined, : undefined,
outlineOffset: 2, outlineOffset: 2,
// Without a title the icon has no header to sit in, so it takes a column
// of its own beside the tiles rather than a row above them.
...(SHOW_ROW_ICONS && !SHOW_ROW_TITLES
? { display: "flex", alignItems: "center", gap: 12 }
: {}),
...glassTint(GROUP_HUE[shelfGroup]), ...glassTint(GROUP_HUE[shelfGroup]),
}} }}
> >
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && ( {SHOW_ROW_TITLES && (
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6, gap: 10,
marginBottom: 12, marginBottom: 12,
fontSize: 20, fontSize: 20,
fontWeight: 900, fontWeight: 900,
@@ -241,10 +246,14 @@ function BrowseViewImpl({
}} }}
> >
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>} {SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]} {GROUP_LABEL[shelfGroup]}
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>} <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
</div> </div>
)} )}
{SHOW_ROW_ICONS && !SHOW_ROW_TITLES && (
<span className="group-icon">{GROUP_ICON[shelfGroup]}</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{shelfCategories.length === 0 ? ( {shelfCategories.length === 0 ? (
<div <div
style={{ style={{
@@ -275,6 +284,7 @@ function BrowseViewImpl({
</ShelfRow> </ShelfRow>
)} )}
</div> </div>
</div>
))} ))}
</div> </div>
</> </>

View File

@@ -1,66 +1,104 @@
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there, /** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
* it changes here. */ * it changes here. */
const KEYS: Array<[caps: string[], label: string]> = [ import { Fragment } from "react";
const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string]>]> = [
[
"Abspielen",
[
[["LEER"], "Play / Pause"], [["LEER"], "Play / Pause"],
[["SHIFT+H", "SHIFT+L"], "Song zurück / vor"], [["⇧H", "⇧L"], "Song zurück / vor"],
[["SHIFT+J", "SHIFT+K"], "Leiser / lauter"], [["⇧J", "⇧K"], "Leiser / lauter"],
[["SHIFT+M"], "Stumm"], [["⇧M"], "Stumm"],
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"], [["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
],
],
[
"Auswählen",
[
[["← ↑ ↓ →"], "Auswahl bewegen"], [["← ↑ ↓ →"], "Auswahl bewegen"],
[["CTRL+H/J/K/L"], "Auswahl bewegen (Raster oder Titelliste)"], [["STRG+HJKL"], "Auswahl bewegen"],
[["CTRL+D", "CTRL+U"], "Eine halbe Seite weiter / zurück"],
[["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"],
[["TAB"], "Musik / Hörbücher / Podcasts"],
[["ENTER"], "Auswahl abspielen"], [["ENTER"], "Auswahl abspielen"],
[["⇧ENTER"], "Titelliste öffnen"], [["⇧ENTER"], "Titelliste öffnen"],
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"], [["TAB"], "Musik / Hörbücher / Podcasts"],
],
],
[
"Suchen",
[
[["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"],
[["/"], "Suche öffnen / Alben ↔ Titel"],
],
],
[
"Sonstiges",
[
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
[["F1"], "Diese Hilfe"], [["F1"], "Diese Hilfe"],
[["ESC"], "Schließen / Suche löschen"], [["ESC"], "Schließen / Suche löschen"],
],
],
]; ];
const CAP_STYLE = { minWidth: 0, padding: "3px 7px", fontSize: 11 } as const;
export function HelpOverlay({ onClose }: { onClose: () => void }) { export function HelpOverlay({ onClose }: { onClose: () => void }) {
return ( return (
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}> <div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}> <div className="sheet" style={{ padding: "18px 22px" }}>
<div <div
style={{ style={{
fontSize: 17, fontSize: 15,
fontWeight: 800, fontWeight: 800,
color: "var(--ink)", color: "var(--ink)",
marginBottom: 14, marginBottom: 4,
letterSpacing: 0.3, letterSpacing: 0.3,
}} }}
> >
⌨️ Zaubertasten ⌨️ Zaubertasten
</div> </div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{KEYS.map(([caps, label]) => (
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "flex", gap: 4 }}>
{caps.map((cap) => (
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
{cap}
</div>
))}
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
{label}
</div>
</div>
))}
</div>
<div <div
style={{ style={{
marginTop: 16, display: "grid",
fontSize: 12, gridTemplateColumns: "max-content max-content",
fontWeight: 600, columnGap: 14,
color: "oklch(50% 0.03 210 / .8)", rowGap: 5,
textAlign: "center", alignItems: "center",
}} }}
> >
Tippe irgendwohin, um zu schließen {SECTIONS.map(([title, rows]) => (
<Fragment key={title}>
<div
style={{
gridColumn: "1 / -1",
marginTop: 10,
fontSize: 10,
fontWeight: 800,
textTransform: "uppercase",
letterSpacing: 1,
color: "oklch(50% 0.03 210 / .8)",
}}
>
{title}
</div>
{rows.map(([caps, label]) => (
<Fragment key={label + caps.join()}>
<div style={{ display: "flex", gap: 3, justifyContent: "flex-end" }}>
{caps.map((cap) => (
<span key={cap} className="key-cap" style={CAP_STYLE}>
{cap}
</span>
))}
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 210)" }}>
{label}
</div>
</Fragment>
))}
</Fragment>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -122,23 +122,6 @@ describe("keyboard", () => {
); );
}); });
it("jumps a half-page with Ctrl+d/Ctrl+u", () => {
const ui = { ...initialUiState, group: "music" as const, cols: 2 };
const grid: Results = {
songs: [],
categories: Array.from({ length: 10 }, (_, i) => ({ key: String(i), albums: [] })),
albums: [],
total: 10,
};
// 3 rows of 2 columns each = 6.
expect(handleKey(key("d", { ctrlKey: true }), { ...ui, selIndex: 0 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 6 } },
]);
expect(handleKey(key("u", { ctrlKey: true }), { ...ui, selIndex: 8 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("does nothing with Ctrl+hjkl outside the browse grid, so it never fights the play view's own bindings", () => { it("does nothing with Ctrl+hjkl outside the browse grid, so it never fights the play view's own bindings", () => {
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]); expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
}); });

View File

@@ -85,9 +85,6 @@ export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10; export const VOLUME_STEP = 10;
export const SEEK_STEP = 15; export const SEEK_STEP = 15;
/** How far Ctrl+d/Ctrl+u jump through the grid - vim's own half-page scroll, applied to
* rows of cards instead of lines of text. */
export const PAGE_ROWS = 3;
/** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of /** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of
* every other vim-ish media app. Keyed on the exact shifted letter, so this only ever * every other vim-ish media app. Keyed on the exact shifted letter, so this only ever
@@ -292,8 +289,7 @@ export function handleKey(
// Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's // Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's
// track list if one is open, otherwise the browse grid - the same job the arrow keys // track list if one is open, otherwise the browse grid - the same job the arrow keys
// already do, just reachable without leaving the home row. Ctrl+d/u are vim's own // already do, just reachable without leaving the home row.
// half-page jump, applied to rows of cards.
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
if (state.openAlbumId !== null) { if (state.openAlbumId !== null) {
switch (key.toLowerCase()) { switch (key.toLowerCase()) {
@@ -317,7 +313,7 @@ export function handleKey(
} }
if (state.view !== "browse" || state.showHelp) return []; if (state.view !== "browse" || state.showHelp) return [];
const lower = key.toLowerCase(); const lower = key.toLowerCase();
if (!"hjkldu".includes(lower)) return []; if (!"hjkl".includes(lower)) return [];
if (isRootShelf(state)) { if (isRootShelf(state)) {
switch (lower) { switch (lower) {
case "h": case "h":
@@ -325,10 +321,8 @@ export function handleKey(
case "l": case "l":
return moveRootShelf(state, 1, 0, rootShelves); return moveRootShelf(state, 1, 0, rootShelves);
case "j": case "j":
case "d":
return moveRootShelf(state, 0, 1, rootShelves); return moveRootShelf(state, 0, 1, rootShelves);
case "k": case "k":
case "u":
return moveRootShelf(state, 0, -1, rootShelves); return moveRootShelf(state, 0, -1, rootShelves);
default: default:
return []; return [];
@@ -343,10 +337,6 @@ export function handleKey(
return moveSelection(state, results, 0, 1); return moveSelection(state, results, 0, 1);
case "k": case "k":
return moveSelection(state, results, 0, -1); return moveSelection(state, results, 0, -1);
case "d":
return moveSelection(state, results, 0, PAGE_ROWS);
case "u":
return moveSelection(state, results, 0, -PAGE_ROWS);
default: default:
return []; return [];
} }

View File

@@ -21,12 +21,19 @@ import type { UiState } from "./keyboard";
export const SHOW_ROW_TINT = false; export const SHOW_ROW_TINT = false;
/** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */ /** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */
export const SHOW_ROW_TITLES = true; export const SHOW_ROW_TITLES = false;
/** Show the small icon badge (🎵/📖/🎙️) beside the title. Stands alone fine with /** Show the small icon badge (🎵/📖/🎙️) beside the title. With `SHOW_ROW_TITLES`
* `SHOW_ROW_TITLES` off (an icon-only header), but the original look needs both. */ * off it moves out of the header into a column left of the tiles, so an icon-only
* shelf is no taller than one with neither; the original look needs both. */
export const SHOW_ROW_ICONS = true; export const SHOW_ROW_ICONS = true;
/** Show the mascot + title header ("Musik Delfin", "Mein Zimmer") at the top of the
* browse and room screens. Off reclaims its vertical space; the connection/figure
* status pill it carries still shows, on a slim row of its own, whenever it has
* something to say. */
export const SHOW_APP_TITLE = false;
/** Vertical gap between shelves on the root browse screen, in px. */ /** Vertical gap between shelves on the root browse screen, in px. */
export const ROW_SPACING = 25; export const ROW_SPACING = 25;