Redesign All view as stacked bands with horizontal scrolling sheet on top

This commit is contained in:
Fabricio Giglio
2026-06-21 21:50:01 +02:00
parent 27be691fe2
commit 6ba588aedb
5 changed files with 285 additions and 125 deletions
@@ -170,9 +170,13 @@ export function FallingNotesView() {
onWheel={handleWheel}
style={{
minHeight: 200,
// In "all" the parent column carries one continuous glow (so sheet +
// falling read as one surface, no seam). Other modes paint their own.
background:
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: "1px solid var(--color-notes-border)",
settings.layoutMode === "all"
? "transparent"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: settings.layoutMode === "all" ? "none" : "1px solid var(--color-notes-border)",
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
@@ -5,30 +5,30 @@ import type { CustomColors, NoteLabelMode } from "../../store/useAppStore";
import { lighten, hexToNum } from "../../utils/colorUtils";
// ── Layout constants ──────────────────────────────────────────────────────────
const MIDI_MIN = 21; // A0
const MIDI_MIN = 21; // A0
const MIDI_MAX = 108; // C8
function isWhite(midi: number) { return !isBlackKey(midi); }
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i)
.filter(isWhite).length;
function isWhite(midi: number) {
return !isBlackKey(midi);
}
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i).filter(
isWhite
).length;
// ── Color tokens ──────────────────────────────────────────────────────────────
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
const C_WHITE_MID = 0xf5f5f0; // main key face
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
const C_BLACK_TOP = 0x2c2c38; // top of black key
const C_BLACK_MID = 0x181820; // main body
const C_BLACK_BTM = 0x363644; // slight gloss at bottom
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
const C_WHITE_MID = 0xf5f5f0; // main key face
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
const C_BORDER = 0x9a9a90;
const C_LABEL = 0x6b7280;
const C_FINGER = 0xffffff;
const C_BORDER = 0x9a9a90;
const C_LABEL = 0x6b7280;
const C_FINGER = 0xffffff;
// Active state colors (hand-aware)
const C_PLAYBACK = 0x6366f1;
const C_INPUT = 0x22c55e;
const C_LEFT = 0x3b82f6;
const C_RIGHT = 0xef4444;
const C_PLAYBACK = 0x6366f1;
const C_INPUT = 0x22c55e;
const C_LEFT = 0x3b82f6;
const C_RIGHT = 0xef4444;
// ── Gradient factory ──────────────────────────────────────────────────────────
function lg(stops: Array<[number, number | string]>): PIXI.FillGradient {
@@ -52,21 +52,55 @@ function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number
const GRAD = {
whiteNormal: (ivory: boolean) => {
const c = whiteKeyColors(ivory);
return lg([[0, c.top], [0.07, c.mid], [0.88, c.mid], [1, c.btm]]);
return lg([
[0, c.top],
[0.07, c.mid],
[0.88, c.mid],
[1, c.btm],
]);
},
blackNormal: () => lg([[0, C_BLACK_TOP], [0.35, C_BLACK_MID], [0.88, C_BLACK_MID], [1, C_BLACK_BTM]]),
activeWhite: (base: number) => lg([
[0, 0x050508], // deep hinge shadow
[0.12, lighten(base, 0.58)],
[0.22, base],
[0.83, base],
[1, lighten(base, 1.45)], // bright front lip
]),
activeBlack: (base: number) => lg([
[0, lighten(base, 0.6)],
[0.5, base],
[1, lighten(base, 1.5)],
]),
// Horizontal overlay: soft shadow down each side, transparent in the middle.
// Overlaid on the (vertical) white-key gradient, it makes each white key read
// as rounded / raised instead of a flat slab - the main "fuller, more real"
// cue (black keys sit between white keys and cast a soft shadow onto them).
whiteSideShade: () =>
new PIXI.FillGradient({
type: "linear",
start: { x: 0, y: 0 },
end: { x: 1, y: 0 },
textureSpace: "local",
colorStops: [
{ offset: 0, color: "rgba(0,0,0,0.10)" },
{ offset: 0.16, color: "rgba(0,0,0,0)" },
{ offset: 0.84, color: "rgba(0,0,0,0)" },
{ offset: 1, color: "rgba(0,0,0,0.10)" },
],
}),
// Subtle dimension via the gradient alone: a soft sheen at the top, dark
// body, and a slightly lighter "front lip" at the bottom so the key isn't a
// flat 100% black slab. (Kept restrained - stronger glossy/face attempts read
// as plasticky on our renderer.)
blackNormal: () =>
lg([
[0, 0x34343f], // top sheen - catches a bit of light
[0.18, 0x1c1c24],
[0.82, 0x141419], // darkest part of the body
[1, 0x3a3a46], // lighter front lip at the bottom
]),
activeWhite: (base: number) =>
lg([
[0, 0x050508], // deep hinge shadow
[0.12, lighten(base, 0.58)],
[0.22, base],
[0.83, base],
[1, lighten(base, 1.45)], // bright front lip
]),
activeBlack: (base: number) =>
lg([
[0, lighten(base, 0.6)],
[0.5, base],
[1, lighten(base, 1.5)],
]),
};
export interface PianoKeyboardOptions {
@@ -96,10 +130,10 @@ export class PianoKeyboard {
private _initialized = false;
private keyContainers = new Map<number, PIXI.Container>();
private keys = new Map<number, PIXI.Graphics>();
private keyLabels = new Map<number, PIXI.Text>();
private fingerLabels = new Map<number, PIXI.Text>();
private keyStates = new Map<number, KeyState>();
private keys = new Map<number, PIXI.Graphics>();
private keyLabels = new Map<number, PIXI.Text>();
private fingerLabels = new Map<number, PIXI.Text>();
private keyStates = new Map<number, KeyState>();
private opts: PianoKeyboardOptions;
private whiteW = 0;
@@ -151,7 +185,7 @@ export class PianoKeyboard {
this.whiteW = width / WHITE_COUNT;
this.whiteH = height;
this.blackW = this.whiteW * 0.58;
this.blackH = height * 0.60;
this.blackH = height * 0.6;
let wi = 0;
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
@@ -169,7 +203,7 @@ export class PianoKeyboard {
}
private drawKeys() {
const root = new PIXI.Container();
const root = new PIXI.Container();
const whiteLayer = new PIXI.Container();
const blackLayer = new PIXI.Container();
@@ -192,9 +226,7 @@ export class PianoKeyboard {
// Note label (created for every key; text/visibility set by noteLabelMode)
{
const labelStr = this.noteLabel(midi);
const lFontSz = white
? Math.max(7, this.whiteW * 0.42)
: Math.max(6, this.blackW * 0.55);
const lFontSz = white ? Math.max(7, this.whiteW * 0.42) : Math.max(6, this.blackW * 0.55);
const label = new PIXI.Text({
text: labelStr,
style: {
@@ -244,7 +276,10 @@ export class PianoKeyboard {
}
// Hover highlight
const st = this.keyStates.get(midi);
if (st && !st.hovered) { st.hovered = true; this.redrawKey(midi); }
if (st && !st.hovered) {
st.hovered = true;
this.redrawKey(midi);
}
});
container.on("pointerout", () => {
if (this.dragKeys.has(midi)) {
@@ -253,15 +288,24 @@ export class PianoKeyboard {
}
// Remove hover highlight
const st = this.keyStates.get(midi);
if (st && st.hovered) { st.hovered = false; this.redrawKey(midi); }
if (st && st.hovered) {
st.hovered = false;
this.redrawKey(midi);
}
});
this.keys.set(midi, g);
this.keyContainers.set(midi, container);
this.keyStates.set(midi, { playbackActive: false, inputActive: false, hand: null, finger: null, hovered: false });
this.keyStates.set(midi, {
playbackActive: false,
inputActive: false,
hand: null,
finger: null,
hovered: false,
});
if (white) whiteLayer.addChild(container);
else blackLayer.addChild(container);
else blackLayer.addChild(container);
}
root.addChild(whiteLayer, blackLayer);
@@ -272,18 +316,22 @@ export class PianoKeyboard {
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
let g = this.gradCache.get(key);
if (!g) { g = factory(); this.gradCache.set(key, g); }
if (!g) {
g = factory();
this.gradCache.set(key, g);
}
return g;
}
private paintKey(
g: PIXI.Graphics,
w: number, h: number,
w: number,
h: number,
white: boolean,
active: boolean,
activeColor: number | null,
_hand: "left" | "right" | null,
hovered = false,
hovered = false
) {
g.clear();
const r = white ? 4 : 3;
@@ -311,12 +359,18 @@ export class PianoKeyboard {
if (active && white) {
// Keybed shadow strip at bottom (felt bumper contact)
g.rect(1, h - 4, w - 2, 4);
g.fill({ color: 0x000000, alpha: 0.40 });
g.fill({ color: 0x000000, alpha: 0.4 });
// Side edge depth shadows
g.rect(0.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 });
g.fill({ color: 0x000000, alpha: 0.1 });
g.rect(w - 2.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 });
g.fill({ color: 0x000000, alpha: 0.1 });
}
// ── Inactive white key: soft side shading for a rounded, fuller look ────
if (white && !active) {
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
g.fill(this.getGrad("wshade", GRAD.whiteSideShade));
}
// White highlight strip at top of active black keys
@@ -355,14 +409,14 @@ export class PianoKeyboard {
}
private redrawKey(midi: number) {
const g = this.keys.get(midi);
const g = this.keys.get(midi);
const state = this.keyStates.get(midi);
const cont = this.keyContainers.get(midi);
const cont = this.keyContainers.get(midi);
if (!g || !state || !cont) return;
const white = isWhite(midi);
const w = white ? this.whiteW : this.blackW;
const h = white ? this.whiteH : this.blackH;
const white = isWhite(midi);
const w = white ? this.whiteW : this.blackW;
const h = white ? this.whiteH : this.blackH;
const active = state.playbackActive || state.inputActive;
// 3D press: shift container down when active
@@ -373,9 +427,14 @@ export class PianoKeyboard {
if (active) {
const cc = this.opts.customColors;
if (this.opts.showHandColors && state.hand) {
color = state.hand === "left"
? (cc ? hexToNum(cc.leftHand) : C_LEFT)
: (cc ? hexToNum(cc.rightHand) : C_RIGHT);
color =
state.hand === "left"
? cc
? hexToNum(cc.leftHand)
: C_LEFT
: cc
? hexToNum(cc.rightHand)
: C_RIGHT;
} else if (state.inputActive) {
color = C_INPUT;
} else {
@@ -402,7 +461,7 @@ export class PianoKeyboard {
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
}
if (mode === "white" && !white) return "";
if (mode === "black" && white) return "";
if (mode === "black" && white) return "";
// "white", "black", "all": show note name class only (no octave)
return midiToNoteName(midi, this.opts.useFlats);
}
@@ -414,7 +473,7 @@ export class PianoKeyboard {
if (!state) return;
if (source === "playback") {
state.playbackActive = true;
state.hand = note.hand === "unknown" ? null : note.hand as "left" | "right";
state.hand = note.hand === "unknown" ? null : (note.hand as "left" | "right");
state.finger = note.finger;
} else {
state.inputActive = true;
@@ -427,7 +486,10 @@ export class PianoKeyboard {
if (!state) return;
if (source === "playback") {
state.playbackActive = false;
if (!state.inputActive) { state.hand = null; state.finger = null; }
if (!state.inputActive) {
state.hand = null;
state.finger = null;
}
} else {
state.inputActive = false;
}
@@ -463,11 +525,10 @@ export class PianoKeyboard {
}
setOptions(opts: Partial<PianoKeyboardOptions>) {
const needsFullRebuild = (
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
const needsFullRebuild =
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats)
);
("useFlats" in opts && opts.useFlats !== this.opts.useFlats);
Object.assign(this.opts, opts);
@@ -25,6 +25,11 @@ export function SheetMusicView() {
const scoreLoaded = useRef(false); // true once score SVG is rendered
const lastScrollTop = useRef(-1);
const lastScrollMs = useRef(0);
// Horizontal "all" mode: a rAF lerp loop slides scrollLeft toward this target
// every frame for continuous, buttery score-following (instead of stepped
// bar-to-bar jumps). onTick updates the target; the loop animates toward it.
const scrollTargetRef = useRef(0);
const rafScrollId = useRef<number | null>(null);
// ── AlphaTab init (runs once on mount) ──────────────────────────────────
useEffect(() => {
@@ -34,11 +39,22 @@ export function SheetMusicView() {
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
if (!containerRef.current) return;
// In the "all" view the sheet sits in a full-width band at the top and
// renders as a single horizontal strip (layoutMode 1) that scrolls
// left->right following playback. The dedicated "Sheet" mode keeps page
// mode (vertical, multi-line) which is better for reading a full score.
// SheetMusicView mounts fresh per layout mode (not a singleton), so reading
// the mode once at init is safe for this instance's lifetime.
const horizontal = useAppStore.getState().settings.layoutMode === "all";
const api = new AlphaTabApi(containerRef.current, {
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
display: {
layoutMode: 0, // page mode - vertical scroll
scale: 0.9,
layoutMode: horizontal ? 1 : 0, // 1 = horizontal strip, 0 = page (vertical)
// Smaller scale in the horizontal "all" band so the full grand staff
// (both clefs) fits the short band height without the bass clef being
// clipped. Page mode keeps the larger, more readable 0.9.
scale: horizontal ? 0.62 : 0.9,
// stretchForce 0 = notes keep their natural rhythmic proportions (like
// a printed score). A very small value (0.1) avoids a ragged right
// edge on the last system without visibly compressing the spacing.
@@ -102,6 +118,32 @@ export function SheetMusicView() {
/* ignore */
}
// ── Continuous horizontal scroll loop (lerp toward scrollTargetRef) ────
// In horizontal mode the strip glides smoothly under the cursor instead of
// jumping bar to bar. The 0.12 factor is a gentle chase (~0.13s) that also
// averages out the ~8 fps cursor steps into continuous motion. Gated: the
// loop self-stops once paused AND settled; onTick (which only fires while
// playing, plus once on seek) restarts it via ensureScrollLoop().
const lerpScroll = () => {
const scrollEl = scrollRef.current;
let settling = false;
if (scrollEl) {
const diff = scrollTargetRef.current - scrollEl.scrollLeft;
if (Math.abs(diff) > 0.5) {
scrollEl.scrollLeft += diff * 0.12;
settling = true;
}
}
if (useAppStore.getState().status === "playing" || settling) {
rafScrollId.current = requestAnimationFrame(lerpScroll);
} else {
rafScrollId.current = null; // idle - stop until the next tick kicks it
}
};
const ensureScrollLoop = () => {
if (rafScrollId.current === null) rafScrollId.current = requestAnimationFrame(lerpScroll);
};
// ── Transport tick: cursor position + scroll ─────────────────────────
// IMPORTANT: do NOT close over `doc` here - it would always be null
// (stale closure from first render). Always read fresh state instead.
@@ -119,11 +161,9 @@ export function SheetMusicView() {
return;
}
// Scroll cursor into view - throttled to ~4 fps to avoid jitter
const now = performance.now();
if (now - lastScrollMs.current < 250) return;
lastScrollMs.current = now;
// Update scroll to follow the cursor. Horizontal mode sets a lerp target
// (the rAF loop animates toward it for a continuous glide); page mode
// does a throttled jump with the browser's smooth-scroll.
requestAnimationFrame(() => {
const scrollEl = scrollRef.current; // outer scroll container
const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
@@ -133,15 +173,26 @@ export function SheetMusicView() {
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
innerEl.querySelector<HTMLElement>(".at-cursor-bar");
if (!cursor) return;
// cursor position relative to the scrollable content area
const cursorTop =
cursor.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
if (Math.abs(target - lastScrollTop.current) > 80) {
lastScrollTop.current = target;
scrollEl.scrollTo({ top: target, behavior: "smooth" });
const cRect = cursor.getBoundingClientRect();
const sRect = scrollEl.getBoundingClientRect();
if (horizontal) {
// Absolute content x of the cursor; keep it ~40% from the left so
// there's lookahead. The rAF loop slides scrollLeft toward this.
const cursorLeft = cRect.left - sRect.left + scrollEl.scrollLeft;
scrollTargetRef.current = Math.max(0, cursorLeft - scrollEl.clientWidth * 0.4);
ensureScrollLoop();
} else {
// Page mode: throttled (~4 fps) jump, keep cursor at ~30% from top.
const now = performance.now();
if (now - lastScrollMs.current < 250) return;
lastScrollMs.current = now;
const cursorTop = cRect.top - sRect.top + scrollEl.scrollTop;
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
if (Math.abs(target - lastScrollTop.current) > 80) {
lastScrollTop.current = target;
scrollEl.scrollTo({ top: target, behavior: "smooth" });
}
}
});
};
@@ -152,6 +203,10 @@ export function SheetMusicView() {
return () => {
cleanupBus?.();
if (rafScrollId.current !== null) {
cancelAnimationFrame(rafScrollId.current);
rafScrollId.current = null;
}
playerReady.current = false;
scoreLoaded.current = false;
if (apiRef.current) {
@@ -168,15 +223,14 @@ export function SheetMusicView() {
const isDark = settings.theme === "dark";
const keepWhite = isDark && settings.sheetMusicWhiteBackground;
const invertSheet = isDark && !settings.sheetMusicWhiteBackground;
// The "all" view renders the sheet as a horizontal strip (see init effect).
const horizontalSheet = settings.layoutMode === "all";
// ── Adapt stretchForce to layout mode ────────────────────────────────────
// "all" mode gives the sheet a narrow shared column, so we keep more
// stretching (0.5) to fill each line. In dedicated sheet mode we use
// near-natural note spacing (0.1) for a printed-score look.
// The value is stored in a ref so the load effect below can read it fresh.
// ── stretchForce ──────────────────────────────────────────────────────────
// Near-natural note spacing (0.1) for a printed-score look in both page mode
// and the horizontal "all" strip. (The old 0.5 was to fill the narrow "all"
// sidebar, which no longer exists - "all" is now a full-width band.)
const stretchForceRef = useRef(0.1);
// eslint-disable-next-line react-hooks/refs -- intentional: keeps ref in sync with derived state for effect to read fresh
stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1;
useEffect(() => {
// Re-render the current score when the user switches between layout modes.
@@ -214,32 +268,47 @@ export function SheetMusicView() {
}, [doc?.musicXml, doc?.mxlBuffer]);
// Background logic:
// light / dark normal: radial gradient + var(--color-notes-bg), matching FallingNotesView
// dark + keep white: explicit #fff so black notation stays readable on white
// The gradient (7% purple at bottom-center to transparent) is the same subtle glow used
// by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent.
// "all" (horizontal band): TRANSPARENT - the parent column carries one
// continuous glow that both the sheet band and the falling-notes pane sit
// over, so there's no seam and the overall tone matches the other views.
// page mode: radial gradient + var(--color-notes-bg), matching FallingNotesView
const scrollBg = keepWhite
? "#ffffff"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
: horizontalSheet
? "transparent"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
return (
<div
ref={scrollRef}
className="flex-1 overflow-auto"
className="flex-1"
style={{
background: scrollBg,
minHeight: 0,
// Top border sits against the header in every mode (matches the other
// views' header separator). In "all" this is the TOP of the sheet band,
// not the seam with the falling-notes pane (that seam stays borderless).
borderTop: "1px solid var(--color-notes-border)",
// Horizontal strip scrolls left<->right; page mode scrolls up/down.
overflowX: horizontalSheet ? "auto" : "hidden",
overflowY: horizontalSheet ? "hidden" : "auto",
}}
>
{/*
Page-width wrapper - centres the score and caps it at ~A4 screen width
(900 px). On wide monitors this creates margins on both sides so the
score never looks like a stretched banner; on narrow screens it fills
the full width just like before. position:relative is needed so the
key-signature badge stays anchored inside the content column.
Page mode: page-width wrapper centres the score and caps it at ~A4
screen width (900 px). Horizontal mode: no width cap - the strip must
extend as wide as the score so it can scroll. position:relative keeps
the key-signature badge anchored.
*/}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
<div
style={{
maxWidth: horizontalSheet ? "none" : 900,
margin: horizontalSheet ? 0 : "0 auto",
padding: "8px 16px",
position: "relative",
}}
>
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
+3 -2
View File
@@ -297,9 +297,10 @@ input[type="range"]::-moz-range-progress {
border-radius: 1px;
}
/* Bar cursor - wider semi-transparent highlight for the current measure */
/* Bar cursor - the "spotlight" highlight on the current measure. Slightly more
prominent than a hairline so it's easy to track while following along. */
.at-cursor-bar {
background: rgba(147, 51, 234, 0.12);
background: rgba(147, 51, 234, 0.16);
border-radius: 2px;
}
+47 -22
View File
@@ -268,29 +268,54 @@ export function PracticeView() {
</header>
{/* ── Main content - layout-aware ──────────────────────────────────────
PianoKeyboardView is a SINGLETON at the bottom of the main column so
PixiJS doesn't re-init on every view switch (skeleton flash + perf cost).
Same for FallingNotesView in falling/all modes - kept mounted across
the two modes that show it. Only the conditional top-content elements
(PianoModeBackground / SheetMusicView / sheet sidebar) mount and
unmount on layoutMode change. */}
<main className="flex flex-1 overflow-hidden min-h-0 relative">
{/* Optional sheet sidebar (only in "all" mode) */}
{layoutMode === "all" && (
<div
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative"
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }}
>
<SheetMusicView />
</div>
)}
Single vertical column. The piano keyboard is a SINGLETON pinned at the
bottom (keyed) so PixiJS never re-inits on a view switch. FallingNotesView
is keyed too, so it stays mounted across the falling<->all toggle (the
sheet band appears above it in "all" without remounting it).
{/* Main column: top content varies, piano persists across all modes */}
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{layoutMode === "piano" && <PianoModeBackground />}
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView />}
{layoutMode === "sheet" && <SheetMusicView />}
<PianoKeyboardView />
"all" mode = three full-width horizontal bands stacked vertically:
sheet (top, horizontal strip) / falling notes (middle) / piano (bottom).
The dedicated "sheet" mode keeps a full vertical page (better for reading). */}
<main className="flex flex-1 overflow-hidden min-h-0 relative">
<div
className="flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
// "all": one continuous glow on the whole column (sheet + falling
// read transparently over it), so there's no seam and the overall
// tone matches the other views (whose panes carry the same glow).
// Anchored just above the piano (calc(100% - keyboard height)) so the
// glow's brightest point lands where the other views' do - otherwise
// it sits behind the opaque keybed and reads slightly dimmer (visible
// in dark mode, where the purple contrasts more).
background:
layoutMode === "all"
? "radial-gradient(ellipse at 50% calc(100% - 160px), rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"
: undefined,
}}
>
{/* Piano-only mode: decorative backdrop above the keyboard */}
{layoutMode === "piano" && <PianoModeBackground key="piano-bg" />}
{/* Dedicated Sheet mode: full-height vertical page */}
{layoutMode === "sheet" && <SheetMusicView key="sheet-full" />}
{/* All mode: sheet as a full-width horizontal band at the top */}
{layoutMode === "all" && (
<div
key="sheet-band"
className="flex flex-col min-h-0 overflow-hidden"
style={{ flex: "0 0 32%", minHeight: 130 }}
>
<SheetMusicView />
</div>
)}
{/* Falling notes: middle (all) or full (falling). Keyed so it stays
mounted across the falling<->all toggle (no PixiJS re-init). */}
{(layoutMode === "falling" || layoutMode === "all") && <FallingNotesView key="falling" />}
{/* Piano keyboard: always the bottom singleton (keyed). */}
<PianoKeyboardView key="kbd" />
</div>
</main>