@vibes.diy/prompts 14.3.58 → 14.3.64

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 (65) hide show
  1. package/llms/access.md +4 -0
  2. package/llms/d3.js +0 -1
  3. package/llms/d3.js.map +1 -1
  4. package/llms/fireproof.js +0 -1
  5. package/llms/fireproof.js.map +1 -1
  6. package/llms/p5.js +0 -1
  7. package/llms/p5.js.map +1 -1
  8. package/llms/spotify.js +0 -1
  9. package/llms/spotify.js.map +1 -1
  10. package/llms/three-js.js +0 -1
  11. package/llms/three-js.js.map +1 -1
  12. package/llms/types.d.ts +0 -1
  13. package/llms/use-viewer.md +3 -0
  14. package/llms/voxel.js +0 -1
  15. package/llms/voxel.js.map +1 -1
  16. package/llms/web-audio.js +0 -1
  17. package/llms/web-audio.js.map +1 -1
  18. package/llms/webxr.js +0 -1
  19. package/llms/webxr.js.map +1 -1
  20. package/llms/youtube.js +0 -1
  21. package/llms/youtube.js.map +1 -1
  22. package/package.json +5 -4
  23. package/prompts.d.ts +1 -1
  24. package/prompts.js +11 -54
  25. package/prompts.js.map +1 -1
  26. package/style-prompts.d.ts +1 -1
  27. package/style-prompts.js +2 -0
  28. package/style-prompts.js.map +1 -1
  29. package/themes/colors/aqua.yaml +79 -0
  30. package/themes/colors/brutalist.yaml +4 -4
  31. package/themes/colors/celery.yaml +79 -0
  32. package/themes/colors/home.yaml +79 -0
  33. package/themes/colors/house.yaml +2 -2
  34. package/themes/colors/lilac.yaml +79 -0
  35. package/themes/colors/mint.yaml +79 -0
  36. package/themes/colors/peach.yaml +79 -0
  37. package/themes/colors/periwinkle.yaml +79 -0
  38. package/themes/colors/rose.yaml +79 -0
  39. package/themes/colorsets-bundle.js +654 -6
  40. package/themes/colorsets-bundle.js.map +1 -1
  41. package/themes/colorsets.d.ts +2 -1
  42. package/themes/colorsets.js +21 -1
  43. package/themes/colorsets.js.map +1 -1
  44. package/themes/looks/index.d.ts +3 -1
  45. package/themes/looks/index.js +38 -5
  46. package/themes/looks/index.js.map +1 -1
  47. package/themes/palette-gen.d.ts +5 -0
  48. package/themes/palette-gen.js +55 -1
  49. package/themes/palette-gen.js.map +1 -1
  50. package/themes/pouf-rooms.d.ts +47 -0
  51. package/themes/pouf-rooms.js +274 -0
  52. package/themes/pouf-rooms.js.map +1 -0
  53. package/themes/style-presets.d.ts +2 -0
  54. package/themes/style-presets.js +29 -1
  55. package/themes/style-presets.js.map +1 -1
  56. package/llms/d3.draft.md +0 -154
  57. package/llms/fireproof.draft.md +0 -47
  58. package/llms/p5.draft.md +0 -179
  59. package/llms/spotify.draft.md +0 -179
  60. package/llms/three-js.draft.md +0 -161
  61. package/llms/voxel.draft.md +0 -196
  62. package/llms/web-audio.draft.md +0 -180
  63. package/llms/webxr.draft.md +0 -159
  64. package/llms/youtube.draft.md +0 -183
  65. package/system-prompt-initial-draft.md +0 -121
