27c71978d2
Add the `design/` directory containing the design handoff document and
HTML/React reference prototypes for the planned Steam-inspired redesign
of the launcher UI.
Contents:
- `design/README.md` — handoff spec. Defines screens (main library,
game detail overlay, in-app Settings dialog), the game card anatomy,
interaction behavior, transitions, state shape, design tokens
(colors, typography, spacing, shadows) and out-of-scope items.
Selects layout variant A (single-row top bar) as the primary
direction. High-fidelity: colors / typography / spacing / animations
are decided, pixel-fidelity to the mock is the goal.
- `design/design_reference/` — Babel-in-browser React prototypes built
to communicate intended look and behavior. Includes:
* `SoftLAN Launcher.html` — entry that wires React + Babel and
mounts the design canvas with all variants side-by-side.
* `styles.css` — full visual spec as CSS custom properties + named
component classes (`.topbar`, `.seg`, `.card`, `.modal`, etc.).
* `data.jsx` — mock game catalog plus filter/sort helpers and a
mock STORAGE record used by the storage meter.
* `components.jsx` — reusable building blocks (Icon set, GameCover
placeholder generator, StateChip, ActionButton, GameCard,
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
SettingsDialog).
* `launcher.jsx` — composes top bar + grid + modals into a complete
launcher screen, in both `single`-row and `two`-row chrome
variants.
These files are reference material, not production code. They are not
imported by the Vite/Tauri build and ship outside the frontend crate
(`crates/lanspread-tauri-deno-ts/`). They are committed so the design
intent is reviewable in-repo and surviving across implementations.
The accompanying production implementation against this spec is in
follow-up commits.
Trailer
-------
Refs: design/README.md (canonical handoff)
473 lines
24 KiB
React
473 lines
24 KiB
React
// 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
|
||
// ────────────────────────────────────────────────────────────────────
|
||
function ActionButton({ state, accent, size = 'md', onClick, full = false }) {
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// 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/>
|
||
</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 (A–Z)' },
|
||
{ 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];
|
||
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.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>
|
||
)}
|
||
{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, GameCard,
|
||
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
|
||
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
|
||
SettingsDialog,
|
||
});
|