Files
lanspread/design/design_reference/components.jsx
T
ddidderr 91c709960a docs: update launcher design for profile and server actions
Document the profile settings added to the launcher design and the new
Start Server detail action. The settings contract now includes a persisted
username and language choice, and the game detail overlay shows Start Server
only for installed games that can host a dedicated server.

The reference mock now includes the matching Profile controls, a server icon,
server-capable sample catalog entries, and the updated detail/settings
artboards so implementation can follow the selected design direction.

Test Plan:
- git diff --cached --check

Refs: design/README.md
2026-05-21 21:32:27 +02:00

611 lines
31 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// components.jsx — UI building blocks for the SoftLAN launcher
// Loaded after data.jsx; relies on GAMES/STATE_META/ACTION_FOR_STATE/etc. on window.
const { useState, useMemo, useRef, useEffect } = React;
// ────────────────────────────────────────────────────────────────────
// Iconography (tiny inline SVGs; no emoji)
// ────────────────────────────────────────────────────────────────────
const Icon = {
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
kebab: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}><circle cx="8" cy="3.2" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="8" cy="12.8" r="1.4"/></svg>,
sort: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4h10"/><path d="M4.5 8h7"/><path d="M6 12h4"/></svg>,
users: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="6" cy="6" r="2.4"/><path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13"/><circle cx="11.2" cy="5.4" r="1.8"/><path d="M10.4 9.8c1.7 0 3 1 3.6 2.6"/></svg>,
close: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...p}><path d="m4 4 8 8M12 4l-8 8"/></svg>,
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
};
// ────────────────────────────────────────────────────────────────────
// Cover art — stylized box-art placeholder
// ────────────────────────────────────────────────────────────────────
function GameCover({ game, aspect = 'box', size = 'normal' }) {
const { c1, c2, accent } = game.cover;
// Pick title sizing — shrink for longer names; line-clamp:2 handles wrap
const title = game.title;
const len = title.length;
const fontPx = aspect === 'banner' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: aspect === 'square' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: (len > 26 ? 15 : len > 20 ? 17 : len > 14 ? 21 : 26);
// Stable but varied per-game accent shape (id hash → angle / size)
const h = [...game.id].reduce((a, c) => a + c.charCodeAt(0), 0);
const angle = 110 + (h % 60); // 110-170
const blobX = 60 + (h % 30);
const blobY = 10 + ((h * 7) % 30);
return (
<div className="cover" data-aspect={aspect}>
{/* base gradient */}
<div className="cover-base" style={{
background: `linear-gradient(${angle}deg, ${c1} 0%, ${c2} 100%)`,
}}/>
{/* radial accent blob */}
<div className="cover-blob" style={{
background: `radial-gradient(ellipse at ${blobX}% ${blobY}%, ${accent}38, transparent 55%)`,
}}/>
{/* scanline / grain */}
<div className="cover-grain"/>
{/* faint geometric mark */}
<svg className="cover-mark" viewBox="0 0 100 100" preserveAspectRatio="xMaxYMax slice" aria-hidden="true">
<path d={`M ${100 - (h%30)} ${100 - (h%20)} L 100 ${60 + (h%25)} L 100 100 Z`}
fill={accent} fillOpacity="0.12"/>
<circle cx={(h*3)%100} cy={(h*5)%100} r="0.6" fill={accent} fillOpacity="0.4"/>
</svg>
{/* title */}
<div className="cover-titlewrap">
<div className="cover-title" style={{ fontSize: fontPx, textShadow: `0 4px 16px ${c2}aa, 0 1px 0 rgba(0,0,0,.3)` }}>
{title}
</div>
</div>
{/* bottom darkening */}
<div className="cover-vignette"/>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// State chip (corner of cover)
// ────────────────────────────────────────────────────────────────────
function StateChip({ state }) {
const meta = STATE_META[state];
if (!meta || !meta.label) return null;
return (
<div className="state-chip" data-state={state}>
<span className="state-dot" style={{ background: meta.dot }}/>
{meta.label}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Action button — Play / Install / Download / [Downloading progress]
// ────────────────────────────────────────────────────────────────────
function ActionButton({ state, accent, size = 'md', onClick, full = false, game }) {
if (state === 'downloading' && game) {
return <DownloadProgress game={game} accent={accent} size={size} full={full}/>;
}
const action = ACTION_FOR_STATE[state];
const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`;
const icon = action.kind === 'play' ? <Icon.play/>
: action.kind === 'install' ? <Icon.install/>
: <Icon.download/>;
return (
<button className={cls} onClick={(e) => { e.stopPropagation(); onClick && onClick(); }}
style={action.kind === 'install' ? { background: accent } : undefined}>
{icon}<span>{action.label}</span>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Live download progress — animates from initial progress upward,
// jitters speed slightly. Two layouts:
// md → card tile. Container-query: shows %+speed, falls back to % only.
// lg → detail overlay. Two-line stats + percentage on the right.
// ────────────────────────────────────────────────────────────────────
function useLiveDownload(game) {
const [p, setP] = useState(game.progress ?? 0.1);
const [speed, setSpeed] = useState(game.speed ?? 30);
useEffect(() => {
const id = setInterval(() => {
setP(prev => {
// step proportional to current speed so smaller games finish faster
const stepGb = (speed / 1024) * 0.6; // 600ms tick
const next = prev + stepGb / game.size;
return next >= 0.985 ? (game.progress ?? 0.1) : next;
});
setSpeed(prev => {
const target = (game.speed ?? 30);
const drift = (Math.random() - 0.5) * 6;
return Math.max(2, target * 0.85 + prev * 0.15 + drift);
});
}, 600);
return () => clearInterval(id);
}, [game.id, game.size, game.speed, game.progress]);
return { progress: p, speed };
}
function DownloadProgress({ game, accent, size = 'md', full = false }) {
const { progress, speed } = useLiveDownload(game);
const pct = Math.min(99, Math.round(progress * 100));
const downloadedGb = game.size * progress;
const remainingGb = Math.max(0, game.size - downloadedGb);
const etaSec = remainingGb * 1024 / Math.max(speed, 0.1);
const isLg = size === 'lg';
const onCancel = (e) => { e.stopPropagation(); /* mock */ };
if (isLg) {
return (
<div className={`dl dl-lg ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden="true"/>
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{fmtBytes(downloadedGb)}</strong>
<span className="dl-of"> / {fmtBytes(game.size)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{fmtSpeed(speed)}</span>
{game.peers > 0 && (
<React.Fragment>
<span className="dl-sep dl-sep-peers">·</span>
<span className="dl-peers" title={`Downloading from ${game.peers} ${game.peers === 1 ? 'peer' : 'peers'} on the LAN`}>
<Icon.users/>
<span>{game.peers}</span>
</span>
</React.Fragment>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{fmtEta(etaSec)} left</span>
</div>
<div className="dl-lg-pct">{pct}<span className="dl-pct-sym">%</span></div>
<button className="dl-cancel" onClick={onCancel} aria-label="Cancel download">
<Icon.close/>
</button>
</div>
</div>
);
}
return (
<div className={`dl dl-md ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}
title={`${pct}% · ${fmtSpeed(speed)} · ${fmtEta(etaSec)} left`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden="true"/>
{pct}<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{fmtSpeedShort(speed)}</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Game card
// ────────────────────────────────────────────────────────────────────
function GameCard({ game, accent, aspect, onOpen }) {
return (
<article className="card" onClick={() => onOpen && onOpen(game)} tabIndex={0}>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect}/>
<StateChip state={game.state}/>
<div className="card-mp" title={`${game.players} players`}>
<Icon.users/><span>{game.players}</span>
</div>
</div>
<div className="card-body">
<div className="card-title" title={game.title}>{game.title}</div>
<div className="card-meta">
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
</article>
);
}
// ────────────────────────────────────────────────────────────────────
// Filter controls
// ────────────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'All Games' },
{ key: 'local', label: 'Local' },
{ key: 'installed', label: 'Installed' },
];
function SegmentedFilters({ value, onChange, counts, accent }) {
const ref = useRef(null);
const [thumb, setThumb] = useState({ left: 0, width: 0 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current.querySelector(`[data-key="${value}"]`);
if (el) setThumb({ left: el.offsetLeft, width: el.offsetWidth });
}, [value]);
return (
<div className="seg" ref={ref}>
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width, background: accent }}/>
{FILTER_TABS.map(t => (
<button key={t.key} data-key={t.key} className={`seg-btn ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}>
<span>{t.label}</span>
<span className="seg-count">{counts[t.key]}</span>
</button>
))}
</div>
);
}
function UnderlineFilters({ value, onChange, counts, accent }) {
return (
<div className="utabs">
{FILTER_TABS.map(t => (
<button key={t.key} className={`utab ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}
style={value === t.key ? { '--accent': accent } : undefined}>
<span className="utab-label">{t.label}</span>
<span className="utab-count">{counts[t.key]}</span>
<span className="utab-underline" style={{ background: accent }}/>
</button>
))}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Search input
// ────────────────────────────────────────────────────────────────────
function SearchField({ value, onChange, accent, wide = false }) {
return (
<div className={`search ${wide ? 'search-wide' : ''}`} style={{ '--accent': accent }}>
<Icon.search/>
<input type="text" placeholder="Search games" value={value}
onChange={(e) => onChange(e.target.value)}/>
<span className="search-kbd">/</span>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Sort menu (simple dropdown)
// ────────────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'az', label: 'Name (AZ)' },
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
];
function SortMenu({ value, onChange, accent }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
const current = SORTS.find(s => s.key === value) || SORTS[0];
return (
<div className="sort" ref={ref}>
<button className="sort-btn" onClick={() => setOpen(o => !o)}>
<Icon.sort/>
<span>Sort: <strong>{current.label}</strong></span>
<Icon.chevron/>
</button>
{open && (
<div className="sort-menu">
{SORTS.map(s => (
<button key={s.key} onClick={() => { onChange(s.key); setOpen(false); }}
className={s.key === value ? 'is-active' : ''}
style={s.key === value ? { color: accent } : undefined}>
<span className="sort-check">{s.key === value ? <Icon.check/> : null}</span>
{s.label}
</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Storage meter
// ────────────────────────────────────────────────────────────────────
function StorageMeter({ accent, compact = false }) {
const { installed, local, total } = STORAGE;
const pctI = (installed / total) * 100;
const pctL = (local / total) * 100;
return (
<div className={`storage ${compact ? 'storage-compact' : ''}`}>
<div className="storage-bar">
<div className="storage-i" style={{ width: `${pctI}%`, background: accent }}/>
<div className="storage-l" style={{ width: `${pctL}%`, background: `${accent}55` }}/>
</div>
<div className="storage-text">
<span><span className="storage-sq" style={{ background: accent }}/>{installed.toFixed(0)} GB installed</span>
<span><span className="storage-sq" style={{ background: `${accent}55` }}/>{local.toFixed(0)} GB local</span>
<span className="storage-free">{STORAGE.free.toFixed(0)} GB free</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Directory button (shows path)
// ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) {
const short = path.length > 36 ? '…' + path.slice(-34) : path;
return (
<button className="dirbtn" title={path}>
<Icon.folder/>
<span className="dirbtn-label">Game directory</span>
<span className="dirbtn-path">{short}</span>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Menu (kebab)
// ────────────────────────────────────────────────────────────────────
function KebabMenu({ items }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
return (
<div className="kebab" ref={ref}>
<button className="kebab-btn" onClick={() => setOpen(o => !o)} aria-label="More"><Icon.kebab/></button>
{open && (
<div className="kebab-menu">
{items.map((it, i) => it === '-' ? <div key={i} className="kebab-sep"/> : (
<button key={i} onClick={() => { setOpen(false); it.onClick && it.onClick(); }}>{it.label}</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Detail Modal
// ────────────────────────────────────────────────────────────────────
function GameDetailModal({ game, accent, onClose }) {
if (!game) return null;
const action = ACTION_FOR_STATE[game.state];
const showCancel = game.state === 'downloading';
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
<div className="modal-hero">
<GameCover game={game} aspect="banner"/>
<div className="modal-hero-fade"/>
<div className="modal-hero-text">
<div className="modal-tags">
{game.tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
</div>
<h2 className="modal-title">{game.title}</h2>
</div>
<div className="modal-state">
<StateChip state={game.state}/>
</div>
</div>
<div className="modal-body">
<div className="modal-meta">
<div className="meta-cell">
<div className="meta-label">Size</div>
<div className="meta-value">{fmtSize(game.size)}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Players</div>
<div className="meta-value"><Icon.users/> {game.players}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">{game.version}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{STATE_META[game.state].label || 'Not downloaded'}</div>
</div>
</div>
<p className="modal-desc">{game.desc}</p>
<div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
{game.canHostServer && game.state === 'installed' && (
<button className="act-btn act-lg act-server"
style={{ '--accent': accent }}
onClick={(e) => e.stopPropagation()}>
<Icon.server/><span>Start Server</span>
</button>
)}
{game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
)}
{game.state === 'local' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Delete from disk</span></button>
)}
{showCancel && (
<button className="ghost-btn ghost-danger"><Icon.close/><span>Cancel</span></button>
)}
{game.state !== 'none' && <div className="modal-actions-spacer"/>}
<button className="ghost-btn">View files</button>
</div>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Settings Dialog — in-app version of the Tweaks panel
// ────────────────────────────────────────────────────────────────────
const SETTING_OPTIONS = {
accent: [
{ value: '#3b82f6', label: 'Blue' },
{ value: '#22d3ee', label: 'Cyan' },
{ value: '#a855f7', label: 'Violet' },
{ value: '#22c55e', label: 'Green' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#ef4444', label: 'Red' },
],
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
};
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
return (
<div className="settings-text" style={{ '--accent': accent }}>
<input type="text"
value={value || ''}
placeholder={placeholder}
maxLength={maxLength}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}/>
</div>
);
}
function SettingsRow({ label, hint, children }) {
return (
<div className="settings-row">
<div className="settings-row-info">
<div className="settings-row-label">{label}</div>
{hint && <div className="settings-row-hint">{hint}</div>}
</div>
<div className="settings-row-control">{children}</div>
</div>
);
}
function SegmentedRadio({ value, options, onChange, accent }) {
return (
<div className="srad">
{options.map(o => (
<button key={o.value}
className={`srad-btn ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={value === o.value ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
))}
</div>
);
}
function ColorSwatchPicker({ value, options, onChange }) {
return (
<div className="swatch-row">
{options.map(o => (
<button key={o.value}
className={`swatch ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={{ color: o.value }}
title={o.label}
aria-label={o.label}>
<span className="swatch-dot" style={{ background: o.value }}/>
{value === o.value && <span className="swatch-check"><Icon.check/></span>}
</button>
))}
</div>
);
}
function SettingsDialog({ settings, onChange, onClose }) {
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-head">
<h2>Settings</h2>
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
</div>
<div className="settings-body">
<div className="settings-section">
<div className="settings-section-title">Profile</div>
<SettingsRow label="Username" hint="Shown to other players on the LAN">
<SettingsTextInput value={settings.username}
placeholder="Enter a username"
onChange={(v) => onChange('username', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Language" hint="Interface language">
<SegmentedRadio value={settings.language || 'en'}
options={SETTING_OPTIONS.language}
onChange={(v) => onChange('language', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Appearance</div>
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
<ColorSwatchPicker value={settings.accent}
options={SETTING_OPTIONS.accent}
onChange={(v) => onChange('accent', v)}/>
</SettingsRow>
<SettingsRow label="Background" hint="Backdrop behind the library">
<SegmentedRadio value={settings.bg}
options={SETTING_OPTIONS.bg}
onChange={(v) => onChange('bg', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Library</div>
<SettingsRow label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio value={settings.density}
options={SETTING_OPTIONS.density}
onChange={(v) => onChange('density', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Cover aspect" hint="Shape of the cover art on each card">
<SegmentedRadio value={settings.aspect}
options={SETTING_OPTIONS.aspect}
onChange={(v) => onChange('aspect', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
</div>
<div className="settings-foot">
<button className="ghost-btn settings-done" onClick={onClose}
style={{ background: settings.accent, borderColor: settings.accent, color: 'white' }}>
Done
</button>
</div>
</div>
</div>
);
}
Object.assign(window, {
Icon, GameCover, StateChip, ActionButton, DownloadProgress, GameCard,
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
SettingsDialog,
});