@homie-rocks/studio 0.6.0 → 0.8.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.
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * The studio site's pages, in the studio's own look (site/SITE.md): the same sections as homie.rocks
3
+ * (Home, Games, Music, Videos, Rooms, Posts, each shown only when the studio has something in it), an epic
4
+ * landing for every game (a full-bleed hero from the game's own footage or art, the pitch, a big Play button
5
+ * into a public room, phone / computer / TV, live rooms, how to play, credits, and "Make a game like this"),
6
+ * posts with Atom and JSON feeds, and a "Made with Homie" footer. Plain HTML from the Worker; every word a
7
+ * studio or a player wrote is escaped; the one script is /_homie/site.js.
8
+ *
9
+ * What a studio puts in site/ wins: a whole page (site/pages), a partial (site/partials), its tokens
10
+ * (site/theme.json) and CSS (site/theme.css). The build gathers them into games.json and site/dist/_site/.
11
+ */
12
+ import { qrSvg } from './qr.mjs';
13
+ import { STUDIO_VERSION_TAG } from './version.mjs';
14
+
15
+ export const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
16
+ const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
17
+
18
+ /* ------------------------------------------------------------------ headers */
19
+
20
+ /**
21
+ * Every generated page: never framed by another site (x-frame-options, and frame-ancestors for browsers that
22
+ * read only CSP), scripts only from this site, and `no-transform` so an edge in front of a custom domain never
23
+ * injects anything into it (a zone's Web Analytics beacon posts to a /cdn-cgi/rum a Worker does not serve).
24
+ */
25
+ export const PAGE_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
26
+ export const pageHeaders = (extra = {}) => ({
27
+ 'content-type': 'text/html; charset=utf-8',
28
+ 'cache-control': 'no-store, no-transform',
29
+ 'x-frame-options': 'DENY',
30
+ 'content-security-policy': PAGE_CSP,
31
+ 'x-content-type-options': 'nosniff',
32
+ // An origin, never a path: what another studio's stats need to count a visitor this site sent them.
33
+ 'referrer-policy': 'strict-origin-when-cross-origin',
34
+ ...extra,
35
+ });
36
+
37
+ /* ------------------------------------------------------------------ icons */
38
+
39
+ const I = {
40
+ games: '<rect x="2.5" y="6.5" width="19" height="11" rx="4"/><path d="M7 12h3M8.5 10.5v3"/><circle cx="16" cy="11" r="1"/><circle cx="18" cy="13.5" r="1"/>',
41
+ music: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
42
+ videos: '<rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="M10 9.5l5 2.5-5 2.5z"/>',
43
+ rooms: '<path d="M3 10.5L12 4l9 6.5"/><path d="M5.5 9.5V19h13V9.5"/><path d="M10 19v-4.5h4V19"/>',
44
+ posts: '<path d="M5 4.5h10l4 4v11H5z"/><path d="M8.5 11h7M8.5 14.5h7M8.5 7.5h4"/>',
45
+ tv: '<rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/>',
46
+ phone: '<rect x="6.5" y="2.5" width="11" height="19" rx="2.4"/><path d="M10.5 18.5h3"/>',
47
+ computer: '<rect x="3" y="4.5" width="18" height="12" rx="2"/><path d="M1.5 19.5h21"/>',
48
+ feed: '<path d="M5 5a14 14 0 0 1 14 14"/><path d="M5 11a8 8 0 0 1 8 8"/><circle cx="6" cy="18" r="1.4"/>',
49
+ copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2"/><path d="M15.5 8.5V6a1.5 1.5 0 0 0-1.5-1.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/>',
50
+ arrow: '<path d="M5 12h13M13 6l6 6-6 6"/>',
51
+ spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>',
52
+ home: '<path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M10 20v-5h4v5"/>',
53
+ };
54
+ const icon = (name, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${I[name] ?? ''}</svg>`;
55
+ const PLAY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg>';
56
+ /** Homie's mark, for the "Made with Homie" footer. */
57
+ const HOMIE_MARK = '<svg class="homie" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.5 11.2 12 4.5l8.5 6.7V20a.5.5 0 0 1-.5.5h-5v-5.5h-6v5.5H4a.5.5 0 0 1-.5-.5Z" fill="currentColor"/></svg>';
58
+
59
+ /* ------------------------------------------------------------------ the look */
60
+
61
+ const BASE_CSS = `
62
+ *{box-sizing:border-box}
63
+ html{background:var(--bg);-webkit-text-size-adjust:100%;scroll-behavior:smooth}
64
+ body{margin:0;background:var(--bg);color:var(--fg);font:400 17px/1.55 var(--text);-webkit-font-smoothing:antialiased;overflow-x:hidden;min-height:100vh;display:flex;flex-direction:column}
65
+ main{flex:1}
66
+ a{color:inherit}
67
+ img,video{display:block;max-width:100%}
68
+ [hidden]{display:none!important}
69
+ .ico{width:18px;height:18px;flex:none}
70
+ .skip{position:absolute;left:-9999px;top:12px;z-index:40;padding:10px 14px;border-radius:10px;background:var(--fg);color:var(--bg)}
71
+ .skip:focus{left:12px}
72
+ .kicker{margin:0 0 12px;font:600 12px/1.5 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--glow)}
73
+ :focus-visible{outline:3px solid var(--fg);outline-offset:3px}
74
+ @media (max-width:559px){.k-opt{display:none}}
75
+
76
+ /* ---- the top line: the studio, and its sections ---- */
77
+ .top{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 18px;
78
+ padding:calc(env(safe-area-inset-top,0px) + 12px) max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px),calc((100vw - 1180px) / 2)) 12px max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px),calc((100vw - 1180px) / 2));
79
+ background:color-mix(in srgb,var(--bg) 80%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom:1px solid var(--line)}
80
+ .has-hero .top{position:absolute;inset:0 0 auto;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg) 55%,transparent),transparent);border-bottom-color:transparent;-webkit-backdrop-filter:none;backdrop-filter:none}
81
+ .mark{display:inline-flex;align-items:center;gap:10px;min-height:40px;font:800 14px/1 var(--display);letter-spacing:var(--mark-track,.3em);text-transform:var(--mark-case,uppercase);text-decoration:none;color:var(--fg)}
82
+ .mark i{width:10px;height:10px;border-radius:3px;background:var(--hot);box-shadow:0 0 14px var(--hot)}
83
+ .mark img{height:30px;width:auto}
84
+ .nav{display:flex;align-items:center;gap:6px;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
85
+ .nav::-webkit-scrollbar{display:none}
86
+ .nav a{display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 13px;border-radius:999px;font:600 14px/1 var(--text);text-decoration:none;color:var(--soft);white-space:nowrap;border:1px solid transparent}
87
+ .nav a:hover{color:var(--fg);background:color-mix(in srgb,var(--fg) 7%,transparent)}
88
+ .nav a[aria-current]{color:var(--fg);background:color-mix(in srgb,var(--fg) 11%,transparent);border-color:var(--line)}
89
+ .has-hero .nav a{background:color-mix(in srgb,var(--bg) 42%,transparent);border-color:var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}
90
+ .nav .ico{width:17px;height:17px}
91
+ @media (max-width:719px){
92
+ .nav{order:3;flex:1 0 100%;margin:0 calc(var(--gutter) * -1);padding:0 var(--gutter)}
93
+ .nav a{min-height:36px;padding:0 11px;font-size:13px}
94
+ }
95
+
96
+ /* ---- the hero: the game, moving ---- */
97
+ .hero{position:relative;isolation:isolate;display:flex;align-items:flex-end;min-height:100vh;min-height:100svh;overflow:hidden;padding-top:calc(env(safe-area-inset-top,0px) + var(--top-h))}
98
+ .hero.home{min-height:min(92vh,900px);min-height:min(92svh,900px)}
99
+ .hero-media{position:absolute;inset:0;z-index:-2;overflow:hidden;background:
100
+ radial-gradient(60% 50% at 70% 30%,color-mix(in srgb,var(--glow) 26%,transparent),transparent 70%),
101
+ radial-gradient(55% 45% at 25% 70%,color-mix(in srgb,var(--hot) 24%,transparent),transparent 70%),var(--bg)}
102
+ .hero-media img,.hero-media video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:var(--focus,50% 50%)}
103
+ /* The studio's own colour over the picture, so a bright cover still carries the words (game.json landing.hero.tint, 0 to 80). */
104
+ .hero-media::before{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;background:color-mix(in srgb,var(--bg) var(--tint,16%),transparent)}
105
+ .hero-media.drift::before{background:color-mix(in srgb,var(--bg) var(--tint,40%),transparent)}
106
+ .hero-media.drift img{filter:saturate(1.08) contrast(1.04)}
107
+ .hero-media video{opacity:0;transition:opacity .9s ease}
108
+ .hero-media.is-playing video{opacity:1}
109
+ .hero-media.drift img{transform-origin:var(--focus,50% 50%);animation:drift 26s ease-in-out infinite alternate;will-change:transform}
110
+ .hero-media.drift::after,.hero-media.bare::after{content:"";position:absolute;inset:-25%;z-index:2;pointer-events:none;mix-blend-mode:screen;opacity:.75;
111
+ background:radial-gradient(34% 26% at 30% 40%,color-mix(in srgb,var(--glow) 30%,transparent),transparent 70%),radial-gradient(30% 24% at 72% 62%,color-mix(in srgb,var(--hot) 26%,transparent),transparent 70%);
112
+ animation:sweep 17s ease-in-out infinite alternate}
113
+ .hero-media .word{position:absolute;right:-.06em;bottom:-.18em;font:800 clamp(140px,34vw,460px)/.8 var(--display);letter-spacing:-.06em;color:color-mix(in srgb,var(--fg) 5%,transparent);white-space:nowrap;user-select:none}
114
+ @keyframes drift{from{transform:scale(1.05) translate3d(-1.4%,.9%,0)}to{transform:scale(1.17) translate3d(1.4%,-.9%,0)}}
115
+ @keyframes sweep{from{transform:translate3d(-7%,-4%,0) rotate(-2deg)}to{transform:translate3d(7%,4%,0) rotate(2deg)}}
116
+ .hero-grain{position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.07;mix-blend-mode:overlay;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
117
+ .hero-shade{position:absolute;inset:0;z-index:-1;pointer-events:none;background:
118
+ linear-gradient(to top,var(--bg) 0%,color-mix(in srgb,var(--bg) 88%,transparent) 20%,color-mix(in srgb,var(--bg) 40%,transparent) 46%,transparent 68%),
119
+ linear-gradient(to bottom,color-mix(in srgb,var(--bg) 70%,transparent) 0%,transparent 22%)}
120
+ .hero-copy{width:100%;max-width:780px;padding:0 max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px)) calc(env(safe-area-inset-bottom,0px) + clamp(26px,5.5vh,72px)) max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px),calc((100vw - 1180px) / 2))}
121
+ .title{margin:0;font:800 clamp(54px,15vw,72px)/.9 var(--display);letter-spacing:-.035em;overflow-wrap:anywhere;text-wrap:balance;
122
+ text-shadow:0 0 38px color-mix(in srgb,var(--hot) 50%,transparent),0 2px 0 color-mix(in srgb,var(--bg) 60%,transparent)}
123
+ .title.t-long{font-size:clamp(44px,12vw,64px)}
124
+ .title.t-xlong{font-size:clamp(38px,10vw,54px)}
125
+ .line{margin:14px 0 22px;max-width:36ch;font:500 17px/1.45 var(--text);color:var(--soft);text-wrap:pretty}
126
+ .play{display:flex;width:100%;align-items:center;justify-content:center;gap:12px;min-height:62px;padding:0 30px;border-radius:18px;
127
+ background:var(--hot);color:var(--hot-ink);text-decoration:none;font:800 20px/1 var(--display);letter-spacing:-.01em;
128
+ box-shadow:0 12px 44px color-mix(in srgb,var(--hot) 55%,transparent),inset 0 -3px 0 color-mix(in srgb,var(--bg) 22%,transparent);
129
+ transition:transform .15s ease,box-shadow .25s ease}
130
+ .play svg{width:20px;height:20px;flex:none}
131
+ .play:hover{transform:translateY(-2px);box-shadow:0 16px 60px color-mix(in srgb,var(--hot) 72%,transparent),inset 0 -3px 0 color-mix(in srgb,var(--bg) 22%,transparent)}
132
+ .play:active{transform:translateY(1px)}
133
+ .live{display:flex;align-items:center;gap:10px;margin:16px 0 0;font:600 14px/1.35 var(--text);color:var(--soft)}
134
+ .live-dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--glow);box-shadow:0 0 12px var(--glow)}
135
+ .live:not([data-n="0"])[data-n] .live-dot,.dot-on{background:#43ff9e;box-shadow:0 0 0 0 rgba(67,255,158,.6);animation:pulse 1.8s infinite}
136
+ @keyframes pulse{70%{box-shadow:0 0 0 10px rgba(67,255,158,0)}100%{box-shadow:0 0 0 0 rgba(67,255,158,0)}}
137
+ .also{display:inline-flex;align-items:center;gap:8px;margin-top:14px;font:600 14px/1 var(--text);color:var(--dim);text-decoration:none;min-height:36px}
138
+ .also:hover{color:var(--fg)}
139
+ .also+.also{margin-left:18px}
140
+ @media (min-aspect-ratio:3/4){.hero-media picture.tall-only{display:none}}
141
+ @media (min-width:760px){
142
+ .hero-shade{background:
143
+ linear-gradient(to top,var(--bg) 0%,color-mix(in srgb,var(--bg) 70%,transparent) 18%,transparent 52%),
144
+ linear-gradient(to right,color-mix(in srgb,var(--bg) 82%,transparent) 0%,color-mix(in srgb,var(--bg) 35%,transparent) 42%,transparent 66%),
145
+ linear-gradient(to bottom,color-mix(in srgb,var(--bg) 70%,transparent) 0%,transparent 18%)}
146
+ .hero-copy{max-width:none}
147
+ .hero-copy>*{max-width:min(64rem,94vw)}
148
+ .title{font-size:clamp(76px,8.6vw,140px)}
149
+ .title.t-long{font-size:clamp(64px,6.6vw,108px)}
150
+ .title.t-xlong{font-size:clamp(54px,5.2vw,86px)}
151
+ .line{font-size:clamp(18px,1.55vw,22px);margin:18px 0 30px;max-width:40ch}
152
+ .play{display:inline-flex;width:auto;min-height:66px;padding:0 40px;font-size:22px}
153
+ .row{display:flex;align-items:center;flex-wrap:wrap;gap:12px 26px}
154
+ .row .live{margin:0}
155
+ .also{margin-top:18px}
156
+ }
157
+ @media (max-height:540px) and (min-aspect-ratio:4/3){
158
+ .hero{padding-top:calc(env(safe-area-inset-top,0px) + 62px)}
159
+ .hero-copy{max-width:min(46rem,100%);padding-bottom:calc(env(safe-area-inset-bottom,0px) + 18px)}
160
+ .kicker{margin-bottom:6px;font-size:11px}
161
+ .title,.title.t-long,.title.t-xlong{font-size:clamp(40px,12vh,60px)}
162
+ .line{margin:8px 0 14px;max-width:48ch;font-size:16px;line-height:1.4}
163
+ .play{display:inline-flex;width:auto;min-height:54px;padding:0 30px;font-size:19px;border-radius:16px}
164
+ .row{display:flex;align-items:center;flex-wrap:wrap;gap:10px 22px}
165
+ .row .live{margin:0}
166
+ .hero-copy>.also{display:none}
167
+ }
168
+
169
+ /* ---- bands below the fold ---- */
170
+ .band{position:relative;padding:clamp(56px,9vw,120px) max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px)) clamp(56px,9vw,120px) max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px));border-top:1px solid var(--line)}
171
+ .band.tight{padding-top:clamp(40px,6vw,72px);padding-bottom:clamp(40px,6vw,72px)}
172
+ .band-in{max-width:1180px;margin:0 auto}
173
+ .band h2,.h2{margin:0;font:800 clamp(32px,5.2vw,64px)/.98 var(--display);letter-spacing:-.03em;max-width:18ch;text-wrap:balance}
174
+ .band h2.small-h{font-size:clamp(26px,3.4vw,40px)}
175
+ .lead{margin:18px 0 0;max-width:52ch;font-size:clamp(17px,1.5vw,20px);color:var(--soft);text-wrap:pretty}
176
+ .glowband{background:radial-gradient(90% 70% at 85% 10%,color-mix(in srgb,var(--glow) 12%,transparent),transparent 60%),var(--bg)}
177
+ .hotband{background:radial-gradient(80% 70% at 10% 90%,color-mix(in srgb,var(--hot) 13%,transparent),transparent 60%),var(--bg)}
178
+ .split{display:grid;gap:clamp(32px,5vw,72px);align-items:center}
179
+ @media (min-width:900px){.split{grid-template-columns:1.05fr .95fr}}
180
+ .split>*,.way>*{min-width:0}
181
+ .head-row{display:flex;align-items:flex-end;justify-content:space-between;gap:12px 24px;flex-wrap:wrap}
182
+ .more{display:inline-flex;align-items:center;gap:8px;font:700 15px/1 var(--text);text-decoration:none;color:var(--soft);min-height:40px}
183
+ .more:hover{color:var(--fg)}
184
+ .small{margin:18px 0 0;font-size:14px;color:var(--dim)}
185
+ .small a{color:var(--soft)}
186
+ .addr{font-family:var(--mono);font-size:.95em;color:var(--fg);overflow-wrap:anywhere}
187
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:10px;min-height:50px;padding:0 24px;border-radius:14px;font:800 17px/1 var(--display);text-decoration:none;
188
+ background:var(--hot);color:var(--hot-ink);border:0;cursor:pointer;box-shadow:0 8px 28px color-mix(in srgb,var(--hot) 35%,transparent);transition:transform .15s ease}
189
+ .btn:hover{transform:translateY(-1px)}
190
+ .btn svg{width:18px;height:18px}
191
+ .ghost{display:inline-flex;align-items:center;justify-content:center;gap:10px;min-height:50px;padding:0 22px;border-radius:14px;
192
+ font:700 16px/1 var(--display);text-decoration:none;color:var(--fg);border:1px solid color-mix(in srgb,var(--fg) 30%,transparent);background:transparent;cursor:pointer}
193
+ .ghost:hover{border-color:var(--fg);background:color-mix(in srgb,var(--fg) 6%,transparent)}
194
+ .ghost svg{width:18px;height:18px}
195
+ .keys{display:flex;flex-wrap:wrap;gap:12px;margin-top:26px}
196
+
197
+ /* phone / computer / TV */
198
+ .ways{list-style:none;margin:30px 0 0;padding:0;display:grid;gap:14px}
199
+ .way{display:grid;grid-template-columns:44px 1fr;gap:14px;align-items:start;padding:18px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);color:var(--soft)}
200
+ .way .n{display:grid;place-items:center;width:44px;height:44px;border-radius:12px;color:var(--hot-ink);background:var(--hot)}
201
+ .way:nth-child(2) .n{background:var(--glow);color:var(--bg)}
202
+ .way:nth-child(3) .n{background:var(--fg);color:var(--bg)}
203
+ .way .n svg{width:22px;height:22px}
204
+ .way h3{margin:2px 0 4px;font:800 19px/1.2 var(--display);color:var(--fg)}
205
+ .way p{margin:0}
206
+ .way ol{margin:8px 0 0;padding-left:20px}
207
+ .way li{margin:3px 0}
208
+ .way strong{color:var(--fg)}
209
+ .way .scan{display:flex;gap:16px;align-items:center;margin-top:12px}
210
+ .qr{flex:none;width:112px;height:112px;padding:6px;border-radius:12px;background:#fff;box-shadow:0 10px 30px color-mix(in srgb,var(--hot) 25%,transparent)}
211
+ .qr svg{width:100%;height:100%;display:block}
212
+ [data-hand="phone"] .desk-only,[data-hand="desk"] .phone-only{display:none}
213
+ .tvart{position:relative;margin:0;padding-bottom:9%}
214
+ .tvart .screen{position:relative;aspect-ratio:16/9;border-radius:16px;overflow:hidden;border:10px solid #0b0c10;background:var(--panel);
215
+ box-shadow:0 0 0 1px var(--line),0 30px 80px color-mix(in srgb,var(--glow) 18%,transparent)}
216
+ .tvart .screen img{width:100%;height:100%;object-fit:cover}
217
+ .tvart .screen .qr{position:absolute;right:5%;top:8%;width:17%;height:auto;aspect-ratio:1;padding:4px;box-shadow:none;border-radius:6px}
218
+ .tvart .phone{position:absolute;bottom:0;width:16%;aspect-ratio:9/19;border-radius:14px;background:#0b0c10;border:3px solid #1c1f28;
219
+ box-shadow:0 12px 30px rgba(0,0,0,.55),0 0 22px color-mix(in srgb,var(--hot) 35%,transparent)}
220
+ .tvart .phone::after{content:"";position:absolute;inset:14% 12%;border-radius:50%;
221
+ background:radial-gradient(circle at 50% 50%,var(--hot) 0 18%,transparent 19%),radial-gradient(circle,color-mix(in srgb,var(--fg) 12%,transparent) 0 60%,transparent 61%)}
222
+ .tvart .p1{left:14%;transform:rotate(-8deg)}
223
+ .tvart .p2{left:36%;transform:rotate(6deg)}
224
+
225
+ /* live rooms */
226
+ .rooms{list-style:none;margin:26px 0 0;padding:0;display:grid;gap:10px}
227
+ .room{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;padding:12px 14px;border-radius:calc(var(--r) - 4px);background:var(--panel);border:1px solid var(--line)}
228
+ .room .thumb{width:76px;aspect-ratio:16/10;border-radius:10px;overflow:hidden;background:color-mix(in srgb,var(--hot) 20%,var(--panel))}
229
+ .room .thumb img{width:100%;height:100%;object-fit:cover}
230
+ .room b{display:block;font:800 17px/1.2 var(--display)}
231
+ .room span{color:var(--dim);font-size:14px}
232
+ .pips{display:flex;gap:3px;flex-wrap:wrap;margin-top:7px}
233
+ .pips i{width:8px;height:8px;border-radius:50%;background:color-mix(in srgb,var(--fg) 18%,transparent)}
234
+ .pips i.on{background:#43ff9e;box-shadow:0 0 8px rgba(67,255,158,.55)}
235
+ .join{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 18px;border-radius:12px;background:var(--hot);color:var(--hot-ink);font:800 15px/1 var(--display);text-decoration:none}
236
+ .quiet{margin:22px 0 0;padding:18px 20px;border-radius:var(--r);border:1px dashed color-mix(in srgb,var(--fg) 22%,transparent);color:var(--soft)}
237
+ .stat{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}
238
+ .stat span{padding:8px 12px;border-radius:999px;background:var(--panel);border:1px solid var(--line);font:600 14px/1.2 var(--text);color:var(--soft)}
239
+ .stat b{color:var(--fg)}
240
+ @media (max-width:479px){.room{grid-template-columns:1fr auto}.room .thumb{display:none}}
241
+
242
+ /* how to play, facts, credits */
243
+ .facts{display:flex;flex-wrap:wrap;gap:10px;margin:22px 0 0;padding:0;list-style:none}
244
+ .facts li{padding:9px 14px;border-radius:999px;border:1px solid var(--line);background:var(--panel);font:600 14px/1.2 var(--text);color:var(--soft)}
245
+ .howto{margin:22px 0 0;padding-left:22px;color:var(--soft)}
246
+ .howto li{margin:6px 0}
247
+ .media-card{display:block;border-radius:var(--r);overflow:hidden;background:var(--panel);border:1px solid var(--line);text-decoration:none}
248
+ .media-card .art{position:relative;aspect-ratio:16/9;background:color-mix(in srgb,var(--hot) 16%,var(--panel))}
249
+ .media-card .art img{width:100%;height:100%;object-fit:cover}
250
+ .media-card .art .big{position:absolute;inset:0;margin:auto;width:74px;height:74px;border-radius:50%;display:grid;place-items:center;background:color-mix(in srgb,var(--bg) 55%,transparent);border:1px solid color-mix(in srgb,var(--fg) 40%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
251
+ .media-card .art .big svg{width:26px;height:26px;margin-left:4px}
252
+ .media-card .cap{padding:14px 18px}
253
+ .media-card .cap b{display:block;font:800 19px/1.2 var(--display)}
254
+ .media-card .cap span{color:var(--dim);font-size:14px}
255
+ .credits{display:grid;gap:18px;margin-top:30px}
256
+ @media (min-width:760px){.credits{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}}
257
+ .credit{overflow-wrap:anywhere;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);color:var(--soft);font-size:15px}
258
+ .credit h3{margin:0 0 10px;font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
259
+ .credit p{margin:0 0 8px}
260
+ .credit ul{margin:0;padding-left:18px}
261
+ .credit li{margin:4px 0}
262
+ .credit strong{color:var(--fg)}
263
+ .credit a{color:var(--fg)}
264
+
265
+ /* make a game like this */
266
+ .make{position:relative;overflow:hidden;border-radius:calc(var(--r) + 8px);padding:clamp(26px,4vw,48px);border:1px solid color-mix(in srgb,var(--hot) 55%,transparent);
267
+ background:radial-gradient(70% 90% at 100% 0%,color-mix(in srgb,var(--hot) 20%,transparent),transparent 60%),radial-gradient(60% 80% at 0% 100%,color-mix(in srgb,var(--glow) 14%,transparent),transparent 60%),var(--panel);
268
+ box-shadow:0 20px 70px color-mix(in srgb,var(--hot) 16%,transparent)}
269
+ .make h2{max-width:16ch}
270
+ .say{display:grid;gap:10px;margin:24px 0 0;padding:0;list-style:none;counter-reset:say}
271
+ .say li{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:10px 10px 10px 16px;border-radius:12px;background:color-mix(in srgb,var(--bg) 70%,transparent);border:1px solid var(--line)}
272
+ .say code{font:500 14px/1.45 var(--mono);color:var(--fg);overflow-wrap:anywhere}
273
+ .say .label{display:block;font:600 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-bottom:3px}
274
+ .copy{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 12px;border-radius:10px;border:1px solid var(--line);background:transparent;color:var(--soft);font:600 13px/1 var(--text);cursor:pointer}
275
+ .copy:hover{color:var(--fg);border-color:color-mix(in srgb,var(--fg) 35%,transparent)}
276
+ .copy svg{width:15px;height:15px}
277
+
278
+ /* pages without a hero */
279
+ .head{position:relative;overflow:hidden;padding:clamp(44px,8vw,110px) max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px),calc((100vw - 1180px) / 2)) clamp(22px,3vw,36px) max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px),calc((100vw - 1180px) / 2))}
280
+ .head::before{content:"";position:absolute;inset:-40% -10% auto;height:140%;z-index:-1;pointer-events:none;
281
+ background:radial-gradient(40% 50% at 80% 30%,color-mix(in srgb,var(--glow) 16%,transparent),transparent 70%),radial-gradient(35% 45% at 15% 60%,color-mix(in srgb,var(--hot) 14%,transparent),transparent 70%)}
282
+ .head{isolation:isolate}
283
+ .head h1{margin:0;font:800 clamp(48px,10vw,120px)/.9 var(--display);letter-spacing:-.04em;text-wrap:balance}
284
+ .head .lead{margin-top:16px}
285
+ .head .feeds{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
286
+ .head .feeds a{display:inline-flex;align-items:center;gap:7px;min-height:36px;padding:0 12px;border-radius:999px;border:1px solid var(--line);text-decoration:none;color:var(--soft);font:600 13px/1 var(--text)}
287
+ .wrap{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:0 max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px)) clamp(56px,8vw,110px) max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px))}
288
+
289
+ /* game cards */
290
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr));gap:20px;margin-top:28px}
291
+ .gcard{position:relative;display:flex;flex-direction:column;border-radius:var(--r);overflow:hidden;background:var(--panel);border:1px solid var(--line);transition:transform .25s ease,border-color .25s ease,box-shadow .25s ease}
292
+ .gcard:hover{transform:translateY(-4px);border-color:color-mix(in srgb,var(--hot) 50%,transparent);box-shadow:0 18px 50px color-mix(in srgb,var(--hot) 18%,transparent)}
293
+ .gcard .art{position:relative;display:block;aspect-ratio:16/10;overflow:hidden;
294
+ background:radial-gradient(70% 80% at 80% 20%,color-mix(in srgb,var(--glow) 28%,transparent),transparent 70%),radial-gradient(60% 70% at 15% 85%,color-mix(in srgb,var(--hot) 30%,transparent),transparent 70%),var(--panel)}
295
+ .gcard .art img{width:100%;height:100%;object-fit:cover;transition:transform .7s cubic-bezier(.2,.7,.2,1)}
296
+ .gcard:hover .art img{transform:scale(1.06)}
297
+ .gcard .art .word{position:absolute;left:16px;bottom:10px;right:16px;font:800 clamp(30px,4vw,44px)/.95 var(--display);letter-spacing:-.03em;color:var(--fg);text-shadow:0 0 30px color-mix(in srgb,var(--hot) 60%,transparent)}
298
+ .badge{position:absolute;left:12px;top:12px;display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;background:rgba(0,0,0,.62);color:#b8ffd6;font:700 12px/1.2 var(--text);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}
299
+ .badge i{width:7px;height:7px;border-radius:50%;background:#43ff9e}
300
+ .gcard .body{display:flex;flex-direction:column;gap:8px;flex:1;padding:18px 20px 20px}
301
+ .gcard h3{margin:0;font:800 24px/1.08 var(--display);letter-spacing:-.02em}
302
+ .gcard h3 a{text-decoration:none}
303
+ .gcard p{margin:0;color:var(--soft)}
304
+ .meta{display:flex;flex-wrap:wrap;gap:6px 14px;color:var(--dim);font-size:13px}
305
+ .acts{display:flex;gap:10px;margin-top:auto;padding-top:10px}
306
+ .acts .btn,.acts .ghost{min-height:46px;font-size:16px;flex:1}
307
+ .cards.feature{grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))}
308
+
309
+ /* posts */
310
+ .plist{list-style:none;margin:28px 0 0;padding:0;display:grid;gap:14px}
311
+ .pitem>a{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);text-decoration:none;transition:border-color .2s ease,transform .2s ease}
312
+ .pitem>a:hover{border-color:color-mix(in srgb,var(--hot) 45%,transparent);transform:translateY(-2px)}
313
+ .pitem time,.post-meta{font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
314
+ .pitem h3{margin:6px 0 6px;font:800 clamp(22px,2.6vw,30px)/1.1 var(--display);letter-spacing:-.02em}
315
+ .pitem p{margin:0;color:var(--soft)}
316
+ .pitem img{width:168px;aspect-ratio:16/10;object-fit:cover;border-radius:12px}
317
+ .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
318
+ .chips span{padding:5px 10px;border-radius:999px;border:1px solid var(--line);font:600 12px/1.2 var(--text);color:var(--soft)}
319
+ @media (max-width:599px){.pitem>a{grid-template-columns:1fr}.pitem img{width:100%;order:-1}}
320
+ .cards.posts{grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))}
321
+ .pcard{display:flex;flex-direction:column;gap:8px;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);text-decoration:none;transition:border-color .2s ease,transform .2s ease}
322
+ .pcard:hover{border-color:color-mix(in srgb,var(--hot) 45%,transparent);transform:translateY(-2px)}
323
+ .pcard img{width:calc(100% + 44px);max-width:none;margin:-20px -22px 8px;aspect-ratio:16/9;object-fit:cover}
324
+ .pcard h3{margin:0;font:800 22px/1.12 var(--display);letter-spacing:-.02em}
325
+ .pcard p{margin:0;color:var(--soft);font-size:15px}
326
+ .article{max-width:760px;margin:0 auto}
327
+ .article h1{margin:10px 0 0;font:800 clamp(40px,7vw,84px)/.95 var(--display);letter-spacing:-.035em;text-wrap:balance}
328
+ .article .dek{margin:18px 0 0;font-size:clamp(18px,1.8vw,22px);color:var(--soft)}
329
+ .article .cover{margin:30px 0 0;border-radius:var(--r);overflow:hidden;border:1px solid var(--line)}
330
+ .prose{margin-top:34px;font-size:clamp(17px,1.35vw,19px);line-height:1.7;color:color-mix(in srgb,var(--fg) 90%,transparent)}
331
+ .prose h2,.prose h3,.prose h4{color:var(--fg);font-family:var(--display);letter-spacing:-.02em;line-height:1.12;margin:1.6em 0 .5em}
332
+ .prose h2{font-size:clamp(26px,3vw,36px)}
333
+ .prose h3{font-size:clamp(21px,2.2vw,26px)}
334
+ .prose a{color:var(--hot);text-underline-offset:3px}
335
+ .prose img{border-radius:12px}
336
+ .prose figure{margin:1.6em 0}
337
+ .prose figcaption{margin-top:8px;font-size:14px;color:var(--dim)}
338
+ .prose blockquote{margin:1.4em 0;padding:.2em 0 .2em 1.1em;border-left:3px solid var(--hot);color:var(--soft)}
339
+ .prose pre{padding:14px 16px;border-radius:12px;background:var(--panel);border:1px solid var(--line);overflow-x:auto;font:14px/1.55 var(--mono)}
340
+ .prose code{font-family:var(--mono);font-size:.9em}
341
+ .prose hr{border:0;border-top:1px solid var(--line);margin:2em 0}
342
+ .linked{display:grid;gap:14px;margin:40px 0 0;grid-template-columns:1fr}
343
+ @media (min-width:640px){.linked .gcard{flex-direction:row}.linked .gcard .art{width:44%;flex:none;aspect-ratio:auto;min-height:220px}.linked .media-card{display:grid;grid-template-columns:44% 1fr;align-items:center}}
344
+
345
+ /* songs and videos */
346
+ .player{margin:8px 0 20px}
347
+ .player audio{width:100%}
348
+ .player video{width:100%;max-height:78vh;border-radius:var(--r);background:#000;display:block}
349
+ .player video.vertical{max-width:420px;margin:0 auto}
350
+ .mfacts{color:var(--dim);font-size:14px;display:flex;gap:16px;flex-wrap:wrap;margin:0 0 18px}
351
+ .rights{color:var(--dim);font-size:13px;border-top:1px solid var(--line);padding-top:14px;margin-top:24px;max-width:70ch}
352
+ .lyrics{white-space:pre-wrap;font-size:17px;line-height:1.6;max-width:60ch}
353
+ .files{list-style:none;padding:0;margin:0;display:grid;gap:8px;max-width:560px}
354
+ .files a{display:flex;justify-content:space-between;gap:12px;padding:10px 14px;border:1px solid var(--line);border-radius:12px;text-decoration:none}
355
+ .files span{color:var(--dim);font-size:13px}
356
+ .songhead{display:grid;gap:clamp(24px,4vw,48px);align-items:end}
357
+ @media (min-width:760px){.songhead.has-cover{grid-template-columns:minmax(0,1fr) min(340px,34%)}}
358
+ .songhead .cover{width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--r);box-shadow:0 24px 70px color-mix(in srgb,var(--hot) 25%,transparent)}
359
+ .sec{margin:44px 0 12px;font:600 12px/1.4 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--dim)}
360
+ .licence{white-space:pre-wrap;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;font:13px/1.55 var(--mono);color:var(--soft);overflow-x:auto}
361
+
362
+ /* the footer, and "Made with Homie" */
363
+ .foot{padding:34px max(var(--gutter),calc(env(safe-area-inset-right,0px) + 12px)) calc(env(safe-area-inset-bottom,0px) + 34px) max(var(--gutter),calc(env(safe-area-inset-left,0px) + 12px));border-top:1px solid var(--line);color:var(--dim);font-size:14px}
364
+ .foot-in{max-width:1180px;margin:0 auto;display:flex;flex-wrap:wrap;gap:14px 28px;align-items:center;justify-content:space-between}
365
+ .foot-links{display:flex;flex-wrap:wrap;gap:6px 20px}
366
+ .foot-links a{text-decoration:none;color:var(--dim)}
367
+ .foot-links a:hover{color:var(--fg)}
368
+ .made{display:inline-flex;align-items:center;gap:9px;min-height:40px;padding:0 14px 0 11px;border-radius:999px;border:1px solid var(--line);text-decoration:none;color:var(--soft);font:600 14px/1 var(--text);transition:border-color .2s ease,color .2s ease}
369
+ .made:hover{color:var(--fg);border-color:color-mix(in srgb,var(--hot) 55%,transparent)}
370
+ .made .homie{width:18px;height:18px;color:var(--hot)}
371
+ .made b{color:var(--fg);font-weight:800}
372
+
373
+ /* motion */
374
+ .js .reveal{opacity:0;transform:translateY(22px);transition:opacity .8s ease,transform .8s cubic-bezier(.2,.7,.2,1)}
375
+ .js .reveal.in{opacity:1;transform:none}
376
+ @media (prefers-reduced-motion:reduce){
377
+ html{scroll-behavior:auto}
378
+ .play,.live-dot,.dot-on,.gcard,.gcard .art img,.pitem>a,.pcard{transition:none;animation:none}
379
+ .hero-media.drift img,.hero-media.drift::after,.hero-media.bare::after{animation:none}
380
+ .js .reveal{opacity:1;transform:none;transition:none}
381
+ }
382
+ `;
383
+
384
+ /* ------------------------------------------------------------------ the script (served at /_homie/site.js) */
385
+
386
+ export const SITE_JS = String.raw`(function(){
387
+ 'use strict';
388
+ var d=document,root=d.documentElement,body=d.body;
389
+ var mq=function(q){try{return matchMedia(q).matches}catch(e){return false}};
390
+ var still=mq('(prefers-reduced-motion: reduce)')||!!(navigator.connection&&navigator.connection.saveData);
391
+ // Things already on screen are shown at once; the rest rise into view as they scroll in.
392
+ var rev=[].slice.call(d.querySelectorAll('.reveal'));
393
+ var vh=innerHeight||800;
394
+ rev.forEach(function(e){if(still||e.getBoundingClientRect().top<vh)e.classList.add('in')});
395
+ root.classList.add('js');
396
+ if(!still&&'IntersectionObserver' in window){var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target)}})},{rootMargin:'0px 0px -6% 0px'});rev.forEach(function(e){if(!e.classList.contains('in'))io.observe(e)})}
397
+ else rev.forEach(function(e){e.classList.add('in')});
398
+ addEventListener('beforeprint',function(){rev.forEach(function(e){e.classList.add('in')})});
399
+ // The hero's footage fades in once it plays; reduced motion or data saver keeps the still.
400
+ [].forEach.call(d.querySelectorAll('[data-hero-media]'),function(m){var v=m.querySelector('video');if(!v)return;
401
+ if(still){v.removeAttribute('autoplay');try{v.pause()}catch(e){}v.remove();m.classList.remove('drift');return}
402
+ v.addEventListener('playing',function(){m.classList.add('is-playing');m.classList.remove('drift')},{once:true});
403
+ var p=v.play&&v.play();if(p&&p.catch)p.catch(function(){})});
404
+ if(still)[].forEach.call(d.querySelectorAll('.drift'),function(m){m.classList.remove('drift')});
405
+ // On a touch screen the play page seats a phone (?hand=phone); a computer keeps its keys.
406
+ var desk=function(){return mq('(hover: hover) and (pointer: fine)')};
407
+ var hand=function(){body.setAttribute('data-hand',desk()?'desk':'phone');
408
+ [].forEach.call(d.querySelectorAll('[data-play]'),function(a){var h=a.getAttribute('data-href')||a.getAttribute('href');a.setAttribute('data-href',h);
409
+ a.setAttribute('href',desk()?h:h+(h.indexOf('?')<0?'?':'&')+'hand=phone')})};
410
+ hand();addEventListener('resize',hand);
411
+ // A song's or video's page: its one stats beacon when the player first starts (worker/stats.mjs), and the 9:16 cut
412
+ // on a portrait phone.
413
+ [].forEach.call(d.querySelectorAll('[data-beat]'),function(box){var m=box.querySelector('audio,video');if(!m)return;
414
+ var v=box.getAttribute('data-vertical');if(v&&m.tagName==='VIDEO'&&mq('(orientation: portrait) and (max-width: 700px)')){m.src=v;m.classList.add('vertical')}
415
+ if(!navigator.sendBeacon)return;m.addEventListener('play',function(){try{navigator.sendBeacon('/api/stats/beat',box.getAttribute('data-beat'))}catch(e){}},{once:true})});
416
+ // Copy a line to the clipboard.
417
+ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.target.closest('[data-copy]');if(!b)return;
418
+ var t=b.getAttribute('data-copy'),w=b.querySelector('[data-copy-word]')||b,old=w.textContent;
419
+ var done=function(){w.textContent='Copied';setTimeout(function(){w.textContent=old},1600)};
420
+ if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,function(){})});
421
+ // Live: the line under Play and the rooms list follow the site's own room counts.
422
+ var el=function(tag,cls,text){var e=d.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e};
423
+ var roomRow=function(r){var li=el('li','room');var th=el('span','thumb');if(r.cover){var im=el('img');im.src=r.cover;im.alt='';im.loading='lazy';th.appendChild(im)}li.appendChild(th);
424
+ var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+r.label));mid.appendChild(el('span',null,r.players+' of '+r.max+' seats taken · bots hold the rest'));
425
+ var pips=el('div','pips');for(var i=0;i<Math.min(r.max,32);i++)pips.appendChild(el('i',i<r.players?'on':''));mid.appendChild(pips);li.appendChild(mid);
426
+ var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');li.appendChild(a);return li};
427
+ var paintRooms=function(list,rooms){if(!list)return;var quiet=d.querySelector('[data-rooms-quiet]');list.textContent='';
428
+ rooms.forEach(function(r){list.appendChild(roomRow(r))});list.hidden=!rooms.length;if(quiet)quiet.hidden=!!rooms.length;hand()};
429
+ var live=d.querySelector('[data-live]'),list=d.querySelector('[data-rooms]');
430
+ var src=(live&&live.getAttribute('data-live'))||(list&&list.getAttribute('data-src'));
431
+ if(src){
432
+ var words=live&&live.querySelector('[data-live-words]');
433
+ var one=live&&live.getAttribute('data-one')||'player',many=live&&live.getAttribute('data-many')||'players';
434
+ var idle=live&&(live.getAttribute('data-idle')||(words&&words.textContent));
435
+ var ask=function(){if(d.hidden)return;fetch(src,{credentials:'omit',cache:'no-store'}).then(function(r){return r.ok?r.json():null}).then(function(b){
436
+ if(!b||b.ok!==true)return;var n=Number(b.playing)||0;
437
+ if(live){live.setAttribute('data-n',String(n));if(words)words.textContent=n<=0?idle:(n===1?'1 '+one+' playing right now':n+' '+many+' playing right now')}
438
+ if(list&&Array.isArray(b.rooms))paintRooms(list,b.rooms)}).catch(function(){})};
439
+ var go=function(){setInterval(ask,15000);d.addEventListener('visibilitychange',ask)};
440
+ if(d.prerendering===true)d.addEventListener('prerenderingchange',function(){ask();go()},{once:true});else go();
441
+ }
442
+ })();`;
443
+
444
+ /* ------------------------------------------------------------------ the shell */
445
+
446
+ const TOKEN_DEFAULTS = { bg: '#0b0c12', fg: '#f1f3f9', accent: '#ffcf5a', glow: '#7dffb0' };
447
+
448
+ /** The studio's tokens as CSS custom properties (site/theme.json, checked at build), plus its fonts. */
449
+ function tokensCss(theme = {}, over = null) {
450
+ const t = { ...TOKEN_DEFAULTS, ...theme, ...(over ?? {}) };
451
+ const fonts = (theme.fonts ?? []).map((f) => `@font-face{font-family:"${f.family}";src:url("${f.src}");font-weight:${f.weight};font-style:${f.style};font-display:swap}`).join('');
452
+ const display = theme.display ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
453
+ const text = theme.text ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
454
+ const mono = theme.mono ?? 'ui-monospace,"SF Mono",Menlo,Consolas,monospace';
455
+ return `${fonts}:root{--bg:${t.bg};--fg:${t.fg};--hot:${t.accent};--glow:${t.glow};--hot-ink:${t.accentInk ?? t.bg};
456
+ --soft:color-mix(in srgb,var(--fg) 80%,transparent);--dim:color-mix(in srgb,var(--fg) 62%,transparent);--line:color-mix(in srgb,var(--fg) 14%,transparent);
457
+ --panel:${t.panel ?? 'color-mix(in srgb,var(--fg) 5%,var(--bg))'};--display:${display};--text:${text};--mono:${mono};--r:${Number.isFinite(t.radius) ? t.radius : 18}px;
458
+ --gutter:clamp(18px,5vw,72px);--top-h:72px;color-scheme:${theme.scheme === 'light' ? 'light' : 'dark'}${theme.uppercase === false ? ';--mark-case:none;--mark-track:.02em' : ''}}
459
+ @media (max-width:719px){:root{--top-h:112px}}`;
460
+ }
461
+
462
+ const studioName = (cat) => cat.studio?.name ?? 'Studio';
463
+
464
+ /** Which sections this studio has: a section with nothing in it has no tab (and its page answers 404). */
465
+ export function sectionsOf(cat) {
466
+ const games = (cat.games ?? []).length > 0;
467
+ return [
468
+ games && { key: 'games', href: '/games/', label: 'Games' },
469
+ (cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
470
+ (cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
471
+ games && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
472
+ (cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
473
+ ].filter(Boolean);
474
+ }
475
+
476
+ /** A partial from site/partials, with {{studio.name}}, {{game.name}}, {{game.id}}, {{game.play}} and {{year}} filled in (escaped). */
477
+ export function partial(cat, name, vars = {}) {
478
+ const text = cat.site?.partials?.[name];
479
+ if (typeof text !== 'string') return null;
480
+ const values = { 'studio.name': studioName(cat), year: String(new Date().getUTCFullYear()), ...vars };
481
+ return text.replace(/\{\{\s*([a-z.]+)\s*\}\}/g, (m, k) => (k in values ? esc(values[k]) : m));
482
+ }
483
+
484
+ function markHtml(cat) {
485
+ const t = cat.studio?.theme ?? {};
486
+ const name = studioName(cat);
487
+ if (t.mark) return `<a class="mark" href="/"><img src="${esc(t.mark)}" alt="${esc(name)}"></a>`;
488
+ return `<a class="mark" href="/"><i aria-hidden="true"></i><span>${esc(t.wordmark ?? name)}</span></a>`;
489
+ }
490
+
491
+ export function header(cat, active = null) {
492
+ const custom = partial(cat, 'header');
493
+ if (custom !== null) return custom;
494
+ const tabs = sectionsOf(cat).map((s) => `<a href="${s.href}"${s.key === active ? ' aria-current="page"' : ''}>${icon(s.key)}<span>${esc(s.label)}</span></a>`).join('');
495
+ return `<header class="top" data-top>${markHtml(cat)}${tabs ? `<nav class="nav" aria-label="${esc(studioName(cat))}">${tabs}</nav>` : ''}</header>`;
496
+ }
497
+
498
+ /** The footer: the studio, its sections, and "Made with Homie" (site/partials/footer.html replaces it; site/theme.css restyles it). */
499
+ export function footer(cat) {
500
+ const custom = partial(cat, 'footer');
501
+ if (custom !== null) return custom;
502
+ const links = sectionsOf(cat).map((s) => `<a href="${s.href}">${esc(s.label)}</a>`);
503
+ if ((cat.posts ?? []).length) links.push('<a href="/posts/feed.xml">Feed</a>');
504
+ return `<footer class="foot"><div class="foot-in">
505
+ ${markHtml(cat)}
506
+ <nav class="foot-links" aria-label="Sections">${links.join('')}</nav>
507
+ <a class="made" href="https://homie.rocks/studio/" data-made-with-homie>${HOMIE_MARK}<span>Made with <b>Homie</b></span></a>
508
+ </div></footer>`;
509
+ }
510
+
511
+ const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta ${k.startsWith('twitter:') ? 'name' : 'property'}="${esc(k)}" content="${esc(v)}">`).join('\n');
512
+
513
+ /**
514
+ * A whole generated page. `hero` pages draw the top line over the hero; `style` is the game's own accent on its
515
+ * landing (game.json landing.theme).
516
+ */
517
+ export function layout(cat, { title, description = '', origin = '', path = '/', image = null, type = 'website', active = null, page = 'page', hero = false, head = '', main, over = null, status = 200, extraHeaders = {} }) {
518
+ const theme = cat.studio?.theme ?? {};
519
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
520
+ const icon = theme.icon ? `<link rel="icon" href="${esc(theme.icon)}">` : `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="3" fill="${(over?.accent ?? theme.accent ?? TOKEN_DEFAULTS.accent)}"/></svg>`)}">`;
521
+ const feeds = (cat.posts ?? []).length ? `<link rel="alternate" type="application/atom+xml" href="/posts/feed.xml" title="${esc(studioName(cat))}: posts"><link rel="alternate" type="application/feed+json" href="/posts/feed.json" title="${esc(studioName(cat))}: posts">` : '';
522
+ const html = `<!doctype html>
523
+ <html lang="en">
524
+ <head>
525
+ <meta charset="utf-8">
526
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
527
+ <title>${esc(title)}</title>
528
+ ${description ? `<meta name="description" content="${esc(description)}">` : ''}
529
+ ${origin ? `<link rel="canonical" href="${esc(`${origin}${path}`)}">` : ''}
530
+ <meta name="theme-color" content="${esc(over?.bg ?? theme.bg ?? TOKEN_DEFAULTS.bg)}">
531
+ ${ogTags({ 'og:title': title, 'og:description': description, 'og:type': type, 'og:url': origin ? `${origin}${path}` : null, 'og:image': abs(image ?? theme.social ?? null), 'og:site_name': studioName(cat), 'twitter:card': image || theme.social ? 'summary_large_image' : 'summary' })}
532
+ ${icon}${feeds}${head}
533
+ <style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
534
+ ${partial(cat, 'head') ?? ''}
535
+ </head>
536
+ <body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}">
537
+ <a class="skip" href="#main">Skip to content</a>
538
+ ${header(cat, active)}
539
+ <main id="main">
540
+ ${main}
541
+ </main>
542
+ ${footer(cat)}
543
+ <script src="/_homie/site.js?v=${STUDIO_VERSION_TAG}" defer></script>
544
+ </body>
545
+ </html>`;
546
+ return new Response(html, { status, headers: pageHeaders(extraHeaders) });
547
+ }
548
+
549
+ /**
550
+ * A whole page from site/pages, served as it is, in the site's frame rules (never framed, no-transform). Four
551
+ * markers let it borrow the generated parts: <!-- homie:style --> (the tokens and the stylesheet),
552
+ * <!-- homie:header -->, <!-- homie:footer --> ("Made with Homie") and <!-- homie:script -->.
553
+ */
554
+ export function customPage(cat, html, { active = null } = {}) {
555
+ const out = String(html)
556
+ .replace(/<!--\s*homie:style\s*-->/g, () => `<style>${tokensCss(cat.studio?.theme ?? {})}${BASE_CSS}${cat.site?.css ?? ''}</style>`)
557
+ .replace(/<!--\s*homie:header\s*-->/g, () => header(cat, active))
558
+ .replace(/<!--\s*homie:footer\s*-->/g, () => footer(cat))
559
+ .replace(/<!--\s*homie:script\s*-->/g, () => `<script src="/_homie/site.js?v=${STUDIO_VERSION_TAG}" defer></script>`);
560
+ // The studio's own page may carry its own scripts, so only the frame and sniffing rules apply.
561
+ return new Response(out, { headers: pageHeaders({ 'content-security-policy': "frame-ancestors 'none'" }) });
562
+ }
563
+
564
+ /* ------------------------------------------------------------------ pieces */
565
+
566
+ const minutes = (s) => (Number.isFinite(s) && s > 0 ? (s % 60 ? `${s}\u00a0s rounds` : `${s / 60}\u2011minute rounds`) : null);
567
+ const playersText = (g) => (g.players?.max ? (g.players.max === 1 ? 'one player' : `1–${g.players.max} players`) : 'multiplayer');
568
+ const titleClass = (name) => { const n = String(name ?? '').length; return n > 22 ? 't-xlong' : n > 12 ? 't-long' : ''; };
569
+ const coverOf = (g) => g.landing?.cover ?? (g.cover ? `/games/${g.id}/${g.cover}` : null);
570
+
571
+ /** Hero media: the game's footage (wide and tall, AV1 first), else its art with slow motion, else the studio's colours. */
572
+ function heroMedia(h = {}, { alt = '', word = '' } = {}) {
573
+ const vars = [h.focus ? `--focus:${esc(h.focus)}` : '', Number.isFinite(h.tint) ? `--tint:${h.tint}%` : ''].filter(Boolean).join(';');
574
+ const focus = vars ? ` style="${vars}"` : '';
575
+ const still = h.wideImage || h.tallImage;
576
+ const picture = still ? `<picture>${h.wideImage && h.tallImage ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(h.wideImage)}">` : ''}<img src="${esc(h.tallImage ?? h.wideImage)}" alt="" fetchpriority="high" decoding="async"></picture>` : '';
577
+ const sources = [
578
+ h.wideAv1 && h.wide ? `<source media="(min-aspect-ratio: 3/4)" src="${esc(h.wideAv1)}" type="video/mp4; codecs=&quot;av01.0.08M.08&quot;">` : '',
579
+ h.wide && h.tall ? `<source media="(min-aspect-ratio: 3/4)" src="${esc(h.wide)}" type="video/mp4">` : '',
580
+ h.tallAv1 && h.tall ? `<source src="${esc(h.tallAv1)}" type="video/mp4; codecs=&quot;av01.0.08M.08&quot;">` : '',
581
+ h.tall ? `<source src="${esc(h.tall)}" type="video/mp4">` : (h.wide ? `<source src="${esc(h.wide)}" type="video/mp4">` : ''),
582
+ ].join('');
583
+ const video = sources ? `<video autoplay muted loop playsinline preload="metadata" disablepictureinpicture disableremoteplayback${alt ? ` aria-label="${esc(alt)}"` : ' aria-hidden="true"'}>${sources}</video>` : '';
584
+ const cls = video ? '' : still ? ' drift' : ' bare';
585
+ return `<div class="hero-media${cls}" data-hero-media${focus}>${picture}${video}${!still && !video && word ? `<span class="word" aria-hidden="true">${esc(word)}</span>` : ''}</div><div class="hero-grain" aria-hidden="true"></div><div class="hero-shade" aria-hidden="true"></div>`;
586
+ }
587
+
588
+ function liveLine(g, playing, { idle } = {}) {
589
+ const w = g.landing?.words ?? {};
590
+ const one = w.one ?? 'player';
591
+ const many = w.many ?? 'players';
592
+ const words = playing > 0 ? (playing === 1 ? `1 ${one} playing right now` : `${playing} ${many} playing right now`) : idle;
593
+ return `<p class="live" data-live="/${esc(g.id)}/live" data-n="${playing}" data-one="${esc(one)}" data-many="${esc(many)}" data-idle="${esc(idle)}" aria-live="polite"><span class="live-dot" aria-hidden="true"></span><span data-live-words>${esc(words)}</span></p>`;
594
+ }
595
+
596
+ function gameCard(g, playing = 0) {
597
+ const cover = coverOf(g);
598
+ return `<article class="gcard reveal">
599
+ <a class="art" href="/${esc(g.id)}/" aria-label="${esc(g.name)}">${cover ? `<img src="${esc(cover)}" alt="" loading="lazy" decoding="async">` : `<span class="word">${esc(g.name)}</span>`}${playing ? `<span class="badge"><i></i>${esc(playing)} playing now</span>` : ''}</a>
600
+ <div class="body">
601
+ <h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
602
+ <p>${esc(g.landing?.pitch ?? g.blurb)}</p>
603
+ <div class="meta"><span>${esc(playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
604
+ <div class="acts"><a class="btn" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}Play</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
605
+ </div>
606
+ </article>`;
607
+ }
608
+
609
+ /** A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). */
610
+ export function roomView(g, r, max) {
611
+ const n = Number(String(r.name ?? r.room ?? '').replace(/^pub-/, '')) || null;
612
+ return { game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room: r.name ?? r.room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(r.name ?? r.room)}` };
613
+ }
614
+
615
+ function roomRows(rows, { names = true } = {}) {
616
+ return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken · bots hold the rest</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><a class="join" href="${esc(r.play)}" data-play>Join</a></li>`).join('');
617
+ }
618
+
619
+ const fmtDay = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : ''; };
620
+
621
+ function postCard(p) {
622
+ return `<a class="pcard reveal" href="/posts/${esc(p.slug)}/">${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy" decoding="async">` : ''}<time datetime="${esc(p.date)}" class="post-meta">${esc(fmtDay(p.date))}</time><h3>${esc(p.title)}</h3><p>${esc(p.summary)}</p></a>`;
623
+ }
624
+
625
+ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
626
+ const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
627
+ const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
628
+
629
+ function mediaCard(e, kind) {
630
+ const art = fileOf(e, kind === 'music' ? 'cover' : 'poster');
631
+ return `<a class="media-card reveal" href="/${kind}/${esc(e.slug)}/"><span class="art">${art ? `<img src="${esc(art.url)}" alt="" loading="lazy" decoding="async">` : ''}<span class="big">${PLAY_ICON}</span></span><span class="cap"><b>${esc(e.title)}</b><span>${esc(KIND_WORD[e.kind] ?? (kind === 'music' ? 'Music' : 'Video'))}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span></span></a>`;
632
+ }
633
+
634
+ /* ------------------------------------------------------------------ Home */
635
+
636
+ /** The studio's featured game: studio.json `site.featured`, else the first with its own footage, else the first with art. */
637
+ export function featuredOf(cat) {
638
+ const games = cat.games ?? [];
639
+ const want = cat.studio?.site?.featured;
640
+ return games.find((g) => g.id === want) ?? games.find((g) => g.landing?.hero?.wide || g.landing?.hero?.tall) ?? games.find((g) => coverOf(g)) ?? games[0] ?? null;
641
+ }
642
+
643
+ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
644
+ const name = studioName(cat);
645
+ const games = cat.games ?? [];
646
+ const posts = (cat.posts ?? []).slice(0, 3);
647
+ const songs = cat.songs ?? [];
648
+ const videos = cat.videos ?? [];
649
+ const f = featuredOf(cat);
650
+ const tagline = cat.studio?.tagline ?? '';
651
+ let hero;
652
+ if (f) {
653
+ const playing = live[f.id] ?? 0;
654
+ hero = `<section class="hero home" aria-labelledby="hero-title">
655
+ ${heroMedia(f.landing?.hero, { alt: f.landing?.hero?.alt })}
656
+ <div class="hero-copy">
657
+ <p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || 'featured game')}</span></p>
658
+ <h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
659
+ <p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
660
+ <div class="row"><a class="play" href="/${esc(f.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>${liveLine(f, playing, { idle: 'Bots hold every empty seat · press Play and you are in a round' })}</div>
661
+ <a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/games/">${icon('games')}<span>All ${games.length} games</span></a>` : ''}
662
+ </div>
663
+ </section>`;
664
+ } else {
665
+ const lead = videos[0] ?? songs[0] ?? null;
666
+ const art = lead ? fileOf(lead, videos[0] ? 'poster' : 'cover')?.url : null;
667
+ hero = `<section class="hero home" aria-labelledby="hero-title">
668
+ ${heroMedia(art ? { wideImage: art } : {}, { word: name })}
669
+ <div class="hero-copy">
670
+ <p class="kicker">${esc(tagline || (songs.length ? 'Music' : videos.length ? 'Videos' : 'A Homie studio'))}</p>
671
+ <h1 class="title ${titleClass(name)}" id="hero-title">${esc(name)}</h1>
672
+ ${lead ? `<p class="line">${esc(lead.title)}${lead.blurb ? ` — ${esc(lead.blurb)}` : ''}</p><div class="row"><a class="play" href="/${videos[0] ? 'videos' : 'music'}/${esc(lead.slug)}/">${PLAY_ICON}<span>${videos[0] ? 'Watch' : 'Listen'}</span></a></div>` : `<p class="line">${esc(tagline || 'Nothing published yet.')}</p>`}
673
+ </div>
674
+ </section>`;
675
+ }
676
+ const roomsBand = games.length ? `<section class="band tight glowband" aria-labelledby="live-title"><div class="band-in">
677
+ <div class="head-row reveal"><div><p class="kicker">Live now</p><h2 class="small-h" id="live-title">${rooms.length ? `${esc(rooms.reduce((n, r) => n + r.players, 0))} playing across ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'}` : 'Rooms open the moment you press Play'}</h2></div><a class="more" href="/rooms/">All rooms ${icon('arrow')}</a></div>
678
+ <ol class="rooms" data-rooms data-src="/api/rooms"${rooms.length ? '' : ' hidden'}>${roomRows(rooms.slice(0, 6))}</ol>
679
+ <p class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>Nobody is in a room right now. Press Play on any game: you start at once with bots in the empty seats, and the next person who presses Play joins you.</p>
680
+ </div></section>` : '';
681
+ const gamesBand = games.length ? `<section class="band" aria-labelledby="games-title"><div class="band-in">
682
+ <div class="head-row reveal"><div><p class="kicker">Games</p><h2 id="games-title">${esc(games.length === 1 ? `One game, live now` : `${games.length} games, live now`)}</h2></div>${games.length > 3 ? `<a class="more" href="/games/">All games ${icon('arrow')}</a>` : ''}</div>
683
+ <div class="cards">${games.slice(0, 6).map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div>
684
+ </div></section>` : '';
685
+ const postsBand = posts.length ? `<section class="band hotband" aria-labelledby="posts-title"><div class="band-in">
686
+ <div class="head-row reveal"><div><p class="kicker">Posts</p><h2 id="posts-title">From the studio</h2></div><a class="more" href="/posts/">All posts ${icon('arrow')}</a></div>
687
+ <div class="cards posts">${posts.map(postCard).join('')}</div>
688
+ </div></section>` : '';
689
+ const mediaBand = (list, kind, title) => (list.length ? `<section class="band" aria-labelledby="${kind}-title"><div class="band-in">
690
+ <div class="head-row reveal"><div><p class="kicker">${kind === 'music' ? 'Music' : 'Videos'}</p><h2 id="${kind}-title">${esc(title)}</h2></div><a class="more" href="/${kind}/">All ${kind === 'music' ? 'music' : 'videos'} ${icon('arrow')}</a></div>
691
+ <div class="cards">${list.slice(0, 3).map((e) => mediaCard(e, kind)).join('')}</div>
692
+ </div></section>` : '');
693
+ const extra = partial(cat, 'home');
694
+ return layout(cat, {
695
+ title: tagline ? `${name} — ${tagline}` : name,
696
+ description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : name),
697
+ origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true,
698
+ head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
699
+ main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
700
+ });
701
+ }
702
+
703
+ /* ------------------------------------------------------------------ Games, Rooms, Posts */
704
+
705
+ function pageHead(kicker, title, lead, extra = '') {
706
+ return `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
707
+ }
708
+
709
+ export function gamesPage(cat, { origin = '', live = {} } = {}) {
710
+ const games = cat.games ?? [];
711
+ const name = studioName(cat);
712
+ return layout(cat, {
713
+ title: `Games · ${name}`, description: `Every game from ${name}: press Play and you are in a live room.`, origin, path: '/games/', page: 'games', active: 'games',
714
+ main: `${pageHead(name, 'Games', `${games.length === 1 ? 'One game' : `${games.length} games`}. Press Play on a phone, a computer or a TV browser and you are in a live public room with whoever is playing; bots hold the empty seats.`)}
715
+ <div class="wrap"><div class="cards">${games.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div>`,
716
+ });
717
+ }
718
+
719
+ export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
720
+ const name = studioName(cat);
721
+ const games = cat.games ?? [];
722
+ const total = rooms.reduce((n, r) => n + r.players, 0);
723
+ return layout(cat, {
724
+ title: `Rooms · ${name}`, description: `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
725
+ main: `${pageHead('Live now', 'Rooms', rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
726
+ <div class="wrap">
727
+ <ol class="rooms" data-rooms data-src="/api/rooms"${rooms.length ? '' : ' hidden'}>${roomRows(rooms)}</ol>
728
+ <div class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>Nobody is in a room right now. Press Play on any game below: you start at once with bots in the empty seats, and whoever presses Play next lands in your room.</div>
729
+ <p class="sec">Start one</p>
730
+ <div class="cards">${games.map((g) => gameCard(g, 0)).join('')}</div>
731
+ </div>`,
732
+ });
733
+ }
734
+
735
+ export function postsPage(cat, { origin = '' } = {}) {
736
+ const posts = cat.posts ?? [];
737
+ const name = studioName(cat);
738
+ const gameName = (id) => (cat.games ?? []).find((g) => g.id === id)?.name;
739
+ const items = posts.map((p) => `<li class="pitem reveal"><a href="/posts/${esc(p.slug)}/"><div><time datetime="${esc(p.date)}">${esc(fmtDay(p.date))}</time><h3>${esc(p.title)}</h3><p>${esc(p.summary)}</p>${p.links?.game && gameName(p.links.game) ? `<div class="chips"><span>${esc(gameName(p.links.game))}</span></div>` : ''}</div>${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy" decoding="async">` : ''}</a></li>`).join('');
740
+ return layout(cat, {
741
+ title: `Posts · ${name}`, description: `News and drops from ${name}.`, origin, path: '/posts/', page: 'posts', active: 'posts',
742
+ main: `${pageHead(name, 'Posts', 'News, drops and notes from the studio.', `<div class="feeds"><a href="/posts/feed.xml">${icon('feed')}Atom feed</a><a href="/posts/feed.json">${icon('feed')}JSON feed</a></div>`)}
743
+ <div class="wrap"><ol class="plist">${items}</ol></div>`,
744
+ });
745
+ }
746
+
747
+ export function postPage(cat, p, { origin = '' } = {}) {
748
+ const name = studioName(cat);
749
+ const g = p.links?.game ? (cat.games ?? []).find((x) => x.id === p.links.game) : null;
750
+ const song = p.links?.song ? (cat.songs ?? []).find((x) => x.slug === p.links.song) : null;
751
+ const video = p.links?.video ? (cat.videos ?? []).find((x) => x.slug === p.links.video) : null;
752
+ const others = (cat.posts ?? []).filter((x) => x.slug !== p.slug).slice(0, 2);
753
+ const extra = partial(cat, 'post', { 'post.title': p.title, 'post.slug': p.slug });
754
+ const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos') : '', song ? mediaCard(song, 'music') : ''].filter(Boolean).join('');
755
+ return layout(cat, {
756
+ title: `${p.title} · ${name}`, description: p.summary, origin, path: `/posts/${p.slug}/`, image: p.image ?? (g ? coverOf(g) : null), type: 'article', page: 'post', active: 'posts',
757
+ head: `<meta property="article:published_time" content="${esc(p.date)}">${p.updated ? `<meta property="article:modified_time" content="${esc(p.updated)}">` : ''}`,
758
+ main: `<div class="wrap" style="padding-top:clamp(36px,7vw,90px)"><article class="article">
759
+ <p class="post-meta"><time datetime="${esc(p.date)}">${esc(fmtDay(p.date))}</time>${p.author ? ` · ${esc(p.author)}` : ''}</p>
760
+ <h1>${esc(p.title)}</h1>
761
+ ${p.summary ? `<p class="dek">${esc(p.summary)}</p>` : ''}
762
+ ${p.image ? `<div class="cover"><img src="${esc(p.image)}" alt="" decoding="async"></div>` : ''}
763
+ <div class="prose">${p.html ?? ''}</div>
764
+ ${linked ? `<div class="linked cards">${linked}</div>` : ''}
765
+ ${extra ?? ''}
766
+ </article>
767
+ ${others.length ? `<p class="sec" style="max-width:760px;margin-left:auto;margin-right:auto">More from ${esc(name)}</p><div class="cards posts" style="max-width:760px;margin:0 auto">${others.map(postCard).join('')}</div>` : ''}
768
+ </div>`,
769
+ });
770
+ }
771
+
772
+ /* ------------------------------------------------------------------ feeds */
773
+
774
+ const xml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
775
+ const absHtml = (html, origin) => String(html ?? '').replace(/(href|src)="\/(?!\/)/g, `$1="${origin}/`);
776
+
777
+ /** Atom 1.0: every post, newest first, with its full HTML (links made absolute). */
778
+ export function atomFeed(cat, posts, origin) {
779
+ const name = studioName(cat);
780
+ const updated = posts.reduce((m, p) => ((p.updated ?? p.date) > m ? (p.updated ?? p.date) : m), posts[0]?.date ?? new Date(0).toISOString());
781
+ const body = `<?xml version="1.0" encoding="utf-8"?>
782
+ <feed xmlns="http://www.w3.org/2005/Atom">
783
+ <title>${xml(name)}</title>
784
+ <subtitle>${xml(cat.studio?.tagline ?? `News and drops from ${name}`)}</subtitle>
785
+ <id>${xml(`${origin}/posts/`)}</id>
786
+ <link rel="alternate" type="text/html" href="${xml(`${origin}/posts/`)}"/>
787
+ <link rel="self" type="application/atom+xml" href="${xml(`${origin}/posts/feed.xml`)}"/>
788
+ <updated>${xml(updated)}</updated>
789
+ <author><name>${xml(name)}</name><uri>${xml(`${origin}/`)}</uri></author>
790
+ <generator uri="https://homie.rocks/studio/">Homie</generator>
791
+ ${posts.map((p) => ` <entry>
792
+ <title>${xml(p.title)}</title>
793
+ <id>${xml(`${origin}/posts/${p.slug}/`)}</id>
794
+ <link rel="alternate" type="text/html" href="${xml(`${origin}/posts/${p.slug}/`)}"/>
795
+ <published>${xml(p.date)}</published>
796
+ <updated>${xml(p.updated ?? p.date)}</updated>
797
+ ${p.author ? `<author><name>${xml(p.author)}</name></author>` : ''}
798
+ <summary>${xml(p.summary)}</summary>
799
+ <content type="html">${xml(absHtml(p.html, origin))}</content>
800
+ </entry>`).join('\n')}
801
+ </feed>
802
+ `;
803
+ return new Response(body, { headers: { 'content-type': 'application/atom+xml; charset=utf-8', 'cache-control': 'public, max-age=300', 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' } });
804
+ }
805
+
806
+ /**
807
+ * JSON Feed 1.1. Each item also carries `_homie.record`: the post as a record (title, text, createdAt, links to a
808
+ * game, song or video by id) in the shape it takes when studios publish posts to atproto later.
809
+ */
810
+ export function jsonFeed(cat, posts, origin) {
811
+ const name = studioName(cat);
812
+ const body = {
813
+ version: 'https://jsonfeed.org/version/1.1',
814
+ title: name,
815
+ home_page_url: `${origin}/`,
816
+ feed_url: `${origin}/posts/feed.json`,
817
+ ...(cat.studio?.tagline ? { description: cat.studio.tagline } : {}),
818
+ authors: [{ name, url: `${origin}/` }],
819
+ language: 'en',
820
+ items: posts.map((p) => ({
821
+ id: `${origin}/posts/${p.slug}/`,
822
+ url: `${origin}/posts/${p.slug}/`,
823
+ title: p.title,
824
+ summary: p.summary,
825
+ content_html: absHtml(p.html, origin),
826
+ date_published: p.date,
827
+ ...(p.updated ? { date_modified: p.updated } : {}),
828
+ ...(p.image ? { image: p.image.startsWith('/') ? `${origin}${p.image}` : p.image } : {}),
829
+ ...(p.author ? { authors: [{ name: p.author }] } : {}),
830
+ _homie: {
831
+ links: Object.fromEntries(Object.entries(p.links ?? {}).map(([k, id]) => [k, { id, url: `${origin}${k === 'game' ? `/${id}/` : k === 'song' ? `/music/${id}/` : `/videos/${id}/`}` }])),
832
+ record: p.record ?? null,
833
+ },
834
+ })),
835
+ };
836
+ return new Response(`${JSON.stringify(body, null, 2)}\n`, { headers: { 'content-type': 'application/feed+json; charset=utf-8', 'cache-control': 'public, max-age=300', 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' } });
837
+ }
838
+
839
+ /* ------------------------------------------------------------------ a game's landing */
840
+
841
+ /**
842
+ * The landing every game gets: a full-bleed hero from its own footage or art, the pitch, a big Play button into a
843
+ * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
844
+ * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
845
+ */
846
+ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null } = {}) {
847
+ const name = studioName(cat);
848
+ const L = g.landing ?? {};
849
+ const h = L.hero ?? {};
850
+ const host = origin.replace(/^https?:\/\//, '');
851
+ const playUrl = `${origin}/${g.id}/play`;
852
+ let qr = '';
853
+ try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
854
+ const tvOn = L.tv !== false;
855
+ const max = g.players?.max ?? 8;
856
+ const words = L.words ?? {};
857
+ const many = words.many ?? 'players';
858
+ const kicker = L.kicker ?? `Free in your browser · <span class="k-opt">phone, computer or TV · </span>no download`;
859
+ const kickerHtml = L.kicker ? esc(L.kicker) : kicker;
860
+ const trailer = L.trailer ? (cat.videos ?? []).find((v) => v.slug === L.trailer) : null;
861
+ const song = (L.music ?? []).map((s) => (cat.songs ?? []).find((m) => m.slug === s)).find(Boolean) ?? null;
862
+ const hero = `<section class="hero" aria-labelledby="game-title">
863
+ ${heroMedia(h, { alt: h.alt })}
864
+ <div class="hero-copy">
865
+ <p class="kicker">${kickerHtml}</p>
866
+ <h1 class="title ${titleClass(g.name)}" id="game-title">${esc(g.name)}</h1>
867
+ <p class="line">${esc(L.pitch ?? g.blurb)}</p>
868
+ <div class="row">
869
+ <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
870
+ ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
871
+ </div>
872
+ ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}
873
+ </div>
874
+ </section>`;
875
+
876
+ const ways = `<section class="band glowband" id="anywhere" aria-labelledby="anywhere-title"><div class="band-in split">
877
+ <div class="reveal">
878
+ <p class="kicker">Phone · computer${tvOn ? ' · TV' : ''}</p>
879
+ <h2 id="anywhere-title">${tvOn ? 'Play it on anything. Put it on the TV.' : 'Play it on your phone or your computer.'}</h2>
880
+ <p class="lead">Nothing to download and nothing to install: ${esc(g.name)} runs in the browser you already have, and everyone who presses Play meets in the same public room.</p>
881
+ <ol class="ways">
882
+ <li class="way"><span class="n">${icon('phone')}</span><div><h3>On your phone</h3><p>${esc(L.controls?.phone ?? 'Touch controls appear under your thumbs; the middle of the screen stays clear.')}</p>
883
+ <div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.</p></div>
884
+ <p class="phone-only" style="margin-top:8px"><strong>You are on it:</strong> press Play above.</p></div></li>
885
+ <li class="way"><span class="n">${icon('computer')}</span><div><h3>On your computer</h3><p>${esc(L.controls?.computer ?? 'Keys and mouse, in any modern browser; the game shows you which keys.')}</p></div></li>
886
+ ${tvOn ? `<li class="way"><span class="n">${icon('tv')}</span><div><h3>On the TV</h3><ol><li>Open <span class="addr">${esc(host)}/${esc(g.id)}/tv</span> in the TV’s browser, or on a laptop plugged into it.</li><li>Scan the code it shows with your phone’s camera.</li><li>Play on your phone. Everyone on the couch joins the same way, up to ${esc(max)} ${esc(many)}, and the TV shows the whole room.</li></ol>${L.controls?.tv ? `<p style="margin-top:8px">${esc(L.controls.tv)}</p>` : ''}</div></li>` : ''}
887
+ </ol>
888
+ <div class="keys">${tvOn ? `<a class="ghost" href="/${esc(g.id)}/tv">${icon('tv')}<span>Open the big-screen view</span></a>` : ''}<a class="ghost desk-only" href="/${esc(g.id)}/play" data-play>${icon('computer')}<span>Play in this browser</span></a></div>
889
+ </div>
890
+ ${tvOn ? `<figure class="tvart reveal" aria-hidden="true"><div class="screen">${h.wideImage || L.cover ? `<img src="${esc(h.wideImage ?? L.cover)}" alt="" loading="lazy" decoding="async">` : ''}${qr ? `<span class="qr">${qr}</span>` : ''}</div><span class="phone p1"></span><span class="phone p2"></span></figure>` : ''}
891
+ </div></section>`;
892
+
893
+ const liveBand = `<section class="band tight" aria-labelledby="rooms-title"><div class="band-in">
894
+ <div class="head-row reveal"><div><p class="kicker">Live rooms</p><h2 class="small-h" id="rooms-title">${rooms.length ? `${esc(playing)} ${playing === 1 ? esc(words.one ?? 'player') : esc(many)} in ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'} right now` : 'Rooms open the moment you press Play'}</h2></div><a class="more" href="/rooms/">Every room ${icon('arrow')}</a></div>
895
+ <ol class="rooms" data-rooms data-src="/${esc(g.id)}/live"${rooms.length ? '' : ' hidden'}>${roomRows(rooms, { names: false })}</ol>
896
+ <p class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>No one is in a room this minute. Press Play and you start at once, with bots in the empty seats; whoever presses Play next takes a bot’s place in your room.</p>
897
+ ${week ? `<div class="stat"><span>Played this week: <b>${esc(week.plays)}</b> ${week.plays === 1 ? 'time' : 'times'}</span><span><b>${esc(week.rounds)}</b> ${week.rounds === 1 ? 'round' : 'rounds'} with people</span>${week.peak ? `<span>Most at once today: <b>${esc(week.peak)}</b></span>` : ''}</div>` : ''}
898
+ </div></section>`;
899
+
900
+ const facts = ['free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
901
+ const side = trailer ? mediaCard(trailer, 'videos') : song ? mediaCard(song, 'music') : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
902
+ const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
903
+ <div class="reveal">
904
+ <p class="kicker">How to play</p>
905
+ <h2 class="small-h" id="how-title">${esc(L.headline ?? `${g.players?.max > 1 ? `Up to ${g.players.max} players` : 'One player'}${minutes(g.roundSeconds) ? `, ${minutes(g.roundSeconds)}` : ''}.`)}</h2>
906
+ ${L.about ? `<p class="lead">${esc(L.about)}</p>` : ''}
907
+ ${(L.how ?? []).length ? `<ul class="howto">${L.how.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : ''}
908
+ <ul class="facts">${facts.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
909
+ </div>
910
+ ${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music')}</div>` : ''}</div>` : ''}
911
+ </div></section>`;
912
+
913
+ const C = L.credits ?? {};
914
+ const o = C.original;
915
+ const creditCards = [
916
+ `<div class="credit"><h3>Made by</h3><p><strong><a href="/">${esc(name)}</a></strong>${cat.studio?.tagline ? ` · ${esc(cat.studio.tagline)}` : ''}</p>${(C.people ?? []).length ? `<ul>${C.people.map((c) => `<li>${c.role ? `${esc(c.role)}: ` : ''}${c.url ? `<a href="${esc(c.url)}" rel="noopener">${esc(c.name)}</a>` : `<strong>${esc(c.name)}</strong>`}</li>`).join('')}</ul>` : ''}</div>`,
917
+ o && !o.house ? `<div class="credit"><h3>Based on</h3><p><strong>${esc(o.title)}</strong>${o.author ? ` by <strong>${esc(o.author)}</strong>` : ''}${o.year ? ` (${esc(o.year)})` : ''}${o.licence ? `, ${esc(o.licence)} licence` : ''}.</p>${o.url ? `<p><a href="${esc(o.url)}" rel="noopener">${esc(o.url.replace(/^https:\/\//, ''))}</a></p>` : ''}<p>Made multiplayer with Homie: public rooms, bots, join-in-progress, a new host when one leaves, touch controls and the big screen.</p></div>` : '',
918
+ (C.parts ?? []).length ? `<div class="credit"><h3>Also inside</h3><ul>${C.parts.map((p) => `<li>${esc(p.what)}${p.author ? `: ${esc(p.author)}` : ''}${p.url ? ` (<a href="${esc(p.url)}" rel="noopener">source</a>)` : ''}${p.licence ? `, ${p.licenceUrl ? `<a href="${esc(p.licenceUrl)}" rel="noopener">${esc(p.licence)}</a>` : esc(p.licence)}` : ''}</li>`).join('')}</ul></div>` : '',
919
+ `<div class="credit"><h3>Built with</h3><p>Homie’s open engine: <a href="https://github.com/homie-rocks/homie" rel="noopener">@homie-rocks/studio</a> (rooms, netplay, the big screen), Apache-2.0.</p>${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
920
+ ].filter(Boolean).join('');
921
+ const creditsBand = `<section class="band tight" aria-labelledby="credits-title"><div class="band-in"><p class="kicker reveal">Credits</p><h2 class="small-h reveal" id="credits-title">Who made ${esc(g.name)}</h2><div class="credits reveal">${creditCards}</div></div></section>`;
922
+
923
+ const source = `${origin}/games/${g.id}/source.json`;
924
+ const say = L.source
925
+ ? `Remix ${g.name} from ${source} into a game of my own in my Homie studio`
926
+ : `Make a multiplayer game like ${g.name} in my Homie studio`;
927
+ const makeHref = L.source ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
928
+ const makeBand = `<section class="band" aria-labelledby="make-title"><div class="band-in"><div class="make reveal">
929
+ <p class="kicker">${L.source ? 'Open to remix' : 'Your turn'}</p>
930
+ <h2 id="make-title">Make a game like this</h2>
931
+ <p class="lead">${L.source ? `${esc(g.name)}’s source is shared. Homie is a plugin for Claude Code and Codex: your AI copies this game into a studio of your own, makes it yours, and puts it online with public rooms like these, on your own free Cloudflare account.` : `Homie is a plugin for Claude Code and Codex: your AI sets up a studio of your own and makes a multiplayer game like ${esc(g.name)}, with public rooms like these, on your own free Cloudflare account.`}</p>
932
+ <ol class="say">
933
+ <li><div><span class="label">1 · In Claude Code</span><code>/plugin marketplace add homie-rocks/homie</code></div><button class="copy" type="button" data-copy="/plugin marketplace add homie-rocks/homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
934
+ <li><div><span class="label">2</span><code>/plugin install homie@homie</code></div><button class="copy" type="button" data-copy="/plugin install homie@homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
935
+ <li><div><span class="label">3 · Then say</span><code>${esc(say)}</code></div><button class="copy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></li>
936
+ </ol>
937
+ <div class="keys"><a class="btn" href="${esc(makeHref)}">${icon('spark')}<span>Make a game like this</span></a>${L.source ? `<a class="ghost" href="/games/${esc(g.id)}/source.json">See the source</a>` : ''}</div>
938
+ </div></div></section>`;
939
+
940
+ // A partial that is its own <section> stands as it is; anything else sits in one of the page's bands.
941
+ const band = (html) => (!html ? '' : /^\s*<section\b/i.test(html) ? html : `<section class="band tight"><div class="band-in">${html}</div></section>`);
942
+ const vars = { 'game.name': g.name, 'game.id': g.id, 'game.play': `/${g.id}/play` };
943
+ const ld = { '@context': 'https://schema.org', '@type': 'VideoGame', name: g.name, description: L.pitch ?? g.blurb ?? '', url: `${origin}/${g.id}/`, playMode: 'MultiPlayer', gamePlatform: ['Web browser', 'Phone', 'TV'], applicationCategory: 'Game', ...(h.wideImage ? { image: `${origin}${h.wideImage}` } : {}), author: { '@type': 'Organization', name, url: `${origin}/` }, offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' } };
944
+ return layout(cat, {
945
+ title: `${g.name} — play free in your browser`,
946
+ description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
947
+ origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: L.theme ?? null,
948
+ head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}<script type="application/ld+json">${jsonScript(ld)}</script>`,
949
+ main: `${hero}${ways}${liveBand}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
950
+ });
951
+ }
952
+
953
+ /** /<game>/credits: the licence texts the game ships with (CREDITS.md and every licence file credits.json names). */
954
+ export function creditsPage(cat, g, texts, { origin = '' } = {}) {
955
+ const name = studioName(cat);
956
+ return layout(cat, {
957
+ title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
958
+ main: `${pageHead(g.name, 'Credits and licences', null, `<div class="keys"><a class="ghost" href="/${esc(g.id)}/">${icon('arrow')}<span>Back to ${esc(g.name)}</span></a></div>`)}
959
+ <div class="wrap">${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
960
+ });
961
+ }
962
+
963
+ /* ------------------------------------------------------------------ music and videos */
964
+
965
+ function rightsLine(e) {
966
+ const bits = [];
967
+ if (e.credits) bits.push(esc(e.credits));
968
+ const r = e.rights;
969
+ if (r && typeof r === 'object') {
970
+ if (r.commercial === true) bits.push(`Licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
971
+ else if (r.commercial === false) bits.push(`Not licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
972
+ if (r.attribution) bits.push(esc(r.attribution));
973
+ }
974
+ if (e.honesty) bits.push(esc(e.honesty));
975
+ return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
976
+ }
977
+
978
+ /** The page's one stats beacon: when its player first starts, one POST to /api/stats/beat (worker/stats.mjs). */
979
+ const beaconAttr = (kind, slug) => ` data-beat="${esc(JSON.stringify({ k: kind, s: slug }))}"`;
980
+
981
+ /** /music/ and /videos/: every published entry of one kind (the site answers 404 when there is none). */
982
+ export function mediaIndexPage(cat, kind, { origin = '' } = {}) {
983
+ const name = studioName(cat);
984
+ const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
985
+ const title = kind === 'music' ? 'Music' : 'Videos';
986
+ return layout(cat, {
987
+ title: `${title} · ${name}`, description: `${title} from ${name}.`, origin, path: `/${kind}/`, page: kind, active: kind,
988
+ main: `${pageHead(name, title, kind === 'music' ? 'Songs, scores and loops from the studio.' : 'Trailers, music videos and clips from the studio.')}
989
+ <div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind)).join('')}</div></div>`,
990
+ });
991
+ }
992
+
993
+ export function songPage(cat, e, origin = '') {
994
+ const name = studioName(cat);
995
+ const audio = fileOf(e, 'audio');
996
+ const cover = fileOf(e, 'cover');
997
+ const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
998
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
999
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
1000
+ return layout(cat, {
1001
+ title: `${e.title} · ${name}`, description: e.blurb ?? '', origin, path: `/music/${e.slug}/`, image: cover?.url ?? null, type: 'music.song', page: 'song', active: 'music',
1002
+ head: ogTags({ 'og:audio': abs(audio.url) }),
1003
+ main: `<div class="wrap" style="padding-top:clamp(36px,7vw,90px)">
1004
+ <section class="songhead${cover ? ' has-cover' : ''}">
1005
+ <div>
1006
+ <p class="kicker">${esc(KIND_WORD[e.kind] ?? 'Music')}</p>
1007
+ <h1 class="title ${titleClass(e.title)}">${esc(e.title)}</h1>
1008
+ ${e.blurb ? `<p class="lead">${esc(e.blurb)}</p>` : ''}
1009
+ <div class="player"${beaconAttr('song', e.slug)}><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
1010
+ <div class="mfacts">${[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>
1011
+ </div>
1012
+ ${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
1013
+ </section>
1014
+ ${e.lyrics ? `<p class="sec">Words</p><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
1015
+ ${extras.length ? `<p class="sec">Loops and stems</p><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>` : ''}
1016
+ ${game ? `<div class="keys"><a class="btn" href="/${esc(game.id)}/play" data-play>${PLAY_ICON}<span>Play ${esc(game.name)}</span></a></div>` : ''}
1017
+ ${rightsLine(e)}
1018
+ </div>`,
1019
+ });
1020
+ }
1021
+
1022
+ export function videoPage(cat, e, origin = '') {
1023
+ const name = studioName(cat);
1024
+ const video = fileOf(e, 'video');
1025
+ const vertical = fileOf(e, 'vertical');
1026
+ const poster = fileOf(e, 'poster');
1027
+ const captions = fileOf(e, 'captions');
1028
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
1029
+ const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
1030
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
1031
+ return layout(cat, {
1032
+ title: `${e.title} · ${name}`, description: e.blurb ?? '', origin, path: `/videos/${e.slug}/`, image: poster?.url ?? null, type: 'video.other', page: 'video', active: 'videos',
1033
+ head: ogTags({ 'og:video': abs(video.url) }),
1034
+ main: `<div class="wrap" style="padding-top:clamp(28px,5vw,64px)">
1035
+ <div class="player"${beaconAttr('video', e.slug)}${vertical ? ` data-vertical="${esc(vertical.url)}"` : ''}><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>
1036
+ <p class="kicker">${esc(KIND_WORD[e.kind] ?? 'Video')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</p>
1037
+ <h1 class="title ${titleClass(e.title)}" style="text-shadow:none">${esc(e.title)}</h1>
1038
+ ${e.blurb ? `<p class="lead">${esc(e.blurb)}</p>` : ''}
1039
+ <div class="mfacts" style="margin-top:14px">${song ? `<a href="/music/${esc(song.slug)}/">${esc(song.title)}</a>` : ''}</div>
1040
+ <div class="keys">${game ? `<a class="btn" href="/${esc(game.id)}/play" data-play>${PLAY_ICON}<span>Play ${esc(game.name)}</span></a>` : ''}${vertical ? `<a class="ghost" href="${esc(vertical.url)}">Vertical cut</a>` : ''}</div>
1041
+ ${rightsLine(e)}
1042
+ </div>`,
1043
+ });
1044
+ }
1045
+
1046
+ export function notFoundPage(why, cat = null) {
1047
+ const c = cat ?? { studio: { name: 'Studio' }, games: [], songs: [], videos: [], posts: [] };
1048
+ return layout(c, {
1049
+ title: 'Not found', page: 'missing', status: 404,
1050
+ main: `${pageHead('404', 'Not here', why, `<div class="keys"><a class="btn" href="/">${icon('home')}<span>Home</span></a>${(c.games ?? []).length ? `<a class="ghost" href="/games/">${icon('games')}<span>Games</span></a>` : ''}</div>`)}`,
1051
+ });
1052
+ }