@@ -1,161 +0,0 @@
1
- # Three.js — the first draft: one lit object turning in a viewport
2
-
3
- A Three.js app's first draft is the whole page around the scene (title, copy,
4
- a card holding the viewport, a control row) with one real, lit object turning
5
- inside it. The seeded document names the scene and holds its color, and the
6
- control row repaints the object live. Loading models, physics, particles,
7
- camera controls and textures are the next pass. What the draft owes that pass
8
- is the platform's own engine skeleton: the renderer, scene, camera, lights and
9
- loop are built once inside one effect, sized to their host, and fully disposed
10
- on unmount, and React talks to the running scene through a ref, so the next
11
- pass adds meshes and systems to this file rather than rewriting it.
12
-
13
- ### `App.jsx`
14
- ```jsx
15
- import React, { useEffect, useRef } from "react";
16
- import { useFireproof } from "use-fireproof";
17
- import * as THREE from "three";
18
- import { Card, CardHeader, CardTitle, CardContent, Button, Badge } from "@vibes.diy/look";
19
-
20
- // The finishes the control row offers, named once for the buttons and the material.
21
- const FINISHES = [
22
- { label: "Lagoon", color: "#3fa7d6" },
23
- { label: "Marigold", color: "#f4b942" },
24
- { label: "Orchid", color: "#c86bd8" },
25
- { label: "Moss", color: "#6fae5b" },
26
- ];
27
-
28
- export default function App() {
29
- const { database, useLiveQuery } = useFireproof("spinStudio");
30
- const { docs: scenes } = useLiveQuery("type", { key: "scene" });
31
- const scene = scenes[0];
32
- const hostRef = useRef(null);
33
- // The running engine reads the color from here each frame, so a change never rebuilds it.
34
- const colorRef = useRef(scene?.color ?? FINISHES[0].color);
35
-
36
- useEffect(() => {
37
- if (scene?.color) colorRef.current = scene.color;
38
- }, [scene?.color]);
39
-
40
- // The engine, built once: renderer, scene, camera, lights, one mesh, loop, resize, cleanup.
41
- useEffect(() => {
42
- const host = hostRef.current;
43
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
44
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
45
- renderer.setSize(host.clientWidth, host.clientHeight);
46
- host.appendChild(renderer.domElement);
47
-
48
- const world = new THREE.Scene();
49
- const camera = new THREE.PerspectiveCamera(50, host.clientWidth / host.clientHeight, 0.1, 100);
50
- camera.position.set(0, 1.2, 4.2);
51
- camera.lookAt(0, 0, 0);
52
-
53
- world.add(new THREE.HemisphereLight(0xffffff, 0x445066, 0.8));
54
- const sun = new THREE.DirectionalLight(0xffffff, 1.4);
55
- sun.position.set(3, 5, 2);
56
- world.add(sun);
57
-
58
- const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4);
59
- const material = new THREE.MeshStandardMaterial({ color: colorRef.current, roughness: 0.35, metalness: 0.15 });
60
- const mesh = new THREE.Mesh(geometry, material);
61
- world.add(mesh);
62
-
63
- const onResize = () => {
64
- const w = host.clientWidth;
65
- const h = host.clientHeight;
66
- if (w === 0 || h === 0) return;
67
- renderer.setSize(w, h);
68
- camera.aspect = w / h;
69
- camera.updateProjectionMatrix();
70
- };
71
- const observer = new window.ResizeObserver(onResize); // follows the host, not just the window
72
- observer.observe(host);
73
-
74
- const clock = new THREE.Clock();
75
- let raf = 0;
76
- const animate = () => {
77
- const dt = Math.min(clock.getDelta(), 0.05);
78
- mesh.rotation.x += dt * 0.5;
79
- mesh.rotation.y += dt * 0.8;
80
- material.color.set(colorRef.current);
81
- renderer.render(world, camera);
82
- raf = requestAnimationFrame(animate);
83
- };
84
- animate();
85
-
86
- return () => {
87
- cancelAnimationFrame(raf);
88
- observer.disconnect();
89
- geometry.dispose();
90
- material.dispose();
91
- renderer.dispose();
92
- if (renderer.domElement.parentNode === host) host.removeChild(renderer.domElement);
93
- };
94
- }, []);
95
-
96
- const paint = (color) => {
97
- colorRef.current = color;
98
- if (scene) database.put({ ...scene, color });
99
- };
100
-
101
- return (
102
- <main className="min-h-screen bg-background text-foreground font-sans p-6">
103
- <h1 className="text-3xl font-bold">{scene?.name ?? "Spin Studio"}</h1>
104
- <p className="mt-1 text-muted-foreground">A little object on a turntable. Pick a finish and watch it catch the light.</p>
105
-
106
- <Card className="mt-6">
107
- <CardHeader>
108
- <CardTitle>Turntable</CardTitle>
109
- </CardHeader>
110
- <CardContent>
111
- <div ref={hostRef} className="aspect-video w-full rounded-lg overflow-hidden bg-muted" />
112
- {/* TODO(next pass): load a GLTF model, orbit controls, textures, particles and physics into this same scene */}
113
- <div className="mt-4 flex flex-wrap items-center gap-2">
114
- {FINISHES.map((f) => (
115
- <Button key={f.label} size="sm" variant={f.color === scene?.color ? "default" : "outline"} onClick={() => paint(f.color)}>
116
- {f.label}
117
- </Button>
118
- ))}
119
- <Badge variant="secondary" className="ml-auto">{FINISHES.find((f) => f.color === scene?.color)?.label ?? "Lagoon"}</Badge>
120
- </div>
121
- </CardContent>
122
- </Card>
123
- </main>
124
- );
125
- }
126
- ```
127
-
128
- ### `seed.json`
129
- ```json
130
- {
131
- "spinStudio": [
132
- { "key": "turntable", "type": "scene", "name": "Spin Studio", "color": "#3fa7d6" }
133
- ]
134
- }
135
- ```
136
-
137
- Draft note: the next pass adds loaded models, orbit controls, textures, particles and physics to the same scene.
138
-
139
- ## What stays the same in a Three.js draft
140
-
141
- - **The engine is built once** in `useEffect(() => {...}, [])`: renderer,
142
- scene, camera, lights, mesh and `requestAnimationFrame` loop all inside it.
143
- React never rebuilds it; values React changes reach the loop through a ref
144
- (`colorRef`), never through the effect's dependency list.
145
- - **The cleanup is complete**: it cancels the frame, disconnects the resize
146
- observer, disposes every geometry and material it made, disposes the
147
- renderer, and removes the canvas from its host.
148
- - **The viewport sizes to its host**, not the window: a `div` with a fixed
149
- aspect (`aspect-video w-full rounded-lg overflow-hidden`) inside a Card, a
150
- resize handler that updates the renderer size and the camera aspect, and
151
- `setPixelRatio(Math.min(window.devicePixelRatio, 2))`.
152
- - **The hello is one lit object**: a `BoxGeometry` with a
153
- `MeshStandardMaterial` under a hemisphere light and a directional light,
154
- turning on a clock delta. Standard materials need light; the draft always
155
- ships both.
156
- - **The page is complete around it**: title, a sentence of friendly copy, the
157
- Card, and a control row of Buttons whose choice saves to the seeded
158
- Fireproof document and shows up in the scene on the next frame.
159
- - **What waits for the next pass** is named in the `TODO(next pass)` comment
160
- and the draft note: models, controls, textures, particles, physics. The
161
- draft draws none of it and imports nothing beyond `three` itself.
@@ -1,196 +0,0 @@
1
- # Voxel worlds — the first draft: one grass block, turning in the sky
2
-
3
- A voxel app's first draft is a full-screen 3D viewport with the HUD floating
4
- over it, and in that viewport ONE block: a pixel-art grass block, green on top
5
- and dirt on the sides, turning slowly under a blue sky. The name card, the
6
- hotbar, the palette, the seeded world document and the look are all finished;
7
- the block is real, lit and drawn through the same instanced path the whole
8
- world will use.
9
- Terrain, placing and breaking, walking, saving edits and sharing the world are
10
- the next pass. What the draft owes that pass is the right skeleton: the engine
11
- is built once inside an effect, the block is ONE `InstancedMesh` with a
12
- count of 1, the viewport is `fixed inset-0` with the controls on top of it,
13
- and the palette is named once for the HUD and the blocks, so the next pass
14
- raises the count and adds to this file rather than rewriting it.
15
-
16
- Unlike the page-shaped draft above, a world does not flow down the page: the
17
- viewport is `fixed inset-0` and the controls sit on top of it.
18
-
19
- ### `App.jsx`
20
- ```jsx
21
- import React, { useEffect, useRef, useState } from "react";
22
- import { useFireproof } from "use-fireproof";
23
- import * as THREE from "three";
24
- import { Button, Badge } from "@vibes.diy/look";
25
-
26
- // The block-in-hand palette (shared by block colors and HUD swatches).
27
- const VOXEL_PALETTE = {
28
- grass: "#6fcf5b", dirt: "#8a5a3b", stone: "#9b9b9b", wood: "#b5793a", glass: "#9fe3ff", candy: "#ff6fb1",
29
- neon: "#39ff9e", water: "#2f8fe0", lava: "#ff5a2f", erase: "#ff4d6d", ink: "#1a1325", light: "#ffffff",
30
- };
31
- const HOTBAR = ["grass", "dirt", "stone", "wood", "glass", "candy"];
32
- const SKY = 0x8ecbff;
33
-
34
- // A 16×16 pixel-art face, painted once. `fringe` rows of green sit on top of dirt for the sides.
35
- function blockFace(kind) {
36
- const canvas = document.createElement("canvas");
37
- canvas.width = 16;
38
- canvas.height = 16;
39
- const ctx = canvas.getContext("2d");
40
- const px = (x, y, color) => { ctx.fillStyle = color; ctx.fillRect(x, y, 1, 1); };
41
- let n = kind.length * 7;
42
- const rand = () => { n = (n * 9301 + 49297) % 233280; return n / 233280; };
43
- for (let y = 0; y < 16; y++)
44
- for (let x = 0; x < 16; x++) {
45
- const fringe = kind === "side" && y < 3 + (x % 3 === 0 ? 1 : 0);
46
- if (kind === "top" || fringe) px(x, y, rand() < 0.18 ? "#4fa83f" : VOXEL_PALETTE.grass);
47
- else px(x, y, rand() < 0.2 ? "#6e4630" : VOXEL_PALETTE.dirt);
48
- }
49
- const texture = new THREE.CanvasTexture(canvas);
50
- texture.magFilter = THREE.NearestFilter;
51
- texture.minFilter = THREE.NearestFilter;
52
- texture.colorSpace = THREE.SRGBColorSpace;
53
- return texture;
54
- }
55
-
56
- export default function App() {
57
- const { useLiveQuery } = useFireproof("skyIsland");
58
- const { docs: worlds } = useLiveQuery("type", { key: "world" });
59
- const world = worlds[0];
60
- const mountRef = useRef(null);
61
- const [held, setHeld] = useState(HOTBAR[0]);
62
-
63
- // The engine, built once: renderer, scene, lights, the instanced block, the loop. React only draws the HUD over it.
64
- useEffect(() => {
65
- const mount = mountRef.current;
66
- const renderer = new THREE.WebGLRenderer({ antialias: true });
67
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
68
- renderer.setSize(mount.clientWidth, mount.clientHeight);
69
- mount.appendChild(renderer.domElement);
70
-
71
- const scene = new THREE.Scene();
72
- scene.background = new THREE.Color(SKY);
73
- scene.fog = new THREE.Fog(SKY, 8, 30);
74
- const camera = new THREE.PerspectiveCamera(60, mount.clientWidth / mount.clientHeight, 0.1, 100);
75
- camera.position.set(0, 1.4, 3.2);
76
- camera.lookAt(0, 0, 0);
77
- scene.add(new THREE.HemisphereLight(0xffffff, 0x8a5a3b, 0.9));
78
- const sun = new THREE.DirectionalLight(0xffffff, 1.2);
79
- sun.position.set(4, 8, 5);
80
- scene.add(sun);
81
-
82
- // The hello: one grass block, drawn the way the whole world will be — one shared cube, six shared
83
- // face materials (BoxGeometry face order is +x, -x, +y, -y, +z, -z), one InstancedMesh. The next pass
84
- // raises the count and fills more matrices; it never adds a Mesh per block.
85
- const textures = { top: blockFace("top"), side: blockFace("side"), bottom: blockFace("bottom") };
86
- const faces = ["side", "side", "top", "bottom", "side", "side"];
87
- const materials = faces.map((f) => new THREE.MeshLambertMaterial({ map: textures[f] }));
88
- const cube = new THREE.BoxGeometry(1, 1, 1);
89
- const blocks = new THREE.InstancedMesh(cube, materials, 1);
90
- scene.add(blocks);
91
- const matrix = new THREE.Matrix4();
92
- const spin = new THREE.Quaternion();
93
- const up = new THREE.Vector3(0, 1, 0);
94
- const at = new THREE.Vector3();
95
- const one = new THREE.Vector3(1, 1, 1);
96
-
97
- const onResize = () => {
98
- renderer.setSize(mount.clientWidth, mount.clientHeight);
99
- camera.aspect = mount.clientWidth / mount.clientHeight;
100
- camera.updateProjectionMatrix();
101
- };
102
- window.addEventListener("resize", onResize);
103
-
104
- const clock = new THREE.Clock();
105
- let raf;
106
- const loop = () => {
107
- const t = clock.getElapsedTime();
108
- spin.setFromAxisAngle(up, t * 0.5);
109
- at.set(0, Math.sin(t * 1.2) * 0.06, 0);
110
- blocks.setMatrixAt(0, matrix.compose(at, spin, one));
111
- blocks.instanceMatrix.needsUpdate = true;
112
- renderer.render(scene, camera);
113
- raf = requestAnimationFrame(loop);
114
- };
115
- loop();
116
-
117
- return () => {
118
- cancelAnimationFrame(raf);
119
- window.removeEventListener("resize", onResize);
120
- Object.values(textures).forEach((t) => t.dispose());
121
- materials.forEach((m) => m.dispose());
122
- blocks.dispose();
123
- cube.dispose();
124
- renderer.dispose();
125
- mount.removeChild(renderer.domElement);
126
- };
127
- }, []);
128
-
129
- return (
130
- <div className="fixed inset-0 select-none touch-none bg-background text-foreground font-sans">
131
- <div ref={mountRef} className="absolute inset-0" />
132
-
133
- <div className="absolute left-4 top-4 rounded-lg bg-card/90 px-3 py-2 text-card-foreground shadow-md">
134
- <p className="font-bold">{world?.name ?? "Sky Island"}</p>
135
- <p className="text-muted-foreground">Your first block is ready. Pick what goes in your hand.</p>
136
- </div>
137
-
138
- <Badge className="absolute right-4 top-4">Holding {held}</Badge>
139
-
140
- <div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2 rounded-lg bg-card/90 p-2 shadow-md">
141
- {HOTBAR.map((type) => (
142
- <Button key={type} size="sm" variant={type === held ? "default" : "outline"} onClick={() => setHeld(type)}
143
- aria-label={type}>
144
- <span className="inline-block h-4 w-4 rounded-sm" style={{ backgroundColor: VOXEL_PALETTE[type] }} />
145
- {type}
146
- </Button>
147
- ))}
148
- </div>
149
- {/* TODO(next pass): the flat block map and instanced terrain, place and break the held block, walk with pointer lock, save edits as documents, share the world */}
150
- </div>
151
- );
152
- }
153
- ```
154
-
155
- ### `seed.json`
156
- ```json
157
- {
158
- "skyIsland": [
159
- { "key": "island", "type": "world", "name": "Sky Island", "sky": "clear" }
160
- ]
161
- }
162
- ```
163
-
164
- Draft note: the next pass adds the terrain around this block, placing and breaking with the held block, walking the world, saving edits, and sharing the world with friends.
165
-
166
- ## What stays the same in a voxel draft
167
-
168
- - **The viewport fills the screen**: the outer element is `fixed inset-0
169
- select-none touch-none`, the renderer mounts into an `absolute inset-0` div,
170
- and every control is an `absolute` layer over it. Never the page-flow `main`
171
- with padding from the shape above.
172
- - **The engine is built once** in `useEffect(() => {...}, [])` with the
173
- renderer, scene, lights, listeners and loop inside it, and a cleanup that
174
- stops the loop, removes the listener and disposes every texture, material, the
175
- instanced mesh, the geometry and the renderer. Per-frame values never live in React state.
176
- - **`setPixelRatio(Math.min(window.devicePixelRatio, 2))`**, a resize handler
177
- that updates the renderer size and the camera aspect, a hemisphere plus a
178
- directional light, and a `Fog` that matches the sky color.
179
- - **The hello is one grass block, turning, through the fast path**: one shared
180
- `BoxGeometry` and an array of six shared materials, each face a 16×16
181
- `THREE.CanvasTexture` painted in code with `NearestFilter` so the pixels
182
- stay crisp, drawn as `new THREE.InstancedMesh(cube, materials, 1)` and
183
- turned each frame with `setMatrixAt(0, …)` plus
184
- `instanceMatrix.needsUpdate = true`. No texture URLs, no loaders.
185
- - **The palette table** is the Blocktopia voxel palette, in full, named once
186
- for the block colors and the HUD swatches.
187
- - **The next pass keeps this skeleton**: the world becomes a flat `Map` keyed
188
- `"x|y|z"`, drawn through the same ONE `InstancedMesh` of the exposed blocks
189
- (the count raised, a matrix per block) with its shared geometry and
190
- materials. Never a
191
- `THREE.Mesh` per block, and never a geometry or material created inside the
192
- block loop. Pointer lock comes from a click-to-play overlay whose visibility
193
- follows the real lock state, and movement keys are the game's own set.
194
- - **What waits for the next pass** is named in the `TODO(next pass)` comment
195
- and the draft note: terrain, placing and breaking, walking, saving edits,
196
- sharing. The draft draws none of it.
@@ -1,180 +0,0 @@
1
- # Web Audio — the first draft: one tone, played by a tap
2
-
3
- A sound app's first draft is the whole page around the instrument: the name,
4
- a row of named notes, a volume slider, a big Play button, and the last few
5
- notes played, listed live from the database. The capability's hello is real:
6
- every tap plays one short, shaped tone through the speakers, on iOS too. The
7
- sequencer or metronome on the audio clock, the effects chain and recording are
8
- the next pass. What the draft owes that pass is the right skeleton: one
9
- `AudioContext` in a ref, created inside the first gesture and resumed on every
10
- one after it, and a plain `playTone` helper that takes the context and a note,
11
- so the next pass adds a scheduler beside it rather than rewriting it.
12
-
13
- Web Audio is a browser built-in: there is no import, the constructor is
14
- `window.AudioContext` with the `window.webkitAudioContext` fallback.
15
-
16
- ### `App.jsx`
17
- ```jsx
18
- import React, { useEffect, useRef, useState } from "react";
19
- import { useFireproof } from "use-fireproof";
20
- import { Button, Badge, Card, CardHeader, CardTitle, CardContent } from "@vibes.diy/look";
21
-
22
- // Named notes of a C major pentatonic, so any order of taps sounds sweet.
23
- const NOTES = [
24
- { name: "C4", hz: 261.63 },
25
- { name: "D4", hz: 293.66 },
26
- { name: "E4", hz: 329.63 },
27
- { name: "G4", hz: 392.0 },
28
- { name: "A4", hz: 440.0 },
29
- { name: "C5", hz: 523.25 },
30
- ];
31
- const hzOf = (name) => (NOTES.find((n) => n.name === name) ?? NOTES[4]).hz;
32
- const timeOf = (ms) => new Date(ms).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" });
33
-
34
- // The iOS-safe gate: create or resume the context and return it. Call it FIRST,
35
- // synchronously, inside the click handler, with no await before the sound starts.
36
- function unlockAudio(ctxRef) {
37
- ctxRef.current ||= new (window.AudioContext || window.webkitAudioContext)();
38
- const ctx = ctxRef.current;
39
- if (ctx.state !== "running") ctx.resume(); // iOS suspends after lock or backgrounding
40
- return ctx;
41
- }
42
-
43
- // One short tone: oscillator -> gain -> speakers, with an attack and a release, stopped at ~0.4 s.
44
- function playTone(ctx, hz, volume, wave) {
45
- const t = ctx.currentTime;
46
- const peak = Math.max(volume, 0.0001); // exponential ramps never touch zero
47
- const osc = ctx.createOscillator();
48
- const gain = ctx.createGain();
49
- osc.type = wave;
50
- osc.frequency.setValueAtTime(hz, t);
51
- gain.gain.setValueAtTime(0.0001, t);
52
- gain.gain.linearRampToValueAtTime(peak, t + 0.02); // attack
53
- gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.4); // release
54
- osc.connect(gain).connect(ctx.destination);
55
- osc.start(t);
56
- osc.stop(t + 0.42);
57
- osc.onended = () => {
58
- osc.disconnect();
59
- gain.disconnect();
60
- };
61
- }
62
-
63
- export default function App() {
64
- const { database, useLiveQuery } = useFireproof("glassHarp");
65
- const { docs: instruments } = useLiveQuery("type", { key: "instrument" });
66
- // Only play documents carry createdAt; newest first, a handful of them.
67
- const { docs: plays } = useLiveQuery("createdAt", { descending: true, limit: 6 });
68
- const instrument = instruments[0];
69
- const ctxRef = useRef(null);
70
- const [note, setNote] = useState("A4");
71
- const [volume, setVolume] = useState(0.5);
72
-
73
- // The context is created by the first tap and closed when the app goes away.
74
- useEffect(() => {
75
- return () => {
76
- ctxRef.current?.close();
77
- ctxRef.current = null;
78
- };
79
- }, []);
80
-
81
- const play = (name) => {
82
- const ctx = unlockAudio(ctxRef); // same trusted handler, before anything async
83
- playTone(ctx, hzOf(name), volume, instrument?.wave ?? "triangle");
84
- setNote(name);
85
- // The sound is already playing; saving can take its time.
86
- database.put({ type: "play", note: name, createdAt: Date.now() });
87
- if (instrument) database.put({ ...instrument, lastNote: name });
88
- };
89
-
90
- return (
91
- <main className="min-h-screen bg-background text-foreground font-sans p-6">
92
- <h1 className="text-3xl font-bold">{instrument?.name ?? "Glass Harp"}</h1>
93
- <p className="mt-1 text-muted-foreground">Pick a note, press Play, and let it ring.</p>
94
-
95
- <Card className="mt-6">
96
- <CardHeader>
97
- <CardTitle>Play a note</CardTitle>
98
- </CardHeader>
99
- <CardContent>
100
- <div className="grid gap-5">
101
- <div className="flex flex-wrap gap-2">
102
- {NOTES.map((n) => (
103
- <Button key={n.name} variant={n.name === note ? "default" : "outline"} onClick={() => play(n.name)}>
104
- {n.name}
105
- </Button>
106
- ))}
107
- </div>
108
- <label className="grid gap-2">
109
- <span className="text-muted-foreground">Volume {Math.round(volume * 100)}%</span>
110
- <input type="range" min={0} max={1} step={0.05} value={volume} onChange={(e) => setVolume(Number(e.target.value))} className="w-full" />
111
- </label>
112
- <Button size="lg" onClick={() => play(note)}>
113
- Play {note}
114
- </Button>
115
- </div>
116
- </CardContent>
117
- </Card>
118
-
119
- <Card className="mt-6">
120
- <CardHeader>
121
- <CardTitle>Just played</CardTitle>
122
- </CardHeader>
123
- <CardContent>
124
- <div className="flex flex-wrap gap-2">
125
- {plays.map((p) => (
126
- <Badge key={p._id} variant="secondary">
127
- {p.note} · {timeOf(p.createdAt)}
128
- </Badge>
129
- ))}
130
- </div>
131
- <p className="mt-3 text-muted-foreground">Last note: {instrument?.lastNote ?? note}</p>
132
- </CardContent>
133
- </Card>
134
-
135
- {/* TODO(next pass): a step sequencer and metronome scheduled ahead on ctx.currentTime, a delay with effects in its feedback loop, and recording a take */}
136
- </main>
137
- );
138
- }
139
- ```
140
-
141
- ### `seed.json`
142
- ```json
143
- {
144
- "glassHarp": [
145
- { "key": "harp", "type": "instrument", "name": "Glass Harp", "wave": "triangle", "lastNote": "G4" },
146
- { "key": "play-1", "type": "play", "note": "C4", "createdAt": 1758400000000 },
147
- { "key": "play-2", "type": "play", "note": "E4", "createdAt": 1758400001000 },
148
- { "key": "play-3", "type": "play", "note": "G4", "createdAt": 1758400002000 }
149
- ]
150
- }
151
- ```
152
-
153
- Draft note: the next pass adds a step sequencer and metronome on the audio clock, a delay and filter chain, and recording a take to keep.
154
-
155
- ## What stays the same in a Web Audio draft
156
-
157
- - **One `AudioContext`, in a ref, made by a gesture**: `unlockAudio(ctxRef)`
158
- creates it on the first tap, re-checks `ctx.state` and resumes on every tap
159
- after, and the effect's cleanup closes it. Never a context at module scope,
160
- in `useState`, or in a mount effect.
161
- - **The iOS rule holds in every handler**: the unlock and the first
162
- `osc.start()` run synchronously inside the same click, before any `await`,
163
- timer, promise or state-driven effect. Saving to the database comes after
164
- the sound has started.
165
- - **The hello is one shaped tone**: an `OscillatorNode` into a `GainNode`,
166
- `setValueAtTime` to a floor of `0.0001`, `linearRampToValueAtTime` for the
167
- attack, `exponentialRampToValueAtTime` for the release, `osc.stop` at about
168
- 0.4 s, and both nodes disconnected in `onended`. Nothing plays on load.
169
- - **`playTone(ctx, hz, volume, wave)` is a plain function** outside the
170
- component, taking the context as an argument. The next pass's engine and
171
- scheduler sit beside it in the same shape.
172
- - **Controls are ordinary elements**: named notes as `Button`s, volume as a
173
- plain `<input type="range">`, read at the moment of the tap.
174
- - **The data is real**: the seeded instrument document holds the name, the
175
- wave and the last note; each tap writes a `play` document with `createdAt`,
176
- and a `useLiveQuery` list shows the newest few.
177
- - **What waits for the next pass** is named in the `TODO(next pass)` comment
178
- and the draft note: the sequencer or metronome with lookahead scheduling on
179
- `ctx.currentTime`, effects, recording. The draft schedules nothing on the
180
- clock beyond the one tone.