docs(design): add SoftLAN launcher redesign handoff and references

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)
This commit is contained in:
2026-05-19 19:59:36 +02:00
parent ff35f0d95f
commit 27c71978d2
6 changed files with 2199 additions and 0 deletions
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SoftLAN Launcher — Redesign</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
<link rel="stylesheet" href="styles.css">
<style>
html, body { margin: 0; padding: 0; background: #f0eee9; height: 100%; }
/* Tighten canvas chrome a hair */
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
#root { width: 100%; height: 100%; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="launcher.jsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6",
"density": "normal",
"aspect": "square",
"bg": "gradient"
}/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'ra3'); // installed → modal shows Play + Uninstall
return (
<React.Fragment>
<DesignCanvas>
<DCSection id="chrome" title="Chrome variations"
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"/>
</DCArtboard>
<DCArtboard id="two-row" label="B · Two-row + underlined tabs" width={1340} height={840}>
<Launcher variant="two" tweaks={t} setTweak={setTweak}
initialFilter="installed" initialSort="az"/>
</DCArtboard>
</DCSection>
<DCSection id="detail" title="Game detail overlay"
subtitle="Opens when you click a card. Full description, metadata, primary action + secondary actions (incl. uninstall).">
<DCArtboard id="detail-modal" label="C · Detail overlay (installed game)" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="installed" initialSort="az"
initialOpenGame={heroGame}/>
</DCArtboard>
<DCArtboard id="detail-modal-local" label="D · Detail overlay (downloaded, not installed)" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="local" initialSort="az"
initialOpenGame={GAMES.find(g => g.id === 'cod4mw')}/>
</DCArtboard>
</DCSection>
<DCSection id="settings" title="Settings dialog"
subtitle="Same controls as the dev Tweaks panel, surfaced as an in-app preferences dialog. Open via top-bar menu → Settings.">
<DCArtboard id="settings-open" label="E · Settings dialog (open)" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="all" initialSort="recent"
initialSettingsOpen={true}/>
</DCArtboard>
</DCSection>
</DesignCanvas>
<TweaksPanel>
<TweakSection label="Theme"/>
<TweakColor label="Accent" value={t.accent} options={ACCENTS}
onChange={(v) => setTweak('accent', v)}/>
<TweakRadio label="Background" value={t.bg}
options={['flat', 'gradient', 'animated']}
onChange={(v) => setTweak('bg', v)}/>
<TweakSection label="Grid"/>
<TweakRadio label="Density" value={t.density}
options={['compact', 'normal', 'large']}
onChange={(v) => setTweak('density', v)}/>
<TweakRadio label="Cover aspect" value={t.aspect}
options={['box', 'square', 'banner']}
onChange={(v) => setTweak('aspect', v)}/>
</TweaksPanel>
</React.Fragment>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
+472
View File
@@ -0,0 +1,472 @@
// 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 (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];
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,
});
+192
View File
@@ -0,0 +1,192 @@
// data.jsx — game catalog for the SoftLAN launcher mock
// Each game has: id, title, size (GB), version (date), description, state, players (min-max), tags, cover (color pair + optional accent shape)
// state: 'installed' | 'local' | 'none' (local = downloaded but not installed yet)
const GAMES = [
{
id: '8bitarmies', title: '8-Bit Armies', size: 1.9, version: '2016.10.24',
desc: "A fast-paced retro-styled RTS with bright voxel armies, three factions, and zero patience for slow players. Tank-rush, build queues, and a campaign that doesn't waste your time.",
state: 'installed', players: '18', tags: ['RTS', 'Multiplayer', 'LAN'],
cover: { c1: '#f59e0b', c2: '#b91c1c', accent: '#fde047', mood: 'arcade' },
},
{
id: 'aoe2hd', title: 'Age of Empires II (HD)', size: 8.6, version: '2018.01.31',
desc: "The HD remaster of the strategy classic. Lead one of thirteen civilizations from the dark ages through the imperial age, and finally settle who actually deserved that wonder.",
state: 'local', players: '18', tags: ['RTS', 'Historical', 'LAN'],
cover: { c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24', mood: 'gothic' },
},
{
id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01',
desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.",
state: 'none', players: '216', tags: ['FPS', 'Horror', 'Multiplayer'],
cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' },
},
{
id: 'amongus', title: 'Among Us', size: 0.3, version: '2021.11.05',
desc: "Crewmates fix the ship. Impostors sabotage it and vent through walls. Friendships are tested. The orange one is always sus.",
state: 'installed', players: '415', tags: ['Social Deduction', 'Casual'],
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' },
},
{
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30',
desc: "The original Battlefield. WWII on land, sea, and air across 16 maps. The mod scene basically reinvented PC gaming on top of this engine.",
state: 'installed', players: '264', tags: ['FPS', 'Vehicles', 'LAN'],
cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' },
},
{
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27',
desc: "Modern combat with commander mode, squads, and the kind of jet-vs-jet duels you tell stories about for a decade.",
state: 'local', players: '264', tags: ['FPS', 'Vehicles', 'Tactical'],
cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: 'tactical' },
},
{
id: 'blazerush', title: 'BlazeRush', size: 1.3, version: '2021.12.27',
desc: "Top-down arcade racing with no fuel, no health bar, and absolutely no brakes. Ram, boost, win.",
state: 'none', players: '18', tags: ['Racing', 'Arcade'],
cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' },
},
{
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22',
desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.",
state: 'installed', players: '232', tags: ['FPS', 'War'],
cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: 'war' },
},
{
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21',
desc: "The shooter that flipped the genre to modern combat and minted a generation of esports careers. All Ghillied Up still holds up.",
state: 'local', players: '232', tags: ['FPS', 'Modern'],
cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' },
},
{
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08',
desc: "Expansion to the original CoD. Battle of the Bulge, Sicily, Kursk. Adds tanks, B-17 sequences, and the flamethrower nobody asked for but everybody loved.",
state: 'none', players: '232', tags: ['FPS', 'Expansion'],
cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' },
},
{
id: 'ra3', title: 'C&C: Red Alert 3', size: 8.2, version: '2018.04.12',
desc: "Cold War alt-history RTS. Soviets, Allies, and the Empire of the Rising Sun — every cutscene live-action and absolutely deranged.",
state: 'installed', players: '16', tags: ['RTS', 'Co-op'],
cover: { c1: '#991b1b', c2: '#450a0a', accent: '#fde047', mood: 'propaganda' },
},
{
id: 'cncgen', title: 'C&C Generals: Zero Hour', size: 2.1, version: '2017.11.15',
desc: "Modern-warfare RTS expansion with generals challenges. USA, China, GLA — pick your asymmetry and rush something.",
state: 'local', players: '18', tags: ['RTS', 'Modern'],
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' },
},
{
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21',
desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive', 'LAN'],
cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' },
},
{
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23',
desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive'],
cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' },
},
{
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20',
desc: "Open-source arena FPS with in-game level editing. Fast, free, and one of those things every LAN party always had a copy of.",
state: 'none', players: '216', tags: ['FPS', 'Open Source'],
cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' },
},
{
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31',
desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.",
state: 'local', players: '216', tags: ['FPS', 'Horror'],
cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' },
},
{
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15',
desc: "Co-op zombie survival with the AI Director rewriting every campaign run. Bring four friends or four strangers; the chainsaw works the same.",
state: 'installed', players: '18', tags: ['Co-op', 'FPS', 'Horror'],
cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: 'horror' },
},
{
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01',
desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.",
state: 'installed', players: '2100', tags: ['Sandbox', 'Survival', 'LAN'],
cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' },
},
{
id: 'portal2', title: 'Portal 2', size: 11.0, version: '2014.01.01',
desc: "Puzzle-shooter sequel with a full co-op campaign — two players, two portals each, infinite ways to get GLaDOS to insult you.",
state: 'local', players: '12', tags: ['Puzzle', 'Co-op'],
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' },
},
{
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
state: 'none', players: '216', tags: ['FPS', 'Arena', 'LAN'],
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
},
{
id: 'starcraft', title: 'StarCraft: Brood War', size: 1.2, version: '2018.04.16',
desc: "Sci-fi RTS — Terran, Zerg, Protoss. Still played at the highest level decades later for a reason.",
state: 'installed', players: '18', tags: ['RTS', 'Sci-Fi'],
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' },
},
{
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18',
desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.",
state: 'local', players: '232', tags: ['FPS', 'Class-based'],
cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' },
},
{
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01',
desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.",
state: 'none', players: '232', tags: ['FPS', 'Arena'],
cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' },
},
{
id: 'warcraft3', title: 'Warcraft III: TFT', size: 1.3, version: '2018.03.25',
desc: "Hero-driven RTS whose custom-game scene birthed Dota, tower defense, and at least three other genres that ate the industry.",
state: 'installed', players: '112', tags: ['RTS', 'Fantasy'],
cover: { c1: '#a16207', c2: '#422006', accent: '#fbbf24', mood: 'fantasy' },
},
];
// Helpers
const fmtSize = (gb) => gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb.toFixed(1)} GB`;
const STATE_META = {
installed: { label: 'Installed', dot: '#22c55e' },
local: { label: 'Local', dot: '#f59e0b' },
none: { label: '', dot: 'transparent' },
};
const ACTION_FOR_STATE = {
installed: { label: 'Play', kind: 'play' },
local: { label: 'Install', kind: 'install' },
none: { label: 'Download', kind: 'download' },
};
const countByFilter = (games) => ({
all: games.length,
local: games.filter(g => g.state === 'installed' || g.state === 'local').length,
installed: games.filter(g => g.state === 'installed').length,
});
const filterGames = (games, key) => {
if (key === 'all') return games;
if (key === 'local') return games.filter(g => g.state === 'installed' || g.state === 'local');
if (key === 'installed') return games.filter(g => g.state === 'installed');
return games;
};
// Storage figures (mock)
const STORAGE = {
installed: 78.4, // GB
local: 41.2,
free: 384.1,
total: 512,
};
window.GAMES = GAMES;
window.STATE_META = STATE_META;
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
window.countByFilter = countByFilter;
window.filterGames = filterGames;
window.fmtSize = fmtSize;
window.STORAGE = STORAGE;
+112
View File
@@ -0,0 +1,112 @@
// launcher.jsx — composes top bar + grid into a complete launcher screen
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025';
function applyFilterAndSort(games, filter, sort, query) {
let g = filterGames(games, filter);
if (query.trim()) {
const q = query.toLowerCase();
g = g.filter(x => x.title.toLowerCase().includes(q) || x.tags.some(t => t.toLowerCase().includes(q)));
}
if (sort === 'az') g = [...g].sort((a, b) => a.title.localeCompare(b.title));
else if (sort === 'size') g = [...g].sort((a, b) => b.size - a.size);
else if (sort === 'state') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || a.title.localeCompare(b.title));
} else if (sort === 'recent') {
const order = { installed: 0, local: 1, none: 2 };
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
}
return g;
}
function Launcher({
variant,
tweaks, setTweak,
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
initialOpenGame = null,
initialSettingsOpen = false,
}) {
const { density, aspect, accent, bg } = tweaks;
const [filter, setFilter] = useState(initialFilter);
const [sort, setSort] = useState(initialSort);
const [query, setQuery] = useState(initialQuery);
const [openGame, setOpenGame] = useState(initialOpenGame);
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
const counts = useMemo(() => countByFilter(GAMES), []);
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
const menuItems = [
{ label: 'Settings', onClick: () => setSettingsOpen(true) },
{ label: 'Refresh library', onClick: () => {} },
'-',
{ label: 'Unpack logs', onClick: () => {} },
{ label: 'About SoftLAN', onClick: () => {} },
];
return (
<div className={`launcher launcher-${variant} bg-${bg} density-${density}`}
style={{ '--accent': accent }}>
{variant === 'single' ? (
<header className="topbar topbar-single">
<div className="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN</div>
</div>
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
<SortMenu value={sort} onChange={setSort} accent={accent}/>
<DirectoryButton path={DIR_PATH}/>
<KebabMenu items={menuItems}/>
</header>
) : (
<header className="topbar topbar-two">
<div className="topbar-row topbar-row1">
<div className="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
</div>
<DirectoryButton path={DIR_PATH}/>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<KebabMenu items={menuItems}/>
</div>
</div>
<div className="topbar-row topbar-row2">
<UnderlineFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
<div className="topbar-row2-right">
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
</div>
</header>
)}
<main className="grid-wrap">
{variant === 'single' && (
<div className="results-bar">
<div className="results-count">
Showing <strong>{list.length}</strong> of {counts.all} games
</div>
<StorageMeter accent={accent} compact/>
</div>
)}
<div className="grid">
{list.map(g => (
<GameCard key={g.id} game={g} accent={accent} aspect={aspect}
onOpen={(game) => setOpenGame(game)}/>
))}
</div>
</main>
{openGame && <GameDetailModal game={openGame} accent={accent} onClose={() => setOpenGame(null)}/>}
{settingsOpen && setTweak && (
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
)}
</div>
);
}
window.Launcher = Launcher;
+931
View File
@@ -0,0 +1,931 @@
/* SoftLAN Launcher — styles
Steam-like dark UI, blue accent (configurable). System sans for UI,
Bebas Neue for cover-art display type.
*/
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap');
:root {
--accent: #3b82f6;
--bg-0: #0a0e13;
--bg-1: #0f151c;
--bg-2: #131b25;
--bg-3: #1a2330;
--bg-4: #232f3e;
--bd-1: rgba(255,255,255,0.06);
--bd-2: rgba(255,255,255,0.10);
--bd-3: rgba(255,255,255,0.16);
--t-1: #e6edf3;
--t-2: #9aa6b4;
--t-3: #6b7785;
--t-4: #4a5663;
--ok: #22c55e;
--warn: #f59e0b;
--danger: #ef4444;
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", Inter, system-ui, sans-serif;
--font-display: "Bebas Neue", "Oswald", Impact, "Arial Narrow Bold", sans-serif;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
}
* { box-sizing: border-box; }
/* ─── Launcher root ─── */
.launcher {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-0);
color: var(--t-1);
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.4;
overflow: hidden;
position: relative;
isolation: isolate;
}
/* Background variants */
.bg-flat { background: var(--bg-0); }
.bg-gradient {
background:
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent) 0%, transparent 60%),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
}
.bg-animated {
background:
radial-gradient(ellipse 60% 40% at 20% 0%, color-mix(in srgb, var(--accent) 24%, transparent) 0%, transparent 55%),
radial-gradient(ellipse 55% 40% at 85% 8%, color-mix(in srgb, var(--accent) 16%, transparent) 0%, transparent 55%),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
background-size: 100% 100%;
animation: bgshift 18s ease-in-out infinite alternate;
}
@keyframes bgshift {
0% { background-position: 0% 0%, 0% 0%, 0% 0%; }
100% { background-position: 10% 4%, -6% 2%, 0% 0%; }
}
/* ─── Top bar — shared ─── */
.topbar {
position: relative;
z-index: 10;
background: rgba(10,14,19,0.65);
-webkit-backdrop-filter: blur(20px) saturate(140%);
backdrop-filter: blur(20px) saturate(140%);
border-bottom: 1px solid var(--bd-1);
}
/* Variant 1: single row */
.topbar-single {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 24px;
flex-wrap: nowrap;
}
/* Variant 2: two row */
.topbar-two .topbar-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
}
.topbar-two .topbar-row1 {
border-bottom: 1px solid var(--bd-1);
}
.topbar-two .topbar-row1-right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
.topbar-two .topbar-row2 { padding-top: 4px; padding-bottom: 4px; }
.topbar-two .topbar-row2-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
/* ─── Brand ─── */
.brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.brand-mark {
width: 28px; height: 28px;
border-radius: 7px;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 20px;
letter-spacing: 0.02em;
color: white;
box-shadow: 0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black), inset 0 1px 0 rgba(255,255,255,0.22);
}
.brand-name {
font-weight: 700;
font-size: 15px;
letter-spacing: -0.005em;
color: var(--t-1);
}
.brand-name-soft { color: var(--t-3); font-weight: 500; margin-left: 4px; }
/* ─── Segmented filters (variant 1) ─── */
.seg {
position: relative;
display: inline-flex;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 999px;
padding: 4px;
flex-shrink: 0;
}
.seg-thumb {
position: absolute;
top: 4px; bottom: 4px;
border-radius: 999px;
transition: left .22s cubic-bezier(.4,1.2,.5,1), width .22s cubic-bezier(.4,1.2,.5,1), background .15s;
box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, black);
}
.seg-btn {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
color: var(--t-2);
border: 0;
border-radius: 999px;
font: inherit;
font-weight: 600;
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
transition: color .15s;
}
.seg-btn:hover { color: var(--t-1); }
.seg-btn.is-active { color: white; }
.seg-count {
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: inherit;
font-variant-numeric: tabular-nums;
}
.seg-btn.is-active .seg-count { background: rgba(0,0,0,0.25); color: white; }
/* ─── Underline filters (variant 2) ─── */
.utabs {
display: flex;
align-items: stretch;
gap: 4px;
}
.utab {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px 12px;
background: transparent;
border: 0;
color: var(--t-2);
font: inherit;
font-weight: 600;
font-size: 13.5px;
cursor: pointer;
transition: color .15s;
}
.utab:hover { color: var(--t-1); }
.utab.is-active { color: var(--t-1); }
.utab-count {
font-size: 11.5px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
color: var(--t-3);
font-variant-numeric: tabular-nums;
}
.utab.is-active .utab-count { color: var(--t-1); background: rgba(255,255,255,0.10); }
.utab-underline {
position: absolute;
left: 12px; right: 12px;
bottom: 0;
height: 2px;
border-radius: 2px 2px 0 0;
opacity: 0;
transform: scaleX(0.4);
transform-origin: center;
transition: opacity .2s, transform .25s cubic-bezier(.4,1.2,.5,1);
}
.utab.is-active .utab-underline { opacity: 1; transform: scaleX(1); }
/* ─── Search ─── */
.search {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
height: 36px;
min-width: 220px;
color: var(--t-3);
transition: border-color .15s, background .15s, box-shadow .15s;
}
.search-wide { min-width: 320px; flex: 0 1 380px; }
.search:focus-within {
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
background: var(--bg-1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
.search:focus-within { color: var(--t-1); }
.search input {
flex: 1; min-width: 0;
background: transparent; border: 0; outline: 0;
color: var(--t-1);
font: inherit;
font-size: 13px;
}
.search input::placeholder { color: var(--t-3); }
.search-kbd {
display: inline-grid; place-items: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 4px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--bd-1);
font-size: 11px;
color: var(--t-3);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
.search:focus-within .search-kbd { opacity: 0.4; }
/* ─── Sort menu ─── */
.sort { position: relative; flex-shrink: 0; }
.sort-btn {
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-2);
font: inherit; font-size: 12.5px;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.sort-btn:hover { color: var(--t-1); border-color: var(--bd-2); }
.sort-btn strong { color: var(--t-1); font-weight: 600; }
.sort-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
min-width: 200px;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04);
}
.sort-menu button {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.sort-menu button:hover { background: rgba(255,255,255,0.06); }
.sort-check { width: 14px; display: inline-grid; place-items: center; color: var(--accent); }
/* ─── Directory button ─── */
.dirbtn {
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-2);
font: inherit; font-size: 12.5px;
cursor: pointer;
max-width: 360px;
transition: border-color .15s, color .15s;
flex-shrink: 1;
min-width: 0;
}
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
.dirbtn-path {
color: var(--t-3);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* ─── Kebab menu ─── */
.kebab { position: relative; }
.kebab-btn {
width: 36px; height: 36px;
display: grid; place-items: center;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-2);
cursor: pointer;
transition: color .15s, border-color .15s;
}
.kebab-btn:hover { color: var(--t-1); border-color: var(--bd-2); }
.kebab-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
min-width: 180px;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.kebab-menu button {
display: block;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.kebab-menu button:hover { background: rgba(255,255,255,0.06); }
.kebab-sep { height: 1px; background: var(--bd-1); margin: 4px 0; }
/* ─── Storage meter ─── */
.storage {
display: flex; flex-direction: column;
gap: 6px;
min-width: 240px;
}
.storage-compact { min-width: 200px; }
.storage-bar {
position: relative;
height: 6px;
background: rgba(255,255,255,0.06);
border-radius: 3px;
overflow: hidden;
}
.storage-i { position: absolute; top: 0; left: 0; bottom: 0; }
.storage-l {
position: absolute; top: 0; bottom: 0;
left: calc((var(--installed-pct, 15.3)) * 1%);
}
.storage-compact .storage-bar { height: 4px; }
.storage-text {
display: flex; align-items: center;
gap: 10px;
font-size: 11px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
}
.storage-text > span { display: inline-flex; align-items: center; gap: 5px; }
.storage-sq {
width: 8px; height: 8px;
border-radius: 2px;
}
.storage-free { margin-left: auto; color: var(--t-2); }
/* ─── Grid wrapper / results bar ─── */
.grid-wrap {
flex: 1;
overflow: auto;
padding: 18px 24px 32px;
scrollbar-width: thin;
scrollbar-color: var(--bd-3) transparent;
}
.grid-wrap::-webkit-scrollbar { width: 10px; }
.grid-wrap::-webkit-scrollbar-thumb { background: var(--bd-3); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
.results-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 4px 16px;
gap: 16px;
}
.results-count { color: var(--t-2); font-size: 12.5px; }
.results-count strong { color: var(--t-1); font-weight: 700; }
/* ─── Grid ─── */
.grid {
display: grid;
gap: var(--card-gap, 16px);
grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 188px), 1fr));
}
.density-compact { --card-min: 148px; --card-gap: 12px; }
.density-normal { --card-min: 188px; --card-gap: 16px; }
.density-large { --card-min: 244px; --card-gap: 20px; }
/* ─── Card ─── */
.card {
position: relative;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
border: 1px solid var(--bd-1);
border-radius: var(--radius-md);
cursor: pointer;
overflow: hidden;
transition: transform .18s cubic-bezier(.4,1.2,.5,1), border-color .18s, box-shadow .18s;
outline: 0;
}
.card:hover, .card:focus-visible {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2));
box-shadow:
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
.card:focus-visible {
box-shadow:
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
0 0 0 2px var(--accent);
}
/* ─── Cover ─── */
.card-cover-wrap {
position: relative;
width: 100%;
overflow: hidden;
background: var(--bg-3);
}
.card-cover-wrap[data-aspect="box"] { aspect-ratio: 2 / 3; }
.card-cover-wrap[data-aspect="square"] { aspect-ratio: 1 / 1; }
.card-cover-wrap[data-aspect="banner"] { aspect-ratio: 16 / 9; }
.cover {
position: absolute; inset: 0;
overflow: hidden;
transition: transform .35s cubic-bezier(.4,1.2,.5,1);
}
.card:hover .cover { transform: scale(1.03); }
.cover-base, .cover-blob, .cover-grain, .cover-vignette, .cover-mark { position: absolute; inset: 0; pointer-events: none; }
.cover-grain {
background-image:
repeating-linear-gradient(0deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(0,0,0,0.04) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
opacity: 0.7;
}
.cover-vignette {
background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%);
}
.cover-mark { width: 100%; height: 100%; }
.cover-titlewrap {
position: absolute;
left: 0; right: 0; bottom: 0;
z-index: 2;
padding: 14px 14px 14px;
}
.cover-title {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: 0.018em;
line-height: 1.02;
text-transform: uppercase;
color: white;
overflow-wrap: normal;
word-break: normal;
}
.cover-sub {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.85;
}
/* banner mode: title centered + smaller padding */
.card-cover-wrap[data-aspect="banner"] .cover-titlewrap {
padding: 14px 18px;
}
/* ─── State chip ─── */
.state-chip {
position: absolute;
top: 10px; right: 10px;
z-index: 3;
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 9px;
border-radius: 999px;
background: rgba(8,12,16,0.78);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.08);
font-size: 10.5px;
font-weight: 600;
color: var(--t-1);
letter-spacing: 0.01em;
}
.state-dot { width: 6px; height: 6px; border-radius: 999px; box-shadow: 0 0 8px currentColor; }
.state-chip[data-state="installed"] .state-dot { box-shadow: 0 0 8px var(--ok); }
.state-chip[data-state="local"] .state-dot { box-shadow: 0 0 8px var(--warn); }
/* Multiplayer badge */
.card-mp {
position: absolute;
top: 10px; left: 10px;
z-index: 3;
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(8,12,16,0.65);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.06);
font-size: 10.5px;
font-weight: 600;
color: var(--t-1);
font-variant-numeric: tabular-nums;
}
/* ─── Card body ─── */
.card-body {
padding: 11px 12px 12px;
display: flex; flex-direction: column;
gap: 8px;
}
.card-title {
font-weight: 600;
font-size: 13.5px;
color: var(--t-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
}
.card-meta {
display: flex; align-items: center; gap: 6px;
font-size: 11.5px;
color: var(--t-3);
font-variant-numeric: tabular-nums;
}
.card-meta .card-dot { opacity: 0.5; }
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
.density-compact .card-title { font-size: 12.5px; }
.density-compact .card-meta { font-size: 11px; }
.density-large .card-body { padding: 14px 14px 14px; gap: 10px; }
.density-large .card-title { font-size: 15px; }
/* ─── Action buttons ─── */
.act-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border-radius: 7px;
border: 0;
font: inherit;
font-weight: 600;
font-size: 12.5px;
letter-spacing: 0.005em;
cursor: pointer;
transition: transform .12s, filter .12s, background .15s;
white-space: nowrap;
}
.act-btn:hover { filter: brightness(1.12); }
.act-btn:active { transform: scale(0.98); }
.act-full { width: 100%; }
.act-lg { height: 44px; padding: 0 22px; font-size: 14px; gap: 8px; }
.act-lg svg { width: 14px; height: 14px; }
.act-play {
color: white;
background: linear-gradient(180deg, #2bd07f 0%, #1aa460 100%);
box-shadow: 0 6px 16px -8px #1aa460, inset 0 1px 0 rgba(255,255,255,0.25);
}
.act-install {
color: white;
background: var(--accent);
box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent) 80%, black), inset 0 1px 0 rgba(255,255,255,0.22);
}
.act-download {
color: var(--t-1);
background: rgba(255,255,255,0.08);
border: 1px solid var(--bd-2);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
/* Ghost / secondary */
.ghost-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 44px; padding: 0 18px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 13.5px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.ghost-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
.ghost-danger { color: #f87171; }
.ghost-danger:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.40); color: #fca5a5; }
/* ─── Modal ─── */
.modal-scrim {
position: absolute;
inset: 0;
z-index: 100;
background: rgba(4,7,11,0.7);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
padding: 32px;
animation: fadein .18s ease;
}
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
.modal {
width: min(880px, 100%);
max-height: 100%;
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
border: 1px solid var(--bd-2);
border-radius: 14px;
overflow: hidden;
position: relative;
box-shadow: 0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04);
display: flex; flex-direction: column;
animation: modalin .25s cubic-bezier(.3,1.3,.4,1);
}
@keyframes modalin { from { transform: scale(.96) translateY(8px); opacity: 0 } to { transform: scale(1) translateY(0); opacity: 1 } }
.modal-close {
position: absolute;
top: 14px; right: 14px;
z-index: 5;
width: 32px; height: 32px;
display: grid; place-items: center;
background: rgba(8,12,16,0.7);
border: 1px solid var(--bd-2);
border-radius: 8px;
color: var(--t-1);
cursor: pointer;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
transition: background .15s, border-color .15s;
}
.modal-close:hover { background: rgba(255,255,255,0.10); border-color: var(--bd-3); }
.modal-hero {
position: relative;
aspect-ratio: 16 / 7;
overflow: hidden;
}
.modal-hero .cover { transform: none !important; }
.modal-hero-fade {
position: absolute; inset: 0;
background: linear-gradient(180deg, transparent 40%, var(--bg-2) 100%);
pointer-events: none;
}
.modal-hero-text {
position: absolute;
left: 28px; right: 28px; bottom: 22px;
z-index: 2;
}
.modal-hero-text .modal-title {
font-family: var(--font-ui);
font-size: 32px;
font-weight: 700;
letter-spacing: -0.015em;
color: white;
margin: 6px 0 0;
text-shadow: 0 4px 24px rgba(0,0,0,0.6);
}
.modal-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.modal-tag {
display: inline-block;
padding: 3px 8px;
background: rgba(8,12,16,0.6);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border: 1px solid var(--bd-2);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--t-1);
}
.modal-state {
position: absolute;
top: 18px;
left: 24px;
z-index: 3;
}
.modal-state .state-chip {
position: static;
font-size: 11.5px;
padding: 5px 11px;
}
/* Banner cover treatment inside modal: hide the cover's own title (we show our own h2) */
.modal-hero .cover-titlewrap { opacity: 0.14; }
.modal-body {
padding: 22px 28px 26px;
display: flex; flex-direction: column; gap: 18px;
}
.modal-meta {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.meta-cell {
padding: 10px 12px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.meta-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--t-3);
}
.meta-value {
margin-top: 4px;
font-size: 14px;
font-weight: 600;
color: var(--t-1);
display: flex; align-items: center; gap: 6px;
}
.meta-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
.modal-desc {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--t-2);
text-wrap: pretty;
max-width: 64ch;
}
.modal-actions {
display: flex; align-items: center; gap: 10px;
padding-top: 4px;
}
.modal-actions-spacer { flex: 1; }
/* ─── Settings dialog ─── */
.settings-modal {
width: min(640px, 100%);
background: var(--bg-2);
}
.settings-head {
position: relative;
padding: 22px 28px 18px;
border-bottom: 1px solid var(--bd-1);
}
.settings-head h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--t-1);
}
.settings-close {
position: absolute;
top: 18px;
right: 18px;
background: transparent;
}
.settings-close:hover { background: rgba(255,255,255,0.06); }
.settings-body {
padding: 22px 28px 26px;
display: flex; flex-direction: column;
gap: 26px;
max-height: 70vh;
overflow: auto;
}
.settings-section {
display: flex; flex-direction: column;
gap: 14px;
}
.settings-section-title {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--t-3);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.settings-row-info { min-width: 0; flex: 1; }
.settings-row-label {
font-size: 14px;
font-weight: 600;
color: var(--t-1);
}
.settings-row-hint {
margin-top: 3px;
font-size: 12px;
color: var(--t-3);
}
.settings-row-control { flex-shrink: 0; }
.settings-foot {
display: flex;
justify-content: flex-end;
padding: 14px 22px 18px;
border-top: 1px solid var(--bd-1);
gap: 10px;
}
.settings-done {
height: 36px;
padding: 0 22px;
font-size: 13.5px;
}
.settings-done:hover {
filter: brightness(1.1);
border-color: transparent !important;
}
/* ─── Settings: color swatches ─── */
.swatch-row {
display: inline-flex;
gap: 8px;
}
.swatch {
position: relative;
width: 32px; height: 32px;
padding: 0;
background: transparent;
border: 0;
border-radius: 9px;
cursor: pointer;
}
.swatch-dot {
display: block;
width: 100%; height: 100%;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
transition: transform .15s, box-shadow .15s;
}
.swatch:hover .swatch-dot { transform: scale(1.06); }
.swatch.is-active .swatch-dot {
box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px currentColor;
}
.swatch-check {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));
pointer-events: none;
}
/* ─── Settings: segmented radio ─── */
.srad {
display: inline-flex;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
padding: 3px;
}
.srad-btn {
display: inline-flex; align-items: center;
height: 30px;
padding: 0 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--t-2);
font: inherit;
font-weight: 600;
font-size: 12.5px;
cursor: pointer;
transition: color .15s, background .15s;
white-space: nowrap;
}
.srad-btn:hover { color: var(--t-1); }
.srad-btn.is-active {
color: white;
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18);
}