@homie-rocks/studio 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
110
110
  A studio pins one version, so nothing changes until it asks. To take a newer one:
111
111
 
112
112
  ```sh
113
- npx -y @homie-rocks/studio@0.16.0 upgrade # the plan; changes nothing
114
- npx -y @homie-rocks/studio@0.16.0 upgrade --apply # after the person agrees
113
+ npx -y @homie-rocks/studio@0.16.1 upgrade # the plan; changes nothing
114
+ npx -y @homie-rocks/studio@0.16.1 upgrade --apply # after the person agrees
115
115
  npm install && npm run build
116
116
  ```
117
117
 
@@ -271,7 +271,7 @@ npx homie-studio agents pass night-rush --label Claude
271
271
  ## Setup status
272
272
 
273
273
  ```sh
274
- npx -y @homie-rocks/studio@0.16.0 setup status --connector yes # before a studio exists
274
+ npx -y @homie-rocks/studio@0.16.1 setup status --connector yes # before a studio exists
275
275
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
276
276
  ```
277
277
 
@@ -462,6 +462,34 @@
462
462
  "## Beta": "9d02638207a26e73"
463
463
  }
464
464
  }
465
+ },
466
+ "0.16.1": {
467
+ "files": {
468
+ "CLAUDE.md": "ddf2b2a0376202f6",
469
+ "README.md": "684b3cca8001673c",
470
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
471
+ "games/README.md": "5ad32fe80c61f117",
472
+ "music/README.md": "b8a828de9311bd28",
473
+ "videos/README.md": "70614388cbb203c7",
474
+ "posts/README.md": "37841aae476bd540",
475
+ "site/README.md": "ca131cd0472464fb",
476
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
477
+ },
478
+ "sections": {
479
+ "AGENTS.md": {
480
+ "(intro)": "214aa46eb3a895d5",
481
+ "## Layout": "f14d641159480e6c",
482
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
483
+ "## The site": "ab922bb6bde23293",
484
+ "## Making games": "a8a893eeada33bf5",
485
+ "## The Game Codex and progress": "0738ef719cef3a4c",
486
+ "## Running live games (the back office)": "f0566f07cff04113",
487
+ "## Servers and AI seats": "961ac5e8ccc2a548",
488
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
489
+ "## Rules": "969318425c99f5a5",
490
+ "## Beta": "9d02638207a26e73"
491
+ }
492
+ }
465
493
  }
466
494
  }
467
495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -157,6 +157,8 @@ where the game has room, per device:
157
157
  - It shows the room's name ("Room 7") for a few seconds, then shrinks to a round icon until it is touched;
158
158
  `"label": false` keeps it the icon always (the code is in its sheet), for a corner with little room.
159
159
  - A button at the bottom left sends the "finding a room" chip to the bottom right.
160
+ - On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
161
+ phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
160
162
 
161
163
  Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
162
164
  score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
@@ -219,12 +221,16 @@ its **Quick play** server (`public`), so every old link keeps working.
219
221
  the same door, except that watching a beginner room needs no new account.
220
222
  - **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
221
223
  from, and a named `?room=` is off (except for the owner).
