@homie-rocks/studio 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -2
  3. package/app-starters/welcome/app.json +106 -0
  4. package/app-starters/welcome/index.html +4 -0
  5. package/app-starters/welcome/src/main.ts +107 -0
  6. package/apps/APPS.md +61 -0
  7. package/apps/apps.ts +28 -0
  8. package/bin/homie-studio.mjs +13 -4
  9. package/lib/art-cli.mjs +2 -2
  10. package/lib/art-tools.mjs +6 -6
  11. package/lib/asset-check.mjs +3 -2
  12. package/lib/asset-import.mjs +3 -2
  13. package/lib/asset-manifest.mjs +8 -8
  14. package/lib/build.mjs +6 -3
  15. package/lib/characters.mjs +7 -7
  16. package/lib/check.mjs +49 -0
  17. package/lib/cloudflare.mjs +1 -1
  18. package/lib/codex.mjs +2 -2
  19. package/lib/collision.mjs +1 -1
  20. package/lib/decisions.mjs +7 -7
  21. package/lib/dev.mjs +26 -9
  22. package/lib/directory.mjs +1 -1
  23. package/lib/doctor.mjs +1 -1
  24. package/lib/handoff.mjs +1 -1
  25. package/lib/lab-check.mjs +1 -1
  26. package/lib/lab.mjs +8 -8
  27. package/lib/library.mjs +2 -2
  28. package/lib/mcp-tools.mjs +22 -3
  29. package/lib/parts-build.mjs +4 -3
  30. package/lib/parts-cli.mjs +3 -3
  31. package/lib/parts-store.mjs +8 -7
  32. package/lib/parts-tools.mjs +7 -7
  33. package/lib/parts.mjs +19 -14
  34. package/lib/perf.mjs +2 -1
  35. package/lib/player-image.mjs +2 -2
  36. package/lib/scaffold.mjs +21 -2
  37. package/lib/shop.mjs +1 -1
  38. package/lib/standalone-files.mjs +2 -2
  39. package/lib/standalone.mjs +7 -2
  40. package/lib/stats.mjs +2 -1
  41. package/lib/studio.mjs +32 -6
  42. package/lib/style-board.mjs +10 -9
  43. package/lib/template-history.json +41 -0
  44. package/links/links.d.ts +3 -0
  45. package/links/links.mjs +16 -0
  46. package/package.json +14 -3
  47. package/parts/PARTS.md +10 -5
  48. package/shop/SHOP.md +13 -0
  49. package/site/SITE.md +1 -1
  50. package/worker/account-page.mjs +4 -3
  51. package/worker/app-format.mjs +42 -0
  52. package/worker/app-records.mjs +100 -0
  53. package/worker/app-shell.mjs +29 -0
  54. package/worker/embed.mjs +4 -2
  55. package/worker/index.mjs +33 -9
  56. package/worker/pages.mjs +33 -27
  57. package/worker/parts.mjs +11 -9
  58. package/worker/schema.mjs +6 -5
  59. package/worker/seats.mjs +2 -2
  60. package/worker/shop-page.mjs +4 -3
  61. package/worker/site.mjs +49 -40
  62. package/worker/standalone.mjs +2 -2
  63. package/worker/version.mjs +1 -1
