@homie-rocks/studio 0.31.1 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /*
2
+ * THE DESKTOP SHELL (standalone/STANDALONE.md): one window that shows the game's web folder, and nothing else. It is
3
+ * the same file for every game: what differs is app.json beside it. The page gets no Node, no preload and no way out
4
+ * of its own folder; a link opens in the person's browser.
5
+ *
6
+ * --smoke[=<ms>] a self-check for `homie-studio standalone`: load, wait, print ONE line of JSON saying where the
7
+ * page is, whether the game's frame loaded, what failed to load and what the console said, exit.
8
+ * With --shot=<file.png>, a picture of the window as it was then.
9
+ */
10
+ 'use strict';
11
+ const { app, BrowserWindow, Menu, net, protocol, session, shell } = require('electron');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { Readable } = require('node:stream');
15
+ const { pathToFileURL } = require('node:url');
16
+ const { fileFor, rangeOf, mayNavigate } = require('./files.cjs');
17
+
18
+ const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'app.json'), 'utf8'));
19
+ const WEB = path.join(__dirname, 'web');
20
+ const ORIGIN = 'app://game';
21
+ const smokeArg = process.argv.find((a) => /^--smoke(=\d+)?$/.test(a));
22
+ const SMOKE = smokeArg ? Number(smokeArg.split('=')[1]) || 6000 : 0;
23
+
24
+ // Nothing a page does may raise Electron's own error dialog over the game: said in the log, and the game goes on.
25
+ process.on('uncaughtException', (error) => { try { console.error(`shell: ${String(error && error.stack ? error.stack : error).slice(0, 600)}`); } catch { /* no console */ } });
26
+
27
+ // This game's own storage and its own single-instance lock: two games with the same name never share either.
28
+ // The self-check keeps nothing: its storage is a folder of its own in the temporary folder, removed when it ends.
29
+ const smokeData = SMOKE ? fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'standalone-smoke-')) : null;
30
+ if (smokeData) { app.setPath('userData', smokeData); app.on('quit', () => { try { fs.rmSync(smokeData, { recursive: true, force: true }); } catch { /* the system clears it */ } }); }
31
+ else if (/^[A-Za-z][A-Za-z0-9.]{2,120}$/.test(cfg.appId || '')) app.setPath('userData', path.join(app.getPath('appData'), cfg.appId));
32
+
33
+ // The page's own origin: a standard, secure one, so its storage, its modules and its fetches behave as on a site.
34
+ protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }]);
35
+
36
+ /*
37
+ * STARTED BY STEAM (Steam sets SteamAppId for what it starts; a game.json with a Steam app is not enough). The
38
+ * overlay is a library the Steam client loads into this process, and it draws only when the GPU runs in the browser
39
+ * process, so that switch is passed. This has never been run under Steam, and Electron apps often show no overlay.
40
+ * Chromium's sandbox is NOT turned off here: a switch added at this point is too late for it, and inside Steam's
41
+ * Linux container the way to do that is the launch option --no-sandbox, set in Steamworks (STANDALONE.md says so).
42
+ */
43
+ const underSteam = Boolean(process.env.SteamAppId || process.env.SteamGameId || process.env.SteamOverlayGameId);
44
+ if (underSteam) app.commandLine.appendSwitch('in-process-gpu');
45
+ // Steam's own API, only when game.json asked for it and Steam started the game: whatever happens is said in the log,
46
+ // and the game runs either way. This binding has never been run by the people who wrote this file.
47
+ if (underSteam && cfg.steam && cfg.steam.api) {
48
+ try {
49
+ const steam = require('steamworks-ffi-node');
50
+ const s = (steam.SteamworksSDK || steam.default || steam).getInstance();
51
+ // Steam's own libraries, copied beside this file from the SDK the studio's owner downloaded (never shipped by Homie).
52
+ s.setSdkPath(path.join(__dirname, 'steamworks_sdk'));
53
+ const started = s.init({ appId: cfg.steam.app });
54
+ if (started) { console.log('steam: the Steamworks binding started'); app.on('before-quit', () => { try { s.shutdown(); } catch { /* gone */ } }); }
55
+ else console.error('steam: the Steamworks binding said it did not start (init answered false); playing without it');
56
+ } catch (error) { console.error(`steam: the Steamworks binding did not start (${String(error && error.message).slice(0, 200)}); playing without it`); }
57
+ }
58
+
59
+ const MEDIA = { '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.mp4': 'video/mp4', '.webm': 'video/webm' };
60
+ async function serve(request) {
61
+ const file = fileFor(WEB, request.url);
62
+ if (!file) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
63
+ // A part of a file (a player seeking in a song): answered here, since a file fetch is always the whole of it.
64
+ let size = 0;
65
+ try { size = fs.statSync(file).size; } catch { return new Response('not found', { status: 404 }); }
66
+ const range = rangeOf(request.headers.get('range'), size);
67
+ if (range === 'whole') return net.fetch(pathToFileURL(file).toString());
68
+ if (range === 'none') return new Response(null, { status: 416, headers: { 'content-range': `bytes */${size}` } });
69
+ const stream = fs.createReadStream(file, range);
70
+ // A player that stops asking (a seek, a closed page) ends the stream early: that is not an error of anybody's.
71
+ stream.on('error', () => {});
72
+ return new Response(Readable.toWeb(stream), { status: 206, headers: { 'content-type': MEDIA[path.extname(file).toLowerCase()] || 'application/octet-stream', 'content-length': String(range.end - range.start + 1), 'content-range': `bytes ${range.start}-${range.end}/${size}`, 'accept-ranges': 'bytes' } });
73
+ }
74
+
75
+ /** macOS wants a menu bar: the app's own (Quit), Edit (so copy and paste work in the room-code field) and Window. No Reload, no tools, no Help. */
76
+ function menu() {
77
+ if (process.platform !== 'darwin') return null;
78
+ return Menu.buildFromTemplate([
79
+ { label: cfg.name, submenu: [{ role: 'about', label: `About ${cfg.name}` }, { type: 'separator' }, { role: 'hide', label: `Hide ${cfg.name}` }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit', label: `Quit ${cfg.name}` }] },
80
+ { label: 'Edit', submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }] },
81
+ { label: 'Window', submenu: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'togglefullscreen' }] },
82
+ ]);
83
+ }
84
+
85
+ function open() {
86
+ const win = new BrowserWindow({
87
+ width: cfg.width || 1280, height: cfg.height || 720, minWidth: 480, minHeight: 320, show: !SMOKE,
88
+ title: cfg.name, backgroundColor: cfg.background || '#04060c', autoHideMenuBar: true,
89
+ ...(process.platform === 'linux' && fs.existsSync(path.join(__dirname, 'icon.png')) ? { icon: path.join(__dirname, 'icon.png') } : {}),
90
+ // The developer tools are for the copy a studio runs from its own folder, never for the one it ships.
91
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false, devTools: !app.isPackaged },
92
+ });
93
+ const wc = win.webContents;
94
+ wc.on('will-navigate', (event, url) => { if (!mayNavigate(url, { main: true })) event.preventDefault(); });
95
+ wc.on('will-frame-navigate', (event) => { if (!mayNavigate(event.url, { main: event.isMainFrame })) event.preventDefault(); });
96
+ wc.setWindowOpenHandler(({ url }) => { if (/^https:\/\//i.test(url)) shell.openExternal(url); return { action: 'deny' }; });
97
+ wc.on('before-input-event', (event, input) => {
98
+ if (input.type === 'keyDown' && (input.key === 'F11' || (input.key === 'Enter' && input.alt))) { win.setFullScreen(!win.isFullScreen()); event.preventDefault(); }
99
+ });
100
+ if (SMOKE) smoke(win);
101
+ win.loadURL(`${ORIGIN}/index.html`);
102
+ }
103
+
104
+ /** The self-check: what a person would see go wrong, as one line of JSON, then exit (1 when the game did not load). */
105
+ function smoke(win) {
106
+ const wc = win.webContents;
107
+ const failed = []; const errors = []; let requests = 0;
108
+ session.defaultSession.webRequest.onCompleted((d) => { requests += 1; if (d.statusCode >= 400) failed.push(`${d.statusCode} ${d.url}`.slice(0, 200)); });
109
+ session.defaultSession.webRequest.onErrorOccurred((d) => { failed.push(`${d.error} ${d.url}`.slice(0, 200)); });
110
+ wc.on('console-message', (event) => { if (event.level === 'error') errors.push(String(event.message).slice(0, 300)); });
111
+ wc.on('did-fail-load', (event, code, why, url) => failed.push(`${code} ${why} ${url}`.slice(0, 200)));
112
+ const probe = `(() => { const f = document.querySelector('iframe.game'); let g = null;
113
+ try { const w = f.contentWindow, d = f.contentDocument, n = w.__homieNet; g = { href: w.location.href, ready: d.readyState, canvas: d.querySelectorAll('canvas').length, net: w.HOMIE_NET ? { url: w.HOMIE_NET.url, room: w.HOMIE_NET.room || null, app: w.HOMIE_NET.app === true } : null, helper: n ? { role: n.role, offline: n.offline, connected: n.connected, link: n.link, roster: Array.isArray(n.roster) ? n.roster.length : null } : null }; } catch (e) { g = { error: String(e) }; }
114
+ return { origin: location.origin, title: document.title, shell: window.__shell ? { room: window.__shell.room, online: window.__shell.online, link: window.__shell.link, starts: window.__shell.starts } : null, game: g }; })()`;
115
+ setTimeout(async () => {
116
+ let page = null;
117
+ try { page = await wc.executeJavaScript(probe); } catch (error) { errors.push(`probe: ${String(error && error.message)}`); }
118
+ const shot = process.argv.find((x) => x.startsWith('--shot='));
119
+ if (shot) { try { fs.writeFileSync(shot.slice(7), (await wc.capturePage()).toPNG()); } catch (error) { errors.push(`shot: ${String(error && error.message)}`); } }
120
+ const loaded = Boolean(page && page.game && page.game.ready === 'complete');
121
+ process.stdout.write(`${JSON.stringify({ smoke: 1, ok: loaded && !failed.length && !errors.length, loaded, packaged: app.isPackaged, requests, failed, errors, ...page })}\n`);
122
+ app.exit(loaded ? 0 : 1);
123
+ }, SMOKE);
124
+ }
125
+
126
+ if (!SMOKE && !app.requestSingleInstanceLock()) app.quit();
127
+ else {
128
+ app.on('second-instance', () => { const w = BrowserWindow.getAllWindows()[0]; if (w) { if (w.isMinimized()) w.restore(); w.focus(); } });
129
+ app.whenReady().then(() => {
130
+ protocol.handle('app', serve);
131
+ // The page may lock the pointer, go full screen and put an invite link on the clipboard. Nothing else is asked for.
132
+ const allowed = new Set(['pointerLock', 'fullscreen', 'clipboard-sanitized-write']);
133
+ session.defaultSession.setPermissionRequestHandler((wc, permission, done) => done(allowed.has(permission)));
134
+ session.defaultSession.setPermissionCheckHandler((wc, permission) => allowed.has(permission));
135
+ Menu.setApplicationMenu(menu());
136
+ open();
137
+ });
138
+ app.on('window-all-closed', () => app.quit());
139
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Package the desktop app with @electron/packager (standalone/STANDALONE.md):
4
+ * `node pack.mjs <platform> <arch> <debug|release>`, run in the standalone project by `homie-studio standalone
5
+ * build`. It is the same file for every game: what it packs is electron/ (main.cjs, app.json, the web folder),
6
+ * unpacked beside the Electron binary (no asar: Steam patches a build file by file, and the game's files stay the
7
+ * files the web build made). The result is out/<os>/<debug|release>/: that folder IS the app's folder.
8
+ *
9
+ * macOS signing comes from the environment only, never from a file or an argument:
10
+ * HOMIE_APPLE_IDENTITY the signing identity's name ("Developer ID Application: …", or - for an ad hoc
11
+ * signature that only this Mac trusts); with it the app is signed with the hardened
12
+ * runtime. The signer's own entitlements are used, which differ for the app and each
13
+ * helper; a game with a Steam app gets entitlements.mac.plist instead (see the
14
+ * generator), because the signer replaces, never merges. So does an ad hoc
15
+ * signature: with no team in it, the hardened runtime would refuse the app's own
16
+ * frameworks unless library validation is off
17
+ * HOMIE_APPLE_NOTARY_PROFILE a notarytool keychain profile (xcrun notarytool store-credentials, made by the
18
+ * person in their own terminal); with it the signed app is sent to Apple's notary
19
+ * "notarized" is said only when `xcrun stapler validate` agrees afterwards; otherwise the result says what was asked
20
+ * for and that it was not confirmed.
21
+ * It prints ONE line of JSON: { ok, platform, arch, path, app, exe, signed, notarized, checks } or { ok: false, why }.
22
+ */
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { packager } from '@electron/packager';
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const [platform, arch, kind = 'debug'] = process.argv.slice(2);
31
+ const FOLDER = { darwin: 'mac', win32: 'windows', linux: 'linux' };
32
+ const say = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
33
+
34
+ try {
35
+ if (!FOLDER[platform] || !arch || !['debug', 'release'].includes(kind)) throw new Error('usage: node pack.mjs darwin|win32|linux <arch> debug|release');
36
+ const meta = JSON.parse(readFileSync(join(here, 'electron', 'package.json'), 'utf8'));
37
+ const app = JSON.parse(readFileSync(join(here, 'electron', 'app.json'), 'utf8'));
38
+ const electron = JSON.parse(readFileSync(join(here, 'node_modules', 'electron', 'package.json'), 'utf8')).version;
39
+ const identity = process.env.HOMIE_APPLE_IDENTITY || '';
40
+ const profile = process.env.HOMIE_APPLE_NOTARY_PROFILE || '';
41
+ const sign = platform === 'darwin' && Boolean(identity);
42
+ const adhoc = identity === '-';
43
+ const notarize = sign && !adhoc && Boolean(profile);
44
+ const icon = { darwin: join(here, 'icons', 'icon.icns'), win32: join(here, 'icons', 'icon.ico') }[platform];
45
+ const entitlements = join(here, 'entitlements.mac.plist');
46
+ const staging = join(here, 'out', `.pack-${FOLDER[platform]}-${kind}`);
47
+ rmSync(staging, { recursive: true, force: true });
48
+ const [made] = await packager({
49
+ // The file's name is the game's name made safe for every file system (the toolkit's fileName). macOS shows that
50
+ // name in the menu bar and the Dock too (the packager writes it over any other); the window's title is the game's own.
51
+ dir: join(here, 'electron'), out: staging, name: meta.fileName, executableName: platform === 'darwin' ? undefined : meta.name,
52
+ platform, arch, electronVersion: electron, overwrite: true, asar: false, prune: true, quiet: true,
53
+ appBundleId: app.appId, appVersion: meta.version, buildVersion: String(app.build ?? 1), appCopyright: meta.author ? `© ${meta.author}` : undefined,
54
+ appCategoryType: 'public.app-category.games',
55
+ // Where Electron's own download is kept between builds: its usual cache, or the folder Electron's installer is told.
56
+ ...(process.env.electron_config_cache ? { download: { cacheRoot: process.env.electron_config_cache } } : {}),
57
+ ...(icon && existsSync(icon) ? { icon } : {}),
58
+ ...(platform === 'win32' ? { win32metadata: { CompanyName: meta.author ?? '', ProductName: meta.productName, FileDescription: meta.productName } } : {}),
59
+ ...(sign ? { osxSign: { identity, ...(adhoc ? { identityValidation: false } : {}), optionsForFile: () => ({ hardenedRuntime: true, ...((app.steam || adhoc) && existsSync(entitlements) ? { entitlements } : {}) }) } } : {}),
60
+ ...(notarize ? { osxNotarize: { keychainProfile: profile } } : {}),
61
+ });
62
+ if (!made) throw new Error('the packager made nothing');
63
+ const out = join(here, 'out', FOLDER[platform], kind);
64
+ rmSync(out, { recursive: true, force: true });
65
+ mkdirSync(dirname(out), { recursive: true });
66
+ renameSync(made, out);
67
+ rmSync(staging, { recursive: true, force: true });
68
+ // What is really there, by looking: never a name worked out from the game's.
69
+ const names = readdirSync(out);
70
+ const bundle = platform === 'darwin' ? names.find((n) => n.endsWith('.app')) : null;
71
+ const exe = platform === 'darwin' ? (bundle ? join(out, bundle, 'Contents', 'MacOS', readdirSync(join(out, bundle, 'Contents', 'MacOS'))[0]) : null) : join(out, platform === 'win32' ? `${meta.name}.exe` : meta.name);
72
+ if (!exe || !existsSync(exe)) throw new Error(`the packager finished, and the app's program is not where it should be in ${out}`);
73
+ // What was asked of the signer and the notary is one thing; what the finished app answers is what gets said.
74
+ const checks = {};
75
+ const ask = (name, cmd, args) => { try { execFileSync(cmd, args, { stdio: 'pipe', timeout: 60_000 }); checks[name] = true; } catch (error) { checks[name] = false; checks[`${name}Says`] = String(error?.stderr ?? error?.message ?? '').trim().split('\n').slice(-2).join(' ').slice(0, 300); } };
76
+ if (sign && bundle) ask('signature', 'codesign', ['--verify', '--deep', '--strict', join(out, bundle)]);
77
+ if (notarize && bundle) ask('stapled', 'xcrun', ['stapler', 'validate', join(out, bundle)]);
78
+ say({ ok: true, platform, arch, kind, path: out, app: bundle ? join(out, bundle) : out, exe, electron, signed: sign && checks.signature === true, adhoc, notarizeAsked: notarize, notarized: notarize && checks.stapled === true, checks });
79
+ } catch (error) {
80
+ say({ ok: false, why: String(error?.message ?? error).split('\n').slice(0, 6).join(' ').slice(0, 800) });
81
+ process.exitCode = 1;
82
+ }
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
+ <title>Game</title>
7
+ <link rel="stylesheet" href="shell.css">
8
+ </head>
9
+ <body>
10
+ <iframe class="game" title="Game" allow="fullscreen; autoplay; gamepad"></iframe>
11
+ <div class="room at-top-right" data-room-ui>
12
+ <button class="pill" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room and invite"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Finding a room…</span></button>
13
+ <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
14
+ <div class="code"><b data-room-label>Offline</b><span data-room-state></span></div>
15
+ <div class="acts">
16
+ <button type="button" class="primary" data-invite>Invite</button>
17
+ <button type="button" data-quick>Quick play</button>
18
+ </div>
19
+ <span class="link" data-room-link></span>
20
+ <form class="join" data-join-form>
21
+ <input type="text" data-join-code inputmode="latin" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="32" placeholder="Room code" aria-label="Room code">
22
+ <button type="submit">Join a room</button>
23
+ </form>
24
+ <div class="foot"><button type="button" data-private>New private room</button><button type="button" data-offline>Play offline</button></div>
25
+ </div>
26
+ </div>
27
+ <div class="toast" data-toast role="status" hidden></div>
28
+ <div class="notice" data-notice role="alertdialog" aria-modal="true" hidden>
29
+ <div class="box"><h1 data-notice-title></h1><p data-notice-text></p>
30
+ <div class="acts"><button type="button" class="primary" data-notice-quick>Quick play</button><button type="button" data-notice-offline>Play offline</button></div>
31
+ </div>
32
+ </div>
33
+ <script src="config.js"></script>
34
+ <script src="shell.js"></script>
35
+ </body>
36
+ </html>
@@ -0,0 +1,46 @@
1
+ /*
2
+ * The standalone shell's own look: one room button and its sheet over the game, as on the site's play page
3
+ * (worker/pages.mjs), in the game's colours when it has an art direction (shell.js sets --paper, --text and --hot
4
+ * from config.js). The screen belongs to the game: no page pan, pinch-zoom, selection or callout.
5
+ */
6
+ :root { --hot: #ffcf5a; --paper: rgba(6,9,16,.62); --text: #eef1f8; }
7
+ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
8
+ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
9
+ .room { --dx: 0px; --dy: 0px; position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
10
+ .room.at-top-right { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; }
11
+ .room.at-top-left { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; }
12
+ .room.at-top-center { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(50% + var(--dx)); transform: translateX(-50%); align-items: center; }
13
+ .room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
14
+ .room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
15
+ .pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: var(--paper); color: var(--text); font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
16
+ .pill svg { width: 15px; height: 15px; flex: none; }
17
+ .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
18
+ .pill.dim span { display: none; }
19
+ .pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
20
+ .pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
21
+ .pill.icon, .pill.icon:hover, .pill.icon:focus-visible, .pill.icon[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
22
+ .pill.icon span, .pill.icon:hover span, .pill.icon:focus-visible span, .pill.icon[aria-expanded="true"] span { display: none; }
23
+ .sheet { box-sizing: border-box; width: min(300px, calc(100vw - 16px)); padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
24
+ .sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
25
+ .sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
26
+ .sheet .code span { color: #aab3c7; font-weight: 500; font-size: 12px; text-align: right; }
27
+ .sheet .acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
28
+ .sheet button, .sheet input { font: inherit; color: inherit; }
29
+ .sheet .acts > *, .sheet .join button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: transparent; cursor: pointer; }
30
+ .sheet .acts .primary { background: var(--hot); color: #0b0b10; border-color: transparent; font-weight: 800; }
31
+ .sheet .link { display: block; margin: 10px 2px 2px; color: #aab3c7; font: 500 11.5px/1.35 ui-monospace, Menlo, monospace; word-break: break-all; }
32
+ .sheet .join { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 10px; }
33
+ .sheet .join input { box-sizing: border-box; min-width: 0; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: rgba(255,255,255,.06); -webkit-user-select: text; user-select: text; }
34
+ .sheet .foot { display: flex; justify-content: space-between; margin-top: 10px; font-weight: 500; }
35
+ .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; cursor: pointer; }
36
+ .sheet .foot button:hover { color: #fff; }
37
+ .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); max-width: calc(100vw - 24px); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.25 ui-sans-serif, system-ui, sans-serif; text-align: center; pointer-events: none; }
38
+ .toast.tap { pointer-events: auto; cursor: pointer; border: 1px solid var(--hot); }
39
+ .notice { box-sizing: border-box; position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgba(4,6,12,.86); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: #eef1f8; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; touch-action: manipulation; }
40
+ .notice .box { box-sizing: border-box; width: min(420px, 100%); padding: 22px; border-radius: 20px; background: #0d111c; border: 1px solid rgba(255,255,255,.14); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
41
+ .notice h1 { margin: 0 0 8px; font-size: 22px; letter-spacing: -.01em; }
42
+ .notice p { margin: 0 0 10px; color: #c3cad9; }
43
+ .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
44
+ .notice .acts button { min-height: 44px; padding: 0 16px; border-radius: 12px; font: inherit; font-weight: 700; color: #eef1f8; background: transparent; border: 1px solid rgba(255,255,255,.18); cursor: pointer; }
45
+ .notice .acts button.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
46
+ [hidden] { display: none !important; }