222
- - **The play shell** on a server: a pill beside the room button ("First Light · Beginner"; tap: the policy's line,
223
- "AI level: Steady" with **Change** when the game's bots read the dial, and **Quiet AI**, which hides AI chat on
224
- this screen only); the chip says "4 playing · 2 AI"; every AI row and result carries an AI pill.
224
+ - **The play shell** on a server: a pill beside the room button, in the same band at the room button's place
225
+ (`screen.share`), never under it ("First Light · Beginner", then a dot after a few seconds; on a phone held
226
+ upright, the dot from the start; tap: the server's name, the policy's line, "AI level: Steady" with **Change**
227
+ when the game's bots read the dial, and **Quiet AI**, which hides AI chat on this screen only). A server room's
228
+ button says "Room 2", as Quick play's does. The chip says "First Light · 4 playing · 2 AI"; every AI row and result
229
+ carries an AI pill. A game's HUD keeps that band clear (Gem Rush starts its scores under it).
225
230
  - **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
226
231
  party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
227
- every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The
232
+ every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The card
233
+ is opaque: nothing of the game shows through it. The
228
234
  result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
229
235
  `"agents": { "vote": "game" }` (`false`: none).
230
236
  - `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
@@ -726,9 +726,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
726
726
  const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
727
727
  ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
728
728
  ctx.textAlign = 'right';
729
+ // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
730
+ // default): the scores start under that band, so the buttons never cover a score.
731
+ const board = top + 18 + 44;
729
732
  rows.forEach((a, i) => {
730
733
  ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
731
- ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
734
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
732
735
  });
733
736
  // role badge
734
737
  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
@@ -6,6 +6,8 @@
6
6
  * else, connect only to this site). Everything a player typed is set as text, never as markup. Never cached,
7
7
  * never indexed, never framed.
8
8
  */
9
+ import { SKILLS } from './agents.mjs';
10
+ import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
9
11
  import { esc } from './site.mjs';
10
12
 
11
13
  const PRIVATE = {
@@ -103,8 +105,54 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
103
105
  .warnbox { margin: 10px 16px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(255,179,92,.45); color: var(--warn); font-size: 14px; }
104
106
  .secret { font: 600 13px/1.4 ui-monospace, Menlo, monospace; word-break: break-all; padding: 10px; border-radius: 10px; background: var(--bg); border: 1px solid var(--warn); }
105
107
  .chip.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
106
- .newsrv { display: grid; gap: 8px; padding: 10px 0 0; }
107
- .newsrv .line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
108
+ /* The New server form: one panel, grouped, every control labelled, help under it in the office's quiet grey. */
109
+ .newsrv { display: grid; gap: 16px; max-width: 820px; margin-top: 10px; padding: 16px; border-radius: 14px; background: var(--panel2); border: 1px solid var(--line); }
110
+ .newsrv [hidden] { display: none !important; }
111
+ .newsrv h4 { margin: 0; font-size: 17px; letter-spacing: -.01em; }
112
+ .newsrv fieldset { border: 0; border-top: 1px solid var(--line); margin: 0; padding: 14px 0 0; min-width: 0; display: grid; gap: 10px; }
113
+ .newsrv legend { float: left; width: 100%; padding: 0 0 4px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); font-weight: 700; }
114
+ .newsrv legend + * { clear: both; }
115
+ .newsrv .frow { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 6px 14px; align-items: start; }
116
+ .newsrv .frow > label, .newsrv .frow > .lbl { padding-top: 9px; font-size: 14px; font-weight: 600; color: var(--ink); }
117
+ .newsrv .ctl { display: grid; gap: 5px; min-width: 0; }
118
+ .newsrv .help { margin: 0; color: var(--faint); font-size: 13px; line-height: 1.45; }
119
+ .newsrv .help b { color: var(--dim); font-weight: 600; }
120
+ .newsrv .note { margin: 0; padding: 10px 12px; border-radius: 12px; border: 1px dashed var(--line2); color: var(--dim); font-size: 14px; max-width: none; }
121
+ .newsrv input[type=text] { width: 100%; }
122
+ .policies { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
123
+ .policy { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; align-items: start; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--line2); background: var(--bg); cursor: pointer; }
124
+ .policy:hover { border-color: rgba(255,255,255,.3); }
125
+ .policy.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
126
+ .policy input { margin: 2px 0 0; width: 16px; height: 16px; min-height: 0; padding: 0; border: 0; background: none; accent-color: var(--accent); }
127
+ .policy b { font-size: 14px; letter-spacing: .04em; text-transform: uppercase; }
128
+ .policy span { grid-column: 2; color: var(--dim); font-size: 13px; line-height: 1.4; }
129
+ .stepper { display: inline-flex; align-items: center; gap: 6px; }
130
+ .stepper button { width: 38px; padding: 0; font-size: 18px; line-height: 1; }
131
+ .stepper input[type=number] { width: 64px; text-align: center; -moz-appearance: textfield; }
132
+ .stepper input::-webkit-outer-spin-button, .stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
133
+ .stepper .of { color: var(--dim); font-size: 14px; }
134
+ .seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--line2); border-radius: 999px; padding: 3px; gap: 2px; justify-self: start; }
135
+ .seg label { position: relative; cursor: pointer; }
136
+ .seg input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; min-height: 0; }
137
+ .seg span { display: inline-block; padding: 6px 14px; border-radius: 999px; font-size: 14px; font-weight: 600; color: var(--dim); }
138
+ .seg input:checked + span { background: var(--ink); color: var(--bg); }
139
+ .seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
140
+ .dial { display: grid; gap: 4px; max-width: 420px; }
141
+ .dial input[type=range] { width: 100%; min-height: 0; padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
142
+ .dial .ticks { display: grid; grid-template-columns: repeat(var(--n, 5), 1fr); font-size: 11px; color: var(--faint); }
143
+ .dial .ticks span:not(:first-child):not(:last-child) { text-align: center; }
144
+ .dial .ticks span:last-child { text-align: right; }
145
+ .dial output { font-size: 14px; color: var(--ink); }
146
+ .dial output b { color: var(--accent); }
147
+ .check { display: inline-flex; gap: 10px; align-items: center; font-weight: 600; font-size: 14px; cursor: pointer; }
148
+ .check input { width: 18px; height: 18px; min-height: 0; padding: 0; accent-color: var(--accent); }
149
+ .newsrv .acts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-top: 1px solid var(--line); padding-top: 14px; }
150
+ @media (max-width: 720px) {
151
+ .newsrv { padding: 14px 12px; }
152
+ .newsrv .frow { grid-template-columns: 1fr; gap: 6px; }
153
+ .newsrv .frow > label, .newsrv .frow > .lbl { padding-top: 0; }
154
+ .policies { grid-template-columns: 1fr; }
155
+ }
108
156
  @media (max-width: 720px) { .srvhead { grid-template-columns: 1fr 1fr; } }
109
157
  @media (max-width: 720px) {
110
158
  .rhead { grid-template-columns: 1fr 1fr 1fr; }
@@ -116,7 +164,7 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
116
164
  /* The office's one script (allowed by its hash). It reads /_studio/api/office every 3 s while the page is visible. */
117
165
  export const OFFICE_SCRIPT = String.raw`(function () {
118
166
  'use strict';
119
- var S = { data: null, open: {}, busy: false, err: 0, timer: null };
167
+ var S = { data: null, open: {}, drafts: {}, busy: false, err: 0, timer: null };
120
168
  var root = document.getElementById('games');
121
169
  var liveEl = document.getElementById('live');
122
170
  var holdEl = document.getElementById('hold');
@@ -205,6 +253,12 @@ export const OFFICE_SCRIPT = String.raw`(function () {
205
253
 
206
254
  /* ---------------------------------------------------------------- servers and agent seats (0.16.0) */
207
255
  var POLICY = { open: 'Open', 'humans-only': 'Humans only', hybrid: 'Hybrid', beginner: 'Beginner' };
256
+ // Each policy's badge and line, the kids line and the dial's card names: the same words the site and the play page say.
257
+ var WORDS = ${JSON.stringify(POLICY_WORDS)};
258
+ var KIDS_LINE = ${JSON.stringify(KIDS_LINE)};
259
+ var CARDS = ${JSON.stringify(SKILLS.map((k) => k.card))};
260
+ var ROOMS_MAX = ${SERVER_LIMITS.roomsMax};
261
+ var BEGINNER_DAYS = ${SERVER_LIMITS.beginnerDays};
208
262
  var LEVELS = ['Rookie', 'Steady', 'Fair', 'Strong', 'Maxed'];
209
263
  function sel(options, value, label) { var x = el('select'); options.forEach(function (o) { var op = el('option', '', o[1]); op.value = o[0]; if (String(o[0]) === String(value)) op.selected = true; x.appendChild(op); }); if (label) x.setAttribute('aria-label', label); return x; }
210
264
  function num(value, min, max, label) { var x = el('input'); x.type = 'number'; x.min = String(min); x.max = String(max); x.value = String(value); x.setAttribute('aria-label', label); return x; }
@@ -288,31 +342,209 @@ export const OFFICE_SCRIPT = String.raw`(function () {
288
342
  box.appendChild(body);
289
343
  return box;
290
344
  }
345
+ /** A server's address from its name, as the Worker makes it (servers.mjs checkServer). */
346
+ function serverId(name) { return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20).replace(/-+$/, ''); }
347
+ /** A number with − and + beside it, labelled; onset gets every change. */
348
+ function stepper(id, value, min, max, label, onset) {
349
+ var box = el('div', 'stepper');
350
+ var input = el('input'); input.type = 'number'; input.id = id; input.inputMode = 'numeric'; input.min = String(min); input.max = String(max); input.value = String(value);
351
+ var show = function (v) { var n = Math.max(Number(input.min), Math.min(Number(input.max), Math.round(Number(v)) || 0)); input.value = String(n); less.disabled = n <= Number(input.min); more.disabled = n >= Number(input.max); return n; };
352
+ var set = function (v) { onset(show(v)); };
353
+ var less = btn('−', 'ghost small', function () { set(Number(input.value) - 1); }); less.setAttribute('aria-label', 'Fewer ' + label);
354
+ var more = btn('+', 'ghost small', function () { set(Number(input.value) + 1); }); more.setAttribute('aria-label', 'More ' + label);
355
+ input.onchange = function () { set(input.value); };
356
+ less.disabled = value <= min; more.disabled = value >= max;
357
+ add(box, less, input, more);
358
+ // sync: the value and its bounds as the form now has them (no onset: update() is what calls it).
359
+ return { box: box, input: input, sync: function (v, lo, hi) { input.min = String(lo); input.max = String(hi); return show(v); } };
360
+ }
361
+ /** One choice of a few, as the office's pill switch; each option is a real radio button. */
362
+ function segment(name, options, value, label, onset) {
363
+ var box = el('div', 'seg'); box.setAttribute('role', 'radiogroup'); box.setAttribute('aria-label', label);
364
+ var inputs = {};
365
+ options.forEach(function (o) {
366
+ var r = el('input'); r.type = 'radio'; r.name = name; r.value = o[0]; r.checked = o[0] === value;
367
+ r.onchange = function () { if (r.checked) onset(o[0]); };
368
+ inputs[o[0]] = r;
369
+ box.appendChild(add(el('label'), r, el('span', '', o[1])));
370
+ });
371
+ return { box: box, inputs: inputs };
372
+ }
373
+ /** A 1-5 dial in the vote card's names, with what the chosen level plays like. */
374
+ function dialOf(id, value, label, onset) {
375
+ var box = el('div', 'dial');
376
+ var range = el('input'); range.type = 'range'; range.id = id; range.min = '1'; range.max = '5'; range.step = '1'; range.value = String(value);
377
+ var out = el('output'); out.setAttribute('for', id);
378
+ var ticks = el('div', 'ticks'); ticks.setAttribute('aria-hidden', 'true');
379
+ var show = function () {
380
+ var n = Number(range.value); var top = Number(range.max);
381
+ out.textContent = ''; add(out, el('b', '', LEVELS[n - 1]), document.createTextNode(': ' + CARDS[n - 1]));
382
+ range.setAttribute('aria-valuetext', LEVELS[n - 1]);
383
+ ticks.textContent = ''; ticks.style.setProperty('--n', String(top));
384
+ for (var i = 0; i < top; i++) ticks.appendChild(el('span', '', LEVELS[i]));
385
+ };
386
+ range.oninput = function () { show(); onset(Number(range.value)); };
387
+ range.setAttribute('aria-label', label);
388
+ show();
389
+ add(box, range, ticks, out);
390
+ return { box: box, range: range, set: function (n, top) { range.max = String(top); range.value = String(Math.min(n, top)); show(); } };
391
+ }
392
+ function row(label, forId, control, help) {
393
+ var r = el('div', 'frow');
394
+ var l = forId ? el('label', '', label) : el('span', 'lbl', label); if (forId) l.htmlFor = forId;
395
+ add(r, l, add(el('div', 'ctl'), control, help));
396
+ return r;
397
+ }
398
+ /**
399
+ * The New server form (DESIGN 7.2): name; policy, with each badge's copy; AI seats or guides (capped at the room's
400
+ * seats less one) and kids, for the policies that have them; the level it starts at and its ceiling, in the vote
401
+ * card's names; who can come in; chat; rooms; listed. Only what applies to the chosen policy shows. What the owner
402
+ * set lives in S.drafts, so the office's redraw every few seconds never loses it. It creates at once.
403
+ */
291
404
  function newServer(g) {
292
405
  var key = 'new:' + g.id;
293
406
  var wrap = el('div');
294
- if (!S.open[key]) { wrap.appendChild(btn('+ New server', 'small', function () { S.open[key] = true; render(); })); return wrap; }
295
- var form = el('div', 'newsrv');
296
- var name = el('input'); name.type = 'text'; name.placeholder = 'Name (Night Shift)'; name.maxLength = 40;
297
- var pol = sel([['hybrid', 'Hybrid: AI seats in every room'], ['beginner', 'Beginner: new players, AI guides'], ['humans-only', 'Humans only'], ['open', 'Open']], 'hybrid', 'Policy');
298
- var n = num(2, 0, Math.max(0, g.maxPlayers - 1), 'AI seats or guides');
299
- var kids = el('input'); kids.type = 'checkbox'; kids.setAttribute('aria-label', 'Kids');
300
- var door = sel([['open', 'Door: anyone'], ['accounts', 'Door: accounts'], ['invite', 'Door: invite']], 'open', 'Door');
301
- var level = sel(LEVELS.map(function (x, i) { return [i + 1, 'Level ' + x]; }), 3, 'Default level');
302
- var levelMax = sel(LEVELS.map(function (x, i) { return [i + 1, 'Ceiling ' + x]; }), 5, 'Ceiling');
303
- var speech = sel([['game', 'Chat: the game\'s'], ['lines', 'Chat: quick lines only'], ['off', 'Chat: off']], 'game', 'Speech');
304
- var rooms = num(4, 1, 16, 'Rooms');
305
- var listed = el('input'); listed.type = 'checkbox'; listed.checked = true; listed.setAttribute('aria-label', 'Listed');
306
- add(form, add(el('div', 'line'), name, pol),
307
- add(el('div', 'line'), el('span', 'dim', 'AI seats / guides'), n, add(el('label'), kids, el('span', '', 'kids (beginner)')), door),
308
- add(el('div', 'line'), level, levelMax, speech, el('span', 'dim', 'rooms'), rooms, add(el('label'), listed, el('span', '', 'listed'))),
309
- add(el('div', 'line'), btn('Create', 'small', function () {
310
- var body = { game: g.id, name: name.value, policy: pol.value, door: door.value, level: Number(level.value), levelMax: Number(levelMax.value), speech: speech.value, rooms: Number(rooms.value), listed: listed.checked };
311
- if (pol.value === 'hybrid') body.aiSeats = Number(n.value);
312
- if (pol.value === 'beginner') { body.guides = Number(n.value); body.kids = kids.checked; }
313
- act('/_studio/api/servers', body, (name.value || 'The server') + ' is open.').then(function (r) { if (r && r.ok) { S.open[key] = false; render(); } });
314
- }), btn('Cancel', 'ghost small', function () { S.open[key] = false; render(); })));
407
+ if (!S.open[key]) { wrap.appendChild(btn('+ New server', 'small', function () { S.open[key] = true; S.focusNew = key; render(); })); return wrap; }
408
+ var seats = Math.max(2, Number(g.maxPlayers) || Number(g.seats) || 2);
409
+ var cap = seats - 1;
410
+ var d = S.drafts[key] || (S.drafts[key] = { name: '', policy: 'hybrid', aiSeats: Math.min(2, cap), guides: Math.min(2, cap), kids: false, door: 'open', level: 3, levelMax: 5, levelSet: false, speech: 'game', rooms: 4, listed: true });
411
+ var id = 'ns-' + g.id + '-';
412
+ var form = el('form', 'newsrv'); form.noValidate = true; form.setAttribute('aria-label', 'New server for ' + g.name);
413
+ add(form, el('h4', '', 'New server for ' + g.name));
414
+
415
+ // Name, and the address it makes.
416
+ var name = el('input'); name.type = 'text'; name.id = id + 'name'; name.placeholder = 'Night Shift'; name.maxLength = 40; name.value = d.name; name.autocomplete = 'off';
417
+ var nameHelp = el('p', 'help');
418
+ add(form, row('Name', name.id, name, nameHelp));
419
+
420
+ // Policy: four cards, each with its badge and its line.
421
+ var pf = add(el('fieldset'), el('legend', '', 'Who plays'));
422
+ var cards = el('div', 'policies'); cards.setAttribute('role', 'radiogroup'); cards.setAttribute('aria-label', 'Policy');
423
+ var pol = {};
424
+ ['hybrid', 'beginner', 'humans-only', 'open'].forEach(function (p) {
425
+ var r = el('input'); r.type = 'radio'; r.name = id + 'policy'; r.value = p; r.checked = d.policy === p;
426
+ var badge = el('b'); var line = el('span');
427
+ var card = add(el('label', 'policy'), r, badge, line);
428
+ r.onchange = function () { if (!r.checked) return; d.policy = p; if (!d.levelSet) d.level = p === 'beginner' ? 2 : 3; update(); };
429
+ pol[p] = { input: r, card: card, badge: badge, line: line };
430
+ cards.appendChild(card);
431
+ });
432
+ add(form, add(pf, cards));
433
+
434
+ // AI in every room: seats (hybrid) or guides and kids (beginner); a plain note for the other two.
435
+ var af = add(el('fieldset'), el('legend', '', 'AI in every room'));
436
+ var aiHelp = el('p', 'help');
437
+ var ai = stepper(id + 'ai', d.policy === 'beginner' ? d.guides : d.aiSeats, d.policy === 'hybrid' ? 1 : 0, cap, 'AI seats', function (n) { if (d.policy === 'beginner') d.guides = n; else d.aiSeats = n; update(); });
438
+ var aiLabel = el('label', '', 'AI seats'); aiLabel.htmlFor = id + 'ai';
439
+ var aiRow = add(el('div', 'frow'), aiLabel, add(el('div', 'ctl'), ai.box, aiHelp));
440
+ var kids = el('input'); kids.type = 'checkbox'; kids.id = id + 'kids'; kids.checked = d.kids;
441
+ kids.onchange = function () { d.kids = kids.checked; update(); };
442
+ var kidsRow = row('Kids', null, add(el('label', 'check'), kids, el('span', '', 'A server for kids')), el('p', 'help', KIDS_LINE + ' The AI plays at Fair or gentler, and chat stays quick lines only.'));
443
+ var aiNote = el('p', 'note');
444
+ add(form, add(af, aiRow, kidsRow, aiNote));
445
+
446
+ // The AI's level: where it starts and how high the party may vote it.
447
+ var lf = add(el('fieldset'), el('legend', '', 'AI level'));
448
+ var lvl = dialOf(id + 'level', d.level, 'Starts at', function (n) { d.level = n; d.levelSet = true; if (levels().max < n) d.levelMax = n; update(); });
449
+ var top = dialOf(id + 'ceiling', d.levelMax, 'Ceiling', function (n) { d.levelMax = n; if (d.level > n) { d.level = n; d.levelSet = true; } update(); });
450
+ /** The level and ceiling as the server gets them: a kids server keeps both at Fair or gentler. */
451
+ function levels() { var cap = d.policy === 'beginner' && d.kids ? 3 : 5; var max = Math.min(d.levelMax, cap); return { cap: cap, max: max, level: Math.min(d.level, max) }; }
452
+ add(form, add(lf,
453
+ row('Starts at', id + 'level', lvl.box, null),
454
+ row('Ceiling', id + 'ceiling', top.box, el('p', 'help', 'Before play the party votes how strong the AI should be (the middle vote wins), never above the ceiling. Only games whose bots read the dial change how they play.'))));
455
+
456
+ // Who can come in: the door, how many rooms, and whether it is listed.
457
+ var df = add(el('fieldset'), el('legend', '', 'Who can come in'));
458
+ var DOOR_HELP = { open: 'Anyone with the link plays.', accounts: 'Players sign in with a passkey first.', invite: 'Only people with an invite to this server. Ask your AI for invite links.' };
459
+ var doorHelp = el('p', 'help');
460
+ var door = segment(id + 'door', [['open', 'Anyone'], ['accounts', 'Accounts'], ['invite', 'Invite only']], d.door, 'Door', function (v) { d.door = v; update(); });
461
+ var rooms = stepper(id + 'rooms', d.rooms, 1, ROOMS_MAX, 'rooms', function (n) { d.rooms = n; update(); });
462
+ var roomsHelp = el('p', 'help');
463
+ var listed = el('input'); listed.type = 'checkbox'; listed.id = id + 'listed'; listed.checked = d.listed;
464
+ listed.onchange = function () { d.listed = listed.checked; update(); };
465
+ var listedHelp = el('p', 'help');
466
+ add(form, add(df,
467
+ row('Door', null, door.box, doorHelp),
468
+ row('Rooms', id + 'rooms', rooms.box, roomsHelp),
469
+ row('Listed', null, add(el('label', 'check'), listed, el('span', '', 'Show it to everyone')), listedHelp)));
470
+
471
+ // Chat.
472
+ var cf = add(el('fieldset'), el('legend', '', 'Chat'));
473
+ var CHAT_HELP = { game: 'Players chat the way the game lets them.', lines: 'Only the game\'s quick lines: nobody types.', off: 'No chat at all.' };
474
+ var chatHelp = el('p', 'help');
475
+ var chat = segment(id + 'chat', [['game', 'The game\'s chat'], ['lines', 'Quick lines only'], ['off', 'Off']], d.speech, 'Chat', function (v) { d.speech = v; update(); });
476
+ add(form, add(cf, row('Chat', null, chat.box, chatHelp)));
477
+
478
+ var create = btn('Create', '', function () {});
479
+ create.type = 'submit';
480
+ var close = function () { S.open[key] = false; if (document.activeElement) document.activeElement.blur(); render(); };
481
+ add(form, add(el('div', 'acts'), create, btn('Cancel', 'ghost', close), el('span', 'faint', 'It opens at once; you can change any of it later.')));
482
+ name.oninput = function () { d.name = name.value; update(); };
483
+
484
+ function update() {
485
+ var p = d.policy;
486
+ var n = p === 'beginner' ? d.guides : d.aiSeats;
487
+ // Each card says its own badge and line; Hybrid's with the seats chosen.
488
+ Object.keys(pol).forEach(function (k) {
489
+ var w = WORDS[k];
490
+ pol[k].badge.textContent = w.badge + (k === 'hybrid' ? ' · ' + d.aiSeats : '');
491
+ var line = w.line.replace('{n}', String(d.aiSeats)).replace(/^[^:]+: /, '');
492
+ pol[k].line.textContent = line.charAt(0).toUpperCase() + line.slice(1) + (k === 'beginner' && d.kids ? ' ' + KIDS_LINE : '');
493
+ pol[k].card.classList.toggle('on', k === p);
494
+ });
495
+ // The name and the address it makes.
496
+ var sid = serverId(d.name);
497
+ var nameOk = /[A-Za-z0-9À-￿]/.test(d.name) && sid.length >= 2 && sid !== 'public';
498
+ nameHelp.textContent = '';
499
+ if (!d.name.trim()) nameHelp.textContent = 'Players see it on ' + g.name + '\'s page and on the play page.';
500
+ else if (!nameOk) nameHelp.textContent = 'Use at least two letters or digits (and not "public").';
501
+ else add(nameHelp, document.createTextNode('Its page: '), el('b', '', '/' + g.id + '/s/' + sid + '/'));
502
+ // AI in every room.
503
+ n = ai.sync(n, p === 'hybrid' ? 1 : 0, cap);
504
+ if (p === 'beginner') d.guides = n; else if (p === 'hybrid') d.aiSeats = n;
505
+ aiLabel.textContent = p === 'beginner' ? 'AI guides' : 'AI seats';
506
+ aiRow.hidden = !(p === 'hybrid' || p === 'beginner');
507
+ kidsRow.hidden = p !== 'beginner';
508
+ aiHelp.textContent = p === 'hybrid'
509
+ ? n + ' of the ' + seats + ' seats in every room ' + (n === 1 ? 'is an AI companion' : 'are AI companions') + ', always marked AI; people take the other ' + (seats - n) + '. At least one seat is always a person\'s.'
510
+ : n + ' guide seat' + (n === 1 ? '' : 's') + ' in every room, marked AI, beside new players. The game\'s own bots play them; guides talk only once you say yes.';
511
+ aiNote.hidden = !(p === 'humans-only' || p === 'open');
512
+ aiNote.textContent = p === 'humans-only'
513
+ ? 'No AI can join, not even one with a pass. The game\'s practice bots are off; you can switch them on later.'
514
+ : 'No seats are kept for AI. An AI with an agent pass may sit, always marked AI, and the game\'s bots fill empty seats.';
515
+ // The level: no AI on a humans-only server; kids keep it at Fair or gentler.
516
+ lf.hidden = p === 'humans-only';
517
+ var L = levels();
518
+ lvl.set(L.level, L.cap); top.set(L.max, L.cap);
519
+ // The door.
520
+ doorHelp.textContent = DOOR_HELP[d.door] + (p === 'beginner' ? ' New players only: an account older than ' + BEGINNER_DAYS + ' days is shown another server, unless you made that player a mentor.' : '');
521
+ d.rooms = rooms.sync(d.rooms, 1, ROOMS_MAX);
522
+ roomsHelp.textContent = 'Up to ' + d.rooms + (d.rooms === 1 ? ' room' : ' rooms') + ' at once. When ' + (d.rooms === 1 ? 'it is' : 'they are all') + ' full, newcomers wait for a seat.';
523
+ listedHelp.textContent = d.listed ? 'On ' + g.name + '\'s page, its servers list and the room lists.' : 'Left out of the lists: only people with its link find it.';
524
+ // Chat: a beginner server keeps chat to quick lines (or off).
525
+ var chatNow = p === 'beginner' && d.speech === 'game' ? 'lines' : d.speech;
526
+ chat.inputs.game.parentNode.hidden = p === 'beginner';
527
+ Object.keys(chat.inputs).forEach(function (k) { chat.inputs[k].checked = k === chatNow; });
528
+ chatHelp.textContent = CHAT_HELP[chatNow] + (p === 'beginner' ? ' A beginner server keeps chat to quick lines.' : '');
529
+ create.disabled = !nameOk;
530
+ }
531
+ form.onsubmit = function (e) {
532
+ e.preventDefault();
533
+ update();
534
+ if (create.disabled) { name.focus(); return; }
535
+ var p = d.policy;
536
+ var L = levels();
537
+ var body = { game: g.id, name: d.name, policy: p, door: d.door, level: L.level, levelMax: L.max, speech: p === 'beginner' && d.speech === 'game' ? 'lines' : d.speech, rooms: d.rooms, listed: d.listed };
538
+ if (p === 'hybrid') body.aiSeats = d.aiSeats;
539
+ if (p === 'beginner') { body.guides = d.guides; body.kids = d.kids; }
540
+ create.disabled = true;
541
+ act('/_studio/api/servers', body, d.name + ' is open.').then(function (r) {
542
+ if (r && r.ok) { delete S.drafts[key]; close(); } else create.disabled = false;
543
+ });
544
+ };
545
+ update();
315
546
  wrap.appendChild(form);
547
+ if (S.focusNew === key) { S.focusNew = null; setTimeout(function () { name.focus(); }, 0); }
316
548
  return wrap;
317
549
  }
318
550
  function passes(g) {
@@ -415,8 +647,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
415
647
  var now = Date.now() - (S.skew || 0);
416
648
  liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
417
649
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
418
- var focus = document.activeElement && root.contains(document.activeElement) && /INPUT|SELECT/.test(document.activeElement.tagName);
419
- // Never redraw under a typing owner, or a control waiting for its second tap, or a pass secret shown once.
650
+ var at = document.activeElement;
651
+ var focus = at && root.contains(at) && (/INPUT|SELECT/.test(at.tagName) || Boolean(at.closest && at.closest('.newsrv')));
652
+ // Never redraw under a typing owner (or one working in the New server form), a control waiting for its second
653
+ // tap, or a pass secret shown once.
420
654
  if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
421
655
  root.textContent = '';
422
656
  if (!d.games.length) { root.appendChild(el('p', 'empty', 'This studio has no games yet.')); return; }
package/worker/pages.mjs CHANGED
@@ -113,6 +113,10 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
113
113
  .room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
114
114
  .room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
115
115
  .chip.chip-right { left: auto; right: max(10px, env(safe-area-inset-right)); }
116
+ /* The room button and the server pill share one band at the room button's place, side by side (the server pill on
117
+ the inner side), so the shell never covers more of the game than the place the game gave the room button. */
118
+ .room .pills { display: flex; align-items: center; gap: 8px; }
119
+ .room.at-top-right .pills, .room.at-bottom-right .pills { flex-direction: row-reverse; }
116
120
  .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; }
117
121
  .pill svg { width: 15px; height: 15px; flex: none; }
118
122
  .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
@@ -164,8 +168,8 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
164
168
  const first = places.desk;
165
169
  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>` : '';
166
170
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
167
- <button class="pill${first.label ? '' : ' icon'}" 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>
168
- <button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button>
171
+ <div class="pills"><button class="pill${first.label ? '' : ' icon'}" 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>
172
+ <button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
169
173
  <div class="sheet ssheet" id="server-sheet" role="dialog" aria-label="This server" data-server-sheet hidden>
170
174
  <div class="code"><b data-server-name></b><span data-server-badge></span></div>
171
175
  <p class="sline" data-server-line></p>
@@ -197,20 +201,27 @@ ${joinCard}${share}
197
201
  }
198
202
 
199
203
  /*
200
- * SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button (its name and
201
- * badge; its sheet has the policy's line, the AI's level with "Change", and Quiet AI), and the party's vote card on
202
- * how strong the AI should be (a bottom sheet on a phone; keys 1-5 on a computer). Nothing covers the middle of the
203
- * screen except the card, for a few seconds, when a vote is open.
204
+ * SERVERS AND THE AI DIAL (0.16.0, NETPLAY.md section 17): the server pill beside the room button, in the same band
205
+ * (its name and badge, or on a phone held upright its dot; its sheet has the policy's line, the AI's level with
206
+ * "Change", and Quiet AI), and the party's vote card on how strong the AI should be (an opaque bottom sheet on a
207
+ * phone; keys 1-5 on a computer). Nothing covers the middle of the screen except the card, for a few seconds, when a
208
+ * vote is open.
204
209
  */
205
210
  const SERVER_CSS = `.spill .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--hot); flex: none; }
206
211
  .spill.dim .sdot { display: block; }
212
+ /* A phone held upright has no width to spare at its top: the server pill is its dot from the start (its sheet says
213
+ the name, the badge and the line; the chip names the server), so the band is the room button's label and one dot. */
214
+ @media (max-width: 540px) {
215
+ .spill, .spill:hover, .spill:focus-visible, .spill[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
216
+ .spill span[data-server-label], .spill:hover span[data-server-label], .spill:focus-visible span[data-server-label], .spill[aria-expanded="true"] span[data-server-label] { display: none; }
217
+ }
207
218
  .ssheet .sline { margin: 0 2px 10px; color: #c3cad9; font-weight: 500; line-height: 1.4; }
208
219
  .ssheet .srow { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 40px; margin: 0 2px; border-top: 1px solid rgba(255,255,255,.08); font-weight: 600; }
209
220
  .ssheet .srow small { display: block; color: #8b93a7; font-weight: 500; font-size: 11px; }
210
221
  .ssheet .srow button { min-height: 34px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; font: inherit; cursor: pointer; }
211
222
  .ssheet .srow input { width: 20px; height: 20px; accent-color: var(--hot); }
212
223
  .aipill { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: rgba(255,207,90,.2); color: #ffe7a8; font: 800 10px/1.6 ui-monospace, Menlo, monospace; letter-spacing: .06em; vertical-align: 1px; }
213
- .vote { box-sizing: border-box; position: fixed; left: 50%; top: 50%; z-index: 12; transform: translate(-50%, -50%); width: min(560px, calc(100vw - 24px)); padding: 16px 16px 14px; border-radius: 18px; background: rgba(8,12,22,.95); border: 1px solid var(--hot); color: #eef1f8; font: 600 14px/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.55); touch-action: manipulation; -webkit-user-select: none; user-select: none; animation: rise .25s ease-out; }
224
+ .vote { box-sizing: border-box; position: fixed; left: 50%; top: 50%; z-index: 12; transform: translate(-50%, -50%); width: min(560px, calc(100vw - 24px)); padding: 16px 16px 14px; border-radius: 18px; background: #080c16; border: 1px solid var(--hot); color: #eef1f8; font: 600 14px/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.55); touch-action: manipulation; -webkit-user-select: none; user-select: none; animation: rise .25s ease-out; }
214
225
  @keyframes rise { from { opacity: 0; transform: translate(-50%, -46%); } }
215
226
  .vote .vh { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 2px 12px; }
216
227
  .vote .vh b { font-size: 18px; letter-spacing: -.01em; }
@@ -308,9 +319,16 @@ const SHELL_JS = String.raw`(function () {
308
319
  }
309
320
  place();
310
321
  addEventListener('resize', place);
311
- function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
322
+ function labelOf(room) {
323
+ var m = /^pub-(\d+)$/.exec(room);
324
+ if (m) return 'Room ' + m[1];
325
+ // A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
326
+ var at = state.server ? 's-' + state.server.id + '-' : null;
327
+ var n = at && room.indexOf(at) === 0 ? room.slice(at.length) : '';
328
+ return /^\d+$/.test(n) ? 'Room ' + n : room;
329
+ }
312
330
  function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
313
- // The room pill and the server pill fade to dots after 6 s, so neither sits on the game's own HUD.
331
+ // The room pill and the server pill (in one band, at the room button's place) fade to dots after 6 s.
314
332
  function wake() {
315
333
  if (!toggle) return;
316
334
  var sp = document.querySelector('[data-server-toggle]');
@@ -549,7 +567,7 @@ const SHELL_JS = String.raw`(function () {
549
567
  var label = name + ' · ' + words.badge + (state.server && state.server.mentor ? ' · Mentor' : '');
550
568
  var labelEl = sToggle.querySelector('[data-server-label]');
551
569
  // A new policy (the owner's change) shows in full for a moment, then fades with the room pill.
552
- if (labelEl.textContent !== label) { labelEl.textContent = label; wake(); }
570
+ if (labelEl.textContent !== label) { labelEl.textContent = label; sToggle.setAttribute('aria-label', label + ': this server'); wake(); }
553
571
  sSheet.querySelector('[data-server-name]').textContent = name;
554
572
  sSheet.querySelector('[data-server-badge]').textContent = words.badge;
555
573
  sSheet.querySelector('[data-server-line]').textContent = words.line;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.16.0';
2
+ export const STUDIO_VERSION_TAG = '0.16.1';