@homie-rocks/studio 0.15.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.
@@ -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 = {
@@ -93,6 +95,65 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
93
95
  .toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: #1f2433; border: 1px solid var(--line2); padding: 10px 16px; border-radius: 12px; font-weight: 600; max-width: calc(100vw - 32px); z-index: 9; }
94
96
  .note { color: var(--dim); font-size: 13px; max-width: 75ch; margin-top: 26px; }
95
97
  .err { color: var(--bad); }
98
+ .servers { padding: 12px 16px 14px; border-bottom: 1px solid var(--line); }
99
+ .srv { border-top: 1px dashed var(--line); padding: 8px 0; }
100
+ .srv:first-of-type { border-top: 0; }
101
+ .srvhead { display: grid; grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) minmax(150px, 1.3fr) auto; gap: 6px 12px; align-items: center; cursor: pointer; font-size: 14px; }
102
+ .srvhead b { font-size: 15px; }
103
+ .srvbody { padding: 8px 0 4px 14px; display: grid; gap: 8px; font-size: 14px; }
104
+ .srvbody .line { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
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; }
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); }
107
+ .chip.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
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
+ }
156
+ @media (max-width: 720px) { .srvhead { grid-template-columns: 1fr 1fr; } }
96
157
  @media (max-width: 720px) {
97
158
  .rhead { grid-template-columns: 1fr 1fr 1fr; }
98
159
  .rhead .racts { grid-column: 1 / -1; justify-content: flex-start; }
@@ -103,7 +164,7 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
103
164
  /* The office's one script (allowed by its hash). It reads /_studio/api/office every 3 s while the page is visible. */
104
165
  export const OFFICE_SCRIPT = String.raw`(function () {
105
166
  'use strict';
106
- var S = { data: null, open: {}, busy: false, err: 0, timer: null };
167
+ var S = { data: null, open: {}, drafts: {}, busy: false, err: 0, timer: null };
107
168
  var root = document.getElementById('games');
108
169
  var liveEl = document.getElementById('live');
109
170
  var holdEl = document.getElementById('hold');
@@ -190,6 +251,335 @@ export const OFFICE_SCRIPT = String.raw`(function () {
190
251
  return box;
191
252
  }
192
253
 
254
+ /* ---------------------------------------------------------------- servers and agent seats (0.16.0) */
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};
262
+ var LEVELS = ['Rookie', 'Steady', 'Fair', 'Strong', 'Maxed'];
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; }
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; }
265
+ function srvSummary(sv) {
266
+ var bits = [POLICY[sv.policy] + (sv.policy === 'hybrid' ? ' · ' + sv.aiSeats : ''), sv.policy === 'beginner' ? sv.guides + ' guides' : null, sv.kids ? 'kids' : null,
267
+ sv.bots === 'off' ? 'bots off' : 'bots fill', LEVELS[sv.level - 1], sv.door !== 'open' ? 'door: ' + sv.door : null, sv.state !== 'open' ? sv.state.toUpperCase() : null];
268
+ return bits.filter(Boolean).join(' · ');
269
+ }
270
+ /** The owner may narrow a server: a second thought first (a stricter door, humans-only, fewer rooms, closing). */
271
+ function narrowing(sv, f) {
272
+ var rank = { open: 0, accounts: 1, invite: 2 };
273
+ return (f.door && rank[f.door] > rank[sv.door]) || (f.policy && f.policy !== sv.policy && (f.policy === 'humans-only' || f.policy === 'beginner')) || (f.rooms !== undefined && f.rooms < sv.rooms);
274
+ }
275
+ function setServer(g, sv, f, done) {
276
+ if (narrowing(sv, f) && !confirm('This takes something away on ' + sv.name + (f.policy === 'humans-only' ? ': its AI players leave after the current round' : '') + '. Go ahead?')) return;
277
+ act('/_studio/api/servers/set', Object.assign({ game: g.id, server: sv.id }, f), done || sv.name + ' changed.');
278
+ }
279
+ function serverRow(g, sv) {
280
+ var box = el('div', 'srv');
281
+ var key = 'srv:' + g.id + '/' + sv.id;
282
+ var head = el('div', 'srvhead');
283
+ head.onclick = function (e) { if (e.target.closest('button,select,input')) return; S.open[key] = !S.open[key]; render(); };
284
+ var live = sv.live || { rooms: 0, players: 0, ai: 0 };
285
+ add(head, add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + sv.name + (sv.id === 'public' ? ' (public)' : '')), el('div', 'faint', sv.id === 'public' ? 'Quick play' : sv.id)),
286
+ el('div', 'dim', srvSummary(sv)),
287
+ el('div', 'stat', live.rooms ? live.rooms + (live.rooms === 1 ? ' room' : ' rooms') + ' · ' + live.players + ' playing · ' + live.ai + ' AI' : '—'),
288
+ el('div', 'faint', sv.members ? sv.members + ' members' : ''));
289
+ box.appendChild(head);
290
+ if (!S.open[key]) return box;
291
+ var body = el('div', 'srvbody');
292
+ var pol = sel([['open', 'Open'], ['hybrid', 'Hybrid'], ['beginner', 'Beginner'], ['humans-only', 'Humans only']], sv.policy, 'Policy');
293
+ var door = sel([['open', 'Door: anyone'], ['accounts', 'Door: accounts'], ['invite', 'Door: invite']], sv.door, 'Door');
294
+ var bots = sel([['fill', 'Bots fill empty seats'], ['off', 'Bots off']], sv.bots, 'Bots');
295
+ var level = sel(LEVELS.map(function (n, i) { return [i + 1, 'Default ' + n]; }), sv.level, 'Default level');
296
+ var levelMax = sel(LEVELS.map(function (n, i) { return [i + 1, 'Ceiling ' + n]; }), sv.levelMax, 'Ceiling');
297
+ var ai = num(sv.policy === 'beginner' ? sv.guides : sv.aiSeats, 0, 31, sv.policy === 'beginner' ? 'Guides' : 'AI seats');
298
+ add(body, add(el('div', 'line'), pol, door, bots, level, levelMax, el('span', 'faint', sv.policy === 'beginner' ? 'guides' : 'AI seats'), ai,
299
+ btn('Save', 'small', function () {
300
+ var f = {};
301
+ if (pol.value !== sv.policy) f.policy = pol.value;
302
+ if (door.value !== sv.door) f.door = door.value;
303
+ if (bots.value !== sv.bots) f.bots = bots.value;
304
+ if (Number(level.value) !== sv.level) f.level = Number(level.value);
305
+ if (Number(levelMax.value) !== sv.levelMax) f.levelMax = Number(levelMax.value);
306
+ var n = Math.floor(Number(ai.value));
307
+ if ((pol.value === 'beginner' ? 'guides' : 'aiSeats') && n !== (sv.policy === 'beginner' ? sv.guides : sv.aiSeats)) f[pol.value === 'beginner' ? 'guides' : 'aiSeats'] = n;
308
+ if (!Object.keys(f).length) { toast('Nothing changed.'); return; }
309
+ setServer(g, sv, f);
310
+ })));
311
+ add(body, el('div', 'faint', sv.line));
312
+ if (sv.id !== 'public' || sv.state !== 'open') {
313
+ add(body, add(el('div', 'line'), Object.assign(el('a', '', 'Its page'), { href: sv.page, target: '_blank', rel: 'noopener' }),
314
+ sv.state === 'open' ? armed('Close server', 'bad', function () { act('/_studio/api/servers/close', { game: g.id, server: sv.id }, sv.name + ' closes after the current round.'); })
315
+ : btn('Open it again', 'ghost small', function () { act('/_studio/api/servers/close', { game: g.id, server: sv.id, reopen: true }, sv.name + ' is open again.'); })));
316
+ } else add(body, add(el('div', 'line'), armed('Hide Quick play', 'bad', function () { act('/_studio/api/servers/close', { game: g.id, server: 'public' }, 'Quick play is hidden: Play shows the other servers.'); })));
317
+ if (sv.policy !== 'humans-only') {
318
+ var brain = sel([['script', 'Guides: scripted'], ['off', 'Guides: off'], ['workers-ai', 'Guides talk (Workers AI)'], ['owner-key', 'Guides talk (your key)']], sv.brain, 'AI guides');
319
+ add(body, add(el('div', 'line'), el('span', 'dim', 'AI guides\' brain'), brain, btn('Set', 'ghost small', function () {
320
+ if ((brain.value === 'workers-ai' || brain.value === 'owner-key') && !S.data.agentsTalk && !confirm('Let AI guides talk? They speak only the lines the game\'s agents.json gives them, at most one every 8 seconds, never about a person, and any player can quiet them.')) return;
321
+ act('/_studio/api/agents/brain', { game: g.id, server: sv.id, mode: brain.value });
322
+ })));
323
+ }
324
+ if (sv.id !== 'public') {
325
+ var mbox = el('div', 'line'); mbox.appendChild(el('span', 'dim', 'Members:'));
326
+ var showM = btn('Show', 'ghost small', function () {
327
+ fetch('/_studio/api/servers/members?game=' + encodeURIComponent(g.id) + '&server=' + encodeURIComponent(sv.id), { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (d) {
328
+ mbox.textContent = ''; mbox.appendChild(el('span', 'dim', 'Members:'));
329
+ (d.members || []).forEach(function (m) {
330
+ var who = m.account ? (m.account.handle || m.account.id) : m.player;
331
+ var span = add(el('span', 'chip' + (m.role !== 'member' ? ' owner' : '')), document.createTextNode(who + (m.home ? ' ★' : '') + (m.role !== 'member' ? ' · ' + m.role : '')));
332
+ mbox.appendChild(span);
333
+ if (m.role !== 'mentor') mbox.appendChild(btn('Mentor', 'ghost small', function () { act('/_studio/api/servers/member', { game: g.id, server: sv.id, player: m.player, role: 'mentor', name: who }, who + ' is a mentor of ' + sv.name + '.'); }));
334
+ mbox.appendChild(armed('Remove', 'bad', function () { act('/_studio/api/servers/member', { game: g.id, server: sv.id, player: m.player, remove: true, name: who }, who + ' was removed from ' + sv.name + '.'); }));
335
+ });
336
+ if (!(d.members || []).length) mbox.appendChild(el('span', 'faint', d.ok ? 'nobody yet' : (d.message || 'not available')));
337
+ });
338
+ });
339
+ mbox.appendChild(showM);
340
+ body.appendChild(mbox);
341
+ }
342
+ box.appendChild(body);
343
+ return box;
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
+ */
404
+ function newServer(g) {
405
+ var key = 'new:' + g.id;
406
+ var wrap = el('div');
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();
546
+ wrap.appendChild(form);
547
+ if (S.focusNew === key) { S.focusNew = null; setTimeout(function () { name.focus(); }, 0); }
548
+ return wrap;
549
+ }
550
+ function passes(g) {
551
+ var box = el('div', 'servers');
552
+ add(box, el('h3', '', 'Agent passes (an AI\'s way into a seat, always marked AI)'));
553
+ (g.passes || []).forEach(function (p) {
554
+ var row = el('div', 'inv' + (p.live ? '' : ' closed'));
555
+ add(row, el('code', '', p.name), el('span', 'faint', p.role + ' · hands ' + p.hands + (p.server ? ' · ' + p.server : ' · any server') + (p.expiresAt ? ' · until ' + new Date(p.expiresAt).toLocaleDateString() : '') + (p.revoked ? ' · revoked' : !p.live ? ' · ended' : '')));
556
+ if (p.live) row.appendChild(armed('Revoke', 'bad', function () { act('/_studio/api/agents/pass', { action: 'revoke', game: g.id, id: p.id }, 'Pass revoked: that AI left.'); }));
557
+ box.appendChild(row);
558
+ });
559
+ var label = el('input'); label.type = 'text'; label.placeholder = 'Its name (Claude)'; label.maxLength = 16;
560
+ var server = sel([['', 'any server that lets AI in']].concat((g.servers || []).filter(function (x) { return x.policy !== 'humans-only'; }).map(function (x) { return [x.id, x.name]; })), '', 'Server');
561
+ var hands = sel([['self', 'runs the game itself'], ['host', 'moved by the host\'s bots']], 'self', 'Hands');
562
+ var out = el('div');
563
+ add(box, add(el('div', 'newinv'), label, server, hands, btn('Issue a pass', 'small', function () {
564
+ post('/_studio/api/agents/pass', { action: 'create', game: g.id, label: label.value, server: server.value || null, hands: hands.value, days: 7 }).then(function (r) {
565
+ out.textContent = '';
566
+ if (!r || !r.ok) { toast((r && r.message) || 'No pass made.', true); return; }
567
+ add(out, el('p', 'dim', 'Shown once. Give it to the AI only; it plays as ' + r.pass.name + ' for 7 days.'), el('div', 'secret', r.secret));
568
+ label.value = ''; S.keep = true;
569
+ });
570
+ })), out);
571
+ var fill = el('input', 'switch'); fill.type = 'checkbox'; fill.disabled = true; fill.setAttribute('aria-label', 'Fill-a-spot service');
572
+ add(box, add(el('label', 'faint'), fill, el('span', '', (S.data.fillSpot && S.data.fillSpot.note) || 'Let a fill-a-spot service seat AI (coming later)')));
573
+ return box;
574
+ }
575
+ function serversBox(g) {
576
+ var box = el('div', 'servers');
577
+ add(box, el('h3', '', 'Servers'));
578
+ (g.servers || []).forEach(function (sv) { box.appendChild(serverRow(g, sv)); });
579
+ box.appendChild(newServer(g));
580
+ return box;
581
+ }
582
+
193
583
  function person(g, r, c, now) {
194
584
  var row = el('div', 'person');
195
585
  var who = el('div', 'who');
@@ -197,7 +587,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
197
587
  c.seat !== null && c.seat !== undefined ? el('span', 'faint', 'seat ' + (c.seat + 1)) : el('span', 'faint', c.waiting ? 'waiting for a seat' : 'watching'),
198
588
  el('span', 'chip', c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'big screen' : 'computer'),
199
589
  c.role === 'host' ? el('span', 'chip host', 'host') : null,
200
- c.as === 'owner' ? el('span', 'chip owner', 'you') : c.as === 'invited' ? el('span', 'chip invited', 'invited') : c.account ? el('span', 'chip owner', 'player ' + (c.account.handle || c.account.id)) : el('span', 'chip', 'guest'),
590
+ c.agent ? el('span', 'chip ai', 'AI ' + (c.agent.role === 'guide' ? 'guide' : c.agent.role === 'party' ? 'companion' : 'player')) : c.as === 'owner' ? el('span', 'chip owner', 'you') : c.as === 'invited' ? el('span', 'chip invited', 'invited') : c.account ? el('span', 'chip owner', 'player ' + (c.account.handle || c.account.id)) : el('span', 'chip', 'guest'),
201
591
  c.muted ? el('span', 'chip muted', 'muted') : null,
202
592
  el('span', 'faint', 'here ' + dur(now - c.joinedAt)),
203
593
  c.browser ? el('span', 'faint', 'browser ' + c.browser) : null);
@@ -215,7 +605,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
215
605
  var box = el('div', 'room');
216
606
  var key = g.id + '/' + r.room;
217
607
  var head = el('div', 'rhead');
218
- head.onclick = function (e) { if (e.target.closest('button')) return; S.open[key] = !S.open[key]; render(); };
608
+ head.onclick = function (e) { if (e.target.closest('button,select')) return; S.open[key] = !S.open[key]; render(); };
219
609
  var round = r.round ? 'Round ' + (r.round.n || '?') + (r.round.phase === 'live' && r.round.endsAt ? ' · ' + clock(r.round.endsAt - now) + ' left' : r.round.phase === 'over' ? ' · results' : '') : 'no round yet';
220
610
  var announce = btn('Announce', 'ghost small', function () {
221
611
  var text = prompt('Announce to everyone in ' + r.label + ' of ' + g.name + ':');
@@ -224,10 +614,14 @@ export const OFFICE_SCRIPT = String.raw`(function () {
224
614
  var acts = add(el('div', 'racts'), announce, r.closedUntil
225
615
  ? btn('Reopen', 'ghost small', function () { act('/_studio/api/close', { game: g.id, room: r.room, reopen: true }, r.label + ' is open again.'); })
226
616
  : armed('Close', 'bad', function () { act('/_studio/api/close', { game: g.id, room: r.room, minutes: hold() }, r.label + ' closed for ' + hold() + ' min.'); }));
617
+ // The room's dial (room_level): the owner sets it at once, as the party's vote does.
618
+ var lvl = r.policy ? sel([1, 2, 3, 4, 5].filter(function (n) { return n <= (r.policy.levelMax || 5); }).map(function (n) { return [n, LEVELS[n - 1]]; }), r.policy.level, 'AI level') : null;
619
+ if (lvl) lvl.onchange = function () { act('/_studio/api/room-level', { game: g.id, room: r.room, level: Number(lvl.value) }, 'AI in ' + r.label + ' set to ' + LEVELS[Number(lvl.value) - 1] + '.'); };
620
+ var srvName = ((g.servers || []).filter(function (x) { return x.id === r.server; })[0] || {}).name;
227
621
  add(head,
228
- add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + r.label), el('div', 'faint', r.public ? 'public room' : 'named room')),
229
- add(el('div', 'stat'), el('span', '', 'players'), document.createTextNode((r.players) + ' / ' + r.max)),
230
- add(el('div', 'stat'), el('span', '', 'bots'), document.createTextNode(String(r.bots))),
622
+ add(el('div'), el('b', '', (S.open[key] ? '▾ ' : '▸ ') + r.label), el('div', 'faint', (r.server && r.server !== 'public' ? (srvName || r.server) + ' · ' : '') + (r.public ? 'public room' : r.server !== 'public' ? 'server room' : 'named room'))),
623
+ add(el('div', 'stat'), el('span', '', 'players'), document.createTextNode((r.players) + ' / ' + (r.humanSeats || r.max))),
624
+ add(el('div', 'stat'), el('span', '', 'AI · bots'), document.createTextNode((r.ai || 0) + ' · ' + String(r.bots)), lvl ? add(el('div'), lvl) : null),
231
625
  add(el('div', 'stat'), el('span', '', 'round'), document.createTextNode(round)),
232
626
  add(el('div', 'stat'), el('span', '', 'up'), document.createTextNode(r.closedUntil ? 'closed ' + dur(r.closedUntil - now) : dur(now - r.openedAt))),
233
627
  acts);
@@ -239,7 +633,8 @@ export const OFFICE_SCRIPT = String.raw`(function () {
239
633
  var people = el('div', 'people');
240
634
  (r.clients || []).forEach(function (c) { people.appendChild(person(g, r, c, now)); });
241
635
  var bots = (r.slots || []).filter(function (s) { return s.bot; });
242
- if (bots.length) people.appendChild(el('div', 'held', 'Bots: ' + bots.map(function (s) { return s.name; }).join(', ')));
636
+ if (bots.length) people.appendChild(el('div', 'held', 'AI and bots: ' + bots.map(function (s) { return s.name + (s.agent ? ' (AI ' + (s.agent.role === 'guide' ? 'guide' : 'seat') + ')' : ''); }).join(', ')));
637
+ if (r.vote && r.vote.open) people.appendChild(el('div', 'held', 'The party is voting on the AI\'s level: ' + r.vote.voters + ' of ' + r.vote.of_total + ' voted.'));
243
638
  (r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
244
639
  box.appendChild(people);
245
640
  }
@@ -252,9 +647,11 @@ export const OFFICE_SCRIPT = String.raw`(function () {
252
647
  var now = Date.now() - (S.skew || 0);
253
648
  liveEl.textContent = d.playing ? d.playing + (d.playing === 1 ? ' playing now' : ' playing now') : 'nobody playing right now';
254
649
  document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
255
- var focus = document.activeElement && root.contains(document.activeElement) && /INPUT|SELECT/.test(document.activeElement.tagName);
256
- // Never redraw under a typing owner, or a control waiting for its second tap; the next poll will.
257
- if (focus || root.querySelector('[data-armed="1"]')) return;
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.
654
+ if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
258
655
  root.textContent = '';
259
656
  if (!d.games.length) { root.appendChild(el('p', 'empty', 'This studio has no games yet.')); return; }
260
657
  d.games.forEach(function (g) {
@@ -263,6 +660,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
263
660
  add(head, add(el('div', 'name'), el('h2', '', g.name), el('span', 'faint', g.playing ? g.playing + ' playing' : 'quiet')),
264
661
  add(el('div', 'links'), Object.assign(el('a', '', 'Play'), { href: g.play, target: '_blank', rel: 'noopener' }), Object.assign(el('a', '', 'Page'), { href: g.page, target: '_blank', rel: 'noopener' })));
265
662
  add(card, head, gameSettings(g));
663
+ // A build from before servers (netplay rev 5 or older): what it cannot do yet, and the fix.
664
+ if (g.build && g.build.predates && (g.servers || []).some(function (x) { return x.id !== 'public' && (x.aiSeats || x.guides); })) card.appendChild(el('div', 'warnbox', 'This build predates servers (netplay rev ' + (g.build.netplayRev || '5 or older') + '): reserved AI seats stay empty and its bots don\'t read the dial. Rebuild with @homie-rocks/studio 0.16.'));
665
+ card.appendChild(serversBox(g));
666
+ card.appendChild(passes(g));
266
667
  if (g.launch === 'invite' || (g.invites && g.invites.length)) card.appendChild(invites(g));
267
668
  var rooms = el('div', 'rooms');
268
669
  if (!g.rooms.length) rooms.appendChild(el('div', 'empty', g.launch === 'private' ? 'Private: only you can open it. Nobody is in a room.' : 'Nobody is in a room right now.'));
@@ -322,6 +723,7 @@ export async function officePage(cat, headers = {}) {
322
723
  <p class="dim" id="status" role="status"></p>
323
724
  <div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
324
725
  <div class="toast" id="toast" role="status" hidden></div>
726
+ <p class="note">Servers: each game's named room pools. Open: anyone, AI with a pass marked AI. Humans only: no AI at all. Hybrid: some seats in every room are AI companions; the party votes their level. Beginner: new players, AI guides, quick lines only. Changing a server reaches its live rooms at once; AI leave after the round when a server becomes humans-only.</p>
325
727
  <p class="note">Live from this studio's own rooms, every few seconds. A kick holds that player out of that room for the time you pick (their browser, and their account once players sign in); a mute stops their chat and emotes; Close sends everyone in a room out with a thank-you. Private: only you can open the game. Invite-only: invited players only (each invite link or code lets a browser in). Public: anyone, and listed. A game that is not public leaves the Homie directory the next time it reads this studio. This page is yours alone, never cached or indexed.</p>`;
326
728
  return new Response(shell(cat, `Office · ${name}`, body, { script: OFFICE_SCRIPT }), {
327
729
  headers: {
@@ -341,7 +743,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
341
743
  return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
342
744
  }
343
745
 
344
- const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it' };
746
+ const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
345
747
 
346
748
  /** The owner's one tap for what their AI asked, and what came of it. */
347
749
  export function confirmPage(cat, ask, { missing = false } = {}) {
@@ -353,7 +755,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
353
755
  const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
354
756
  body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
355
757
  <p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
356
- <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
758
+ <form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
357
759
  <p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
358
760
  } else {
359
761
  const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;