mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
launcher: v0.1 — PhotinoNET shell + multi-version orchestration
Single-download .NET 9 self-contained launcher for hosting / joining
DorkNet servers. Reads versions.json from main to discover supported
client builds, fetches the matching server binary + client patcher
from GitHub Releases per branch, manages the server subprocess +
Cloudflare quick-tunnel, and wraps it all in a PhotinoNET window.
Code layout:
- Program.cs PhotinoNET window + message bridge bootstrap
- Backend/ AppPaths, AppState, VersionsManifest,
ReleaseDownloader, ServerProcess, ClientPatcher,
Tunnel, RecRoomPicker, JoinCode, MessageBridge
- ui/ index.html + style.css + app.js (no build step)
- RELEASES.md GitHub Releases artifact contract per-version
branches must publish to
Per-version branches need to ship two release assets per tag:
- dorknet-server-{version_key}-win-x64.zip
- dorknet-clientpatch-{version_key}.zip
(see launcher/RELEASES.md for the full naming + arg contract)
Photon AppIds, JWT secret, and the picked Rec Room path stay
per-machine; the launcher never bundles them.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
// DorkNet Launcher UI — single-file JS, no framework. State held in
|
||||
// `appState`; views toggled via `showView(name)`. C# is reached through
|
||||
// `bridge.send({type, payload})`; C# pushes events via
|
||||
// `window.external.receiveMessage(handler)`.
|
||||
|
||||
'use strict';
|
||||
|
||||
let appState = null; // mirrors the C# AppState payload
|
||||
let versions = null; // versions.json contents
|
||||
let previousView = null; // for the settings 'back' button
|
||||
|
||||
// ── Message bridge ───────────────────────────────────────────────────
|
||||
const bridge = {
|
||||
send(envelope) {
|
||||
// PhotinoNET exposes window.external.sendMessage(string) — the C#
|
||||
// side parses the JSON envelope and routes by `type`.
|
||||
window.external.sendMessage(JSON.stringify(envelope));
|
||||
}
|
||||
};
|
||||
|
||||
window.external.receiveMessage(raw => {
|
||||
let env;
|
||||
try { env = JSON.parse(raw); }
|
||||
catch { console.error('bridge: non-JSON message', raw); return; }
|
||||
handleEvent(env.type, env.payload);
|
||||
});
|
||||
|
||||
function handleEvent(type, payload) {
|
||||
switch (type) {
|
||||
case 'state-changed':
|
||||
appState = payload;
|
||||
reflectStateInUi();
|
||||
break;
|
||||
case 'versions':
|
||||
versions = payload;
|
||||
reflectVersionsInUi();
|
||||
break;
|
||||
case 'host-status':
|
||||
renderHostStatus(payload);
|
||||
break;
|
||||
case 'join-status':
|
||||
renderJoinStatus(payload);
|
||||
break;
|
||||
case 'download-progress':
|
||||
renderDownloadProgress(payload);
|
||||
break;
|
||||
case 'join-code-decoded':
|
||||
renderJoinPreview(payload);
|
||||
break;
|
||||
case 'error':
|
||||
showToast(payload?.message ?? 'Unknown error', 'error');
|
||||
break;
|
||||
default:
|
||||
console.warn('bridge: unhandled event', type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View routing ─────────────────────────────────────────────────────
|
||||
function showView(name) {
|
||||
for (const sec of document.querySelectorAll('section[data-view]')) {
|
||||
const matches = sec.dataset.view === name;
|
||||
sec.hidden = !matches;
|
||||
}
|
||||
}
|
||||
|
||||
function reflectStateInUi() {
|
||||
if (!appState) return;
|
||||
|
||||
// First-run check.
|
||||
if (appState.mode === 'unset') { showView('first-run'); return; }
|
||||
|
||||
// Mirror persisted values into the right view's inputs.
|
||||
const recDisplay = document.getElementById('recroom-path-display');
|
||||
const recDisplayJoin = document.getElementById('recroom-path-display-join');
|
||||
const path = appState.recRoomPath || '(not picked)';
|
||||
if (recDisplay) recDisplay.textContent = path;
|
||||
if (recDisplayJoin) recDisplayJoin.textContent = path;
|
||||
|
||||
document.getElementById('photon-app-id').value = appState.photonAppId || '';
|
||||
document.getElementById('photon-voice-app-id').value = appState.photonVoiceAppId || '';
|
||||
document.getElementById('photon-region').value = appState.photonRegion || 'us';
|
||||
document.getElementById('server-name').value = appState.serverName || '';
|
||||
|
||||
// Enable join button only when prerequisites are met.
|
||||
refreshJoinReadiness();
|
||||
|
||||
// Show the mode the user picked unless they're in settings.
|
||||
if (!isSettingsOpen()) showView(appState.mode);
|
||||
}
|
||||
|
||||
function reflectVersionsInUi() {
|
||||
const sel = document.getElementById('version-select');
|
||||
if (!sel || !versions) return;
|
||||
sel.innerHTML = '';
|
||||
for (const v of versions.branches.filter(b => b.supported)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v.versionKey;
|
||||
opt.textContent = `Rec Room ${v.clientBuild} (${v.branch})`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (appState?.selectedVersion) sel.value = appState.selectedVersion;
|
||||
}
|
||||
|
||||
function isSettingsOpen() {
|
||||
const s = document.querySelector('section[data-view="settings"]');
|
||||
return s && !s.hidden;
|
||||
}
|
||||
|
||||
// ── Toasts ───────────────────────────────────────────────────────────
|
||||
function showToast(msg, kind = '') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast ${kind}`.trim();
|
||||
el.textContent = msg;
|
||||
document.getElementById('toasts').appendChild(el);
|
||||
setTimeout(() => el.remove(), 6000);
|
||||
}
|
||||
|
||||
// ── Event wiring ─────────────────────────────────────────────────────
|
||||
document.addEventListener('click', e => {
|
||||
const t = e.target.closest('[data-action], [data-mode-pick]');
|
||||
if (!t) return;
|
||||
|
||||
if (t.dataset.modePick) {
|
||||
bridge.send({ type: 'set-mode', payload: { mode: t.dataset.modePick } });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (t.dataset.action) {
|
||||
case 'pick-recroom':
|
||||
bridge.send({ type: 'pick-recroom' });
|
||||
break;
|
||||
case 'open-settings':
|
||||
previousView = document.querySelector('section[data-view]:not([hidden])')?.dataset.view;
|
||||
showView('settings');
|
||||
break;
|
||||
case 'back-from-settings':
|
||||
showView(previousView || appState?.mode || 'first-run');
|
||||
break;
|
||||
case 'copy-join-code':
|
||||
const code = document.getElementById('join-code-value').textContent;
|
||||
navigator.clipboard.writeText(code).then(
|
||||
() => showToast('Join code copied', 'ok'),
|
||||
() => showToast('Copy failed — select + Ctrl+C manually', 'error'));
|
||||
break;
|
||||
case 'decode-join-code':
|
||||
const codeRaw = document.getElementById('join-code-input').value.trim();
|
||||
bridge.send({ type: 'decode-join-code', payload: { code: codeRaw } });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Photon inputs auto-persist on blur.
|
||||
['photon-app-id', 'photon-voice-app-id', 'photon-region'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', () => {
|
||||
bridge.send({
|
||||
type: 'set-photon',
|
||||
payload: {
|
||||
appId: document.getElementById('photon-app-id').value.trim(),
|
||||
voiceAppId: document.getElementById('photon-voice-app-id').value.trim(),
|
||||
region: document.getElementById('photon-region').value,
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('server-name').addEventListener('change', e => {
|
||||
bridge.send({ type: 'set-server-name', payload: { name: e.target.value.trim() } });
|
||||
});
|
||||
|
||||
// Host start/stop.
|
||||
document.getElementById('host-start').addEventListener('click', () => {
|
||||
if (!appState?.recRoomPath) { showToast('Pick your Rec Room install first', 'error'); return; }
|
||||
if (!appState?.photonAppId) { showToast('Photon AppId required (free at dashboard.photonengine.com)', 'error'); return; }
|
||||
const versionKey = document.getElementById('version-select').value;
|
||||
document.getElementById('host-start').disabled = true;
|
||||
document.getElementById('host-stop').hidden = false;
|
||||
bridge.send({ type: 'host-start', payload: { versionKey } });
|
||||
});
|
||||
|
||||
document.getElementById('host-stop').addEventListener('click', () => {
|
||||
bridge.send({ type: 'host-stop' });
|
||||
document.getElementById('host-stop').hidden = true;
|
||||
document.getElementById('host-start').disabled = false;
|
||||
document.getElementById('join-code-row').hidden = true;
|
||||
});
|
||||
|
||||
// Join apply.
|
||||
document.getElementById('join-apply').addEventListener('click', () => {
|
||||
const code = document.getElementById('join-code-input').value.trim();
|
||||
bridge.send({ type: 'join-apply', payload: { code } });
|
||||
});
|
||||
|
||||
document.getElementById('join-code-input').addEventListener('input', refreshJoinReadiness);
|
||||
|
||||
function refreshJoinReadiness() {
|
||||
const btn = document.getElementById('join-apply');
|
||||
if (!btn) return;
|
||||
const hasCode = document.getElementById('join-code-input').value.trim().length > 10;
|
||||
const hasPath = !!appState?.recRoomPath;
|
||||
btn.disabled = !(hasCode && hasPath);
|
||||
}
|
||||
|
||||
// ── Render helpers for incoming events ───────────────────────────────
|
||||
function renderHostStatus(p) {
|
||||
const el = document.getElementById('host-status');
|
||||
if (!p) return;
|
||||
if (p.stage === 'ready') {
|
||||
el.className = 'status ok';
|
||||
el.textContent = `Hosting at ${p.publicUrl}`;
|
||||
document.getElementById('join-code-value').textContent = p.joinCode;
|
||||
document.getElementById('join-code-row').hidden = false;
|
||||
} else if (p.stage === 'stopped') {
|
||||
el.className = 'status';
|
||||
el.textContent = 'Stopped.';
|
||||
document.getElementById('host-stop').hidden = true;
|
||||
document.getElementById('host-start').disabled = false;
|
||||
document.getElementById('join-code-row').hidden = true;
|
||||
} else {
|
||||
el.className = 'status';
|
||||
el.textContent = humanStage(p.stage);
|
||||
}
|
||||
}
|
||||
|
||||
function renderJoinStatus(p) {
|
||||
const el = document.getElementById('join-status');
|
||||
if (!p) return;
|
||||
el.className = p.stage === 'ready' ? 'status ok' : 'status';
|
||||
el.textContent = p.stage === 'ready'
|
||||
? 'Patched. Launch Rec Room from Steam to connect.'
|
||||
: humanStage(p.stage);
|
||||
}
|
||||
|
||||
function renderJoinPreview(p) {
|
||||
const el = document.getElementById('join-preview');
|
||||
if (!el) return;
|
||||
if (!p) {
|
||||
el.className = 'status error';
|
||||
el.textContent = 'Invalid join code.';
|
||||
el.hidden = false;
|
||||
return;
|
||||
}
|
||||
el.className = 'status';
|
||||
el.innerHTML = `Connecting to <b>${escapeHtml(p.name || '(unnamed server)')}</b><br>` +
|
||||
`Host: <code>${escapeHtml(p.host)}</code><br>` +
|
||||
`Version: <code>${escapeHtml(p.v)}</code>`;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function renderDownloadProgress(p) {
|
||||
// Could render a progress bar; for v0.1 just flash a status line.
|
||||
if (!p || !p.total || p.total <= 0) return;
|
||||
const pct = Math.round(p.fraction * 100);
|
||||
const mb = (p.bytes / (1024 * 1024)).toFixed(1);
|
||||
const totalMb = (p.total / (1024 * 1024)).toFixed(1);
|
||||
const hostEl = document.getElementById('host-status');
|
||||
const joinEl = document.getElementById('join-status');
|
||||
const text = `Downloading… ${mb} / ${totalMb} MB (${pct}%)`;
|
||||
if (hostEl && !hostEl.classList.contains('ok')) hostEl.textContent = text;
|
||||
if (joinEl && !joinEl.classList.contains('ok')) joinEl.textContent = text;
|
||||
}
|
||||
|
||||
function humanStage(stage) {
|
||||
return ({
|
||||
'downloading-server': 'Downloading server binary…',
|
||||
'opening-tunnel': 'Opening Cloudflare tunnel…',
|
||||
'starting-server': 'Starting the server…',
|
||||
'patching-client': 'Patching your Rec Room client…',
|
||||
'downloading-patcher': 'Downloading client patcher…',
|
||||
'ready': 'Ready.',
|
||||
'stopped': 'Stopped.',
|
||||
}[stage] || stage);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
// Render an empty 'first-run' until the C# init reply arrives.
|
||||
showView('first-run');
|
||||
bridge.send({ type: 'init' });
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>DorkNet</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<!-- First-run wizard. Hidden once state.mode flips to host or join. -->
|
||||
<section data-view="first-run" hidden>
|
||||
<header>
|
||||
<div class="logo">DorkNet</div>
|
||||
<p class="tagline">Self-hostable 2020 Rec Room backend</p>
|
||||
</header>
|
||||
<div class="card-row">
|
||||
<button class="card" data-mode-pick="host">
|
||||
<span class="card-emoji">🛠️</span>
|
||||
<span class="card-title">Host a server</span>
|
||||
<span class="card-body">Run a server for you and your friends. Picks a Rec Room version, downloads the matching server, opens a tunnel, shares a join code.</span>
|
||||
</button>
|
||||
<button class="card" data-mode-pick="join">
|
||||
<span class="card-emoji">🎮</span>
|
||||
<span class="card-title">Join a friend</span>
|
||||
<span class="card-body">Paste a join code, point at your Rec Room install. The launcher fetches the matching patcher.</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="footnote">You can switch modes later under Settings.</p>
|
||||
</section>
|
||||
|
||||
<!-- Host view: pick version → server downloads → tunnel + patcher → share code. -->
|
||||
<section data-view="host" hidden>
|
||||
<nav class="topbar">
|
||||
<span class="topbar-title">Host</span>
|
||||
<button class="link" data-action="open-settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<label>Rec Room install</label>
|
||||
<div class="path-row">
|
||||
<code id="recroom-path-display">(not picked)</code>
|
||||
<button data-action="pick-recroom">Pick…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="server-name">Server name (shown to joiners)</label>
|
||||
<input id="server-name" type="text" placeholder="e.g. Saturday Hangout" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="version-select">Rec Room version to host for</label>
|
||||
<select id="version-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="row" id="photon-config">
|
||||
<label>Photon AppIds (free at <a href="https://dashboard.photonengine.com" target="_blank">dashboard.photonengine.com</a>)</label>
|
||||
<input id="photon-app-id" type="text" placeholder="Realtime AppId (UUID)" />
|
||||
<input id="photon-voice-app-id" type="text" placeholder="Voice AppId (UUID, optional)" />
|
||||
<select id="photon-region">
|
||||
<option value="us">US</option>
|
||||
<option value="eu">EU</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="jp">Japan</option>
|
||||
<option value="sa">South America</option>
|
||||
<option value="kr">Korea</option>
|
||||
<option value="in">India</option>
|
||||
<option value="au">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="host-start" class="primary">Start hosting</button>
|
||||
<button id="host-stop" class="secondary" hidden>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="host-status" class="status"></div>
|
||||
|
||||
<div id="join-code-row" class="row" hidden>
|
||||
<label>Join code (share this)</label>
|
||||
<div class="join-code-display">
|
||||
<code id="join-code-value"></code>
|
||||
<button data-action="copy-join-code">Copy</button>
|
||||
</div>
|
||||
<small>Joiners paste this into their DorkNet launcher's Join screen.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Join view: paste code → patcher downloads + installs → launch Rec Room. -->
|
||||
<section data-view="join" hidden>
|
||||
<nav class="topbar">
|
||||
<span class="topbar-title">Join</span>
|
||||
<button class="link" data-action="open-settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<label for="join-code-input">Paste the join code your host shared</label>
|
||||
<textarea id="join-code-input" rows="3" placeholder="A long base64-looking string"></textarea>
|
||||
<button data-action="decode-join-code">Preview</button>
|
||||
</div>
|
||||
|
||||
<div id="join-preview" class="status" hidden></div>
|
||||
|
||||
<div class="row">
|
||||
<label>Rec Room install</label>
|
||||
<div class="path-row">
|
||||
<code id="recroom-path-display-join">(not picked)</code>
|
||||
<button data-action="pick-recroom">Pick…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="join-apply" class="primary" disabled>Patch and launch</button>
|
||||
</div>
|
||||
|
||||
<div id="join-status" class="status"></div>
|
||||
</section>
|
||||
|
||||
<!-- Settings drawer (overlay). -->
|
||||
<section data-view="settings" hidden>
|
||||
<nav class="topbar">
|
||||
<span class="topbar-title">Settings</span>
|
||||
<button class="link" data-action="back-from-settings">← back</button>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<label>Mode</label>
|
||||
<button data-mode-pick="host">Switch to Host</button>
|
||||
<button data-mode-pick="join">Switch to Join</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Cache location</label>
|
||||
<small id="cache-paths"></small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>About</label>
|
||||
<small>DorkNet Launcher v0.1 — <a href="https://github.com/DorkSquadRR/DorkNet" target="_blank">GitHub</a> — AGPL-3.0</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toast container for transient errors / progress flashes. -->
|
||||
<div id="toasts"></div>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
/* DorkNet Launcher UI — small surface, opinionated styling.
|
||||
No framework. Stays browser-portable (PhotinoNET is WebView2). */
|
||||
|
||||
:root {
|
||||
--bg: #0e0f13;
|
||||
--bg-elev: #181a21;
|
||||
--bg-elev-2: #21232d;
|
||||
--border: #2a2d3a;
|
||||
--ink: #e8ebf3;
|
||||
--ink-soft: #9aa0b4;
|
||||
--ink-faint: #5e6478;
|
||||
--brand: #7c5cff;
|
||||
--brand-hover: #9a7eff;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #44d68a;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a { color: var(--brand-hover); }
|
||||
a:hover { color: var(--brand); }
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
section[data-view] { animation: fade-in 160ms ease-out; }
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* First-run wizard */
|
||||
section[data-view="first-run"] header { text-align: center; margin-bottom: 28px; }
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--brand) 0%, #ff6bd6 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.tagline { color: var(--ink-soft); margin: 4px 0 0; }
|
||||
|
||||
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
|
||||
.card {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
|
||||
text-align: left; padding: 20px; cursor: pointer;
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--ink); transition: border-color 100ms, transform 100ms;
|
||||
}
|
||||
.card:hover { border-color: var(--brand); transform: translateY(-2px); }
|
||||
.card-emoji { font-size: 28px; }
|
||||
.card-title { font-weight: 600; font-size: 16px; }
|
||||
.card-body { color: var(--ink-soft); font-size: 13px; }
|
||||
|
||||
.footnote { text-align: center; color: var(--ink-faint); font-size: 12px; }
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 0 16px; margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title { font-weight: 600; font-size: 18px; }
|
||||
.link {
|
||||
background: none; border: 0; color: var(--ink-soft); cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.link:hover { color: var(--ink); }
|
||||
|
||||
/* Form rows */
|
||||
.row { margin-bottom: 18px; }
|
||||
.row > label {
|
||||
display: block; color: var(--ink-soft); font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px;
|
||||
}
|
||||
.row input, .row select, .row textarea {
|
||||
width: 100%; padding: 8px 10px; margin-bottom: 6px;
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--ink); font: inherit;
|
||||
}
|
||||
.row input:focus, .row select:focus, .row textarea:focus {
|
||||
outline: 0; border-color: var(--brand);
|
||||
}
|
||||
|
||||
.path-row { display: flex; align-items: center; gap: 8px; }
|
||||
.path-row code {
|
||||
flex: 1; padding: 8px 10px; background: var(--bg-elev); border-radius: 6px;
|
||||
color: var(--ink-soft); font-size: 12px; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button:not(.card):not(.link) {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-elev-2); color: var(--ink);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
cursor: pointer; font: inherit;
|
||||
transition: background 100ms, border-color 100ms;
|
||||
}
|
||||
button:not(.card):not(.link):hover { background: var(--border); }
|
||||
button.primary { background: var(--brand); border-color: var(--brand); color: white; }
|
||||
button.primary:hover { background: var(--brand-hover); border-color: var(--brand-hover); }
|
||||
button.primary:disabled { background: var(--bg-elev-2); border-color: var(--border); color: var(--ink-faint); cursor: not-allowed; }
|
||||
button.secondary { background: var(--bg-elev); }
|
||||
|
||||
.actions { display: flex; gap: 8px; margin: 24px 0 16px; }
|
||||
|
||||
/* Status panel */
|
||||
.status {
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 12px 14px; margin-top: 8px; min-height: 40px;
|
||||
font-size: 13px; color: var(--ink-soft);
|
||||
}
|
||||
.status.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.status.error { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
.join-code-display { display: flex; gap: 8px; align-items: stretch; }
|
||||
.join-code-display code {
|
||||
flex: 1; padding: 8px 10px; background: var(--bg-elev-2); border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
word-break: break-all; font-size: 12px;
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
#toasts {
|
||||
position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column;
|
||||
gap: 8px; z-index: 100; pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px; background: var(--bg-elev-2); border: 1px solid var(--border);
|
||||
border-radius: 6px; min-width: 240px; max-width: 360px;
|
||||
animation: toast-in 200ms ease-out;
|
||||
}
|
||||
.toast.error { border-color: var(--danger); }
|
||||
.toast.ok { border-color: var(--ok); }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user