@homie-rocks/studio 0.48.4 → 0.49.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 (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +15 -11
  34. package/guides/app/SKILL.md +72 -0
  35. package/guides/parts/SKILL.md +158 -0
  36. package/guides/shop/SKILL.md +149 -0
  37. package/guides/studio-setup/SKILL.md +419 -0
  38. package/lib/authoring-guides.mjs +5 -4
  39. package/lib/build.mjs +5 -4
  40. package/lib/check-site.mjs +6 -6
  41. package/lib/check.mjs +8 -1
  42. package/lib/dev.mjs +4 -2
  43. package/lib/doctor.mjs +1 -1
  44. package/lib/handoff.mjs +13 -8
  45. package/lib/mcp-tools.mjs +10 -9
  46. package/lib/parts.mjs +6 -6
  47. package/lib/scaffold.mjs +35 -9
  48. package/lib/shop-links.mjs +2 -2
  49. package/lib/shop.mjs +5 -3
  50. package/lib/studio.mjs +5 -4
  51. package/lib/template-history.json +42 -0
  52. package/package.json +4 -4
  53. package/worker/app-format.mjs +1 -1
  54. package/worker/build-id.mjs +1 -1
  55. package/worker/index.mjs +2 -1
  56. package/worker/parts.mjs +4 -4
  57. package/worker/shop-page.mjs +21 -15
  58. package/worker/shop-rules.mjs +4 -3
  59. package/worker/site.mjs +4 -2
  60. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,31 @@ 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.49.0] - 2026-10-13
21
+
22
+ **Plugin 0.49.0** · Apps from the first request.
23
+
24
+ Businesses and causes can start, check and preview their first live app without hand fixes.
25
+
26
+ ### Added
27
+
28
+ - Business and cause scaffolds, gathering, quiz, service-request and giving app starters; custom pages and Shop in the header.
29
+ - Giving preset with the organisation’s currency, suggested one-time and monthly Stripe Payment Links, and organisation-supplied receipt wording.
30
+ - Local shop preview before Stripe is connected, and a visible fallback when WebGL is unavailable.
31
+
32
+ ### Fixed
33
+
34
+ - Fresh app installs include Three.js types; strict builds need no hand-installed dependencies.
35
+ - App text checks wait for visual readiness, prove wall/two-phone actions and reconnect, and leave receipts visible to deployment hooks.
36
+ - Deployment command parsing excludes quoted documentation and heredocs; Codex exec user events retain task authorization.
37
+ - Starter words and blurb reach the wall. Busy local dev ports select and print a free port.
38
+ - App setup, paths, guides and parts messages use app wording. Grok setup explains trust through the AI.
39
+ - Updated Agents/MCP dependencies and Wrangler to resolve the reported audit advisories.
40
+
41
+ ### Upgrade notes
42
+
43
+ Existing studios and games keep their authored files and policies. New app starters are opt-in. Calendar capacity and booking availability are planned next; service starters collect requests, not confirmed reservations.
44
+
20
45
  ## [0.48.4] - 2026-10-13
21
46
 
22
47
  **Plugin 0.48.4** · Deploys go right.