package/worker/site.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { appWords, openPath } from './app-format.mjs';
1
2
  /**
2
3
  * The studio site's pages, in the studio's own look (site/SITE.md): the same sections as homie.rocks
3
4
  * (Home, Games, Music, Videos, Rooms, Posts, each shown only when the studio has something in it), an epic
@@ -473,7 +474,7 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
473
474
  var el=function(tag,cls,text){var e=d.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e};
474
475
  var EYE='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>';
475
476
  var roomRow=function(r){var li=el('li','room');var th=el('span','thumb');if(r.cover){var im=el('img');im.src=r.cover;im.alt='';im.loading='lazy';th.appendChild(im)}li.appendChild(th);
476
- var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+(r.server?r.server.name+' · ':'')+r.label));mid.appendChild(el('span',null,r.players+' of '+r.max+' seats taken'+(r.ai?' · '+r.ai+' AI':'')+(r.policy==='humans-only'?' · humans only':' · bots hold the rest')));
477
+ var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+(r.server?r.server.name+' · ':'')+r.label));mid.appendChild(el('span',null,(r.kind==='app'?r.players+' people here':r.players+' of '+r.max+' seats taken')+(r.ai?' · '+r.ai+' AI':'')+(r.kind==='app'?'':r.policy==='humans-only'?' · humans only':' · bots hold the rest')));
477
478
  var pips=el('div','pips');for(var i=0;i<Math.min(r.max,32);i++)pips.appendChild(el('i',i<r.players?'on':''));mid.appendChild(pips);li.appendChild(mid);
478
479
  var acts=el('div','room-acts');
479
480
  if(r.watch){var w=el('a','watchb');w.href=r.watch;w.setAttribute('aria-label','Watch '+(r.name?r.name+' · ':'')+r.label);w.innerHTML=EYE;w.appendChild(el('span',null,'Watch'));acts.appendChild(w)}
@@ -488,7 +489,7 @@ if(src){
488
489
  var idle=live&&(live.getAttribute('data-idle')||(words&&words.textContent));
489
490
  var ask=function(){if(d.hidden)return;fetch(src,{credentials:'omit',cache:'no-store'}).then(function(r){return r.ok?r.json():null}).then(function(b){
490
491
  if(!b||b.ok!==true)return;var n=Number(b.playing)||0;
491
- if(live){live.setAttribute('data-n',String(n));if(words)words.textContent=n<=0?idle:(n===1?'1 '+one+' playing right now':n+' '+many+' playing right now')}
492
+ if(live){live.setAttribute('data-n',String(n));if(words)words.textContent=n<=0?idle:(n===1?'1 '+one+(live.hasAttribute('data-app')?' here right now':' playing right now'):n+' '+many+(live.hasAttribute('data-app')?' here right now':' playing right now'))}
492
493
  if(list&&Array.isArray(b.rooms))paintRooms(list,b.rooms)}).catch(function(){})};
493
494
  var go=function(){setInterval(ask,15000);d.addEventListener('visibilitychange',ask)};
494
495
  if(d.prerendering===true)d.addEventListener('prerenderingchange',function(){ask();go()},{once:true});else go();
@@ -551,12 +552,14 @@ const studioName = (cat) => cat.studio?.name ?? 'Studio';
551
552
 
552
553
  /** Which sections this studio has: a section with nothing in it has no tab (and its page answers 404). */
