feat: responsive header

This commit is contained in:
2026-07-24 14:50:59 +02:00
parent 4c146902e3
commit 3d7c0d2332
15 changed files with 364 additions and 610 deletions

View File

@ -10,7 +10,7 @@ import { useNotifications } from './hooks/useNotifications';
import Notifications from './components/Notifications/Notifications'; import Notifications from './components/Notifications/Notifications';
import { DSPState } from './types/dsp408State'; import { DSPState } from './types/dsp408State';
import CreditsPage from './pages/CreditsPage'; import CreditsPage from './pages/CreditsPage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage/HomePage';
function App() { function App() {
const [dsps, setDsps] = useState<DSP[]>([]); const [dsps, setDsps] = useState<DSP[]>([]);

View File

@ -1,22 +0,0 @@
.autoscale-wrapper {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
justify-content: center;
align-items: center;
}
.autoscale-box {
position: relative;
}
.autoscale-content {
position: absolute;
inset: 0;
transform-origin: top left;
}

View File

@ -1,78 +0,0 @@
import { useEffect, useRef, useState } from 'react';
type AutoScaleProps = {
designWidth: number;
designHeight: number;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
};
export default function AutoScale({
designWidth,
designHeight,
children,
className,
style,
}: AutoScaleProps) {
const outerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = outerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (!width || !height) return;
const nextScale = Math.min(width / designWidth, height / designHeight);
setScale(nextScale > 0 ? nextScale : 1);
});
ro.observe(el);
return () => ro.disconnect();
}, [designWidth, designHeight]);
return (
<div
ref={outerRef}
className={className}
style={{
width: '100%',
height: '100%',
minWidth: 0,
minHeight: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
...style,
}}
>
<div
style={{
position: 'relative',
width: designWidth * scale,
height: designHeight * scale,
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: designWidth,
height: designHeight,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
>
{children}
</div>
</div>
</div>
);
}

View File

@ -1,20 +1,23 @@
.btn { .btn {
width: auto; width: 100%;
height: 36px; height: 100%;
padding: 0 var(--space-4);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition:
background var(--dur-base) var(--ease-standard), box-sizing: border-box;
border-color var(--dur-base) var(--ease-standard),
opacity var(--dur-base) var(--ease-standard); padding: 0 var(--space-4);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
} }
.btn:disabled { .btn:disabled {
@ -22,73 +25,25 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Primary */
.btn-primary { .btn-primary {
background: var(--accent-brand); background: var(--accent-brand);
color: #0d0f12; color: #0d0f12;
} }
.btn-primary:hover:not(:disabled) {
background: #6f9bf2;
}
/* Ghost */
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
border-color: var(--border-hairline); border-color: var(--border-hairline);
color: var(--text-muted); color: var(--text-muted);
} }
.btn-ghost:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
/* Toggle */
.btn-toggle { .btn-toggle {
width: 90%;
height: 40px;
margin: 8px auto;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
/* same as ghost */
background: transparent; background: transparent;
border-color: var(--border-hairline); border-color: var(--border-hairline);
color: var(--text-muted); color: var(--text-muted);
} }
.btn-toggle:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: transparent;
}
/* Active states only */
.btn-toggle.active-warning {
background: var(--accent-connecting);
border-color: var(--accent-connecting);
color: var(--bg-void);
}
.btn-toggle.active-danger {
background: var(--accent-down);
border-color: var(--accent-down);
color: var(--text-primary);
}
.btn-outline { .btn-outline {
background: transparent; background: transparent;
border-color: var(--border-hairline); border-color: var(--border-hairline);
color: var(--text-muted); color: var(--text-muted);
} }
.btn-outline:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--text-muted);
}

View File