@@ -0,0 +1,4 @@
1
+ # Gather together
2
+
3
+ Edit app.json words and starter settings. Phone: /<id>/open; wall: /<id>/tv. A site/pages/<id>/index.html replaces only the landing. Records are public; use references, never private contact details. Staff updates require an account grant.
4
+
@@ -0,0 +1,162 @@
1
+ {
2
+ "id": "gathering",
3
+ "name": "Gather together",
4
+ "blurb": "Check in, choose a task, and let the team know you are here.",
5
+ "entry": "src/view.ts",
6
+ "roles": {
7
+ "customer": {
8
+ "signIn": false,
9
+ "can": [
10
+ "read:entries",
11
+ "create:entries"
12
+ ]
13
+ },
14
+ "wall": {
15
+ "signIn": false,
16
+ "can": [
17
+ "read:entries"
18
+ ]
19
+ },
20
+ "staff": {
21
+ "signIn": true,
22
+ "can": [
23
+ "read:entries",
24
+ "update:entries",
25
+ "delete:entries"
26
+ ]
27
+ }
28
+ },
29
+ "surfaces": {
30
+ "phone": "customer",
31
+ "wall": "wall",
32
+ "kiosk": "customer",
33
+ "tablet": "staff"
34
+ },
35
+ "words": {
36
+ "open": "Open",
37
+ "join": "Scan to join",
38
+ "one": "person",
39
+ "many": "people",
40
+ "action": "Check in",
41
+ "total": "Check-ins",
42
+ "choice": "Choose an option",
43
+ "monthly": "Gift frequency",
44
+ "once": "One time",
45
+ "month": "Monthly",
46
+ "give": "Give securely on Stripe",
47
+ "goal": "Intention goal \u00b7 not payments received",
48
+ "prompt": "Choose what works for you.",
49
+ "finished": "All three answers are in. Thank you!",
50
+ "complete": "Mark complete",
51
+ "done": "Complete",
52
+ "pending": "Checked in",
53
+ "thanks": "Thank you \u2014 the wall is up to date.",
54
+ "live": "Together \u00b7 live",
55
+ "reconnecting": "Reconnecting\u2026"
56
+ },
57
+ "records": {
58
+ "persist": true,
59
+ "collections": {
60
+ "entries": {
61
+ "fields": {
62
+ "choice": {
63
+ "type": "string",
64
+ "required": true,
65
+ "maxLength": 100
66
+ },
67
+ "status": {
68
+ "type": "string",
69
+ "required": true,
70
+ "maxLength": 60
71
+ },
72
+ "amount": {
73
+ "type": "number",
74
+ "required": true
75
+ },
76
+ "monthly": {
77
+ "type": "boolean",
78
+ "required": true
79
+ }
80
+ }
81
+ }
82
+ }
83
+ },
84
+ "parts": [],
85
+ "netplay": {
86
+ "v": 1,
87
+ "public": true,
88
+ "version": "1",
89
+ "params": [
90
+ "role",
91
+ "surface",
92
+ "ticket"
93
+ ]
94
+ },
95
+ "playerCard": false,
96
+ "chat": false,
97
+ "agents": {
98
+ "vote": false
99
+ },
100
+ "watch": "overview",
101
+ "screen": {
102
+ "join": "bottom-right",
103
+ "share": "top-right"
104
+ },
105
+ "needs": {
106
+ "three": "0.185.1",
107
+ "@homie-rocks/camera": "0.2.1",
108
+ "@homie-rocks/geom": "0.1.1",
109
+ "@types/three": "0.185.1"
110
+ },
111
+ "check": {
112
+ "action": "[data-action]",
113
+ "observe": "[data-count]"
114
+ },
115
+ "landing": {
116
+ "pitch": "Check in, choose a task, and let the team know you are here."
117
+ },
118
+ "room": {
119
+ "host": "server"
120
+ },
121
+ "players": {
122
+ "min": 1,
123
+ "max": 32
124
+ },
125
+ "starter": {
126
+ "kind": "gathering",
127
+ "choices": [
128
+ "Welcome desk",
129
+ "Packing",
130
+ "Deliveries"
131
+ ],
132
+ "color": "#184b43",
133
+ "currency": "CAD",
134
+ "goal": 2500,
135
+ "questions": [
136
+ {
137
+ "text": "Which planet is called the red planet?",
138
+ "choices": [
139
+ "Venus",
140
+ "Mars",
141
+ "Jupiter"
142
+ ]
143
+ },
144
+ {
145
+ "text": "How many sides does a hexagon have?",
146
+ "choices": [
147
+ "5",
148
+ "6",
149
+ "8"
150
+ ]
151
+ },
152
+ {
153
+ "text": "Which is a mammal?",
154
+ "choices": [
155
+ "Dolphin",
156
+ "Trout",
157
+ "Octopus"
158
+ ]
159
+ }
160
+ ]
161
+ }
162
+ }
@@ -0,0 +1,3 @@
1
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
2
+ *{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
3
+ </style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
@@ -0,0 +1 @@
1
+ {"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
@@ -0,0 +1,3 @@
1
+ // The current rules contract requires a move for a participant, even without an avatar.
2
+ import { defineMove } from '@homie-rocks/studio/rules';
3
+ export const move = defineMove({ participant() {} });
@@ -0,0 +1,21 @@
1
+ // Transient scene truth lives in the room; authorized queue records live in AppRecords.
2
+ // A phone has a logical stationary seat anchor. The wall watches without a seat.
3
+ import { defineRules, f } from '@homie-rocks/studio/rules';
4
+ import { move } from './move';
5
+ export default defineRules({
6
+ contract: 2, space: { dims: 2 }, move,
7
+ shared: { revision: f.u32() },
8
+ shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
9
+ entities: {
10
+ participant: {
11
+ player: true,
12
+ body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
13
+ commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
14
+ },
15
+ },
16
+ room: {
17
+ rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
18
+ join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
19
+ on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
20
+ },
21
+ });
@@ -0,0 +1,74 @@
1
+ import app from '../app.json';
2
+ import { openRoom } from '@homie-rocks/studio/rules/view';
3
+ import type rules from './rules';
4
+ import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
5
+ import { randomId } from '@homie-rocks/studio/links';
6
+ type Entry = { choice: string; status: string; amount: number; monthly: boolean };
7
+ const room = openRoom<typeof rules>();
8
+ const net = room.net;
9
+ const role = net.params.role ?? 'customer';
10
+ const words: Record<string, string> = app.words;
11
+ const config = app.starter;
12
+ const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
13
+ const records = createAppRecords<Entry>({ collection: 'entries' });
14
+ let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
15
+ const action = $<HTMLButtonElement>('[data-action]');
16
+ document.body.className = role;
17
+ document.body.style.setProperty('--tone', config.color);
18
+ $('h1').textContent = app.name; $('#intro').textContent = app.blurb;
19
+ $('#label').textContent = words.total; action.textContent = words.action;
20
+ $('#choice-label').textContent = words.choice;
21
+ $('#frequency-label').textContent = words.monthly;
22
+ $('#once').textContent = words.once; $('#month').textContent = words.month;
23
+ $('#giving-link').textContent = words.give;
24
+ $('#giving-link').hidden = config.kind !== 'giving';
25
+ $('#frequency').hidden = config.kind !== 'giving';
26
+ $('#goal').hidden = config.kind !== 'giving';
27
+ $('#controls').hidden = role === 'wall';
28
+ const select = $<HTMLSelectElement>('#choice');
29
+ function choices() {
30
+ select.replaceChildren();
31
+ const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
32
+ for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
33
+ $('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
34
+ action.hidden = config.kind === 'quiz' && question >= config.questions.length;
35
+ }
36
+ choices();
37
+ function paint() {
38
+ $('[data-count]').textContent = String(rows.length);
39
+ const amount = rows.reduce((n, r) => n + r.data.amount, 0);
40
+ $('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
41
+ $('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
42
+ $<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
43
+ $('#goal-label').textContent = words.goal;
44
+ action.disabled = busy || !ready;
45
+ $('#entries').replaceChildren();
46
+ for (const row of rows.slice(-6).reverse()) {
47
+ const li = document.createElement('li');
48
+ li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
49
+ if (role === 'staff' && row.data.status !== words.done) {
50
+ const button = document.createElement('button'); button.textContent = words.complete;
51
+ button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
52
+ }
53
+ $('#entries').append(li);
54
+ }
55
+ }
56
+ async function refresh() {
57
+ try { rows = await records.list(); ready = true; paint(); net.playable(); }
58
+ catch (error) { $('#status').textContent = (error as Error).message; }
59
+ }
60
+ action.onclick = async () => {
61
+ busy = true; paint();
62
+ try {
63
+ const choice = select.value;
64
+ await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
65
+ $('#status').textContent = words.thanks;
66
+ if (config.kind === 'quiz') { question++; choices(); }
67
+ room.command('refresh', {}); await refresh();
68
+ } catch (error) { $('#status').textContent = (error as Error).message; }
69
+ finally { busy = false; paint(); }
70
+ };
71
+ room.on('refresh', () => { void refresh(); });
72
+ net.on('link', () => { void refresh(); });
73
+ setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
74
+ void refresh();
@@ -0,0 +1,5 @@
1
+ # Together we can
2
+
3
+ Edit app.json words and starter settings. Phone: /<id>/open; wall: /<id>/tv. A site/pages/<id>/index.html replaces only the landing. Records are public; use references, never private contact details. Staff updates require an account grant.
4
+
5
+ Giving totals are public intentions, not received money. The gift link opens the studio shop. Use `shop init --giving --currency CAD` and connect the organisation’s Stripe account. Monthly renewals are managed in Stripe.
@@ -0,0 +1,162 @@
1
+ {
2
+ "id": "giving",
3
+ "name": "Together we can",
4
+ "blurb": "Support our goal with a one-time or monthly gift. Intentions below are not payments.",
5
+ "entry": "src/view.ts",
6
+ "roles": {
7
+ "customer": {
8
+ "signIn": false,
9
+ "can": [
10
+ "read:entries",
11
+ "create:entries"
12
+ ]
13
+ },
14
+ "wall": {
15
+ "signIn": false,
16
+ "can": [
17
+ "read:entries"
18
+ ]
19
+ },
20
+ "staff": {
21
+ "signIn": true,
22
+ "can": [
23
+ "read:entries",
24
+ "update:entries",
25
+ "delete:entries"
26
+ ]
27
+ }
28
+ },
29
+ "surfaces": {
30
+ "phone": "customer",
31
+ "wall": "wall",
32
+ "kiosk": "customer",
33
+ "tablet": "staff"
34
+ },
35
+ "words": {
36
+ "open": "Open",
37
+ "join": "Scan to join",
38
+ "one": "person",
39
+ "many": "people",
40
+ "action": "Record my intention",
41
+ "total": "Giving intentions",
42
+ "choice": "Choose an option",
43
+ "monthly": "Gift frequency",
44
+ "once": "One time",
45
+ "month": "Monthly",
46
+ "give": "Give securely on Stripe",
47
+ "goal": "Intention goal \u00b7 not payments received",
48
+ "prompt": "Choose what works for you.",
49
+ "finished": "All three answers are in. Thank you!",
50
+ "complete": "Mark complete",
51
+ "done": "Complete",
52
+ "pending": "Recorded",
53
+ "thanks": "Thank you \u2014 the wall is up to date.",
54
+ "live": "Together \u00b7 live",
55
+ "reconnecting": "Reconnecting\u2026"
56
+ },
57
+ "records": {
58
+ "persist": true,
59
+ "collections": {
60
+ "entries": {
61
+ "fields": {
62
+ "choice": {
63
+ "type": "string",
64
+ "required": true,
65
+ "maxLength": 100
66
+ },
67
+ "status": {
68
+ "type": "string",
69
+ "required": true,
70
+ "maxLength": 60
71
+ },
72
+ "amount": {
73
+ "type": "number",
74
+ "required": true
75
+ },
76
+ "monthly": {
77
+ "type": "boolean",
78
+ "required": true
79
+ }
80
+ }
81
+ }
82
+ }
83
+ },
84
+ "parts": [],
85
+ "netplay": {
86
+ "v": 1,
87
+ "public": true,
88
+ "version": "1",
89
+ "params": [
90
+ "role",
91
+ "surface",
92
+ "ticket"
93
+ ]
94
+ },
95
+ "playerCard": false,
96
+ "chat": false,
97
+ "agents": {
98
+ "vote": false
99
+ },
100
+ "watch": "overview",
101
+ "screen": {
102
+ "join": "bottom-right",
103
+ "share": "top-right"
104
+ },
105
+ "needs": {
106
+ "three": "0.185.1",
107
+ "@homie-rocks/camera": "0.2.1",
108
+ "@homie-rocks/geom": "0.1.1",
109
+ "@types/three": "0.185.1"
110
+ },
111
+ "check": {
112
+ "action": "[data-action]",
113
+ "observe": "[data-count]"
114
+ },
115
+ "landing": {
116
+ "pitch": "Support our goal with a one-time or monthly gift. Intentions below are not payments."
117
+ },
118
+ "room": {
119
+ "host": "server"
120
+ },
121
+ "players": {
122
+ "min": 1,
123
+ "max": 32
124
+ },
125
+ "starter": {
126
+ "kind": "giving",
127
+ "choices": [
128
+ "10",
129
+ "25",
130
+ "50"
131
+ ],
132
+ "color": "#354d2e",
133
+ "currency": "CAD",
134
+ "goal": 2500,
135
+ "questions": [
136
+ {
137
+ "text": "Which planet is called the red planet?",
138
+ "choices": [
139
+ "Venus",
140
+ "Mars",
141
+ "Jupiter"
142
+ ]
143
+ },
144
+ {
145
+ "text": "How many sides does a hexagon have?",
146
+ "choices": [
147
+ "5",
148
+ "6",
149
+ "8"
150
+ ]
151
+ },
152
+ {
153
+ "text": "Which is a mammal?",
154
+ "choices": [
155
+ "Dolphin",
156
+ "Trout",
157
+ "Octopus"
158
+ ]
159
+ }
160
+ ]
161
+ }
162
+ }
@@ -0,0 +1,3 @@
1
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
2
+ *{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
3
+ </style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
@@ -0,0 +1 @@
1
+ {"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
@@ -0,0 +1,3 @@
1
+ // The current rules contract requires a move for a participant, even without an avatar.
2
+ import { defineMove } from '@homie-rocks/studio/rules';
3
+ export const move = defineMove({ participant() {} });
@@ -0,0 +1,21 @@
1
+ // Transient scene truth lives in the room; authorized queue records live in AppRecords.
2
+ // A phone has a logical stationary seat anchor. The wall watches without a seat.
3
+ import { defineRules, f } from '@homie-rocks/studio/rules';
4
+ import { move } from './move';
5
+ export default defineRules({
6
+ contract: 2, space: { dims: 2 }, move,
7
+ shared: { revision: f.u32() },
8
+ shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
9
+ entities: {
10
+ participant: {
11
+ player: true,
12
+ body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
13
+ commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
14
+ },
15
+ },
16
+ room: {
17
+ rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
18
+ join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
19
+ on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
20
+ },
21
+ });
@@ -0,0 +1,74 @@
1
+ import app from '../app.json';
2
+ import { openRoom } from '@homie-rocks/studio/rules/view';
3
+ import type rules from './rules';
4
+ import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
5
+ import { randomId } from '@homie-rocks/studio/links';
6
+ type Entry = { choice: string; status: string; amount: number; monthly: boolean };
7
+ const room = openRoom<typeof rules>();
8
+ const net = room.net;
9
+ const role = net.params.role ?? 'customer';
10
+ const words: Record<string, string> = app.words;
11
+ const config = app.starter;
12
+ const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
13
+ const records = createAppRecords<Entry>({ collection: 'entries' });
14
+ let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
15
+ const action = $<HTMLButtonElement>('[data-action]');
16
+ document.body.className = role;
17
+ document.body.style.setProperty('--tone', config.color);
18
+ $('h1').textContent = app.name; $('#intro').textContent = app.blurb;
19
+ $('#label').textContent = words.total; action.textContent = words.action;
20
+ $('#choice-label').textContent = words.choice;
21
+ $('#frequency-label').textContent = words.monthly;
22
+ $('#once').textContent = words.once; $('#month').textContent = words.month;
23
+ $('#giving-link').textContent = words.give;
24
+ $('#giving-link').hidden = config.kind !== 'giving';
25
+ $('#frequency').hidden = config.kind !== 'giving';
26
+ $('#goal').hidden = config.kind !== 'giving';
27
+ $('#controls').hidden = role === 'wall';
28
+ const select = $<HTMLSelectElement>('#choice');
29
+ function choices() {
30
+ select.replaceChildren();
31
+ const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
32
+ for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
33
+ $('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
34
+ action.hidden = config.kind === 'quiz' && question >= config.questions.length;
35
+ }
36
+ choices();
37
+ function paint() {
38
+ $('[data-count]').textContent = String(rows.length);
39
+ const amount = rows.reduce((n, r) => n + r.data.amount, 0);
40
+ $('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
41
+ $('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
42
+ $<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
43
+ $('#goal-label').textContent = words.goal;
44
+ action.disabled = busy || !ready;
45
+ $('#entries').replaceChildren();
46
+ for (const row of rows.slice(-6).reverse()) {
47
+ const li = document.createElement('li');
48
+ li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
49
+ if (role === 'staff' && row.data.status !== words.done) {
50
+ const button = document.createElement('button'); button.textContent = words.complete;
51
+ button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
52
+ }
53
+ $('#entries').append(li);
54
+ }
55
+ }
56
+ async function refresh() {
57
+ try { rows = await records.list(); ready = true; paint(); net.playable(); }
58
+ catch (error) { $('#status').textContent = (error as Error).message; }
59
+ }
60
+ action.onclick = async () => {
61
+ busy = true; paint();
62
+ try {
63
+ const choice = select.value;
64
+ await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
65
+ $('#status').textContent = words.thanks;
66
+ if (config.kind === 'quiz') { question++; choices(); }
67
+ room.command('refresh', {}); await refresh();
68
+ } catch (error) { $('#status').textContent = (error as Error).message; }
69
+ finally { busy = false; paint(); }
70
+ };
71
+ room.on('refresh', () => { void refresh(); });
72
+ net.on('link', () => { void refresh(); });
73
+ setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
74
+ void refresh();
@@ -0,0 +1,4 @@
1
+ # At your service
2
+
3
+ Edit app.json words and starter settings. Phone: /<id>/open; wall: /<id>/tv. A site/pages/<id>/index.html replaces only the landing. Records are public; use references, never private contact details. Staff updates require an account grant.
4
+