design: updated design docs

This commit is contained in:
2026-06-21 21:15:09 +02:00
parent d9a6181ae2
commit 6415a4e517
18 changed files with 2452 additions and 605 deletions
@@ -0,0 +1,129 @@
<!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",
"username": "ddidderr",
"language": "en",
"gameFolder": "\/some\/folder\/to\/games"
}/*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 === 'cs'); // installed + canHostServer → shows Play + Start Server + 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, can host server)" 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>
<DCArtboard id="detail-modal-downloading" label="E · Detail overlay (downloading)" width={1340} height={840}>
<Launcher variant="two" tweaks={t} setTweak={setTweak}
initialFilter="local" initialSort="state"
initialOpenGame={GAMES.find(g => g.id === 'avp')}/>
</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="F · 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="Profile"/>
<TweakText label="Username" value={t.username}
onChange={(v) => setTweak('username', v)}/>
<TweakRadio label="Language" value={t.language}
options={[{value: 'en', label: 'English'}, {value: 'de', label: 'Deutsch'}]}
onChange={(v) => setTweak('language', v)}/>
<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)}/>
<TweakSection label="Library"/>
<TweakText label="Game folder" value={t.gameFolder}
onChange={(v) => setTweak('gameFolder', v)}/>
</TweaksPanel>
</React.Fragment>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
@@ -0,0 +1,645 @@
// components.jsx — UI building blocks for the SoftLAN launcher
// Loaded after data.jsx; relies on GAMES/STATE_META/ACTION_FOR_STATE/etc. on window.
const { useState, useMemo, useRef, useEffect } = React;
// ────────────────────────────────────────────────────────────────────
// Iconography (tiny inline SVGs; no emoji)
// ────────────────────────────────────────────────────────────────────
const Icon = {
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
kebab: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}><circle cx="8" cy="3.2" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="8" cy="12.8" r="1.4"/></svg>,
sort: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4h10"/><path d="M4.5 8h7"/><path d="M6 12h4"/></svg>,
users: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="6" cy="6" r="2.4"/><path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13"/><circle cx="11.2" cy="5.4" r="1.8"/><path d="M10.4 9.8c1.7 0 3 1 3.6 2.6"/></svg>,
close: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...p}><path d="m4 4 8 8M12 4l-8 8"/></svg>,
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
};
// ────────────────────────────────────────────────────────────────────
// Cover art — stylized box-art placeholder
// ────────────────────────────────────────────────────────────────────
function GameCover({ game, aspect = 'box', size = 'normal' }) {
const { c1, c2, accent } = game.cover;
// Pick title sizing — shrink for longer names; line-clamp:2 handles wrap
const title = game.title;
const len = title.length;
const fontPx = aspect === 'banner' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: aspect === 'square' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: (len > 26 ? 15 : len > 20 ? 17 : len > 14 ? 21 : 26);
// Stable but varied per-game accent shape (id hash → angle / size)
const h = [...game.id].reduce((a, c) => a + c.charCodeAt(0), 0);
const angle = 110 + (h % 60); // 110-170
const blobX = 60 + (h % 30);
const blobY = 10 + ((h * 7) % 30);
return (
<div className="cover" data-aspect={aspect}>
{/* base gradient */}
<div className="cover-base" style={{
background: `linear-gradient(${angle}deg, ${c1} 0%, ${c2} 100%)`,
}}/>
{/* radial accent blob */}
<div className="cover-blob" style={{
background: `radial-gradient(ellipse at ${blobX}% ${blobY}%, ${accent}38, transparent 55%)`,
}}/>
{/* scanline / grain */}
<div className="cover-grain"/>
{/* faint geometric mark */}
<svg className="cover-mark" viewBox="0 0 100 100" preserveAspectRatio="xMaxYMax slice" aria-hidden="true">
<path d={`M ${100 - (h%30)} ${100 - (h%20)} L 100 ${60 + (h%25)} L 100 100 Z`}
fill={accent} fillOpacity="0.12"/>
<circle cx={(h*3)%100} cy={(h*5)%100} r="0.6" fill={accent} fillOpacity="0.4"/>
</svg>
{/* title */}
<div className="cover-titlewrap">
<div className="cover-title" style={{ fontSize: fontPx, textShadow: `0 4px 16px ${c2}aa, 0 1px 0 rgba(0,0,0,.3)` }}>
{title}
</div>
</div>
{/* bottom darkening */}
<div className="cover-vignette"/>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// State chip (corner of cover)
// ────────────────────────────────────────────────────────────────────
function StateChip({ state }) {
const meta = STATE_META[state];
if (!meta || !meta.label) return null;
return (
<div className="state-chip" data-state={state}>
<span className="state-dot" style={{ background: meta.dot }}/>
{meta.label}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Action button — Play / Install / Download / [Downloading progress]
// ────────────────────────────────────────────────────────────────────
function ActionButton({ state, accent, size = 'md', onClick, full = false, game }) {
if (state === 'downloading' && game) {
return <DownloadProgress game={game} accent={accent} size={size} full={full}/>;
}
const action = ACTION_FOR_STATE[state];
const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`;
const icon = action.kind === 'play' ? <Icon.play/>
: action.kind === 'install' ? <Icon.install/>
: <Icon.download/>;
return (
<button className={cls} onClick={(e) => { e.stopPropagation(); onClick && onClick(); }}
style={action.kind === 'install' ? { background: accent } : undefined}>
{icon}<span>{action.label}</span>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Live download progress — animates from initial progress upward,
// jitters speed slightly. Two layouts:
// md → card tile. Container-query: shows %+speed, falls back to % only.
// lg → detail overlay. Two-line stats + percentage on the right.
// ────────────────────────────────────────────────────────────────────
function useLiveDownload(game) {
const [p, setP] = useState(game.progress ?? 0.1);
const [speed, setSpeed] = useState(game.speed ?? 30);
useEffect(() => {
const id = setInterval(() => {
setP(prev => {
// step proportional to current speed so smaller games finish faster
const stepGb = (speed / 1024) * 0.6; // 600ms tick
const next = prev + stepGb / game.size;
return next >= 0.985 ? (game.progress ?? 0.1) : next;
});
setSpeed(prev => {
const target = (game.speed ?? 30);
const drift = (Math.random() - 0.5) * 6;
return Math.max(2, target * 0.85 + prev * 0.15 + drift);
});
}, 600);
return () => clearInterval(id);
}, [game.id, game.size, game.speed, game.progress]);
return { progress: p, speed };
}
function DownloadProgress({ game, accent, size = 'md', full = false }) {
const { progress, speed } = useLiveDownload(game);
const pct = Math.min(99, Math.round(progress * 100));
const downloadedGb = game.size * progress;
const remainingGb = Math.max(0, game.size - downloadedGb);
const etaSec = remainingGb * 1024 / Math.max(speed, 0.1);
const isLg = size === 'lg';
const onCancel = (e) => { e.stopPropagation(); /* mock */ };
if (isLg) {
return (
<div className={`dl dl-lg ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden="true"/>
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{fmtBytes(downloadedGb)}</strong>
<span className="dl-of"> / {fmtBytes(game.size)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{fmtSpeed(speed)}</span>
{game.peers > 0 && (
<React.Fragment>
<span className="dl-sep dl-sep-peers">·</span>
<span className="dl-peers" title={`Downloading from ${game.peers} ${game.peers === 1 ? 'peer' : 'peers'} on the LAN`}>
<Icon.users/>
<span>{game.peers}</span>
</span>
</React.Fragment>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{fmtEta(etaSec)} left</span>
</div>
<div className="dl-lg-pct">{pct}<span className="dl-pct-sym">%</span></div>
<button className="dl-cancel" onClick={onCancel} aria-label="Cancel download">
<Icon.close/>
</button>
</div>
</div>
);
}
return (
<div className={`dl dl-md ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}
title={`${pct}% · ${fmtSpeed(speed)} · ${fmtEta(etaSec)} left`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden="true"/>
{pct}<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{fmtSpeedShort(speed)}</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Game card
// ────────────────────────────────────────────────────────────────────
function GameCard({ game, accent, aspect, onOpen }) {
return (
<article className="card" onClick={() => onOpen && onOpen(game)} tabIndex={0}>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect}/>
<StateChip state={game.state}/>
<div className="card-mp" title={`${game.players} players`}>
<Icon.users/><span>{game.players}</span>
</div>
</div>
<div className="card-body">
<div className="card-title" title={game.title}>{game.title}</div>
<div className="card-meta">
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
</article>
);
}
// ────────────────────────────────────────────────────────────────────
// Filter controls
// ────────────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'All Games' },
{ key: 'local', label: 'Local' },
{ key: 'installed', label: 'Installed' },
];
function SegmentedFilters({ value, onChange, counts, accent }) {
const ref = useRef(null);
const [thumb, setThumb] = useState({ left: 0, width: 0 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current.querySelector(`[data-key="${value}"]`);
if (el) setThumb({ left: el.offsetLeft, width: el.offsetWidth });
}, [value]);
return (
<div className="seg" ref={ref}>
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width, background: accent }}/>
{FILTER_TABS.map(t => (
<button key={t.key} data-key={t.key} className={`seg-btn ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}>
<span>{t.label}</span>
<span className="seg-count">{counts[t.key]}</span>
</button>
))}
</div>
);
}
function UnderlineFilters({ value, onChange, counts, accent }) {
return (
<div className="utabs">
{FILTER_TABS.map(t => (
<button key={t.key} className={`utab ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}
style={value === t.key ? { '--accent': accent } : undefined}>
<span className="utab-label">{t.label}</span>
<span className="utab-count">{counts[t.key]}</span>
<span className="utab-underline" style={{ background: accent }}/>
</button>
))}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Search input
// ────────────────────────────────────────────────────────────────────
function SearchField({ value, onChange, accent, wide = false }) {
return (
<div className={`search ${wide ? 'search-wide' : ''}`} style={{ '--accent': accent }}>
<Icon.search/>
<input type="text" placeholder="Search games" value={value}
onChange={(e) => onChange(e.target.value)}/>
<span className="search-kbd">/</span>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Sort menu (simple dropdown)
// ────────────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'az', label: 'Name (AZ)' },
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
];
function SortMenu({ value, onChange, accent }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
const current = SORTS.find(s => s.key === value) || SORTS[0];
return (
<div className="sort" ref={ref}>
<button className="sort-btn" onClick={() => setOpen(o => !o)}>
<Icon.sort/>
<span>Sort: <strong>{current.label}</strong></span>
<Icon.chevron/>
</button>
{open && (
<div className="sort-menu">
{SORTS.map(s => (
<button key={s.key} onClick={() => { onChange(s.key); setOpen(false); }}
className={s.key === value ? 'is-active' : ''}
style={s.key === value ? { color: accent } : undefined}>
<span className="sort-check">{s.key === value ? <Icon.check/> : null}</span>
{s.label}
</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Storage meter
// ────────────────────────────────────────────────────────────────────
function StorageMeter({ accent, compact = false }) {
const { installed, local, total } = STORAGE;
const pctI = (installed / total) * 100;
const pctL = (local / total) * 100;
return (
<div className={`storage ${compact ? 'storage-compact' : ''}`}>
<div className="storage-bar">
<div className="storage-i" style={{ width: `${pctI}%`, background: accent }}/>
<div className="storage-l" style={{ width: `${pctL}%`, background: `${accent}55` }}/>
</div>
<div className="storage-text">
<span><span className="storage-sq" style={{ background: accent }}/>{installed.toFixed(0)} GB installed</span>
<span><span className="storage-sq" style={{ background: `${accent}55` }}/>{local.toFixed(0)} GB local</span>
<span className="storage-free">{STORAGE.free.toFixed(0)} GB free</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Directory button (shows path)
// ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) {
const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? path : 'Please select a game folder';
return (
<button
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
>
<Icon.folder/>
<span className="dirbtn-label">{label}</span>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Menu (kebab)
// ────────────────────────────────────────────────────────────────────
function KebabMenu({ items }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
return (
<div className="kebab" ref={ref}>
<button className="kebab-btn" onClick={() => setOpen(o => !o)} aria-label="More"><Icon.kebab/></button>
{open && (
<div className="kebab-menu">
{items.map((it, i) => it === '-' ? <div key={i} className="kebab-sep"/> : (
<button key={i} onClick={() => { setOpen(false); it.onClick && it.onClick(); }}>{it.label}</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Detail Modal
// ────────────────────────────────────────────────────────────────────
function GameDetailModal({ game, accent, onClose }) {
if (!game) return null;
const action = ACTION_FOR_STATE[game.state];
const showCancel = game.state === 'downloading';
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
<div className="modal-hero">
<GameCover game={game} aspect="banner"/>
<div className="modal-hero-fade"/>
<div className="modal-hero-text">
<div className="modal-tags">
{game.tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
</div>
<h2 className="modal-title">{game.title}</h2>
</div>
<div className="modal-state">
<StateChip state={game.state}/>
</div>
</div>
<div className="modal-body">
<div className="modal-meta">
<div className="meta-cell">
<div className="meta-label">Size</div>
<div className="meta-value">{fmtSize(game.size)}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Players</div>
<div className="meta-value"><Icon.users/> {game.players}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">{game.version}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{STATE_META[game.state].label || 'Not downloaded'}</div>
</div>
</div>
<p className="modal-desc">{game.desc}</p>
<div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
{game.canHostServer && game.state === 'installed' && (
<button className="act-btn act-lg act-server"
style={{ '--accent': accent }}
onClick={(e) => e.stopPropagation()}>
<Icon.server/><span>Start Server</span>
</button>
)}
{game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
)}
{game.state === 'local' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Delete from disk</span></button>
)}
{showCancel && (
<button className="ghost-btn ghost-danger"><Icon.close/><span>Cancel</span></button>
)}
{game.state !== 'none' && <div className="modal-actions-spacer"/>}
<button className="ghost-btn">View files</button>
</div>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Settings Dialog — in-app version of the Tweaks panel
// ────────────────────────────────────────────────────────────────────
const SETTING_OPTIONS = {
accent: [
{ value: '#3b82f6', label: 'Blue' },
{ value: '#22d3ee', label: 'Cyan' },
{ value: '#a855f7', label: 'Violet' },
{ value: '#22c55e', label: 'Green' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#ef4444', label: 'Red' },
],
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
};
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
return (
<div className="settings-text" style={{ '--accent': accent }}>
<input type="text"
value={value || ''}
placeholder={placeholder}
maxLength={maxLength}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}/>
</div>
);
}
function SettingsRow({ label, hint, children }) {
return (
<div className="settings-row">
<div className="settings-row-info">
<div className="settings-row-label">{label}</div>
{hint && <div className="settings-row-hint">{hint}</div>}
</div>
<div className="settings-row-control">{children}</div>
</div>
);
}
function SegmentedRadio({ value, options, onChange, accent }) {
return (
<div className="srad">
{options.map(o => (
<button key={o.value}
className={`srad-btn ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={value === o.value ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
))}
</div>
);
}
function GameFolderField({ value, onChange, accent }) {
const isSet = !!(value && value.trim());
const handleChange = () => {
// In production: open native folder picker via Tauri.
// For the prototype, prompt for a path so the field is exercisable.
const next = window.prompt('Game folder path (leave empty to clear)', value || '');
if (next == null) return;
onChange(next.trim());
};
return (
<div className={`folder-field ${isSet ? 'is-set' : 'is-unset'}`}
style={{ '--accent': accent }}>
<span className="folder-field-icon" aria-hidden="true"><Icon.folder/></span>
<div className="folder-field-path" title={isSet ? value : 'No folder selected'}>
{isSet
? <bdi>{value}</bdi>
: <span className="folder-field-empty">Not set</span>}
</div>
<button type="button" className="folder-field-btn" onClick={handleChange}>
{isSet ? 'Change\u2026' : 'Choose\u2026'}
</button>
</div>
);
}
function ColorSwatchPicker({ value, options, onChange }) {
return (
<div className="swatch-row">
{options.map(o => (
<button key={o.value}
className={`swatch ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={{ color: o.value }}
title={o.label}
aria-label={o.label}>
<span className="swatch-dot" style={{ background: o.value }}/>
{value === o.value && <span className="swatch-check"><Icon.check/></span>}
</button>
))}
</div>
);
}
function SettingsDialog({ settings, onChange, onClose }) {
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-head">
<h2>Settings</h2>
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
</div>
<div className="settings-body">
<div className="settings-section">
<div className="settings-section-title">Profile</div>
<SettingsRow label="Username" hint="Shown to other players on the LAN">
<SettingsTextInput value={settings.username}
placeholder="Enter a username"
onChange={(v) => onChange('username', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Language" hint="Interface language">
<SegmentedRadio value={settings.language || 'en'}
options={SETTING_OPTIONS.language}
onChange={(v) => onChange('language', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Appearance</div>
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
<ColorSwatchPicker value={settings.accent}
options={SETTING_OPTIONS.accent}
onChange={(v) => onChange('accent', v)}/>
</SettingsRow>
<SettingsRow label="Background" hint="Backdrop behind the library">
<SegmentedRadio value={settings.bg}
options={SETTING_OPTIONS.bg}
onChange={(v) => onChange('bg', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Library</div>
<SettingsRow label="Game folder" hint="Parent directory where games are downloaded and installed">
<GameFolderField value={settings.gameFolder}
onChange={(v) => onChange('gameFolder', v)}
accent={settings.accent}/>
</SettingsRow>
<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,
});
+216
View File
@@ -0,0 +1,216 @@
// 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: 'downloading', progress: 0.32, speed: 49.4, peers: 5, 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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
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', canHostServer: true,
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
state: 'downloading', progress: 0.71, speed: 12.8, peers: 3, 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', canHostServer: true,
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', canHostServer: true,
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' },
downloading: { label: 'Downloading', dot: 'var(--accent)' },
none: { label: '', dot: 'transparent' },
};
const ACTION_FOR_STATE = {
installed: { label: 'Play', kind: 'play' },
local: { label: 'Install', kind: 'install' },
downloading: { label: 'Downloading', kind: 'downloading' },
none: { label: 'Download', kind: 'download' },
};
// Format helpers for download UI
const fmtSpeed = (mbps) => mbps >= 100 ? `${Math.round(mbps)} MB/s` : `${mbps.toFixed(1)} MB/s`;
const fmtSpeedShort = (mbps) => `${Math.round(mbps)} MB/s`;
const fmtBytes = (gb) => {
if (gb < 1) return `${Math.round(gb * 1024)} MB`;
// No trailing zeros: 35 GB, 11.4 GB, 2.35 GB
if (gb >= 10) return `${gb.toFixed(1).replace(/\.0$/,'')} GB`;
return `${gb.toFixed(2).replace(/0$/,'').replace(/\.$/,'')} GB`;
};
const fmtEta = (sec) => {
if (!isFinite(sec) || sec <= 0) return '—';
if (sec < 60) return `${Math.round(sec)} s`;
const m = Math.round(sec / 60);
if (m < 60) return `${m} min`;
return `${Math.floor(m/60)} h ${m%60} min`;
};
const isLocalish = (s) => s === 'installed' || s === 'local' || s === 'downloading';
const countByFilter = (games) => ({
all: games.length,
local: games.filter(g => isLocalish(g.state)).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 => isLocalish(g.state));
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.fmtSpeed = fmtSpeed;
window.fmtSpeedShort = fmtSpeedShort;
window.fmtBytes = fmtBytes;
window.fmtEta = fmtEta;
window.STORAGE = STORAGE;
@@ -0,0 +1,966 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
dcFlatten(sec.props.children).forEach((ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(dcFlatten(children));
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
@@ -0,0 +1,120 @@
// launcher.jsx — composes top bar + grid into a complete launcher screen
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
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="topbar-left">
<div className="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN</div>
</div>
<div className="topbar-left-trail">
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
</div>
</div>
<div className="topbar-center">
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
</div>
<div className="topbar-right">
<div className="topbar-right-lead">
<SortMenu value={sort} onChange={setSort} accent={accent}/>
</div>
<div className="topbar-right-trail">
<KebabMenu items={menuItems}/>
</div>
</div>
</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>
<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;
+1313
View File
@@ -0,0 +1,1313 @@
/* 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;
container-type: inline-size;
container-name: launcher;
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 — three visual zones with search at geometric center */
.topbar-single {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
column-gap: 16px;
padding: 14px 24px;
}
.topbar-single .topbar-left {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.topbar-single .topbar-left-trail {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-single .topbar-center {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.topbar-single .topbar-center .search { flex: 0 1 360px; min-width: 0; }
.topbar-single .topbar-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.topbar-single .topbar-right-lead {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-single .topbar-right-trail {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
/* When the launcher gets narrow, the three-zone centering breaks down —
collapse to a single left-to-right flowing row. */
@container launcher (max-width: 1100px) {
.topbar-single {
display: flex;
flex-wrap: nowrap;
gap: 16px;
}
.topbar-single .topbar-left,
.topbar-single .topbar-center,
.topbar-single .topbar-right,
.topbar-single .topbar-right-trail {
justify-content: flex-start;
flex: 0 0 auto;
gap: 12px;
}
.topbar-single .topbar-center { flex: 1 1 200px; }
.topbar-single .topbar-center .search { flex: 1 1 auto; }
}
/* 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 {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
flex-shrink: 0;
white-space: nowrap;
}
.dirbtn:hover { border-color: var(--bd-2); }
.dirbtn-label { line-height: 1; }
.dirbtn-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
}
.dirbtn-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
}
/* ─── 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); }
/* Start Server — secondary "primary" action sitting next to Play.
Uses the accent as a tinted fill + border so it reads as host-action
without competing with the green Play button. */
.act-server {
color: var(--t-1);
background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04));
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
}
.act-server:hover {
background: color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.04));
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
filter: none;
}
.act-server svg { color: var(--accent); }
/* ─── Download progress (in place of action button when state === 'downloading') ─── */
.dl {
position: relative;
overflow: hidden;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
background: rgba(255,255,255,0.04);
color: var(--t-1);
font: inherit;
font-variant-numeric: tabular-nums;
container-type: inline-size;
isolation: isolate;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04),
0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
}
.dl-full { width: 100%; }
.dl-fill {
position: absolute; inset: 0 auto 0 0;
width: 0%;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
color-mix(in srgb, var(--accent) 26%, transparent) 100%);
border-right: 1px solid color-mix(in srgb, var(--accent) 75%, transparent);
box-shadow: 2px 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
transition: width 480ms cubic-bezier(.4,0,.2,1);
z-index: 0;
}
/* shimmering scanline on top of the fill so it reads as 'live' */
.dl-fill::after {
content: '';
position: absolute; inset: 0;
background:
repeating-linear-gradient(115deg,
transparent 0 14px,
rgba(255,255,255,0.05) 14px 22px);
background-size: 200% 100%;
animation: dl-stripe 1.4s linear infinite;
mix-blend-mode: screen;
opacity: 0.85;
}
@keyframes dl-stripe {
from { background-position: 0% 0%; }
to { background-position: -36px 0%; }
}
/* live pulse dot */
.dl-pulse {
width: 7px; height: 7px; border-radius: 99px;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
animation: dl-pulse 1.4s ease-out infinite;
flex: 0 0 auto;
}
@keyframes dl-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
/* ── md (card tile) ── */
.dl-md {
height: 32px;
padding: 0 10px;
}
.dl-md-row {
position: relative; z-index: 1;
display: flex; align-items: center; justify-content: space-between;
width: 100%; height: 100%;
gap: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.005em;
}
.dl-md .dl-pct {
display: inline-flex; align-items: center;
color: var(--t-1);
}
.dl-md .dl-pulse { margin-right: 6px; }
.dl-md .dl-pct-sym { opacity: 0.55; font-weight: 600; margin-left: 1px; }
.dl-md .dl-speed {
color: var(--t-2);
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
/* Container-query graceful degradation: when the tile is narrow,
drop the speed and centre the percentage so nothing truncates. */
@container (max-width: 132px) {
.dl-md .dl-speed { display: none; }
.dl-md-row { justify-content: center; gap: 6px; }
}
@container (max-width: 96px) {
.dl-md .dl-pulse { display: none; }
}
/* Density tuning — match the existing button heights */
.density-compact .dl-md { height: 30px; padding: 0 9px; }
.density-compact .dl-md-row { font-size: 11.5px; }
.density-compact .dl-md .dl-speed { font-size: 10.5px; }
.density-large .dl-md { height: 34px; padding: 0 12px; }
.density-large .dl-md-row { font-size: 13px; }
.density-large .dl-md .dl-speed { font-size: 11.5px; }
/* ── lg (detail overlay) ── */
.dl-lg {
height: 56px;
padding: 0;
border-radius: 9px;
flex: 1 1 auto;
min-width: 260px;
}
.dl-lg-grid {
position: relative; z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: auto auto;
grid-template-areas:
"primary pct cancel"
"secondary pct cancel";
align-items: center;
height: 100%;
padding: 0 14px 0 16px;
column-gap: 14px;
row-gap: 2px;
}
.dl-lg-primary {
grid-area: primary;
display: flex; align-items: center; gap: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: color-mix(in srgb, var(--accent) 80%, white);
min-width: 0;
}
.dl-lg-primary .dl-label { white-space: nowrap; }
.dl-lg-secondary {
grid-area: secondary;
display: flex; align-items: center; gap: 7px;
font-size: 12px;
font-weight: 500;
color: var(--t-2);
min-width: 0;
overflow: hidden;
}
.dl-lg-secondary .dl-bytes {
color: var(--t-1);
font-weight: 600;
white-space: nowrap;
}
.dl-lg-secondary .dl-bytes .dl-of { color: var(--t-2); font-weight: 500; }
.dl-lg-secondary .dl-speed { color: var(--t-1); font-weight: 600; white-space: nowrap; }
.dl-lg-secondary .dl-peers {
display: inline-flex; align-items: center; gap: 4px;
color: var(--t-1); font-weight: 600; font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dl-lg-secondary .dl-peers svg { opacity: 0.7; }
.dl-lg-secondary .dl-eta { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.dl-sep { opacity: 0.45; }
/* Gracefully drop the ETA when the modal is narrow */
@container (max-width: 320px) {
.dl-lg-secondary .dl-eta,
.dl-lg-secondary .dl-sep-eta { display: none; }
}
/* Even narrower: drop peers too, keep size + speed */
@container (max-width: 240px) {
.dl-lg-secondary .dl-peers,
.dl-lg-secondary .dl-sep-peers { display: none; }
}
.dl-lg-pct {
grid-area: pct;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--t-1);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.dl-lg-pct .dl-pct-sym {
font-size: 12px;
font-weight: 600;
opacity: 0.55;
margin-left: 1px;
}
.dl-cancel {
grid-area: cancel;
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid var(--bd-2);
background: rgba(255,255,255,0.04);
color: var(--t-2);
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.dl-cancel:hover {
background: rgba(239,68,68,0.12);
border-color: rgba(239,68,68,0.40);
color: #fca5a5;
}
/* 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);
}
/* ─── Settings: text input ─── */
.settings-text {
display: inline-flex;
align-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
padding: 0 12px;
height: 36px;
width: 220px;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.settings-text:focus-within {
background: var(--bg-2);
border-color: var(--accent, #3b82f6);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent, #3b82f6) 22%, transparent);
}
.settings-text input {
flex: 1; min-width: 0;
background: transparent;
border: 0; outline: 0;
color: var(--t-1);
font: inherit;
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0.1px;
}
.settings-text input::placeholder {
color: var(--t-3);
font-weight: 500;
}
/* ─── Settings: game-folder field ─── */
.folder-field {
display: inline-flex;
align-items: center;
gap: 8px;
width: 340px;
height: 36px;
padding: 0 4px 0 12px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
transition: border-color .15s, background .15s;
}
.folder-field:hover { border-color: var(--bd-2); }
.folder-field.is-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
}
.folder-field.is-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
}
.folder-field-icon {
display: inline-flex;
color: var(--t-3);
flex-shrink: 0;
}
.folder-field.is-unset .folder-field-icon { color: #f87171; }
.folder-field-path {
flex: 1;
min-width: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--t-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* truncate from the head so the leaf folder stays visible */
text-align: left;
unicode-bidi: plaintext; /* keep character order intact */
}
.folder-field-empty {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 600;
color: #f87171;
letter-spacing: 0.1px;
}
.folder-field-btn {
flex-shrink: 0;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: var(--t-1);
font: inherit;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.1px;
cursor: pointer;
transition: background .15s, color .15s;
}
.folder-field-btn:hover { background: rgba(255,255,255,0.12); }
.folder-field.is-unset .folder-field-btn {
background: color-mix(in srgb, var(--accent, #3b82f6) 85%, transparent);
color: #fff;
}
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}
@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// deck-stage enables its rail in connectedCallback, but this panel can
// mount before that element has upgraded. The initial read catches the
// common case; the listener covers mounting first. (Older deck-stage.js
// copies still wait for the host's __omelette_rail_enabled postMessage —
// same listener handles those.)
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});