@ -1,42 +1,13 @@
// Button.tsx
import './Button.css'; import './Button.css';
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useMemo } from 'react';
import AutoScale from '../AutoScale/AutoScale';
type ButtonVariant = 'primary' | 'ghost' | 'toggle' | 'outline'; type ButtonVariant = 'primary' | 'ghost' | 'toggle' | 'outline';
// Design-space size per variant (same idea as DESIGN_WIDTH/HEIGHT in the slider)
const DEFAULT_DESIGN_SIZE: Record<ButtonVariant, { width: number; height: number }> = {
primary: { width: 90, height: 36 },
ghost: { width: 90, height: 36 },
toggle: { width: 90, height: 40 },
outline: { width: 90, height: 36 },
};
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 16;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const HORIZONTAL_PADDING = 12; // reserved px inside the box, keeps text off the edges
function getTextLength(children: ReactNode): number {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).length;
}
if (Array.isArray(children)) {
return children.reduce((acc: number, c) => acc + getTextLength(c), 0);
}
return 6; // icons / non-text children: reasonable fallback estimate
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
fontSize?: number; // explicit override, in design px — skips auto-calc fontSize?: number;
designWidth?: number; // override default design size if needed
designHeight?: number;
scale?: boolean; // wrap in AutoScale (default true)
} }
export default function Button({ export default function Button({
@ -44,41 +15,18 @@ export default function Button({
children, children,
className = '', className = '',
disabled, disabled,
onClick, fontSize = 13,
type = 'button', type = 'button',
fontSize,
designWidth,
designHeight,
scale = true,
style, style,
...props ...props
}: ButtonProps) { }: ButtonProps) {
const { width: defaultW, height: defaultH } = DEFAULT_DESIGN_SIZE[variant]; return (
const W = designWidth ?? defaultW;
const H = designHeight ?? defaultH;
const computedFontSize = useMemo(() => {
if (fontSize !== undefined) return fontSize; // user overload wins, no calc
const textLength = getTextLength(children);
if (textLength === 0) return MAX_FONT_SIZE;
const availableWidth = W - HORIZONTAL_PADDING;
const sizeByWidth = availableWidth / (textLength * AVG_CHAR_WIDTH_RATIO);
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, sizeByWidth));
}, [fontSize, children, W]);
const button = (
<button <button
type={type} type={type}
className={`btn btn-${variant} ${className}`} className={`btn btn-${variant} ${className}`}
onClick={onClick}
disabled={disabled} disabled={disabled}
style={{ style={{
width: scale ? '100%' : W, fontSize: `${fontSize}px`,
height: scale ? '100%' : H,
fontSize: `${computedFontSize}px`,
...style, ...style,
}} }}
{...props} {...props}
@ -86,12 +34,4 @@ export default function Button({
{children} {children}
</button> </button>
); );
if (!scale) return button;
return (
<AutoScale designWidth={W} designHeight={H}>
{button}
</AutoScale>
);
} }

View File

@ -0,0 +1,58 @@
/* ---------- LED status dot ---------- */
.led {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
/* remove flex: 1 and align-self: stretch entirely */
}
.led-dot {
width: 80%;
height: 80%;
border-radius: 50%;
background: var(--accent-down);
transition:
width var(--dur-base) var(--ease-standard),
height var(--dur-base) var(--ease-standard),
background var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard);
}
.led-dot--connected {
width: 100%;
height: 100%;
background: var(--accent-live);
box-shadow: 0 0 6px 1px var(--accent-live);
}
.led-dot--connecting {
background: var(--accent-connecting);
box-shadow: 0 0 6px 1px var(--accent-connecting);
animation: pulse 1s var(--ease-standard) infinite;
}
.led-dot--disconnected {
background: var(--accent-down);
box-shadow: 0 0 4px 0 var(--accent-down);
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.25);
opacity: 0.7;
}
}

View File

@ -0,0 +1,17 @@
import "./Led.css"
type LedProps = {
status?: 'connected' | 'connecting' | 'disconnected';
className?: string;
};
export function Led({
status = 'disconnected',
className = '',
}: LedProps) {
return (
<span className={['led', className].filter(Boolean).join(' ')}>
<span className={`led-dot led-dot--${status}`} />
</span>
);
}

View File