553
554
  export function sectionsOf(cat) {
554
- const games = (cat.games ?? []).length > 0;
555
+ const games = (cat.games ?? []).some((g) => g.kind !== 'app');
556
+ const apps = (cat.games ?? []).some((g) => g.kind === 'app');
555
557
  return [
558
+ apps && { key: 'apps', href: '/apps/', label: 'Apps' },
556
559
  games && { key: 'games', href: '/games/', label: 'Games' },
557
560
  (cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
558
561
  (cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
559
- games && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
562
+ (games || apps) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
560
563
  (cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
561
564
  // The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
562
565
  cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
@@ -711,10 +714,10 @@ function heroMedia(h = {}, { alt = '', word = '' } = {}) {
711
714
 
712
715
  function liveLine(g, playing, { idle } = {}) {
713
716
  const w = g.landing?.words ?? {};
714
- const one = w.one ?? 'player';
715
- const many = w.many ?? 'players';
716
- const words = playing > 0 ? (playing === 1 ? `1 ${one} playing right now` : `${playing} ${many} playing right now`) : idle;
717
- return `<p class="live" data-live="/${esc(g.id)}/live" data-n="${playing}" data-one="${esc(one)}" data-many="${esc(many)}" data-idle="${esc(idle)}" aria-live="polite"><span class="live-dot" aria-hidden="true"></span><span data-live-words>${esc(words)}</span></p>`;
717
+ const one = w.one ?? appWords(g).one;
718
+ const many = w.many ?? appWords(g).many;
719
+ const words = playing > 0 ? (playing === 1 ? `1 ${one} ${g.kind === 'app' ? 'here' : 'playing'} right now` : `${playing} ${many} ${g.kind === 'app' ? 'here' : 'playing'} right now`) : idle;
720
+ return `<p class="live" data-live="/${esc(g.id)}/live"${g.kind === 'app' ? ' data-app="1"' : ''} data-n="${playing}" data-one="${esc(one)}" data-many="${esc(many)}" data-idle="${esc(idle)}" aria-live="polite"><span class="live-dot" aria-hidden="true"></span><span data-live-words>${esc(words)}</span></p>`;
718
721
  }
719
722
 
720
723
  function gameCard(g, playing = 0) {
@@ -724,8 +727,8 @@ function gameCard(g, playing = 0) {
724
727
  <div class="body">
725
728
  <h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
726
729
  <p>${esc(g.landing?.pitch ?? g.blurb)}</p>
727
- <div class="meta"><span>${esc(playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
728
- <div class="acts"><a class="btn" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}Play</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
730
+ <div class="meta"><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
731
+ <div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
729
732
  </div>
730
733
  </article>`;
731
734
  }
@@ -750,7 +753,7 @@ export function roomView(g, r, max, srv = null, { chat = false } = {}) {
750
753
  const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
751
754
  const n = m ? Number(m[1]) : null;
752
755
  return {
753
- game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(room)}`,
756
+ ...(g.kind === 'app' ? { kind: 'app' } : {}), game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play: `${openPath(g)}?room=${encodeURIComponent(room)}`,
754
757
  // Watch this room from any player's view (section 16); a game that cannot be watched has no link.
755
758
  watch: watchOf(g) === 'off' ? null : `/${g.id}/watch?room=${encodeURIComponent(room)}`,
756
759
  server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
@@ -763,7 +766,7 @@ export function roomView(g, r, max, srv = null, { chat = false } = {}) {
763
766
  }
764
767
 
765
768
  function roomRows(rows, { names = true } = {}) {
766
- return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${r.server ? `${esc(r.server.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken${r.ai ? ` · ${esc(r.ai)} AI` : ''}${r.policy === 'humans-only' ? ' · humans only' : ' · bots hold the rest'}</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><div class="room-acts">${r.watch ? `<a class="watchb" href="${esc(r.watch)}" aria-label="Watch ${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}">${icon('eye', 'ico')}<span>Watch</span></a>` : ''}<a class="join" href="${esc(r.play)}" data-play>Join</a></div></li>`).join('');
769
+ return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${r.server ? `${esc(r.server.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken${r.ai ? ` · ${esc(r.ai)} AI` : ''}${r.kind === 'app' ? ' · people' : r.policy === 'humans-only' ? ' · humans only' : ' · bots hold the rest'}</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><div class="room-acts">${r.watch ? `<a class="watchb" href="${esc(r.watch)}" aria-label="Watch ${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}">${icon('eye', 'ico')}<span>Watch</span></a>` : ''}<a class="join" href="${esc(r.play)}" data-play>Join</a></div></li>`).join('');
767
770
  }
768
771
 
769
772
  const fmtDay = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : ''; };
@@ -807,7 +810,7 @@ export function featuredOf(cat) {
807
810
  export const homeLd = (cat, origin) => [studioNode(cat, origin, { full: true }), websiteNode(cat, origin)];
808
811
 
809
812
  /** A game landing's structured data: its breadcrumbs and its VideoGame (worker/schema.mjs gameNode). */
810
- export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`]]), gameNode(cat, g, origin, opts)];
813
+ export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`]]), gameNode(cat, g, origin, opts)];
811
814
 
812
815
  /** The public games as a list of their landings (Games, Rooms). */
813
816
  const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.games ?? []).map((g) => ({ name: g.name, path: `/${g.id}/` })));
@@ -821,23 +824,24 @@ const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.gam
821
824
  */
822
825
  export function homeHero(cat, f, { playing = 0 } = {}) {
823
826
  const name = studioName(cat);
824
- const games = cat.games ?? [];
827
+ const games = (cat.games ?? []).filter((g) => (g.kind === 'app') === (f.kind === 'app'));
825
828
  const tagline = cat.studio?.tagline ?? '';
826
829
  return `<section class="hero home" aria-labelledby="hero-title">
827
830
  ${heroMedia(f.landing?.hero, { alt: f.landing?.hero?.alt })}
828
831
  <div class="hero-copy">
829
- <p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || 'featured game')}</span></p>
832
+ <p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || (f.kind === 'app' ? 'featured app' : 'featured game'))}</span></p>
830
833
  <h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
831
834
  <p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
832
- <div class="row"><a class="play" href="/${esc(f.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>${liveLine(f, playing, { idle: 'Bots hold every empty seat · press Play and you are in a round' })}</div>
833
- <a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/games/">${icon('games')}<span>All ${games.length} games</span></a>` : ''}
835
+ <div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(appWords(f).open) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
836
+ <a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/${f.kind === 'app' ? 'apps' : 'games'}/">${icon('games')}<span>All ${games.length} ${f.kind === 'app' ? 'apps' : 'games'}</span></a>` : ''}
834
837
  </div>
835
838
  </section>`;
836
839
  }
837
840
 
838
841
  export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
839
842
  const name = studioName(cat);
840
- const games = cat.games ?? [];
843
+ const games = (cat.games ?? []).filter((g) => g.kind !== 'app');
844
+ const apps = (cat.games ?? []).filter((g) => g.kind === 'app');
841
845
  const posts = (cat.posts ?? []).slice(0, 3);
842
846
  const songs = cat.songs ?? [];
843
847
  const videos = cat.videos ?? [];
@@ -889,6 +893,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
889
893
  <div class="head-row reveal"><div><p class="kicker">Games</p><h2 id="games-title">${esc(games.length === 1 ? `One game, live now` : `${games.length} games, live now`)}</h2></div>${games.length > 3 ? `<a class="more" href="/games/">All games ${icon('arrow')}</a>` : ''}</div>
890
894
  <div class="cards">${games.slice(0, 6).map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div>
891
895
  </div></section>` : '';
896
+ const appsBand = apps.length ? `<section class="band"><div class="band-in"><div class="head-row"><div><p class="kicker">Apps</p><h2>Spaces to do things together</h2></div><a href="/apps/">All apps</a></div><div class="cards">${apps.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div></section>` : '';
892
897
  const postsBand = posts.length ? `<section class="band hotband" aria-labelledby="posts-title"><div class="band-in">
893
898
  <div class="head-row reveal"><div><p class="kicker">Posts</p><h2 id="posts-title">From the studio</h2></div><a class="more" href="/posts/">All posts ${icon('arrow')}</a></div>
894
899
  <div class="cards posts">${posts.map(postCard).join('')}</div>
@@ -903,7 +908,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
903
908
  description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : soon ? `${name}: first game coming soon.` : name),
904
909
  origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true, ld: homeLd(cat, origin),
905
910
  head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
906
- main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
911
+ main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${appsBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
907
912
  });
908
913
  }
909
914
 
@@ -913,13 +918,15 @@ function pageHead(kicker, title, lead, extra = '') {
913
918
  return `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
914
919
  }
915
920
 
916
- export function gamesPage(cat, { origin = '', live = {} } = {}) {
917
- const games = cat.games ?? [];
921
+ export function gamesPage(cat, { origin = '', live = {}, kind = 'game' } = {}) {
922
+ const games = (cat.games ?? []).filter((g) => (g.kind === 'app' ? 'app' : 'game') === kind);
923
+ const apps = kind === 'app';
924
+ const label = apps ? 'Apps' : 'Games';
918
925
  const name = studioName(cat);
919
926
  return layout(cat, {
920
- title: `Games · ${name}`, description: `Every game from ${name}: press Play and you are in a live room.`, origin, path: '/games/', page: 'games', active: 'games',
921
- ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/']]), gamesList(cat, origin, '/games/#list', `Games from ${name}`)],
922
- main: `${pageHead(name, 'Games', `${games.length === 1 ? 'One game' : `${games.length} games`}. Press Play on a phone, a computer or a TV browser and you are in a live public room with whoever is playing; bots hold the empty seats.`)}
927
+ title: `${label} · ${name}`, description: apps ? `Apps from ${name}: one screen, live together.` : `Every game from ${name}: press Play and you are in a live room.`, origin, path: `/${kind}s/`, page: `${kind}s`, active: `${kind}s`,
928
+ ld: [breadcrumbs(origin, [['Home', '/'], [label, `/${kind}s/`]]), gamesList({ ...cat, games }, origin, `/${kind}s/#list`, `${label} from ${name}`)],
929
+ main: `${pageHead(name, label, apps ? 'Open an app on your phone, tablet, kiosk or wall screen.' : `${games.length === 1 ? 'One game' : `${games.length} games`}. Press Play on a phone, a computer or a TV browser and you are in a live public room with whoever is playing; bots hold the empty seats.`)}
923
930
  <div class="wrap"><div class="cards">${games.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div>`,
924
931
  });
925
932
  }
@@ -927,15 +934,16 @@ export function gamesPage(cat, { origin = '', live = {} } = {}) {
927
934
  export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
928
935
  const name = studioName(cat);
929
936
  const games = cat.games ?? [];
937
+ const hasApps = games.some((g) => g.kind === 'app');
930
938
  const total = rooms.reduce((n, r) => n + r.players, 0);
931
939
  return layout(cat, {
932
- title: `Rooms · ${name}`, description: `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
940
+ title: `Rooms · ${name}`, description: hasApps ? `Live rooms across ${name}'s apps and games. Join one.` : `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
933
941
  // The games a room can be opened in, never who is in one now: a cached copy of the page must not go stale.
934
942
  ld: [breadcrumbs(origin, [['Home', '/'], ['Rooms', '/rooms/']]), gamesList(cat, origin, '/rooms/#games', `Games with public rooms at ${name}`)],
935
- main: `${pageHead('Live now', 'Rooms', rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
943
+ main: `${pageHead('Live now', 'Rooms', hasApps ? `${total} people here. Open a screen below to join its room.` : rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
936
944
  <div class="wrap">
937
945
  <ol class="rooms" data-rooms data-src="/api/rooms"${rooms.length ? '' : ' hidden'}>${roomRows(rooms)}</ol>
938
- <div class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>Nobody is in a room right now. Press Play on any game below: you start at once with bots in the empty seats, and whoever presses Play next lands in your room.</div>
946
+ <div class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>${hasApps ? 'Nobody is here right now. Open an app below, or play a game.' : 'Nobody is in a room right now. Press Play on any game below: you start at once with bots in the empty seats, and whoever presses Play next lands in your room.'}</div>
939
947
  <p class="sec">Start one</p>
940
948
  <div class="cards">${games.map((g) => gameCard(g, 0)).join('')}</div>
941
949
  </div>`,
@@ -979,7 +987,7 @@ export function serversPage(cat, g, { origin = '', servers = [], hidden = false,
979
987
  const cards = [...(pub && !hidden ? [pub] : []), ...shown];
980
988
  return layout(cat, {
981
989
  title: `Servers · ${g.name}`, description: `${g.name}'s servers: ${SERVERS_LEAD}`, origin, path: `/${g.id}/servers/`, page: 'servers', active: 'games',
982
- ld: pick ? null : [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`]])],
990
+ ld: pick ? null : [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`]])],
983
991
  main: `${pageHead(g.name, pick ? 'Pick a server' : 'Servers', SERVERS_LEAD)}
984
992
  <div class="wrap">${cards.length ? `<div class="scards">${cards.map((sv) => serverCard(g, sv)).join('')}</div>` : `<p class="quiet">No server is open right now.</p>`}
985
993
  <p class="sec">Every policy</p><ul class="howto">${Object.values(POLICY_WORDS).map((w) => `<li>${esc(w.line.replace('{n}', 'N'))}</li>`).join('')}</ul></div>`,
@@ -1001,7 +1009,7 @@ export function serverPage(cat, g, sv, { origin = '', rooms = [], door = { ok: t
1001
1009
  const blocked = !door.ok ? `<p class="door-why">${esc(doorWords(g, sv, door).line)}</p>` : '';
1002
1010
  return layout(cat, {
1003
1011
  title: `${sv.name} · ${g.name}`, description: `${sv.name}, a ${words.badge.toLowerCase()} server of ${g.name}. ${words.line}`, origin, path: `/${g.id}/s/${sv.id}/`, page: 'server', active: 'games',
1004
- ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`], [sv.name, `/${g.id}/s/${sv.id}/`]])],
1012
+ ld: [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`], [sv.name, `/${g.id}/s/${sv.id}/`]])],
1005
1013
  main: `<header class="head"><p class="kicker">${esc(g.name)} · server</p><h1>${esc(sv.name)}</h1>
1006
1014
  <p class="lead"><span class="pol ${esc(sv.policy)}">${esc(words.badge)}</span> ${esc(words.line)}</p>${sv.blurb ? `<p class="lead">${esc(sv.blurb)}</p>` : ''}
1007
1015
  ${blocked}<div class="keys">${door.ok ? `<a class="btn" href="${esc(play)}" data-play>${PLAY_ICON}<span>Play on ${esc(sv.name)}</span></a>` : ''}${membership}</div></header>
@@ -1165,14 +1173,15 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1165
1173
  const license = licenseLabel(g.license);
1166
1174
  const h = L.hero ?? {};
1167
1175
  const host = origin.replace(/^https?:\/\//, '');
1168
- const playUrl = `${origin}/${g.id}/play`;
1176
+ const playUrl = `${origin}${openPath(g)}`;
1177
+ const app = g.kind === 'app';
1169
1178
  let qr = '';
1170
1179
  // Local development: no code a phone cannot open; the landing says to deploy instead of naming this computer's address.
1171
1180
  const local = isLocalOrigin(origin);
1172
1181
  if (!local) try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
1173
1182
  const tvOn = L.tv !== false;
1174
1183
  const max = g.players?.max ?? 8;
1175
- const words = L.words ?? {};
1184
+ const words = { ...appWords(g), ...L.words };
1176
1185
  const many = words.many ?? 'players';
1177
1186
  const kicker = L.kicker ?? `Free in your browser · <span class="k-opt">phone, computer or TV · </span>no download`;
1178
1187
  const kickerHtml = L.kicker ? esc(L.kicker) : kicker;
@@ -1186,8 +1195,8 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1186
1195
  ${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
1187
1196
  <p class="line">${esc(L.pitch ?? g.blurb)}</p>
1188
1197
  <div class="row">
1189
- <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
1190
- ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
1198
+ <a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(words.open) : 'Play now — free'}</span></a>
1199
+ ${liveLine(g, playing, { idle: app ? 'One screen, live together' : `Bots hold every empty seat · one tap and you are in` })}
1191
1200
  </div>
1192
1201
  ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
1193
1202
  </div>
@@ -1211,7 +1220,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1211
1220
  </div></section>`;
1212
1221
 
1213
1222
  const liveBand = `<section class="band tight" aria-labelledby="rooms-title"><div class="band-in">
1214
- <div class="head-row reveal"><div><p class="kicker">Live rooms</p><h2 class="small-h" id="rooms-title">${rooms.length ? `${esc(playing)} ${playing === 1 ? esc(words.one ?? 'player') : esc(many)} in ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'} right now` : 'Rooms open the moment you press Play'}</h2></div><a class="more" href="/rooms/">Every room ${icon('arrow')}</a></div>
1223
+ <div class="head-row reveal"><div><p class="kicker">Live rooms</p><h2 class="small-h" id="rooms-title">${rooms.length ? `${esc(playing)} ${playing === 1 ? esc(words.one ?? 'player') : esc(many)} in ${rooms.length} ${rooms.length === 1 ? 'room' : 'rooms'} right now` : `Rooms open the moment you press ${appWords(g).open}`}</h2></div><a class="more" href="/rooms/">Every room ${icon('arrow')}</a></div>
1215
1224
  <ol class="rooms" data-rooms data-src="/${esc(g.id)}/live"${rooms.length ? '' : ' hidden'}>${roomRows(rooms, { names: false })}</ol>
1216
1225
  <p class="quiet" data-rooms-quiet${rooms.length ? ' hidden' : ''}>No one is in a room this minute. Press Play and you start at once, with bots in the empty seats; whoever presses Play next takes a bot’s place in your room.</p>
1217
1226
  ${week ? `<div class="stat"><span>Played this week: <b>${esc(week.plays)}</b> ${week.plays === 1 ? 'time' : 'times'}</span><span><b>${esc(week.rounds)}</b> ${week.rounds === 1 ? 'round' : 'rounds'} with people</span>${week.peak ? `<span>Most at once today: <b>${esc(week.peak)}</b></span>` : ''}</div>` : ''}
@@ -1219,7 +1228,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1219
1228
 
1220
1229
  const genres = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).filter((x) => typeof x === 'string' && x.trim());
1221
1230
  const facts = [...genres, 'free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
1222
- const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
1231
+ const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
1223
1232
  const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
1224
1233
  <div class="reveal">
1225
1234
  <p class="kicker">How to play</p>
@@ -1251,13 +1260,13 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1251
1260
  const band = (html) => (!html ? '' : /^\s*<section\b/i.test(html) ? html : `<section class="band tight"><div class="band-in">${html}</div></section>`);
1252
1261
  const vars = { 'game.name': g.name, 'game.id': g.id, 'game.play': `/${g.id}/play` };
1253
1262
  return layout(cat, {
1254
- title: `${g.name} — play free in your browser`,
1255
- description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
1256
- playerGame: listed ? g : null, origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1263
+ title: app ? `${g.name} — ${words.open}` : `${g.name} — play free in your browser`,
1264
+ description: app ? `${g.name}: ${L.pitch ?? g.blurb ?? ''} One transforming screen, live across your devices.` : `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
1265
+ playerGame: listed ? g : null, origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: app ? 'apps' : 'games', over: landingTokens(cat, L),
1257
1266
  head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
1258
1267
  // A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
1259
1268
  ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
1260
- main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
1269
+ main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(words.open)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
1261
1270
  });
1262
1271
  }
1263
1272
 
@@ -1268,7 +1277,7 @@ export function creditsPage(cat, g, texts, { origin = '' } = {}) {
1268
1277
  const of = lineage ? `<p class="sec">Based on</p><p>${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : esc(lineage.name)}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}</p>` : '';
1269
1278
  return layout(cat, {
1270
1279
  title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
1271
- ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
1280
+ ld: [breadcrumbs(origin, [['Home', '/'], [g.kind === 'app' ? 'Apps' : 'Games', g.kind === 'app' ? '/apps/' : '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
1272
1281
  main: `${pageHead(g.name, 'Credits and licences', null, `<div class="keys"><a class="ghost" href="/${esc(g.id)}/">${icon('arrow')}<span>Back to ${esc(g.name)}</span></a></div>`)}
1273
1282
  <div class="wrap">${of}${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1274
1283
  });
@@ -4,9 +4,9 @@
4
4
  * Worker: `app://game` in the desktop app (Electron), `capacitor://localhost` on iOS and `https://localhost` on
5
5
  * Android (Capacitor). Those three are fixed by the shells, never by a game.
6
6
  *
7
- * What an app origin may do here is ONE thing: ask the Lobby which public room to join (`POST /<game>/api/lobby`),
7
+ * A native shell may use public records for an app kind (worker/app-records.mjs), and otherwise: ask the Lobby which public room to join (`POST /<game>/api/lobby`),
8
8
  * and read the answer. The room's socket (`/<game>/__net`) never asked where a page came from, so it needs nothing.
9
- * Everything else a page of this site does stays this site's own: no credentials are allowed across (there is no
9
+ * Privileged records and everything else a page of this site does stay this site's own: no credentials are allowed across (there is no
10
10
  * `access-control-allow-credentials` anywhere here), the lobby is never opened to `*`, and the POSTs that check
11
11
  * `sameOrigin` (players, saves, the shop, chat reports, the office) still refuse an app, as they refuse any other
12
12
  * site. So an app has no account, no cloud saves and no shop, and says so.
@@ -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.36.0';
2
+ export const STUDIO_VERSION_TAG = '0.37.0';