@homie-rocks/studio 0.24.4 → 0.24.5

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/CHANGELOG.md CHANGED
@@ -17,9 +17,32 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.24.5] - 2026-10-03
21
+
22
+ **Plugin 0.25.5** · [#39](https://github.com/homie-rocks/homie/pull/39)
23
+
24
+ Room chat keeps off your game's HUD: on a phone the Chat pill is a round icon, as the room button is, and your game can say where the strip of new lines sits, or keep new lines in the Chat sheet, so a game with a busy HUD keeps typed chat.
25
+
26
+ ### Added
27
+
28
+ - game.json `"screen": { "chat": … }` says where room chat's strip of new lines sits on each screen: `at` (a corner, `top-center` or `bottom-center`), `x` / `y` to move it in from its side and its edge, and `"lines": "sheet-only"` to keep new lines in the Chat sheet while the pill counts them. `desk`, `phone`, `sideways` and `tv` (the big screen's corner of lines) each take their own, changing only what they name. A game whose HUD or controls sit where the strip shows no longer has to keep chat to emoji to stay clear. `chat/CHAT.md` has every field; the template's AGENTS.md and the game skill say when to use it.
29
+ - `homie-studio build` warns about a wrong field in `screen.chat` (the play page uses the default there), and says so when a place or `"sheet-only"` is written in game.json `"chat"` (the room's rules) instead.
30
+
31
+ ### Changed
32
+
33
+ - The Chat pill follows the room button: on a phone (either way up), and beside a room button your game keeps an icon (`screen.share` `"label": false`), it is a round icon with the count of new lines on its corner. On a computer it still says "Chat", unless the word would take the room button's band off the screen or into the middle third of its width, where games keep a clock or a title; then it stays the icon until the window changes size.
34
+
35
+ ### Fixed
36
+
37
+ - On a phone, the Chat pill's word no longer covers the HUD beside the room button: the first digit of a clock, the last letter of a title.
38
+
39
+ ### Upgrade notes
40
+
41
+ - Nothing to do: a game without `screen.chat` shows the strip where it did. If you kept a game's chat to emoji only so the strip stayed off its HUD, tell Claude: "Put room chat where my game has room, and let players type again." It places the strip with `screen.chat` and resets the game's chat rules (`homie-studio chat rules <game> --reset`).
42
+
20
43
  ## [0.24.4] - 2026-10-03
21
44
 
22
- **Plugin 0.25.4** · [#37](https://github.com/homie-rocks/homie/pull/37)
45
+ **Plugin 0.25.4** · [#37](https://github.com/homie-rocks/homie/pull/37) · [release-2026-10-03-studio-0.24.4](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.4)
23
46
 
24
47
  Your AI guides think with Cloudflare's Clef decision model, which does what a player asks; Clef runs on your own computer while you build; and your games can ask it for their own decisions (tactics, a director's call, a turn) in about a quarter of a second.
25
48
 
package/README.md CHANGED
@@ -316,8 +316,9 @@ npx homie-studio chat remove night-rush pub-3 <line id> # take a
316
316
  npx homie-studio chat words # the built-in word list the floor holds
317
317
  ```
318
318
 
319
- - **The play page:** a Chat pill beside the room button (the corner `screen.share` keeps clear of the HUD), its
320
- sheet (the last minutes, the reactions, the quick lines, typing, Report), a short ticker of new lines, and two
319
+ - **The play page:** a Chat pill beside the room button (the corner `screen.share` keeps clear of the HUD; a round
320
+ icon on a phone), its sheet (the last minutes, the reactions, the quick lines, typing, Report), a short strip of
321
+ new lines (game.json `screen.chat` puts it where the game has room, or keeps new lines in the sheet), and two
321
322
  switches each player keeps: "Show my messages over my character" and "Show chat on this screen". The big screen
322
323
  shows the lines in a corner; the watch page has a Chat button; homie.rocks shows a room's chat on its card.
323
324
  - **Rules** per game (game.json `"chat"`) and per server, in the office or with `chat rules`: off, emoji, quick
package/chat/CHAT.md CHANGED
@@ -14,9 +14,12 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
14
14
 
15
15
  - **The Chat pill** on the play page, beside the room button: the room's last minutes, the
16
16
  reactions, the quick lines, typing where allowed, Report on a line, and two switches ("Show my
17
- messages over my character", "Show chat on this screen").
17
+ messages over my character", "Show chat on this screen"). On a phone it is a round icon, as the
18
+ room button is, with the count of new lines on its corner; on a computer it says "Chat" unless
19
+ the word would reach the middle of the top edge, where games keep a clock or a title.
18
20
  - **The float:** every reaction rises up every screen in the room.
19
- - **The ticker:** a new line shows for a moment where the play page's status chip sits.
21
+ - **The strip:** a new line shows for a moment where the play page's status chip sits (the bottom
22
+ left), or where the game says (below).
20
23
  - **The big screen** (`/<game>/tv`) shows the room's lines in a corner; the **watch page** has a
21
24
  Chat button; **homie.rocks** shows the room's chat on its card when the studio allows it.
22
25
 
@@ -49,9 +52,37 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
49
52
  | `block`, `allow` | the studio's own words to hold (`word*` inside words) or to let through | none |
50
53
 
51
54
  `"chat": false` turns it off. A kids server, and every beginner server, keeps chat to emoji and
52
- quick lines whatever the game says. Where new lines show for a moment: game.json
53
- `"screen": { "chat": "bottom-right" }` (any corner, `top-center` or `bottom-center`), or `false`
54
- for only the pill's count.
55
+ quick lines whatever the game says.
56
+
57
+ **Where it sits on the screen, in game.json `"screen"`**, so a game with a busy HUD keeps typed
58
+ chat without anything covering its clock, its title or its controls. The Chat pill sits in the
59
+ room button's band, so `"screen": { "share": … }` places both (`"label": false` keeps both round
60
+ icons). `"screen": { "chat": … }` places the strip of new lines and the big screen's corner:
61
+
62
+ ```json
63
+ "screen": {
64
+ "share": { "phone": { "at": "top-left", "label": false } },
65
+ "chat": {
66
+ "desk": { "at": "top-left", "y": 130 },
67
+ "phone": { "lines": "sheet-only" },
68
+ "tv": { "at": "top-right" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ | Field | | Default |
74
+ |---|---|---|
75
+ | `at` | `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center` or `bottom-right` | the bottom left (the bottom right when the room button is at the bottom left) |
76
+ | `x`, `y` | move it in from its side and its edge, in CSS pixels (0 to 600; in the middle of an edge, `x` moves it either way). Sharing the room button's place, it also sits past the button | 0 |
77
+ | `lines` | `strip` (new lines show for a moment), or `sheet-only` (they wait in the Chat sheet and the pill counts them) | `strip` |
78
+ | `desk`, `phone`, `sideways`, `tv` | a computer, a phone held upright, a phone turned sideways (else as `phone`), the big screen: each changes only what it names | |
79
+
80
+ A plain string (`"chat": "bottom-right"`) is a place for every device, and `false` is
81
+ `"lines": "sheet-only"` for every device. The big screen has no sheet: it takes a corner only
82
+ (the top level's when that is a corner, else the one its join card leaves free) and keeps its
83
+ lines unless its own `tv` entry says `"sheet-only"`, which leaves it the float. A wrong field is
84
+ the default on the page, and `homie-studio build` says which. Look at the play page on a phone
85
+ and a computer while a round is on and somebody says something, and at `/<id>/tv`.
55
86
 
56
87
  **Speech bubbles over characters**, with the port kit (the starters draw them):
57
88
 
package/lib/build.mjs CHANGED
@@ -47,6 +47,7 @@ import { STUDIO_VERSION } from './version.mjs';
47
47
  import { licenseOf, remixRow } from '../worker/license.mjs';
48
48
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
49
49
  import { chatProblems } from '../worker/chat.mjs';
50
+ import { screenChatProblems } from '../worker/chat-page.mjs';
50
51
  import { vocabularyOf } from '../worker/brain.mjs';
51
52
  import { shopForBuild } from './shop.mjs';
52
53
  import { audienceOf } from '../worker/shop-rules.mjs';
@@ -323,6 +324,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
323
324
  if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
324
325
  // A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
325
326
  if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
327
+ // Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
328
+ for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
326
329
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
327
330
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
328
331
  // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
package/lib/scaffold.mjs CHANGED
@@ -221,10 +221,16 @@ studio's pinned copy, never a registry lookup of the bare name.
221
221
  - Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
222
222
  - Phones and computers: touch controls on phones only, keys on computers; keep the
223
223
  centre of the screen clear during play.
224
- - The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
225
- scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
226
- device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
227
- a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
224
+ - The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
225
+ beside it (a round icon on a phone, as the room button is). If the game's scoreboard or a bar is there, move
226
+ both in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per device (\`desk\`, \`phone\`,
227
+ \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep them small icons
228
+ (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Room chat's strip of new lines shows for a moment at the
229
+ bottom left: if the game's HUD or controls are there, \`"screen": { "chat": … }\` puts it where the game has
230
+ room (\`at\`: a corner, \`top-center\` or \`bottom-center\`; \`x\` / \`y\`; per device, and \`tv\` for the big
231
+ screen), or \`"lines": "sheet-only"\` keeps new lines in the Chat sheet, the pill counting them, so a game with
232
+ a busy HUD keeps typed chat (\`node_modules/@homie-rocks/studio/chat/CHAT.md\`). Look at both on a phone and a
233
+ computer while a round is on and somebody says something.
228
234
  - **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
229
235
  and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
230
236
  seat, with a strip of the players to switch between (a tap, keys 1-9, A for Auto, O for the whole room). Point the
@@ -1156,6 +1156,45 @@
1156
1156
  }
1157
1157
  }
1158
1158
  }
1159
+ },
1160
+ "0.24.5": {
1161
+ "files": {
1162
+ "CLAUDE.md": "ddf2b2a0376202f6",
1163
+ "README.md": "684b3cca8001673c",
1164
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1165
+ "games/README.md": "5ad32fe80c61f117",
1166
+ "music/README.md": "a3dc042dadd39df8",
1167
+ "videos/README.md": "16babc3437d5545b",
1168
+ "posts/README.md": "37841aae476bd540",
1169
+ "site/README.md": "ca131cd0472464fb",
1170
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1171
+ },
1172
+ "sections": {
1173
+ "AGENTS.md": {
1174
+ "(intro)": "214aa46eb3a895d5",
1175
+ "## Layout": "8baf49e4291e7e88",
1176
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1177
+ "## The site": "ab922bb6bde23293",
1178
+ "## Making games": "abf2f0d59341512d",
1179
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1180
+ "## Art direction and models": "124772c374de7390",
1181
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1182
+ "## Servers and AI seats": "98d08705087a33ec",
1183
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1184
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1185
+ "## Rules": "7e8458adc1b81ece",
1186
+ "## Beta": "9d02638207a26e73"
1187
+ }
1188
+ },
1189
+ "named": {
1190
+ "Homie Arcade": {
1191
+ "sections": {
1192
+ "AGENTS.md": {
1193
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1159
1198
  }
1160
1199
  }
1161
1200
  }
@@ -1340,16 +1340,20 @@ ticker.
1340
1340
  ### What the page does (worker/chat-page.mjs)
1341
1341
 
1342
1342
  - **Play:** a Chat pill in the room button's band (game.json `screen.share` already keeps that
1343
- corner clear of the game's HUD), with a count of unread lines; its sheet: the room's last
1343
+ corner clear of the game's HUD; a round icon on a phone and beside a room button kept an icon,
1344
+ and on a computer wherever its word would reach the middle third of the top), with a count of
1345
+ unread lines; its sheet: the room's last
1344
1346
  minutes, a row of reactions (2.5 rem circles that pop), the quick lines, typing where the rules
1345
1347
  allow it (and why not where they do not: "Sign in to type here. Emoji are open to everyone."),
1346
1348
  Report on a line, and two switches kept in this browser: **Show my messages over my character**
1347
- and **Show chat on this screen** (off: no ticker, no float, no bubbles in this game). New lines
1348
- show for a moment where the status chip sits (game.json `"screen": { "chat": "bottom-right" }`
1349
- moves them; `false` keeps only the pill's count).
1349
+ and **Show chat on this screen** (off: no strip, no float, no bubbles in this game). New lines
1350
+ show for a moment where the status chip sits; game.json `"screen": { "chat": … }` moves that
1351
+ strip per device (`at`, `x` / `y`, `desk`, `phone`, `sideways`, `tv`) or keeps new lines in the
1352
+ sheet (`"lines": "sheet-only"`, or `false`: only the pill's count). chat/CHAT.md has the fields.
1350
1353
  - **Float:** every reaction rises up every screen in the room, as on the television: at most six
1351
1354
  a second and eighteen at once, 2.4 s each with a little sway. The sender's own floats at once.
1352
- - **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use, no typing.
1355
+ - **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use (or game.json
1356
+ `screen.chat`'s corner), no typing.
1353
1357
  - **Watch:** a Chat button in the band; its panel over the stage (a bottom sheet on a phone).
1354
1358
 
1355
1359
  **Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.24.4",
3
+ "version": "0.24.5",
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, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -169,6 +169,10 @@ where the game has room, per device:
169
169
  - A button at the bottom left sends the "finding a room" chip to the bottom right.
170
170
  - On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
171
171
  phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
172
+ - Room chat's Chat pill sits in the same band: a round icon on a phone (either way up) and beside a button kept an
173
+ icon; on a computer it says "Chat" unless the word would take the band off the screen or into the middle third of
174
+ its width, where games keep a clock or a title. Its strip of new lines (bottom left by default) moves with
175
+ `"screen": { "chat": … }`, per device, or stays in the Chat sheet (`"lines": "sheet-only"`): chat/CHAT.md.
172
176
 
173
177
  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
174
178
  score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
@@ -3,10 +3,11 @@
3
3
  * screen (the TV view) and the watch page. It speaks on the page's own watch socket, so every game has it, old builds
4
4
  * too, with no game code:
5
5
  *
6
- * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD),
7
- * its sheet (the room's last minutes, the room's emoji, its quick lines, typing where the rules allow,
8
- * Report on a line, and for the studio's owner Remove, Mute and Kick), a short ticker of new lines where the
9
- * status chip sits, and reactions floating up the screen
6
+ * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD; the
7
+ * round icon on a phone, as the room button is), its sheet (the room's last minutes, the room's emoji, its
8
+ * quick lines, typing where the rules allow, Report on a line, and for the studio's owner Remove, Mute and
9
+ * Kick), a short strip of new lines where the status chip sits or where game.json screen.chat puts it
10
+ * (chatPlaces; "sheet-only" keeps them in the sheet), and reactions floating up the screen
10
11
  * tv the room's lines in a corner (no typing: a television is read across a room) and the float
11
12
  * watch a Chat button in the watch page's band, its panel over the stage, and the float
12
13
  *
@@ -22,17 +23,86 @@ import { REACTIONS } from './chat.mjs';
22
23
  import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
23
24
  import { NET_PALETTE } from './room.mjs';
24
25
 
26
+ /** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
27
+ export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
28
+ const CHAT_CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
29
+ const CHAT_DEVICES = ['desk', 'phone', 'sideways', 'tv'];
30
+ const CHAT_FIELDS = ['at', 'x', 'y', 'lines'];
31
+
32
+ /** One entry of game.json `screen.chat` as written (a place, false, or an object), keeping only the fields that are right. */
33
+ function chatEntry(v) {
34
+ if (v === false) return { lines: 'sheet-only' };
35
+ if (typeof v === 'string') return CHAT_PLACES.includes(v) ? { at: v } : {};
36
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
37
+ const o = {};
38
+ if (CHAT_PLACES.includes(v.at)) o.at = v.at;
39
+ for (const k of ['x', 'y']) if (v[k] !== undefined && v[k] !== null && v[k] !== '' && Number.isFinite(Number(v[k]))) o[k] = Math.round(Number(v[k]));
40
+ if (v.lines === 'strip' || v.lines === 'sheet-only') o.lines = v.lines;
41
+ return o;
42
+ }
43
+
44
+ /**
45
+ * Where room chat sits on each screen, from game.json `screen.chat` (0.24.5), so a game with a busy HUD keeps its chat
46
+ * off its clock, its title and its controls. The Chat pill itself sits in the room button's band (`screen.share`
47
+ * places both); this places the strip of new lines that shows for a moment, and the big screen's corner of lines:
48
+ *
49
+ * "chat": "bottom-right" every device's strip there (and the big screen's corner)
50
+ * "chat": false no strip: new lines wait in the sheet, the pill counts them
51
+ * "chat": { "at": "top-left", "y": 120 } moved 120 px in from its edge (x: from its side)
52
+ * "chat": { "lines": "strip", "phone": { "lines": "sheet-only" }, "tv": { "at": "top-right" } }
53
+ *
54
+ * `at` is a place from CHAT_PLACES (default: the bottom left, or the bottom right when the room button is there); `x`
55
+ * and `y` move it in, 0 to 600 px (in the middle of an edge, `x` moves it either way); `lines` is "strip" (the default)
56
+ * or "sheet-only". `desk`, `phone` (held upright), `sideways` (else as `phone`) and `tv` (the big screen) each change
57
+ * only what they name. The big screen has no sheet: it takes a corner only, its place follows the top level's when that
58
+ * is a corner, and its lines stay unless its own entry says "sheet-only" (then it shows only the float).
59
+ */
60
+ export function chatPlaces(value) {
61
+ const top = chatEntry(value);
62
+ const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
63
+ const done = (o) => {
64
+ const at = o.at ?? null;
65
+ const clamp = (n, lo) => Math.max(lo, Math.min(600, n ?? 0));
66
+ return { at, x: clamp(o.x, at && at.endsWith('center') ? -600 : 0), y: clamp(o.y, 0), lines: o.lines ?? 'strip' };
67
+ };
68
+ const phone = { ...top, ...chatEntry(per.phone) };
69
+ const { lines: _l, ...where } = top;
70
+ const own = chatEntry(per.tv);
71
+ if (own.at && !CHAT_CORNERS.includes(own.at)) delete own.at;
72
+ const tv = { ...(CHAT_CORNERS.includes(where.at) ? where : {}), ...own };
73
+ return { desk: done({ ...top, ...chatEntry(per.desk) }), phone: done(phone), sideways: done({ ...phone, ...chatEntry(per.sideways) }), tv: done(tv) };
74
+ }
75
+
76
+ /** game.json `screen.chat` as the build reads it: the problems in words (the page uses the default for anything wrong). */
77
+ export function screenChatProblems(value) {
78
+ if (value === undefined || value === null || value === false) return [];
79
+ const out = [];
80
+ const one = (v, name, top) => {
81
+ if (v === false) return;
82
+ if (typeof v === 'string') { if (!CHAT_PLACES.includes(v)) out.push(`${name} "${v}" is not a place (${CHAT_PLACES.join(', ')})`); return; }
83
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { out.push(`${name} is a place, false, or { "at", "x", "y", "lines" }`); return; }
84
+ for (const k of Object.keys(v)) {
85
+ if (top && CHAT_DEVICES.includes(k)) continue;
86
+ if (!CHAT_FIELDS.includes(k)) out.push(`${name}.${k} is not a field (${k === 'corner' || k === 'place' ? 'the place is "at"' : `${CHAT_FIELDS.join(', ')}${top ? `, or ${CHAT_DEVICES.join(', ')}` : ''}`})`);
87
+ }
88
+ if (v.at !== undefined && !CHAT_PLACES.includes(v.at)) out.push(`${name}.at "${v.at}" is not a place (${CHAT_PLACES.join(', ')})`);
89
+ if (name.endsWith('.tv') && v.at !== undefined && !CHAT_CORNERS.includes(v.at)) out.push(`${name}.at: the big screen takes a corner`);
90
+ for (const k of ['x', 'y']) if (v[k] !== undefined && !Number.isFinite(Number(v[k]))) out.push(`${name}.${k} is a number of pixels`);
91
+ if (v.lines !== undefined && v.lines !== 'strip' && v.lines !== 'sheet-only') out.push(`${name}.lines is "strip" or "sheet-only"`);
92
+ };
93
+ one(value, 'screen.chat', true);
94
+ if (value && typeof value === 'object' && !Array.isArray(value)) for (const d of CHAT_DEVICES) if (value[d] !== undefined) one(value[d], `screen.chat.${d}`, false);
95
+ return out.slice(0, 8);
96
+ }
97
+
25
98
  /** What a page's chat starts from (`window.__HOMIE_CHAT`): its surface, the game, and who this browser is to the studio. */
26
- export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null, place = null } = {}) {
27
- const screen = g?.screen?.chat;
28
- const ticker = screen === false ? false : typeof screen === 'string' ? screen : screen && typeof screen === 'object' && typeof screen.at === 'string' ? screen.at : null;
99
+ export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null } = {}) {
29
100
  return {
30
101
  surface, game: g.id, name: g.name, owner: Boolean(owner), acct: Boolean(acct), member: Boolean(member), palette: NET_PALETTE,
31
102
  reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })),
32
103
  ...(room ? { room } : {}),
33
- // game.json "screen": { "chat": "bottom-right" | false }: where new lines show for a moment (false: only the pill's count).
34
- ...(ticker !== null ? { ticker } : {}),
35
- ...(place ? { place } : {}),
104
+ // game.json "screen": { "chat": … }: where the strip of new lines sits on each device, or "sheet-only" (chatPlaces).
105
+ places: chatPlaces(g?.screen?.chat),
36
106
  account: '/account/',
37
107
  };
38
108
  }
@@ -42,7 +112,11 @@ export const CHAT_CSS = `
42
112
  .cpill .cico { width: 15px; height: 15px; flex: none; }
43
113
  .cpill .cbadge { min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 8px; background: var(--hot); color: #0b0b10; font: 800 10px/16px ui-sans-serif, system-ui, sans-serif; text-align: center; }
44
114
  .cpill.dim .cbadge { display: inline-block; }
115
+ /* The pill as the room button's round icon (on a phone, beside a room button kept an icon, or where its word would reach
116
+ the game's clock or title): its word goes, and the count rides its corner. */
117
+ .cpill.icon-only, .cpill.icon-only:hover, .cpill.icon-only:focus-visible, .cpill.icon-only[aria-expanded="true"] { position: relative; width: 34px; padding: 0; justify-content: center; }
45
118
  .cpill.icon-only span.cword { display: none; }
119
+ .cpill.icon-only .cbadge { position: absolute; top: -5px; right: -5px; min-width: 16px; box-shadow: 0 0 0 2px rgba(6,9,16,.85); }
46
120
  .csheet { display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 16px)); }
47
121
  .chead { display: flex; align-items: center; gap: 8px; margin: 0 2px; }
48
122
  .chead b { font: 800 15px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
@@ -88,24 +162,25 @@ export const CHAT_CSS = `
88
162
  .creport { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0 2px; }
89
163
  .creport p { grid-column: 1 / -1; margin: 0; color: #c3cad9; font: 600 12.5px/1.35 ui-sans-serif, system-ui, sans-serif; }
90
164
  .creport button { min-height: 32px; padding: 0 8px; border-radius: 9px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-align: left; }
91
- .ctick { position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
92
- .ctick.at-bottom-left { left: max(10px, env(safe-area-inset-left)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-start; }
93
- .ctick.at-bottom-right { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-end; }
94
- .ctick.at-bottom-center { left: 50%; transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: center; }
95
- .ctick.at-top-left { left: max(10px, env(safe-area-inset-left)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-start; flex-direction: column-reverse; }
96
- .ctick.at-top-right { right: max(10px, env(safe-area-inset-right)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-end; flex-direction: column-reverse; }
97
- .ctick.at-top-center { left: 50%; transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: center; flex-direction: column-reverse; }
165
+ /* The strip of new lines (game.json screen.chat: its place, moved in by --chat-x / --chat-y). */
166
+ .ctick { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
167
+ .ctick.at-bottom-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-start; }
168
+ .ctick.at-bottom-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-end; }
169
+ .ctick.at-bottom-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: center; }
170
+ .ctick.at-top-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-start; flex-direction: column-reverse; }
171
+ .ctick.at-top-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-end; flex-direction: column-reverse; }
172
+ .ctick.at-top-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: center; flex-direction: column-reverse; }
98
173
  .ctick div { padding: 5px 10px; border-radius: 12px; background: rgba(6,9,16,.66); color: #eef1f8; font: 600 13px/1.3 ui-sans-serif, system-ui, -apple-system, sans-serif; overflow-wrap: anywhere; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ctin .2s ease-out; transition: opacity .5s; }
99
174
  .ctick div b { font-weight: 800; margin-right: 6px; }
100
175
  .ctick div.cout { opacity: 0; }
101
176
  @keyframes ctin { from { opacity: 0; transform: translateY(6px); } }
102
177
  .cfloat { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; }
103
- .ctv { position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
178
+ .ctv { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
104
179
  .ctv:empty { display: none; }
105
- .ctv.at-bottom-left { left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); }
106
- .ctv.at-bottom-right { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); }
107
- .ctv.at-top-left { left: max(16px, env(safe-area-inset-left)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
108
- .ctv.at-top-right { right: max(16px, env(safe-area-inset-right)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
180
+ .ctv.at-bottom-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
181
+ .ctv.at-bottom-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
182
+ .ctv.at-top-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
183
+ .ctv.at-top-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
109
184
  .ctv div { overflow-wrap: anywhere; transition: opacity 1s; }
110
185
  .ctv div.cout { opacity: 0; }
111
186
  .ctv b { font-weight: 800; margin-right: .4em; }
@@ -221,21 +296,41 @@ export const CHAT_JS = String.raw`(function () {
221
296
  if (parts.length) raf = requestAnimationFrame(paint);
222
297
  }
223
298
 
224
- /* ------------------------------------------------------------ the ticker: new lines, for a moment, where the chip sits */
299
+ /* ------------------------------------------------------------ where chat sits on this screen (game.json screen.chat) */
300
+ // This screen as the room button knows it (desk, phone held upright, phone turned sideways), else by its size.
301
+ function device() {
302
+ if (surface === 'tv') return 'tv';
303
+ var s = window.__shell && window.__shell.share;
304
+ if (s && s.device) return s.device;
305
+ var w = window.innerWidth || 0; var h = window.innerHeight || 0;
306
+ return w && h && Math.min(w, h) <= 540 ? (w > h ? 'sideways' : 'phone') : 'desk';
307
+ }
308
+ function placeHere() { var p = cfg.places || {}; return p[device()] || p.desk || { at: null, x: 0, y: 0, lines: 'strip' }; }
309
+
310
+ /* ------------------------------------------------------------ the strip: new lines, for a moment, where the chip sits */
225
311
  var tickEl = null;
226
312
  function tickPlace() {
227
- if (cfg.ticker) return cfg.ticker;
313
+ var p = placeHere();
314
+ if (p.at) return p.at;
228
315
  var s = window.__shell && window.__shell.share;
229
316
  return s && s.at === 'bottom-left' ? 'bottom-right' : 'bottom-left';
230
317
  }
231
318
  function tick(m) {
232
- if (surface === 'tv' || cfg.ticker === false || !st.show || st.open) return;
319
+ if (surface === 'tv' || !st.show || st.open) return;
320
+ // "sheet-only": a busy HUD keeps new lines in the sheet; the pill's count says they are there.
321
+ var p = placeHere();
322
+ if (p.lines === 'sheet-only') return;
233
323
  if (!tickEl) {
234
324
  var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
235
325
  if (!host) return;
236
326
  tickEl = el('div', 'ctick'); tickEl.setAttribute('aria-hidden', 'true'); host.appendChild(tickEl); if (surface === 'watch') tickEl.style.position = 'absolute';
237
327
  }
238
- tickEl.className = 'ctick at-' + tickPlace();
328
+ var at = tickPlace();
329
+ tickEl.className = 'ctick at-' + at;
330
+ // Moved in by the game's x / y, and past the room button when the strip shares its place.
331
+ var s = window.__shell && window.__shell.share; var same = surface === 'play' && s && s.at === at;
332
+ tickEl.style.setProperty('--chat-x', ((p.x || 0) + (same ? s.x || 0 : 0)) + 'px');
333
+ tickEl.style.setProperty('--chat-y', ((p.y || 0) + (same ? s.y || 0 : 0)) + 'px');
239
334
  var row = el('div');
240
335
  if (m.name) { var b = el('b', '', m.name); b.style.color = colourOf(m); row.appendChild(b); }
241
336
  row.appendChild(document.createTextNode(m.glyph && m.react ? m.glyph : (m.text || '')));
@@ -247,13 +342,16 @@ export const CHAT_JS = String.raw`(function () {
247
342
  /* ------------------------------------------------------------ the TV's corner */
248
343
  var tvEl = null;
249
344
  function tvLine(m) {
250
- if (!st.show) return;
345
+ var p = placeHere();
346
+ if (!st.show || p.lines === 'sheet-only') return;
251
347
  if (!tvEl) {
252
348
  if (!document.body) return;
253
349
  tvEl = el('div', 'ctv');
254
350
  var join = document.querySelector('[data-join]');
255
351
  var at = join && /join-bottom-left/.test(join.className) ? 'bottom-right' : join && /join-top-left|join-top-right/.test(join.className) ? 'bottom-left' : 'bottom-left';
256
- tvEl.className = 'ctv at-' + (cfg.ticker && cfg.ticker !== 'bottom-center' && cfg.ticker !== 'top-center' ? cfg.ticker : at);
352
+ tvEl.className = 'ctv at-' + (p.at || at);
353
+ tvEl.style.setProperty('--chat-x', (p.x || 0) + 'px');
354
+ tvEl.style.setProperty('--chat-y', (p.y || 0) + 'px');
257
355
  document.body.appendChild(tvEl);
258
356
  }
259
357
  var row = el('div', m.t === 'react' ? 'creact' : '');
@@ -277,6 +375,7 @@ export const CHAT_JS = String.raw`(function () {
277
375
  pill = el('button', 'pill cpill'); pill.type = 'button'; pill.setAttribute('data-chat-toggle', ''); pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-controls', 'chat-sheet'); pill.setAttribute('aria-label', 'Room chat');
278
376
  pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
279
377
  band.appendChild(pill);
378
+ watchBand(band);
280
379
  sheet = el('div', 'sheet csheet'); sheet.id = 'chat-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.hidden = true;
281
380
  ui.appendChild(sheet);
282
381
  // One sheet at a time: the room's and the server's close this one, and this one closes theirs.
@@ -318,6 +417,52 @@ export const CHAT_JS = String.raw`(function () {
318
417
  renderFeed();
319
418
  paintRules();
320
419
  }
420
+ /*
421
+ * The pill's word, as the room button's: on a phone (either way up), or beside a room button that game.json keeps an
422
+ * icon ("label": false), the pill is the round icon from the start. Elsewhere its word shows unless, with it, the band
423
+ * would leave the screen or reach from its corner into the middle third of the screen's width (where games keep a
424
+ * clock or a title); once it has, it stays the icon until the screen changes size, so it never flickers as the room
425
+ * button's own label comes and goes.
426
+ */
427
+ var narrow = false;
428
+ function shareHere() { var s = window.__shell && window.__shell.share; return s || {}; }
429
+ function wordCollides() {
430
+ var band = pill && pill.parentNode;
431
+ var w = window.innerWidth || 0;
432
+ if (!band || !band.getBoundingClientRect || !w) return false;
433
+ var had = pill.classList.contains('icon-only');
434
+ if (had) pill.classList.remove('icon-only');
435
+ var r = band.getBoundingClientRect();
436
+ if (had) pill.classList.add('icon-only');
437
+ if (!r.width) return false;
438
+ if (r.left < 4 || r.right > w - 4) return true;
439
+ var at = shareHere().at || 'top-right';
440
+ if (/center/.test(at)) return false;
441
+ return /right/.test(at) ? r.left < w * 2 / 3 : r.right > w / 3;
442
+ }
443
+ function fitPill() {
444
+ if (!pill || surface !== 'play') return;
445
+ var s = shareHere();
446
+ if (!narrow && s.device !== 'phone' && s.device !== 'sideways' && s.label !== false) narrow = wordCollides();
447
+ var icon = s.device === 'phone' || s.device === 'sideways' || s.label === false || narrow;
448
+ if (icon) { pill.classList.add('icon-only'); pill.title = 'Room chat'; } else { pill.classList.remove('icon-only'); pill.title = ''; }
449
+ st.compact = icon;
450
+ }
451
+ var fitQueued = false;
452
+ function fitSoon() {
453
+ if (fitQueued) return;
454
+ fitQueued = true;
455
+ var go = function () { fitQueued = false; fitPill(); };
456
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go); else setTimeout(go, 16);
457
+ }
458
+ function watchBand(band) {
459
+ fitPill();
460
+ // The band changes as the room code arrives, the server's pill shows and the labels fade to dots: look again.
461
+ try { if (window.ResizeObserver) new ResizeObserver(fitSoon).observe(band); } catch (e) {}
462
+ // A new size (a turned phone, a resized window): the room button moves first, then the word is measured afresh.
463
+ window.addEventListener('resize', function () { setTimeout(function () { narrow = false; fitPill(); }, 60); });
464
+ }
465
+ st.fit = fitPill;
321
466
  function openChat(on, quiet) {
322
467
  if (!sheet) return;
323
468
  st.open = Boolean(on);
package/worker/chat.mjs CHANGED
@@ -209,7 +209,10 @@ export function chatProblems(raw) {
209
209
  const out = [];
210
210
  if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
211
211
  for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
212
- if (raw.lines !== undefined && raw.lines !== null) {
212
+ // Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
213
+ for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
214
+ if (raw.lines === 'strip' || raw.lines === 'sheet-only') out.push(`chat.lines is the quick lines ({ "id": "text" }); "${raw.lines}" goes in "screen": { "chat": { "lines": "${raw.lines}" } }`);
215
+ else if (raw.lines !== undefined && raw.lines !== null) {
213
216
  const pairs = Array.isArray(raw.lines) ? raw.lines.map((x) => [x?.id, x?.text]) : typeof raw.lines === 'object' ? Object.entries(raw.lines) : null;
214
217
  if (!pairs) out.push('chat.lines is { "id": "text" }');
215
218
  else {
@@ -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.24.4';
2
+ export const STUDIO_VERSION_TAG = '0.24.5';