Files
lanspread/design/design_reference/components.jsx
T
ddidderr b96e8c5747 docs(design): trim peer count chip to icon + bare number
The download progress "peers" chip previously rendered as
`[icon] from N peers` — a full mini-sentence sitting between the speed
and ETA stats. With four groups already separated by middots, the extra
preposition and unit made the row read as prose rather than a stat
strip, and the singular/plural switch added a second source of layout
jitter on top of the digit-width change.

Update the design reference (README, components.jsx, styles.css) so the
chip shows just the users glyph and the count, matching the visual
weight of the other stat groups. The full sentence
("Downloading from N peers on the LAN") moves to the `title` tooltip,
which keeps the affordance discoverable without spending row width on
it. The count adopts `var(--t-1)` + 600 + tabular-nums directly on
`.dl-peers` (no inner `<strong>` needed), so the chip is a single span.

Also tighten the container-query breakpoints. Removing the prose makes
the chip much narrower, so the previous 300px cutoff for hiding peers
and 380px cutoff for hiding ETA were over-eager — both stats now fit
comfortably in narrower modals. Drop them to 240px (peers) and 320px
(ETA). The pct/cancel column still never collapses.

Test Plan
- Visual review of the design reference HTML at component widths
  240px / 320px / 380px to confirm peers and ETA drop at the new
  thresholds rather than the old ones.
- Confirm the chip's tooltip still spells out the full sentence.
2026-05-21 00:40:54 +02:00

574 lines
29 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>,
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.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' }],
};
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">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,
});