fix: use var(--x) not hsl(var(--x)) for oklch theme vars, redesign fretboard with explicit string lines

This commit is contained in:
2026-04-09 01:15:49 +02:00
parent ed669b2e3a
commit dbb7cbd92f
2 changed files with 146 additions and 90 deletions

View File

@@ -1,7 +1,13 @@
import type { GuitarVoicing } from '~/lib/chord-voicing'; import type { GuitarVoicing } from '~/lib/chord-voicing';
const FRETS_SHOWN = 4;
const STRING_COUNT = 6; const STRING_COUNT = 6;
const FRET_COUNT = 4;
const STRING_SPACING = 13; // px between adjacent string lines
const FRET_HEIGHT = 18; // px per fret
const DOT_R = 5; // finger dot radius
const GRID_W = STRING_SPACING * (STRING_COUNT - 1); // 65px
const GRID_H = FRET_HEIGHT * FRET_COUNT; // 72px
interface Props { interface Props {
voicing: GuitarVoicing | null; voicing: GuitarVoicing | null;
@@ -10,93 +16,139 @@ interface Props {
export function GuitarFretboard({ voicing }: Props) { export function GuitarFretboard({ voicing }: Props) {
if (!voicing) { if (!voicing) {
return ( return (
<div className="flex items-center justify-center text-muted-foreground text-xs h-20 w-24"> <div
className="flex items-center justify-center text-muted-foreground text-xs"
style={{ height: GRID_H + 20, width: GRID_W + 20 }}
>
no voicing no voicing
</div> </div>
); );
} }
const { frets, baseFret, barre } = voicing; const { frets, baseFret, barre } = voicing;
// Show fret number label when not at open position
const showFretLabel = baseFret > 0; const showFretLabel = baseFret > 0;
return ( return (
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center">
{/* Open/muted string indicators above nut */} {/* O/X string indicators above nut */}
<div className="flex" style={{ gap: 2 }}> <div className="relative" style={{ width: GRID_W, height: 16 }}>
{frets.map((f, i) => ( {frets.map((f, i) =>
<div f === null ? (
key={i} <span
className="flex items-center justify-center text-xs font-mono" key={i}
style={{ width: 14 }} className="absolute font-mono text-destructive"
> style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 4 }}
{f === null ? ( >
<span className="text-destructive"></span>
) : f === 0 ? ( </span>
<span className="text-muted-foreground"></span> ) : f === 0 ? (
) : null} <span
</div> key={i}
))} className="absolute font-mono text-muted-foreground"
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 5 }}
>
</span>
) : null
)}
</div> </div>
{/* Fretboard grid */} {/* Fret number + grid */}
<div className="relative flex" style={{ gap: 0 }}> <div className="flex items-start" style={{ gap: 4 }}>
{/* Fret number label */} {/* Fret number label (barre position) */}
{showFretLabel && ( <div style={{ width: 12, textAlign: 'right' }}>
<div {showFretLabel && (
className="absolute text-xs text-muted-foreground font-mono" <span
style={{ left: -18, top: 0, lineHeight: '18px' }} className="font-mono text-muted-foreground"
> style={{ fontSize: 9, lineHeight: `${FRET_HEIGHT}px` }}
{baseFret} >
</div> {baseFret}
)} </span>
)}
</div>
{/* Strings (columns) */} {/* Fretboard grid */}
{frets.map((fret, stringIdx) => ( <div
<div key={stringIdx} className="relative" style={{ width: 14 }}> className="relative"
{/* Nut or top border */} style={{
width: GRID_W,
height: GRID_H,
// Nut: thick when open position, thin when barre
borderTop: showFretLabel
? `1px solid var(--border)`
: `4px solid var(--foreground)`,
borderBottom: `1px solid var(--border)`,
}}
>
{/* String lines (6 vertical lines) */}
{Array.from({ length: STRING_COUNT }, (_, i) => (
<div <div
key={i}
style={{ style={{
height: showFretLabel ? 2 : 4, position: 'absolute',
background: showFretLabel ? 'hsl(var(--border))' : 'hsl(var(--foreground))', top: 0,
margin: '0 2px', bottom: 0,
left: i * STRING_SPACING,
width: 1,
background: 'var(--foreground)',
opacity: 0.35,
}} }}
/> />
{/* Fret cells */} ))}
{Array.from({ length: FRETS_SHOWN }, (_, fretIdx) => {
// Open position (baseFret=0): rows represent frets 1-4 (nut is shown above)
// Barre position (baseFret>0): rows represent frets baseFret, baseFret+1, …
const absoluteFret = baseFret === 0 ? fretIdx + 1 : baseFret + fretIdx;
const hasDot = fret !== null && fret > 0 && fret === absoluteFret;
return (
<div
key={fretIdx}
className="flex items-center justify-center border-b border-border"
style={{ height: 18, borderLeft: stringIdx === 0 ? '1px solid hsl(var(--border))' : undefined, borderRight: '1px solid hsl(var(--border))' }}
>
{hasDot && (
<div className="w-3 h-3 rounded-full bg-primary z-10" />
)}
</div>
);
})}
</div>
))}
{/* Barre indicator — only for actual barre chords (not open position) */} {/* Fret lines (horizontal, between frets) */}
{barre !== null && barre > 0 && ( {Array.from({ length: FRET_COUNT - 1 }, (_, i) => (
<div <div
className="absolute rounded-full bg-primary" key={i}
style={{ style={{
left: 2, position: 'absolute',
right: 2, left: 0,
height: 8, right: 0,
top: (showFretLabel ? 2 : 4) + (barre - baseFret) * 18 + 5, top: (i + 1) * FRET_HEIGHT,
zIndex: 5, height: 1,
}} background: 'var(--border)',
/> }}
)} />
))}
{/* Barre bar — only for transposed barre chords */}
{barre !== null && barre > 0 && (
<div
className="absolute rounded-full"
style={{
left: 0,
right: 0,
height: DOT_R * 2,
top: (barre - baseFret) * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
background: 'var(--primary)',
zIndex: 5,
}}
/>
)}
{/* Finger dots */}
{frets.map((fret, stringIdx) => {
if (fret === null || fret === 0) return null;
// Skip strings covered by the barre bar
if (barre !== null && fret === barre) return null;
const fretIdx = baseFret === 0 ? fret - 1 : fret - baseFret;
if (fretIdx < 0 || fretIdx >= FRET_COUNT) return null;
return (
<div
key={stringIdx}
className="absolute rounded-full"
style={{
width: DOT_R * 2,
height: DOT_R * 2,
left: stringIdx * STRING_SPACING - DOT_R,
top: fretIdx * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
background: 'var(--primary)',
zIndex: 10,
}}
/>
);
})}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -43,7 +43,7 @@ export function PianoKeys({ notes }: Props) {
return ( return (
<div <div
className="flex items-center justify-center text-muted-foreground text-xs" className="flex items-center justify-center text-muted-foreground text-xs"
style={{ width: totalWidth, height: WHITE_KEY_H + 14 }} style={{ width: totalWidth, height: WHITE_KEY_H + 26 }}
> >
? ?
</div> </div>
@@ -52,7 +52,7 @@ export function PianoKeys({ notes }: Props) {
return ( return (
<div style={{ width: totalWidth }}> <div style={{ width: totalWidth }}>
{/* Keyboard — light container so keys are visible in any theme */} {/* Keyboard — gray container makes keys visible in any theme */}
<div <div
className="relative rounded-sm" className="relative rounded-sm"
style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }} style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }}
@@ -69,21 +69,20 @@ export function PianoKeys({ notes }: Props) {
top: 0, top: 0,
width: WHITE_KEY_W - 2, width: WHITE_KEY_W - 2,
height: WHITE_KEY_H - 1, height: WHITE_KEY_H - 1,
background: active ? 'hsl(var(--primary) / 0.2)' : 'white', background: 'white',
borderBottom: active borderBottom: active ? '3px solid var(--primary)' : '1px solid #bbb',
? '3px solid hsl(var(--primary))'
: '1px solid #bbb',
}} }}
> >
{active && ( {active && (
<div <div
className="absolute rounded-full bg-primary" className="absolute rounded-full"
style={{ style={{
width: 10, width: 10,
height: 10, height: 10,
bottom: 10, bottom: 10,
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
background: 'var(--primary)',
}} }}
/> />
)} )}
@@ -103,19 +102,18 @@ export function PianoKeys({ notes }: Props) {
top: 0, top: 0,
width: BLACK_KEY_W, width: BLACK_KEY_W,
height: BLACK_KEY_H, height: BLACK_KEY_H,
background: active ? 'hsl(var(--primary))' : '#1c1c1c', background: active ? 'var(--primary)' : '#1c1c1c',
}} }}
> >
{active && ( {active && (
<div <div
className="absolute rounded-full bg-white" className="absolute rounded-full bg-white"
style={{ style={{
width: 10, width: 7,
height: 10, height: 7,
bottom: 4, bottom: 5,
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
border: '2px solid hsl(var(--primary))',
}} }}
/> />
)} )}
@@ -124,15 +122,21 @@ export function PianoKeys({ notes }: Props) {
})} })}
</div> </div>
{/* Note labels below keyboard */} {/* White key note labels */}
<div className="flex" style={{ width: totalWidth, marginTop: 2 }}> <div className="flex" style={{ width: totalWidth, marginTop: 2 }}>
{WHITE_KEYS.map((note) => { {WHITE_KEYS.map((note) => {
const active = activeChroma.has(NOTE_CHROMA[note]); const active = activeChroma.has(NOTE_CHROMA[note]);
return ( return (
<div <div
key={note} key={note}
className={`text-center font-mono select-none ${active ? 'text-primary font-bold' : 'text-muted-foreground'}`} className="text-center font-mono select-none"
style={{ width: WHITE_KEY_W, fontSize: 8, lineHeight: '12px' }} style={{
width: WHITE_KEY_W,
fontSize: 8,
lineHeight: '12px',
color: active ? 'var(--primary)' : undefined,
fontWeight: active ? 700 : undefined,
}}
> >
{note} {note}
</div> </div>
@@ -140,13 +144,13 @@ export function PianoKeys({ notes }: Props) {
})} })}
</div> </div>
{/* Active note names — unambiguous list including black keys */} {/* Active note names — clearly lists all notes including black keys */}
<div className="flex flex-wrap justify-center gap-1 mt-1"> <div className="flex flex-wrap justify-center gap-1" style={{ marginTop: 2 }}>
{notes.map((note) => ( {notes.map((note) => (
<span <span
key={note} key={note}
className="font-mono font-semibold text-primary" className="font-mono font-semibold"
style={{ fontSize: 9 }} style={{ fontSize: 9, color: 'var(--primary)' }}
> >
{note} {note}
</span> </span>