@ -1,7 +1,6 @@
// ConnectionPanel.tsx // ConnectionPanel.tsx
import { DSP } from '../../../types/types'; import { DSP } from '../../../types/types';
import Button from '../../Button/Button'; import Button from '../../Button/Button';
import AutoScale from '../../AutoScale/AutoScale';
import './ConnectionSection.css'; import './ConnectionSection.css';
const DESIGN_WIDTH = 340; const DESIGN_WIDTH = 340;
@ -32,7 +31,6 @@ function ConnectionPanel({
className="connection-panel-frame" className="connection-panel-frame"
style={{ '--panel-w': DESIGN_WIDTH, '--panel-h': DESIGN_HEIGHT } as React.CSSProperties} style={{ '--panel-w': DESIGN_WIDTH, '--panel-h': DESIGN_HEIGHT } as React.CSSProperties}
> >
<AutoScale designWidth={DESIGN_WIDTH} designHeight={DESIGN_HEIGHT}>
<div className="card panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}> <div className="card panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="panel-header"> <div className="panel-header">
<div className="panel-header-text"> <div className="panel-header-text">
@ -73,9 +71,6 @@ function ConnectionPanel({
{dsp.status === 'connected' ? ( {dsp.status === 'connected' ? (
<Button <Button
variant="outline" variant="outline"
designWidth={BTN_W}
designHeight={BTN_H}
scale={false}
onClick={onDisconnect} onClick={onDisconnect}
> >
Disconnect Disconnect
@ -83,9 +78,6 @@ function ConnectionPanel({
) : ( ) : (
<Button <Button
variant="primary" variant="primary"
designWidth={BTN_W}
designHeight={BTN_H}
scale={false}
onClick={onConnect} onClick={onConnect}
disabled={dsp.status === 'connecting'} disabled={dsp.status === 'connecting'}
> >
@ -94,7 +86,6 @@ function ConnectionPanel({
)} )}
</div> </div>
</div> </div>
</AutoScale>
</div> </div>
</div> </div>
); );

View File

@ -5,7 +5,6 @@ import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types'; import { NotificationType } from '../../../types/types';
import { useState } from 'react'; import { useState } from 'react';
import Button from '../../Button/Button'; import Button from '../../Button/Button';
import AutoScale from '../../AutoScale/AutoScale';
import ChannelGroup from '../ChannelGroup/ChannelGroup'; import ChannelGroup from '../ChannelGroup/ChannelGroup';
import './GainSection.css'; import './GainSection.css';

View File

@ -1,66 +1,184 @@
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-5);
padding: 0 var(--space-4);
height: var(--header-height); height: var(--header-height);
background: var(--bg-void); background: var(--bg-void);
border-bottom: 1px solid var(--border-hairline); border-bottom: 1px solid var(--border-hairline);
-webkit-app-region: drag; -webkit-app-region: drag;
padding-inline: var(--space-2);
gap: var(--space-2);
} }
.logo-slot { .logo-slot {
display: flex; display: flex;
align-items: center; height: 85%;
justify-content: center;
height: 80%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
width: auto;
flex-shrink: 0;
overflow: hidden;
-webkit-app-region: no-drag;
}
.logo-slot > * { padding: 0;
width: 100%; border: none;
height: 100%; background: transparent;
overflow: hidden;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: var(--radius-pill);
} }
.tabstrip { .tabstrip {
display: flex; display: flex;
align-items: center;
gap: 2px;
height: var(--header-height); height: var(--header-height);
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
min-width: 0;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-hairline) transparent; scrollbar-color: var(--border-hairline) transparent;
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-inline: var(--space-3); scroll-padding-inline: var(--space-3);
scroll-snap-type: x proximity;
align-items: center;
gap: var(--space-2);
container-type: inline-size;
container-name: tabstrip;
-webkit-mask-image: linear-gradient(to right, transparent, black 0);
mask-image: linear-gradient(to right, transparent, black 0);
} }
.tabstrip::-webkit-scrollbar {
height: 4px;
}
.tabstrip::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.tabstrip-actions {
display: flex;
align-items: center;
justify-content: center;
height: var(--header-height);
flex: 0 0 auto;
gap: var(--space-2);
}
.icon-crown,
.icon-tab-add,
.icon-tab-add-empty {
aspect-ratio: 1/1;
height: 70%;
}
.tab-action {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
background: transparent;
border: var(--border-width-active) solid transparent;
}
.tab-action:hover {
background: var(--bg-raised);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.crown-action {
height: 50%;
aspect-ratio: 1;
border-radius: 50%;
color: #facc15;
width: auto;
}
.crown-action:hover {
color: #fde047;
}
.tab-add {
height: 50%;
border-radius: 50%;
color: var(--text-muted);
aspect-ratio: 1;
}
.tab-add-empty {
height: 50%;
border-radius: var(--radius-pill);
border: var(--border-width-active) dashed var(--border-hairline);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--tab-add-empty-font);
font-weight: 500;
flex-shrink: 0;
gap: var(--space-1);
padding-inline: var(--space-1);
}
.tab-add-empty:hover {
border-color: var(--accent-brand);
border-style: solid;
}
/* Tab */
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
height: var(--tab-height); height: var(--tab-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: 1px solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--tab-font-size); font-size: var(--tab-font-size);
cursor: pointer; cursor: pointer;
transition: box-sizing: border-box;
background var(--dur-base) var(--ease-standard), scroll-snap-align: start;
border-color var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard); flex: 0 0 25%;
width: var(--tab-width); max-width: 25%;
min-width: calc(
(var(--space-3) * 2) +
(var(--tab-height) * 0.5) +
(var(--space-2) * 2) +
4ch +
var(--tab-close-size)
);
}
.tab span[title] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tab-led {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto; flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
} }
.tab-close { .tab-close {
@ -75,74 +193,59 @@
font-size: var(--tab-close-font); font-size: var(--tab-close-font);
line-height: 1; line-height: 1;
color: var(--text-muted); color: var(--text-muted);
border-radius: 4px; border-radius: var(--radius-sm);
transition:
background var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard);
} }
.tab-add { .tab:hover {
display: flex; background: var(--bg-raised);
align-items: center; border-color: var(--text-muted);
justify-content: center; color: var(--text-primary);
width: var(--tab-add-size);
height: var(--tab-add-size);
margin-left: 6px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
} }
.tab-add-empty { .tab--active {
display: flex; background: var(--bg-panel);
align-items: center; border-color: var(--accent-brand);
justify-content: center; color: var(--text-primary);
line-height: 1;
gap: 6px;
height: var(--tab-add-empty-height);
padding: 0 var(--space-3) 0 var(--space-3);
border: 1px dashed var(--border-hairline);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--tab-add-empty-font);
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition:
background var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
} }
.led { .tab--dimmed {
width: var(--led-size); opacity: 0.55;
height: var(--led-size);
border-radius: 50%;
flex-shrink: 0;
background: var(--accent-down);
transition: background var(--dur-base) var(--ease-standard);
} }
.led--lg { .tab-close:hover {
width: var(--led-size-lg); background: var(--bg-panel);
height: var(--led-size-lg); color: var(--text-primary);
} }
.icon-tab-add, .tab-add:active {
.icon-tab-add-empty { background: var(--border-hairline);
width: var(--tab-add-icon-size);
height: var(--tab-add-icon-size);
} }
.icon-crown { .tabstrip::-webkit-scrollbar {
width: var(--crown-icon-size); display: none;
height: var(--crown-icon-size); }
.tabstrip[data-fade-start='true'] {
-webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
}
.tabstrip[data-fade-end='true'] {
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
}
.tabstrip[data-fade-start='true'][data-fade-end='true'] {
-webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
}
.tab[data-step='6'],
.tab[data-step='8'],
.tab[data-step='10'] {
min-width: calc(
(var(--space-3) * 2) +
(var(--tab-height) * 0.5) +
(var(--space-2) * 2) +
4ch +
var(--tab-close-size)
);
} }

