@homie-rocks/studio 0.6.0 → 0.7.0

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.
package/worker/pages.mjs CHANGED
@@ -1,221 +1,55 @@
1
1
  /**
2
- * The studio site's pages: home, a game's page, the play shell. Plain HTML
3
- * from the Worker, no framework; every name a player typed is escaped.
2
+ * The play shell: the page around a game's sandboxed frame at /<game>/play and /<game>/tv. Every other page is
3
+ * worker/site.mjs. Every name a player typed is escaped.
4
4
  */
5
+ import { esc, layout } from './site.mjs';
5
6
 
6
- const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
7
+ export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
7
8
 
8
- const BASE_CSS = `
9
- :root { color-scheme: dark; --bg: #07080d; --panel: #10131c; --ink: #eef1f8; --dim: #9aa3b7; --line: rgba(255,255,255,.10); --accent: #ffcf5a; --accent-ink: #1a1405; }
10
- * { box-sizing: border-box; }
11
- html, body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
12
- a { color: inherit; }
13
- .wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 64px; }
14
- header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
15
- .brand { font-weight: 800; letter-spacing: -.02em; font-size: clamp(28px, 5vw, 44px); text-decoration: none; }
16
- .tag { color: var(--dim); font-size: 14px; }
17
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; margin-top: 28px; }
18
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 20px; display: flex; flex-direction: column; gap: 10px; min-height: 190px; }
19
- .card h2 { margin: 0; font-size: 22px; letter-spacing: -.01em; }
20
- .card p { margin: 0; color: var(--dim); flex: 1; }
21
- .meta { color: var(--dim); font-size: 13px; display: flex; gap: 12px; flex-wrap: wrap; }
22
- .live { color: #7dffb0; }
23
- .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 22px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 800; text-decoration: none; font-size: 17px; }
24
- .btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
25
- .hero { padding: 48px 0 24px; }
26
- .hero h1 { font-size: clamp(40px, 9vw, 88px); line-height: .95; margin: 0 0 16px; letter-spacing: -.03em; }
27
- .hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
28
- .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
29
- footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
30
- h2.section { margin: 44px 0 0; font-size: 15px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
31
- h2.section a { text-decoration: none; }
32
- .card .kind { color: var(--accent); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
33
- .thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; background: #000; display: block; }
34
- .thumb.square { aspect-ratio: 1; }
35
- .player { margin: 8px 0 20px; }
36
- .player audio { width: 100%; }
37
- .player video { width: 100%; max-height: 78vh; border-radius: 14px; background: #000; display: block; }
38
- .player video.vertical { max-width: 420px; margin: 0 auto; }
39
- .facts { color: var(--dim); font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; margin: 0 0 18px; }
40
- .rights { color: var(--dim); font-size: 13px; border-top: 1px solid var(--line); padding-top: 14px; margin-top: 24px; max-width: 70ch; }
41
- .lyrics { white-space: pre-wrap; font-size: 17px; line-height: 1.6; max-width: 60ch; }
42
- .files { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; max-width: 560px; }
43
- .files a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; text-decoration: none; }
44
- .files span { color: var(--dim); font-size: 13px; }
45
- .cover { width: min(320px, 100%); aspect-ratio: 1; object-fit: cover; border-radius: 16px; float: right; margin: 0 0 16px 24px; }
46
- @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } .cover { float: none; margin: 0 0 16px; width: 100%; } }
47
- `;
48
-
49
- function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
50
- return new Response(`<!doctype html>
51
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="${viewport}">
52
- <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
53
- <body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
54
- }
55
-
56
- const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
57
- const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
58
- const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
59
-
60
- function songCard(e) {
61
- const cover = fileOf(e, 'cover');
62
- return `
63
- <article class="card">
64
- ${cover ? `<img class="thumb square" src="${esc(cover.url)}" alt="" loading="lazy">` : ''}
65
- <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Music')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
66
- <h2>${esc(e.title)}</h2>
67
- <p>${esc(e.blurb)}</p>
68
- <div class="row"><a class="btn" href="/music/${esc(e.slug)}/">Listen</a></div>
69
- </article>`;
70
- }
71
-
72
- function videoCard(e) {
73
- const poster = fileOf(e, 'poster');
74
- return `
75
- <article class="card">
76
- ${poster ? `<img class="thumb" src="${esc(poster.url)}" alt="" loading="lazy">` : ''}
77
- <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Video')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
78
- <h2>${esc(e.title)}</h2>
79
- <p>${esc(e.blurb)}</p>
80
- <div class="row"><a class="btn" href="/videos/${esc(e.slug)}/">Watch</a></div>
81
- </article>`;
82
- }
83
-
84
- export function homePage(cat, live = {}) {
85
- const name = cat.studio?.name ?? 'Studio';
86
- const games = cat.games ?? [];
87
- const songs = cat.songs ?? [];
88
- const videos = cat.videos ?? [];
89
- const cards = games.map((g) => `
90
- <article class="card">
91
- <h2>${esc(g.name)}</h2>
92
- <p>${esc(g.blurb)}</p>
93
- <div class="meta"><span>${esc(g.players?.max ? `up to ${g.players.max} players` : 'multiplayer')}</span>${live[g.id] ? `<span class="live">● ${live[g.id]} playing now</span>` : ''}</div>
94
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play</a><a class="btn ghost" href="/${esc(g.id)}/">About</a></div>
95
- </article>`).join('');
96
- return page(name, `<div class="wrap">
97
- <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">${games.length ? 'Press Play and you are in a live room.' : 'Games, music and videos.'}</span></header>
98
- ${games.length ? `${songs.length || videos.length ? '<h2 class="section">Games</h2>' : ''}<section class="grid">${cards}</section>` : (songs.length || videos.length ? '' : '<p class="tag" style="margin-top:28px">Nothing published yet.</p>')}
99
- ${videos.length ? `<h2 class="section"><a href="/videos/">Videos</a></h2><section class="grid">${videos.map(videoCard).join('')}</section>` : ''}
100
- ${songs.length ? `<h2 class="section"><a href="/music/">Music</a></h2><section class="grid">${songs.map(songCard).join('')}</section>` : ''}
101
- <footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
102
- </div>`);
103
- }
104
-
105
- /** /music/ and /videos/: every published entry of one kind. */
106
- export function mediaIndexPage(cat, kind) {
107
- const name = cat.studio?.name ?? 'Studio';
108
- const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
109
- const title = kind === 'music' ? 'Music' : 'Videos';
110
- return page(`${title} · ${name}`, `<div class="wrap">
111
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${esc(title)}</span></header>
112
- ${list.length ? `<section class="grid">${list.map(kind === 'music' ? songCard : videoCard).join('')}</section>` : `<p class="tag" style="margin-top:28px">No ${kind === 'music' ? 'music' : 'videos'} published yet.</p>`}
113
- </div>`);
114
- }
115
-
116
- function rightsLine(e) {
117
- const bits = [];
118
- if (e.credits) bits.push(esc(e.credits));
119
- const r = e.rights;
120
- if (r && typeof r === 'object') {
121
- if (r.commercial === true) bits.push(`Licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
122
- else if (r.commercial === false) bits.push(`Not licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
123
- if (r.attribution) bits.push(esc(r.attribution));
124
- }
125
- if (e.honesty) bits.push(esc(e.honesty));
126
- return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
127
- }
9
+ /** A room code: 1 to 32 letters, digits, - or _. */
10
+ export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
128
11
 
129
12
  /**
130
- * The page's one stats beacon: when its player first starts, one POST to /api/stats/beat (a counter, nothing about
131
- * the listener). worker/stats.mjs says what is counted.
13
+ * Who may put the play page (and so the game) inside a frame: this site, plus the https origins studio.json
14
+ * `site.frameAncestors` names (for example a hub that embeds the game). Anything else is refused by the browser.
132
15
  */
133
- const playedBeacon = (kind, slug) => `<script>(function(){var m=document.querySelector(${JSON.stringify(kind === 'song' ? 'audio' : 'video[data-main]')});if(!m||!navigator.sendBeacon)return;m.addEventListener('play',function(){try{navigator.sendBeacon('/api/stats/beat',${JSON.stringify(JSON.stringify({ k: kind, s: slug })).replace(/</g, '\\u003c')});}catch(e){}},{once:true});}());</script>`;
134
-
135
- const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta property="${esc(k)}" content="${esc(v)}">`).join('');
136
-
137
- /** /music/<slug>/: the player, the facts, the words, the loops and stems another game may use. */
138
- export function songPage(cat, e, origin = '') {
139
- const name = cat.studio?.name ?? 'Studio';
140
- const audio = fileOf(e, 'audio');
141
- const cover = fileOf(e, 'cover');
142
- const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
143
- const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
144
- const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
145
- return page(`${e.title} · ${name}`, `<div class="wrap">
146
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/music/">Music</a></header>
147
- <section class="hero">
148
- ${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
149
- <h1>${esc(e.title)}</h1>
150
- ${e.blurb ? `<p>${esc(e.blurb)}</p>` : ''}
151
- <div class="player"><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
152
- <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null, e.bpm ? `${e.bpm} BPM` : null, e.key].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${game ? `<a href="/${esc(game.id)}/">${esc(game.name)}</a>` : ''}</div>
153
- </section>
154
- ${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
155
- ${extras.length ? `<h2 class="section">Loops and stems</h2><ul class="files">${extras.map((f) => `<li><a href="${esc(f.url)}" download><b>${esc(f.name ?? (f.role === 'loop' ? 'Loop' : 'Stem'))}</b><span>${esc([f.role, f.bars ? `${f.bars} bars` : null, f.bytes ? `${Math.max(1, Math.round(f.bytes / 1024))} KB` : null].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ul>` : ''}
156
- ${rightsLine(e)}
157
- </div>${playedBeacon('song', e.slug)}`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
158
- }
159
-
160
- /** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
161
- export function videoPage(cat, e, origin = '') {
162
- const name = cat.studio?.name ?? 'Studio';
163
- const video = fileOf(e, 'video');
164
- const vertical = fileOf(e, 'vertical');
165
- const poster = fileOf(e, 'poster');
166
- const captions = fileOf(e, 'captions');
167
- const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
168
- const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
169
- const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
170
- // A portrait phone gets the 9:16 cut when there is one; everything else the 16:9 cut.
171
- const pick = vertical ? `<script>(function(){var v=document.querySelector('video[data-main]');if(!v)return;if(matchMedia('(orientation: portrait) and (max-width: 700px)').matches){v.src=${JSON.stringify(vertical.url).replace(/</g, '\u003c')};v.classList.add('vertical');}}());</script>` : '';
172
- return page(`${e.title} · ${name}`, `<div class="wrap">
173
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/videos/">Videos</a></header>
174
- <section style="padding-top:28px">
175
- <div class="player"><video data-main controls playsinline preload="metadata" src="${esc(video.url)}"${poster ? ` poster="${esc(poster.url)}"` : ''}>${captions ? `<track kind="captions" src="${esc(captions.url)}" srclang="en" label="English">` : ''}</video></div>
176
- <h1 style="font-size:clamp(30px,6vw,56px);margin:0 0 10px;letter-spacing:-.02em">${esc(e.title)}</h1>
177
- ${e.blurb ? `<p class="tag" style="font-size:17px;max-width:62ch">${esc(e.blurb)}</p>` : ''}
178
- <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${song ? `<a href="/music/${esc(song.slug)}/">${esc(song.title)}</a>` : ''}</div>
179
- ${game ? `<div class="row"><a class="btn" href="/${esc(game.id)}/play">Play ${esc(game.name)}</a>${vertical ? `<a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a>` : ''}</div>` : (vertical ? `<div class="row"><a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a></div>` : '')}
180
- </section>
181
- ${rightsLine(e)}
182
- </div>${pick}${playedBeacon('video', e.slug)}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
183
- }
184
-
185
- export function gamePage(cat, g, playing = 0) {
186
- const name = cat.studio?.name ?? 'Studio';
187
- return page(`${g.name} · ${name}`, `<div class="wrap">
188
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${playing ? `<span class="live">● ${playing} playing now</span>` : 'Anyone who presses Play joins the same room.'}</span></header>
189
- <section class="hero">
190
- <h1>${esc(g.name)}</h1>
191
- <p>${esc(g.blurb)}</p>
192
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/tv">Big screen</a></div>
193
- <div class="meta" style="margin-top:18px"><span>${esc(g.players?.max ? `1–${g.players.max} players` : 'multiplayer')}</span>${g.roundSeconds ? `<span>${esc(g.roundSeconds)} s rounds</span>` : ''}<span>phone or computer</span></div>
194
- </section>
195
- </div>`);
16
+ export function frameAncestors(cat) {
17
+ const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
18
+ .map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
19
+ .filter(Boolean).slice(0, 8);
20
+ return ["'self'", ...extra].join(' ');
196
21
  }
197
22
 
198
- export function notFoundPage(why) {
199
- const res = page('Not found', `<div class="wrap"><h1>Not found</h1><p class="tag">${esc(why)}</p><p><a class="btn ghost" href="/">Home</a></p></div>`);
200
- return new Response(res.body, { status: 404, headers: res.headers });
23
+ /** What a play or big-screen page answers to a room code it cannot use: said on the page, never a silent public room. */
24
+ export function badRoomPage(cat, g, raw, { screen = false } = {}) {
25
+ const shown = String(raw ?? '').slice(0, 64);
26
+ return layout(cat, {
27
+ title: `That room link does not work · ${g.name}`, page: 'bad-room', status: 400,
28
+ main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>That room link does not work</h1>
29
+ <p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or play in a public room.</p>
30
+ <div class="keys"><a class="btn" href="/${esc(g.id)}/${screen ? 'tv' : 'play'}" data-play>Join a public room</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
31
+ });
201
32
  }
202
33
 
203
34
  /**
204
- * The play shell. It asks the Lobby for a public room, boots the game frame at
205
- * once (seated, no waiting), keeps the seat token for a reload, and keeps the
206
- * room's facts in `window.__shell` (what a test or a big screen reads).
35
+ * The play shell. It asks the Lobby for a public room, boots the game frame at once (seated, no waiting), keeps
36
+ * the seat token for a reload, writes the room into the address (so a reload or a copied address comes back to
37
+ * it), and keeps the room's facts in `window.__shell` (what a test or a big screen reads). A small room button at
38
+ * the edge (game.json `screen.share` picks the corner) opens Invite, Big screen and the room code; nothing covers
39
+ * the middle of the screen or a thumb.
207
40
  */
208
41
  export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
42
+ const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
43
+ const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
209
44
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
210
45
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
211
- const css = `html, body { height: 100%; 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; }
46
+ const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
47
+ 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; }
212
48
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
213
- .chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
49
+ .chip { position: fixed; left: max(10px, env(safe-area-inset-left)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transition: opacity .6s; }
214
50
  .chip.quiet { opacity: 0; }
215
- .chip { padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); }
216
- .chip a { pointer-events: auto; text-decoration: none; }
217
51
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
218
- .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
52
+ .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
219
53
  .card ol { margin: 0; padding-left: 18px; }
220
54
  .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
221
55
  .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
@@ -223,21 +57,77 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
223
57
  .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
224
58
  .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
225
59
  .join .qr svg { width: 100%; height: 100%; display: block; }
226
- .join b { display: block; color: #ffcf5a; font-size: 1.15em; margin-bottom: 4px; }
60
+ .join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
227
61
  .join span { opacity: .85; word-break: break-all; }
62
+ .room { 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; }
63
+ .room.at-top-right { top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; }
64
+ .room.at-top-left { top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; }
65
+ .room.at-bottom-right { bottom: max(8px, env(safe-area-inset-bottom)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; flex-direction: column-reverse; }
66
+ .room.at-bottom-left { bottom: max(8px, env(safe-area-inset-bottom)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; flex-direction: column-reverse; }
67
+ .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: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
68
+ .pill svg { width: 15px; height: 15px; flex: none; }
69
+ .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
70
+ .pill.dim span { display: none; }
71
+ .pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
72
+ .pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
73
+ .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; }
74
+ .sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
75
+ .sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
76
+ .sheet .code span { color: #aab3c7; font-weight: 500; font-size: 12px; }
77
+ .sheet .acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
78
+ .sheet .acts > * { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; }
79
+ .sheet .acts .primary { background: var(--hot); color: #0b0b10; border-color: transparent; font-weight: 800; }
80
+ .sheet .acts svg { width: 16px; height: 16px; flex: none; }
81
+ .sheet .link { display: block; margin: 10px 2px 2px; color: #aab3c7; font: 500 11.5px/1.35 ui-monospace, Menlo, monospace; word-break: break-all; }
82
+ .sheet .foot { display: flex; justify-content: space-between; margin-top: 10px; font-weight: 500; }
83
+ .sheet .foot a, .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; font: inherit; text-decoration: none; cursor: pointer; }
84
+ .sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
85
+ .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
228
86
  [hidden] { display: none !important; }`;
229
87
  const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
230
88
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
231
- const corner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.join) ? g.screen.join : 'bottom-right';
232
- const joinCard = screen && joinUrl ? `<div class="join join-${corner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
233
- return page(`${g.name} · play`, `
234
- <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen; autoplay; gamepad"></iframe>
235
- <div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
89
+ const joinCorner = corner('join', 'bottom-right');
90
+ const shareCorner = corner('share', 'top-right');
91
+ const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
92
+ const share = screen ? '' : `<div class="room at-${shareCorner}" data-room-ui>
93
+ <button class="pill" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><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>Room</span></button>
94
+ <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
95
+ <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
96
+ <div class="acts">
97
+ <button type="button" class="primary" data-invite><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5 12 3l4.5 4.5M5 12v7a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-7"/></svg><span data-invite-word>Invite</span></button>
98
+ <a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
99
+ </div>
100
+ <span class="link" data-room-link></span>
101
+ <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a><button type="button" data-copy-link>Copy link</button></div>
102
+ </div>
103
+ </div>
104
+ <div class="toast" data-toast role="status" hidden></div>`;
105
+ return layoutless(`${g.name} · play`, `
106
+ <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
107
+ <div class="chip" data-chip><span data-status>finding a room…</span></div>
236
108
  <div class="card" data-results hidden></div>
237
109
  <div class="card" data-screen hidden></div>
238
- ${joinCard}
110
+ ${joinCard}${share}
239
111
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
240
- <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>`, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover' });
112
+ <script>${SHELL_JS}</script>`, css, frameAncestors(cat));
113
+ }
114
+
115
+ /** The play page's own document (no site chrome: the game owns the whole screen). */
116
+ function layoutless(title, body, css, ancestors = "'self'") {
117
+ return new Response(`<!doctype html>
118
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
119
+ <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${css}</style></head>
120
+ <body>${body}</body></html>`, {
121
+ headers: {
122
+ 'content-type': 'text/html; charset=utf-8',
123
+ 'cache-control': 'no-store, no-transform',
124
+ // Framed only by this site and the origins studio.json names (a browser that reads CSP ignores the older header).
125
+ 'x-frame-options': 'SAMEORIGIN',
126
+ 'content-security-policy': `frame-ancestors ${ancestors}`,
127
+ 'x-content-type-options': 'nosniff',
128
+ 'referrer-policy': 'strict-origin-when-cross-origin',
129
+ },
130
+ });
241
131
  }
242
132
 
243
133
  /* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
@@ -253,10 +143,10 @@ const SHELL_JS = String.raw`(function () {
253
143
  var statusEl = document.querySelector('[data-status]');
254
144
  var results = document.querySelector('[data-results]');
255
145
  var screenCard = document.querySelector('[data-screen]');
256
- var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null };
146
+ var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null };
257
147
  window.__shell = state;
258
148
 
259
- // The chip says who is here, then gets out of the game's way (games draw their own HUD at the top).
149
+ // The chip says who is here, then gets out of the game's way (games draw their own HUD at the top). It never takes a touch.
260
150
  var chip = document.querySelector('[data-chip]');
261
151
  var quietTimer = null;
262
152
  function say(text) {
@@ -267,8 +157,50 @@ const SHELL_JS = String.raw`(function () {
267
157
  quietTimer = setTimeout(function () { chip.classList.add('quiet'); }, 5000);
268
158
  }
269
159
 
160
+ // This room, shared: the code, Invite (the phone's share sheet, or the link copied), Big screen (the TV view of this room).
161
+ var ui = document.querySelector('[data-room-ui]');
162
+ var toggle = ui && ui.querySelector('[data-share-toggle]');
163
+ var sheet = ui && ui.querySelector('[data-share-sheet]');
164
+ var toast = document.querySelector('[data-toast]');
165
+ var dimTimer = null;
166
+ function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
167
+ function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
168
+ function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
169
+ function open(on) { if (!sheet) return; sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) wake(); else { wake(); try { frame.focus(); } catch (e) {} } }
170
+ function copy(text, done) {
171
+ var ok = function () { flash(done || 'Link copied'); };
172
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
173
+ else flash(text);
174
+ }
175
+ function shareReady(room) {
176
+ if (!ui) return;
177
+ var link = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
178
+ state.link = link;
179
+ ui.querySelector('[data-room-code]').textContent = labelOf(room);
180
+ ui.querySelector('[data-room-label]').textContent = labelOf(room);
181
+ ui.querySelector('[data-room-link]').textContent = link.replace(/^https?:\/\//, '');
182
+ ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
183
+ toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
184
+ ui.querySelector('[data-invite]').addEventListener('click', function () {
185
+ var data = { title: boot.name, text: 'Play ' + boot.name + ' with me: join my room.', url: link };
186
+ if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () {});
187
+ else copy(link, 'Invite link copied');
188
+ });
189
+ ui.querySelector('[data-copy-link]').addEventListener('click', function () { copy(link); });
190
+ document.addEventListener('pointerdown', function (e) { if (!sheet.hidden && !ui.contains(e.target)) open(false); }, true);
191
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) open(false); });
192
+ wake();
193
+ }
194
+
270
195
  function start(room) {
271
196
  state.room = room;
197
+ // The room goes into the address: a reload comes back to it, and a copied address brings a friend into it.
198
+ try {
199
+ if (params.get('room') !== room) {
200
+ params.set('room', room);
201
+ history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
202
+ }
203
+ } catch (e) {}
272
204
  var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
273
205
  var token = null;
274
206
  try { token = sessionStorage.getItem(KEY); } catch (e) {}
@@ -280,7 +212,7 @@ const SHELL_JS = String.raw`(function () {
280
212
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
281
213
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
282
214
  frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
283
- window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
215
+ window.addEventListener('pointerdown', function (e) { if (ui && ui.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
284
216
  window.addEventListener('message', function (ev) {
285
217
  if (ev.source !== frame.contentWindow) return;
286
218
  var m = ev.data;
@@ -294,9 +226,10 @@ const SHELL_JS = String.raw`(function () {
294
226
  paint();
295
227
  });
296
228
  watch(room);
229
+ shareReady(room);
297
230
  if (screenMode && !document.querySelector('[data-join]')) {
298
231
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
299
- var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
232
+ var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
300
233
  screenCard.append(h2, div); screenCard.hidden = false;
301
234
  }
302
235
  }
@@ -340,12 +273,16 @@ const SHELL_JS = String.raw`(function () {
340
273
  if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
341
274
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
342
275
  say(bits.join(' · ') || 'joining…');
276
+ var count = ui && ui.querySelector('[data-room-count]');
277
+ if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
343
278
  }
344
279
 
345
280
  // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
281
+ // The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
346
282
  var askedRoom = params.get('room');
347
283
  if (boot.room) start(boot.room);
348
- else if (askedRoom && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
284
+ else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
285
+ else if (askedRoom !== null) { say('that room link does not work'); }
349
286
  else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
350
287
  .then(function (r) { return r.json(); })
351
288
  .then(function (j) { start(j.room || 'main'); })
package/worker/room.mjs CHANGED
@@ -41,6 +41,33 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
41
41
  /** The keyed state channel: at most this many keys and bytes per room. */
42
42
  export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
43
43
 
44
+ /**
45
+ * A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
46
+ * "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
47
+ * At most 16 characters, so a game that trims names at 18 keeps all of it.
48
+ */
49
+ export const HANDLE_WORDS = Object.freeze({
50
+ first: ['Neon', 'Velvet', 'Static', 'Amber', 'Cobalt', 'Lucky', 'Rapid', 'Silent', 'Solar', 'Lunar', 'Crimson', 'Golden',
51
+ 'Frosty', 'Wild', 'Quiet', 'Swift', 'Brave', 'Clever', 'Cosmic', 'Mellow', 'Rusty', 'Shiny', 'Sunny', 'Stormy', 'Turbo',
52
+ 'Pixel', 'Retro', 'Jolly', 'Nimble', 'Bold', 'Misty', 'Copper'],
53
+ second: ['Comet', 'Otter', 'Falcon', 'Fox', 'Panda', 'Rocket', 'Tiger', 'Raven', 'Pilot', 'Ranger', 'Rider', 'Moth', 'Koi',
54
+ 'Lynx', 'Heron', 'Badger', 'Yeti', 'Wolf', 'Bison', 'Gecko', 'Orca', 'Puma', 'Sparrow', 'Beacon', 'Meteor', 'Nova',
55
+ 'Ember', 'Drift', 'Spark', 'Echo', 'Kite', 'Mantis'],
56
+ });
57
+ /** The handle for a seat token (random per seat), skipping any handle another seat in the room already has. */
58
+ export function handleFor(token, taken = new Set()) {
59
+ let h = 2166136261;
60
+ for (const ch of String(token ?? '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
61
+ const { first, second } = HANDLE_WORDS;
62
+ const all = first.length * second.length;
63
+ for (let i = 0; i < all; i += 1) {
64
+ const n = (h + i * 7919) % all;
65
+ const name = `${first[n % first.length]} ${second[Math.floor(n / first.length) % second.length]}`;
66
+ if (!taken.has(name)) return name;
67
+ }
68
+ return `${first[h % first.length]} ${second[h % second.length]}`;
69
+ }
70
+
44
71
  /** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
45
72
  function randomId(n) {
46
73
  const raw = new Uint8Array(n);
@@ -427,7 +454,7 @@ export class NetRoom {
427
454
  c.token = s.token;
428
455
  c.waiting = false;
429
456
  s.present = true;
430
- const name = c.typed || s.name || `Player ${seat + 1}`;
457
+ const name = c.typed || s.name || handleFor(s.token, new Set([...this.seats.values()].map((x) => x.name).filter(Boolean)));
431
458
  if (name !== s.name) { s.name = name; this.seatsDirty = true; }
432
459
  c.name = name;
433
460
  c.colour = seat % PALETTE_SIZE;