design: updated design docs
This commit is contained in:
@@ -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 (A–Z)' },
|
||||
{ key: 'size', label: 'Size (largest)' },
|
||||
{ key: 'recent', label: 'Recently Played' },
|
||||
{ key: 'state', label: 'Status' },
|
||||
];
|
||||
|
||||
function SortMenu({ value, onChange, accent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('click', close);
|
||||
return () => document.removeEventListener('click', close);
|
||||
}, [open]);
|
||||
const current = SORTS.find(s => s.key === value) || SORTS[0];
|
||||
return (
|
||||
<div className="sort" ref={ref}>
|
||||
<button className="sort-btn" onClick={() => setOpen(o => !o)}>
|
||||
<Icon.sort/>
|
||||
<span>Sort: <strong>{current.label}</strong></span>
|
||||
<Icon.chevron/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="sort-menu">
|
||||
{SORTS.map(s => (
|
||||
<button key={s.key} onClick={() => { onChange(s.key); setOpen(false); }}
|
||||
className={s.key === value ? 'is-active' : ''}
|
||||
style={s.key === value ? { color: accent } : undefined}>
|
||||
<span className="sort-check">{s.key === value ? <Icon.check/> : null}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Storage meter
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function StorageMeter({ accent, compact = false }) {
|
||||
const { installed, local, total } = STORAGE;
|
||||
const pctI = (installed / total) * 100;
|
||||
const pctL = (local / total) * 100;
|
||||
return (
|
||||
<div className={`storage ${compact ? 'storage-compact' : ''}`}>
|
||||
<div className="storage-bar">
|
||||
<div className="storage-i" style={{ width: `${pctI}%`, background: accent }}/>
|
||||
<div className="storage-l" style={{ width: `${pctL}%`, background: `${accent}55` }}/>
|
||||
</div>
|
||||
<div className="storage-text">
|
||||
<span><span className="storage-sq" style={{ background: accent }}/>{installed.toFixed(0)} GB installed</span>
|
||||
<span><span className="storage-sq" style={{ background: `${accent}55` }}/>{local.toFixed(0)} GB local</span>
|
||||
<span className="storage-free">{STORAGE.free.toFixed(0)} GB free</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Directory button (shows path)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function DirectoryButton({ path }) {
|
||||
const 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,
|
||||
});
|
||||
@@ -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: '1–8', 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: '1–8', 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: '2–16', 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: '4–15', 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: '2–64', 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: '2–64', 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: '1–8', 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: '2–32', 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: '2–32', 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: '2–32', 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: '1–6', 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: '1–8', 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: '2–32', 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: '2–32', 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: '2–16', 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: '2–16', 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: '1–8', 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: '2–100', 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: '1–2', 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: '2–16', 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: '1–8', 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: '2–32', 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: '2–32', 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: '1–12', 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;
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user