View File

@ -3,31 +3,37 @@ import { DSP, AppPage } from '../../types/types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo'; import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon'; import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon'; import { CrownIcon } from '../../assets/icons/CrownIcon';
import { Led } from '../Led/Led';
import './Topbar.css'; import './Topbar.css';
function getStep(count: number) {
if (count <= 4) return 4;
if (count <= 6) return 6;
if (count <= 8) return 8;
return 10;
}
function Tab({ function Tab({
dsp, dsp, active, dimmed, step, onSelect, onRemove,
active,
dimmed,
onSelect,
onRemove,
}: { }: {
dsp: DSP; dsp: DSP;
active: boolean; active: boolean;
dimmed: boolean; dimmed: boolean;
step: number;
onSelect: () => void; onSelect: () => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
return ( return (
<button <button
className={'tab' + (active ? ' tab--active' : '') + (dimmed ? ' tab--dimmed' : '')} className={'tab' + (active ? ' tab--active' : '') + (dimmed ? ' tab--dimmed' : '')}
data-step={step}
onClick={onSelect} onClick={onSelect}
> >
<span className={'led led--' + dsp.status} /> <span className="tab-led">
<span className="tab-name" title={dsp.name}> <Led status={dsp.status} />
{dsp.name}
</span> </span>
<span title={dsp.name}>{dsp.name}</span>
<span <span
className="tab-close" className="tab-close"
role="button" role="button"
@ -65,6 +71,40 @@ function TopBar({
const hasTabs = dsps.length > 0; const hasTabs = dsps.length > 0;
const tabstripRef = useRef<HTMLDivElement>(null); const tabstripRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(dsps.length); const prevCount = useRef(dsps.length);
const step = getStep(dsps.length);
const updateFade = () => {
const el = tabstripRef.current;
if (!el) return;
const atStart = el.scrollLeft <= 1;
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
el.dataset.fadeStart = String(!atStart);
el.dataset.fadeEnd = String(!atEnd && el.scrollWidth > el.clientWidth);
};
useEffect(() => {
const el = tabstripRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
el.scrollLeft += e.deltaY;
e.preventDefault();
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
useEffect(() => {
updateFade();
const el = tabstripRef.current;
el?.addEventListener('scroll', updateFade);
window.addEventListener('resize', updateFade);
return () => {
el?.removeEventListener('scroll', updateFade);
window.removeEventListener('resize', updateFade);
};
}, [dsps.length]);
useEffect(() => { useEffect(() => {
if (dsps.length > prevCount.current && tabstripRef.current) { if (dsps.length > prevCount.current && tabstripRef.current) {
@ -78,7 +118,7 @@ function TopBar({
return ( return (
<header className="topbar"> <header className="topbar">
<div <button
className="logo-slot" className="logo-slot"
onClick={() => { onClick={() => {
setSelect(null); setSelect(null);
@ -87,13 +127,14 @@ function TopBar({
aria-label="Go to home" aria-label="Go to home"
> >
<AnimatedLogo /> <AnimatedLogo />
</div> </button>
<div className="tabstrip" ref={tabstripRef}> <div className="tabstrip" ref={tabstripRef}>
{dsps.map((dsp) => ( {dsps.map((dsp) => (
<Tab <Tab
key={dsp.id} key={dsp.id}
dsp={dsp} dsp={dsp}
step={step}
active={selected === dsp.id && !(page == 'add-dsp')} active={selected === dsp.id && !(page == 'add-dsp')}
dimmed={page === 'add-dsp'} dimmed={page === 'add-dsp'}
onSelect={() => onSelect(dsp.id)} onSelect={() => onSelect(dsp.id)}
@ -104,19 +145,18 @@ function TopBar({
<div className="tabstrip-actions"> <div className="tabstrip-actions">
{hasTabs ? ( {hasTabs ? (
<button className="tab-add" aria-label="Add DSP" onClick={onAddClick}> <button className="tab-add tab-action" aria-label="Add DSP" onClick={onAddClick}>
<PlusIcon className="icon-tab-add" /> <PlusIcon className="icon-tab-add" />
</button> </button>
) : ( ) : (
<button className="tab-add-empty" onClick={onAddClick}> <button className="tab-add-empty tab-action" onClick={onAddClick}>
<PlusIcon className="icon-tab-add-empty" /> <PlusIcon className="icon-tab-add-empty" />
<span>Add DSP</span> <span>Add DSP</span>
</button> </button>
)} )}
</div>
<button <button
className="crown-action" className="crown-action tab-action"
title="Credits & Support" title="Credits & Support"
aria-label="Credits & Support" aria-label="Credits & Support"
onClick={() => { onClick={() => {
@ -126,6 +166,7 @@ function TopBar({
> >
<CrownIcon className="icon-crown" /> <CrownIcon className="icon-crown" />
</button> </button>
</div>
</header> </header>
); );
} }

View File

View File

@ -1,4 +1,4 @@
import Button from '../components/Button/Button'; import Button from '../../components/Button/Button';
function HomePage({ function HomePage({
dspCount, dspCount,

View File

@ -49,270 +49,12 @@ button:focus-visible,
box-shadow: inset 0 0 0 1px var(--accent-brand); box-shadow: inset 0 0 0 1px var(--accent-brand);
} }
/* ---------- Top bar ---------- */ button {
.topbar {
display: flex;
align-items: center;
gap: var(--space-5);
padding: 0 var(--space-4);
height: 48px;
background: var(--bg-void);
border-bottom: 1px solid var(--border-hairline);
-webkit-app-region: drag;
}
.logo-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
aspect-ratio: 1 / 1;
width: auto;
flex-shrink: 0;
overflow: hidden;
-webkit-app-region: no-drag;
}
.logo-slot > * {
width: 100%;
height: 100%;
}
.tabstrip {
display: flex;
align-items: center;
gap: 2px;
height: 48px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-app-region: no-drag;
scrollbar-width: thin;
scrollbar-color: var(--border-hairline) transparent;
scroll-behavior: smooth;
scroll-padding-inline: var(--space-3);
}
.crown-action {
color: #facc15;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.tabstrip::-webkit-scrollbar {
height: 5px;
}
.tabstrip::-webkit-scrollbar-track {
background: transparent;
}
.tabstrip::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.tabstrip::-webkit-scrollbar-thumb:hover {
background: var(--accent-brand);
}
.tab {
display: flex;
align-items: center;
gap: var(--space-2);
height: 34px;
padding: 0 var(--space-3);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: 13px;
cursor: pointer;
transition: transition:
background var(--dur-base) var(--ease-standard), background var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard), border-color var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard); color var(--dur-base) var(--ease-standard),
width: 160px; transform var(--dur-base) var(--ease-standard);
flex: 0 0 auto;
}
.tab:hover {
background: var(--bg-raised);
border-color: var(--text-muted);
color: var(--text-primary);
}
.tab--active {
background: var(--bg-panel);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.tab--active:hover {
background: var(--bg-panel);
border-color: var(--accent-brand);
}
.tab--dimmed {
opacity: 0.55;
}
.tab-name {
overflow: hidden;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
line-height: 1;
display: flex;
align-items: center;
height: 100%;
mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%);
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin-left: auto;
width: 18px;
height: 18px;
padding: 0;
font-size: 15px;
line-height: 1;
color: var(--text-muted);
border-radius: 4px;
transition:
background var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard);
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
/* Quiet icon button — used once at least one tab exists */
.tab-add {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-left: 6px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
}
/* Lives outside .tabstrip so it never scrolls out of view */
.tabstrip-actions {
display: flex;
align-items: center;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.tab-add:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
.tab-add:active {
background: var(--border-hairline);
}
/* Clearer CTA — used when the bar has nothing else to anchor to */
.tab-add-empty {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
gap: 6px;
height: 30px;
padding: 0 var(--space-3) 0 10px;
border: 1px dashed var(--border-hairline);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition:
background var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
}
.tab-add-empty svg,
.tab-add svg {
display: block;
flex-shrink: 0;
}
.tab-add-empty:hover {
background: var(--bg-raised);
border-color: var(--accent-brand);
border-style: solid;
color: var(--text-primary);
}
/* ---------- LED status dot ---------- */
.led {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent-down);
transition: background var(--dur-base) var(--ease-standard);
}
.led--connected {
background: var(--accent-live);
box-shadow: 0 0 6px 1px var(--accent-live);
}
.led--connecting {
background: var(--accent-connecting);
box-shadow: 0 0 6px 1px var(--accent-connecting);
animation: pulse 1s var(--ease-standard) infinite;
}
.led--disconnected {
background: var(--accent-down);
box-shadow: 0 0 4px 0px var(--accent-down);
}
.led--lg {
width: 12px;
height: 12px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
} }
/* ---------- Stage / main area ---------- */ /* ---------- Stage / main area ---------- */

View File

@ -22,13 +22,10 @@
--tab-close-size: 1.8vh; --tab-close-size: 1.8vh;
--tab-close-font: 1.5vh; --tab-close-font: 1.5vh;
--tab-add-size: 2.6vh; /* --tab-add-size: 2.6vh; */
--tab-add-empty-height: 3vh; --tab-add-empty-height: 3vh;
--tab-add-empty-font: 1.2vh; --tab-add-empty-font: 1.2vh;
--led-size: 0.8vh;
--led-size-lg: 1.2vh;
--crown-icon-size: 2.2vh; --crown-icon-size: 2.2vh;
--tab-add-icon-size: 1.6vh; --tab-add-icon-size: 1.6vh;
--logo-size: 18vh; --logo-size: 18vh;
@ -68,7 +65,7 @@
/* ---------- Radius ---------- */ /* ---------- Radius ---------- */
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 1vh;
--radius-pill: 999px; --radius-pill: 999px;
/* ---------- Shadow ---------- */ /* ---------- Shadow ---------- */
@ -76,10 +73,21 @@
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25);
/* ---------- Border ---------- */ /* ---------- Border ---------- */
--border-width: 1px; --border-width: 0.07vh;
--border-width-active: 2px; --border-width-active: 0.1vh;
/* ---------- Animation ---------- */ /* ---------- Animation ---------- */
--transition-fast: 120ms; --transition-fast: 120ms;
--transition-base: 180ms; --transition-base: 180ms;
--font-xs: 1.2vh;
--font-sm: 1.4vh;
--font-md: 1.6vh;
--font-lg: 1.9vh;
--font-xl: 2.2vh;
--font-button: 1.5vh;
--font-label: 1.4vh;
--font-title: 1.8vh;
--font-value: 1.6vh;
} }