@vibes.diy/prompts 14.3.36 → 14.3.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/llms/d3.draft.md +154 -0
- package/llms/d3.js +1 -0
- package/llms/d3.js.map +1 -1
- package/llms/image-gen.md +1 -0
- package/llms/p5.draft.md +179 -0
- package/llms/p5.js +1 -0
- package/llms/p5.js.map +1 -1
- package/llms/spotify.draft.md +179 -0
- package/llms/spotify.js +1 -0
- package/llms/spotify.js.map +1 -1
- package/llms/three-js.draft.md +161 -0
- package/llms/three-js.js +1 -0
- package/llms/three-js.js.map +1 -1
- package/llms/voxel.draft.md +99 -147
- package/llms/web-audio.draft.md +180 -0
- package/llms/web-audio.js +1 -0
- package/llms/web-audio.js.map +1 -1
- package/llms/webxr.draft.md +159 -0
- package/llms/webxr.js +1 -0
- package/llms/webxr.js.map +1 -1
- package/llms/youtube.draft.md +183 -0
- package/llms/youtube.js +1 -0
- package/llms/youtube.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,180 @@
|
|
|
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.
|
package/llms/web-audio.js
CHANGED
package/llms/web-audio.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-audio.js","sourceRoot":"","sources":["../../jsr/llms/web-audio.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,eAAe;IACtB,WAAW,EACT,2NAA2N;IAC7N,IAAI,EAAE;QACJ,OAAO;QACP,OAAO;QACP,QAAQ;QACR,OAAO;QACP,OAAO;QACP,aAAa;QACb,YAAY;QACZ,WAAW;QACX,cAAc;QACd,MAAM;QACN,QAAQ;QACR,WAAW;KACZ;
|
|
1
|
+
{"version":3,"file":"web-audio.js","sourceRoot":"","sources":["../../jsr/llms/web-audio.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,eAAe;IACtB,WAAW,EACT,2NAA2N;IAC7N,IAAI,EAAE;QACJ,OAAO;QACP,OAAO;QACP,QAAQ;QACR,OAAO;QACP,OAAO;QACP,aAAa;QACb,YAAY;QACZ,WAAW;QACX,cAAc;QACd,MAAM;QACN,QAAQ;QACR,WAAW;KACZ;IAGD,YAAY,EAAE,IAAI;CACnB,CAAC"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Babylon.js WebXR — the first draft: a lit scene with a door into VR
|
|
2
|
+
|
|
3
|
+
A WebXR app's first draft is the page around the scene (title, copy, a card
|
|
4
|
+
holding the canvas) with one real Babylon.js scene running inside it: a lit
|
|
5
|
+
box turning above a ground plane. The VR half is shown honestly: an "Enter
|
|
6
|
+
VR" Button that lights up when the browser reports an immersive headset and
|
|
7
|
+
opens the session through `scene.createDefaultXRExperienceAsync`, and reads
|
|
8
|
+
"VR needs a headset browser" everywhere else. Hit testing, anchors, hand
|
|
9
|
+
tracking, particles and shaders are the next pass. What the draft owes that
|
|
10
|
+
pass is the skeleton: the engine and scene are built once in one effect with a
|
|
11
|
+
full cleanup, the ground mesh the XR floor needs already exists, and the scene
|
|
12
|
+
lives in a ref so the next pass adds features to this file rather than
|
|
13
|
+
rewriting it.
|
|
14
|
+
|
|
15
|
+
### `App.jsx`
|
|
16
|
+
```jsx
|
|
17
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
18
|
+
import { useFireproof } from "use-fireproof";
|
|
19
|
+
import * as BABYLON from "@babylonjs/core";
|
|
20
|
+
import { Card, CardHeader, CardTitle, CardContent, Button, Badge } from "@vibes.diy/look";
|
|
21
|
+
|
|
22
|
+
export default function App() {
|
|
23
|
+
const { useLiveQuery } = useFireproof("floatingRoom");
|
|
24
|
+
const { docs: rooms } = useLiveQuery("type", { key: "room" });
|
|
25
|
+
const room = rooms[0];
|
|
26
|
+
const canvasRef = useRef(null);
|
|
27
|
+
const sceneRef = useRef(null);
|
|
28
|
+
const [vrReady, setVrReady] = useState(false);
|
|
29
|
+
const [inVr, setInVr] = useState(false);
|
|
30
|
+
|
|
31
|
+
// The engine, built once: scene, camera, lights, ground, one box, render loop, resize, cleanup.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const canvas = canvasRef.current;
|
|
34
|
+
const engine = new BABYLON.Engine(canvas, true, { preserveDrawingBuffer: true, stencil: true });
|
|
35
|
+
const scene = new BABYLON.Scene(engine);
|
|
36
|
+
scene.clearColor = new BABYLON.Color4(0.06, 0.08, 0.14, 1);
|
|
37
|
+
|
|
38
|
+
const camera = new BABYLON.FreeCamera("cam", new BABYLON.Vector3(0, 1.7, -5), scene);
|
|
39
|
+
camera.setTarget(new BABYLON.Vector3(0, 1, 0));
|
|
40
|
+
camera.attachControl(canvas, true);
|
|
41
|
+
|
|
42
|
+
const ambient = new BABYLON.HemisphericLight("ambient", new BABYLON.Vector3(0, 1, 0), scene);
|
|
43
|
+
ambient.intensity = 0.5;
|
|
44
|
+
const sun = new BABYLON.DirectionalLight("sun", new BABYLON.Vector3(-1, -2, -1), scene);
|
|
45
|
+
sun.position = new BABYLON.Vector3(5, 10, 5);
|
|
46
|
+
sun.intensity = 0.8;
|
|
47
|
+
|
|
48
|
+
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 12, height: 12 }, scene);
|
|
49
|
+
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
|
|
50
|
+
groundMat.diffuseColor = new BABYLON.Color3(0.22, 0.3, 0.26);
|
|
51
|
+
ground.material = groundMat;
|
|
52
|
+
|
|
53
|
+
const box = BABYLON.MeshBuilder.CreateBox("box", { size: 1 }, scene);
|
|
54
|
+
box.position.y = 1.2;
|
|
55
|
+
const boxMat = new BABYLON.StandardMaterial("boxMat", scene);
|
|
56
|
+
boxMat.diffuseColor = new BABYLON.Color3(0.95, 0.55, 0.25);
|
|
57
|
+
box.material = boxMat;
|
|
58
|
+
|
|
59
|
+
scene.onBeforeRenderObservable.add(() => {
|
|
60
|
+
const dt = engine.getDeltaTime() / 1000;
|
|
61
|
+
box.rotation.y += dt * 0.8;
|
|
62
|
+
box.rotation.x += dt * 0.3;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
sceneRef.current = { scene, ground };
|
|
66
|
+
engine.runRenderLoop(() => scene.render());
|
|
67
|
+
const onResize = () => engine.resize();
|
|
68
|
+
window.addEventListener("resize", onResize);
|
|
69
|
+
|
|
70
|
+
// Ask the browser once whether an immersive headset session is on offer.
|
|
71
|
+
let alive = true;
|
|
72
|
+
if (navigator.xr) {
|
|
73
|
+
navigator.xr
|
|
74
|
+
.isSessionSupported("immersive-vr")
|
|
75
|
+
.then((ok) => { if (alive) setVrReady(ok); })
|
|
76
|
+
.catch(() => { if (alive) setVrReady(false); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
alive = false;
|
|
81
|
+
sceneRef.current = null;
|
|
82
|
+
window.removeEventListener("resize", onResize);
|
|
83
|
+
engine.stopRenderLoop();
|
|
84
|
+
scene.dispose();
|
|
85
|
+
engine.dispose();
|
|
86
|
+
};
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const enterVr = async () => {
|
|
90
|
+
const current = sceneRef.current;
|
|
91
|
+
if (!current) return;
|
|
92
|
+
const xr = await current.scene.createDefaultXRExperienceAsync({ floorMeshes: [current.ground], disableDefaultUI: true });
|
|
93
|
+
xr.baseExperience.onStateChangedObservable.add((state) => setInVr(state === BABYLON.WebXRState.IN_XR));
|
|
94
|
+
await xr.baseExperience.enterXRAsync("immersive-vr", "local-floor");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<main className="min-h-screen bg-background text-foreground font-sans p-6">
|
|
99
|
+
<h1 className="text-3xl font-bold">{room?.name ?? "Floating Room"}</h1>
|
|
100
|
+
<p className="mt-1 text-muted-foreground">{room?.tagline ?? "A quiet room with one bright box. Drag to look around, or step inside with a headset."}</p>
|
|
101
|
+
|
|
102
|
+
<Card className="mt-6">
|
|
103
|
+
<CardHeader>
|
|
104
|
+
<CardTitle>The room</CardTitle>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
<CardContent>
|
|
107
|
+
<canvas ref={canvasRef} className="block aspect-video w-full rounded-lg outline-none touch-none" />
|
|
108
|
+
{/* TODO(next pass): hit testing, anchors, hand tracking, particles and shaders inside this same scene */}
|
|
109
|
+
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
110
|
+
{vrReady ? (
|
|
111
|
+
<Button onClick={enterVr}>Enter VR</Button>
|
|
112
|
+
) : (
|
|
113
|
+
<Button disabled variant="outline">VR needs a headset browser</Button>
|
|
114
|
+
)}
|
|
115
|
+
<Badge variant="secondary">{inVr ? "In VR" : "On screen"}</Badge>
|
|
116
|
+
</div>
|
|
117
|
+
</CardContent>
|
|
118
|
+
</Card>
|
|
119
|
+
</main>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `seed.json`
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"floatingRoom": [
|
|
128
|
+
{ "key": "room", "type": "room", "name": "Floating Room", "tagline": "A quiet room with one bright box. Drag to look around, or step inside with a headset." }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Draft note: the next pass adds hit testing, anchors, hand tracking, particles and shaders to the same scene.
|
|
134
|
+
|
|
135
|
+
## What stays the same in a WebXR draft
|
|
136
|
+
|
|
137
|
+
- **The import is `import * as BABYLON from "@babylonjs/core"`**, never the
|
|
138
|
+
legacy `babylonjs` package, and every engine class is reached through the
|
|
139
|
+
`BABYLON.` namespace.
|
|
140
|
+
- **The engine is built once** in `useEffect(() => {...}, [])`: `Engine`,
|
|
141
|
+
`Scene`, camera, lights, ground, mesh and `engine.runRenderLoop` all inside
|
|
142
|
+
it, per-frame motion in `scene.onBeforeRenderObservable`, and the scene kept
|
|
143
|
+
in a ref for the few calls React makes in.
|
|
144
|
+
- **The cleanup is complete**: it removes the resize listener, stops the render
|
|
145
|
+
loop, disposes the scene and disposes the engine.
|
|
146
|
+
- **Sizing**: the canvas is `block aspect-video w-full` inside a Card, and
|
|
147
|
+
`window` resize calls `engine.resize()`.
|
|
148
|
+
- **The hello is one lit box on a ground plane** under a hemispheric light and
|
|
149
|
+
a directional light, turning on the engine's delta time. The ground doubles
|
|
150
|
+
as the XR floor mesh.
|
|
151
|
+
- **VR is shown, never hidden**: `navigator.xr.isSessionSupported("immersive-vr")`
|
|
152
|
+
is checked in the effect and stored in state; a supported browser gets an
|
|
153
|
+
"Enter VR" Button that awaits `createDefaultXRExperienceAsync` with
|
|
154
|
+
`disableDefaultUI` and then `enterXRAsync("immersive-vr", "local-floor")`,
|
|
155
|
+
and every other browser sees a disabled Button reading "VR needs a headset
|
|
156
|
+
browser".
|
|
157
|
+
- **What waits for the next pass** is named in the `TODO(next pass)` comment
|
|
158
|
+
and the draft note: hit testing, anchors, hand tracking, particles, shaders.
|
|
159
|
+
The draft draws none of it.
|
package/llms/webxr.js
CHANGED
package/llms/webxr.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webxr.js","sourceRoot":"","sources":["../../jsr/llms/webxr.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAc;IACpC,IAAI,EAAE,OAAO;IACb,KAAK,EAAE,kBAAkB;IACzB,WAAW,EACT,gFAAgF;QAChF,4FAA4F;QAC5F,kFAAkF;QAClF,sFAAsF;QACtF,uDAAuD;IACzD,IAAI,EAAE;QACJ,OAAO;QACP,IAAI;QACJ,IAAI;QACJ,iBAAiB;QACjB,mBAAmB;QACnB,SAAS;QACT,WAAW;QACX,eAAe;QACf,SAAS;QACT,OAAO;QACP,YAAY;KACb;
|
|
1
|
+
{"version":3,"file":"webxr.js","sourceRoot":"","sources":["../../jsr/llms/webxr.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAc;IACpC,IAAI,EAAE,OAAO;IACb,KAAK,EAAE,kBAAkB;IACzB,WAAW,EACT,gFAAgF;QAChF,4FAA4F;QAC5F,kFAAkF;QAClF,sFAAsF;QACtF,uDAAuD;IACzD,IAAI,EAAE;QACJ,OAAO;QACP,IAAI;QACJ,IAAI;QACJ,iBAAiB;QACjB,mBAAmB;QACnB,SAAS;QACT,WAAW;QACX,eAAe;QACf,SAAS;QACT,OAAO;QACP,YAAY;KACb;IAGD,YAAY,EAAE,IAAI;IAClB,YAAY,EAAE,iBAAiB;IAC/B,UAAU,EAAE,SAAS;IACrB,UAAU,EAAE,WAAW;CACxB,CAAC"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# YouTube channel stats — the first draft: the whole dashboard on sample rows
|
|
2
|
+
|
|
3
|
+
A YouTube dashboard's first draft is the finished page, painted from seeded
|
|
4
|
+
documents shaped exactly like the rows the real backend returns: the channel
|
|
5
|
+
header with its name and handle, three stat tiles (subscribers, views, videos),
|
|
6
|
+
the recent uploads with their published date and view count, and the setup card
|
|
7
|
+
that walks the owner through getting a YouTube Data API v3 key. A Badge on the
|
|
8
|
+
header reads "sample data until the key is set", so the capability is on screen
|
|
9
|
+
and honestly labeled from the first paint.
|
|
10
|
+
|
|
11
|
+
The next pass writes `backend.js`: it reads `YOUTUBE_API_KEY` from the vibe's
|
|
12
|
+
secrets, resolves the channel from its handle, reads the uploads playlist, and
|
|
13
|
+
saves a daily snapshot. What the draft owes that pass is the row shape: the
|
|
14
|
+
`channel` document carries the same fields `loadChannel` returns (`channelId`,
|
|
15
|
+
`title`, `handle`, `subscribers`, `subscribersHidden`, `views`, `videoCount`)
|
|
16
|
+
and each `video` document carries the same fields `loadVideos` returns (`id`,
|
|
17
|
+
`title`, `published`, `views`, `likes`, `comments`), so the next pass swaps the
|
|
18
|
+
source and keeps every component. The draft writes no `backend.js`, makes no
|
|
19
|
+
network call, reads no secret, and never asks a visitor for a token: the key is
|
|
20
|
+
the owner's, set once in the vibe's settings.
|
|
21
|
+
|
|
22
|
+
### `App.jsx`
|
|
23
|
+
```jsx
|
|
24
|
+
import React, { useState } from "react";
|
|
25
|
+
import { useFireproof } from "use-fireproof";
|
|
26
|
+
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@vibes.diy/look";
|
|
27
|
+
|
|
28
|
+
// The owner's one-time trip to Google, carried in the app because the owner is the one who reads it.
|
|
29
|
+
const SETUP_STEPS = [
|
|
30
|
+
"Open the Google Cloud console and create or select a project.",
|
|
31
|
+
"In APIs & Services, open Library and enable YouTube Data API v3.",
|
|
32
|
+
"In Credentials, choose Create credentials, then API key.",
|
|
33
|
+
"Restrict the key to YouTube Data API v3.",
|
|
34
|
+
"Set it on this vibe as YOUTUBE_API_KEY in the vibe's settings page.",
|
|
35
|
+
];
|
|
36
|
+
const SECRET_NAME = "YOUTUBE_API_KEY";
|
|
37
|
+
|
|
38
|
+
const formatCount = (n) => (n == null ? "hidden" : Number(n).toLocaleString());
|
|
39
|
+
const formatDay = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
40
|
+
|
|
41
|
+
function StatCard({ label, value, caption }) {
|
|
42
|
+
return (
|
|
43
|
+
<Card>
|
|
44
|
+
<CardHeader>
|
|
45
|
+
<p className="text-muted-foreground">{label}</p>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent>
|
|
48
|
+
<p className="text-3xl font-bold">{value}</p>
|
|
49
|
+
{caption && <p className="mt-1 text-muted-foreground">{caption}</p>}
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function VideoCard({ video }) {
|
|
56
|
+
return (
|
|
57
|
+
<Card>
|
|
58
|
+
<CardContent>
|
|
59
|
+
<p className="font-semibold">{video.title}</p>
|
|
60
|
+
<div className="mt-2 flex flex-wrap items-center gap-3 text-muted-foreground">
|
|
61
|
+
<span>{formatDay(video.published)}</span>
|
|
62
|
+
<span className="font-medium text-foreground">{formatCount(video.views)} views</span>
|
|
63
|
+
<span>{formatCount(video.likes)} likes</span>
|
|
64
|
+
<span>{formatCount(video.comments)} comments</span>
|
|
65
|
+
</div>
|
|
66
|
+
</CardContent>
|
|
67
|
+
</Card>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function App() {
|
|
72
|
+
const { useLiveQuery } = useFireproof("youtubeDashboard");
|
|
73
|
+
const { docs: channels } = useLiveQuery("type", { key: "channel" });
|
|
74
|
+
const { docs: videos } = useLiveQuery("type", { key: "video" });
|
|
75
|
+
const [sortBy, setSortBy] = useState("newest");
|
|
76
|
+
const channel = channels[0];
|
|
77
|
+
|
|
78
|
+
const sorted = videos
|
|
79
|
+
.slice()
|
|
80
|
+
.sort((a, b) => (sortBy === "views" ? b.views - a.views : b.published.localeCompare(a.published)));
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<main className="min-h-screen bg-background text-foreground font-sans p-6">
|
|
84
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
85
|
+
<h1 className="text-3xl font-bold">{channel?.title ?? "Your channel"}</h1>
|
|
86
|
+
<Badge variant="secondary">sample data until the key is set</Badge>
|
|
87
|
+
</div>
|
|
88
|
+
<p className="mt-1 text-muted-foreground">{channel?.handle} · how the channel is doing, at a glance</p>
|
|
89
|
+
|
|
90
|
+
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
|
91
|
+
<StatCard
|
|
92
|
+
label="Subscribers"
|
|
93
|
+
value={channel?.subscribersHidden ? "hidden by the channel" : formatCount(channel?.subscribers)}
|
|
94
|
+
caption="YouTube rounds public subscriber counts."
|
|
95
|
+
/>
|
|
96
|
+
<StatCard label="Views" value={formatCount(channel?.views)} />
|
|
97
|
+
<StatCard label="Videos" value={formatCount(channel?.videoCount)} />
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
|
|
101
|
+
<h2 className="text-xl font-semibold">Recent uploads</h2>
|
|
102
|
+
<div className="flex gap-2">
|
|
103
|
+
<Button size="sm" variant={sortBy === "newest" ? "default" : "outline"} onClick={() => setSortBy("newest")}>Newest</Button>
|
|
104
|
+
<Button size="sm" variant={sortBy === "views" ? "default" : "outline"} onClick={() => setSortBy("views")}>Most viewed</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="mt-4 grid gap-3">
|
|
108
|
+
{sorted.map((video) => (
|
|
109
|
+
<VideoCard key={video.id} video={video} />
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
{/* TODO(next pass): the server handler reads YOUTUBE_API_KEY, resolves the channel from its handle, reads the uploads playlist, and saves a daily snapshot these same components render */}
|
|
113
|
+
|
|
114
|
+
<Card className="mt-8">
|
|
115
|
+
<CardHeader>
|
|
116
|
+
<CardTitle>Show your live numbers</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent>
|
|
119
|
+
<p className="text-muted-foreground">Five minutes in the Google Cloud console, once, and this page fills with your channel.</p>
|
|
120
|
+
<ol className="mt-3 grid list-decimal gap-2 pl-5">
|
|
121
|
+
{SETUP_STEPS.map((step) => (
|
|
122
|
+
<li key={step}>{step}</li>
|
|
123
|
+
))}
|
|
124
|
+
</ol>
|
|
125
|
+
<p className="mt-3">
|
|
126
|
+
The secret is named <code className="rounded bg-muted px-1">{SECRET_NAME}</code> and stays on the server.
|
|
127
|
+
</p>
|
|
128
|
+
</CardContent>
|
|
129
|
+
</Card>
|
|
130
|
+
|
|
131
|
+
<Card className="mt-4">
|
|
132
|
+
<CardHeader>
|
|
133
|
+
<CardTitle>From YouTube Analytics</CardTitle>
|
|
134
|
+
</CardHeader>
|
|
135
|
+
<CardContent>
|
|
136
|
+
<p className="text-muted-foreground">
|
|
137
|
+
Watch time, revenue, impressions, traffic sources and demographics live in YouTube Analytics with your own
|
|
138
|
+
sign-in. Not available with an API key.
|
|
139
|
+
</p>
|
|
140
|
+
</CardContent>
|
|
141
|
+
</Card>
|
|
142
|
+
</main>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `seed.json`
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"youtubeDashboard": [
|
|
151
|
+
{ "key": "channel", "type": "channel", "channelId": "UCq8x2Tn4Lm0bVfKe7RdPw3A", "title": "Tidepool Kitchen", "handle": "@tidepoolkitchen", "subscribers": 48200, "subscribersHidden": false, "views": 3915422, "videoCount": 214 },
|
|
152
|
+
{ "key": "v1", "type": "video", "id": "k3Jd9sLq2Xw", "title": "Sourdough on a camp stove (it worked!)", "published": "2026-09-24T15:00:00Z", "views": 18430, "likes": 1204, "comments": 187 },
|
|
153
|
+
{ "key": "v2", "type": "video", "id": "Pz7tR1mVb8c", "title": "Five-minute miso butter, three ways", "published": "2026-09-17T15:00:00Z", "views": 42911, "likes": 3310, "comments": 402 },
|
|
154
|
+
{ "key": "v3", "type": "video", "id": "aQ4nW6yHe0s", "title": "Foraging sea beans at low tide", "published": "2026-09-10T15:00:00Z", "views": 27604, "likes": 2188, "comments": null },
|
|
155
|
+
{ "key": "v4", "type": "video", "id": "Lm2cX8oTf5u", "title": "The pantry tour everyone asked for", "published": "2026-09-03T15:00:00Z", "views": 61378, "likes": null, "comments": 955 }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Draft note: the next pass adds backend.js, which reads `YOUTUBE_API_KEY`, resolves the channel from its handle, reads the uploads playlist and saves a daily snapshot, so the same tiles and cards show live numbers and a chart of change over time.
|
|
161
|
+
|
|
162
|
+
## What stays the same in a YouTube draft
|
|
163
|
+
|
|
164
|
+
- **The whole dashboard paints on the first draft**: header, the three stat
|
|
165
|
+
tiles, the upload list, the setup card and the Analytics label. Nothing
|
|
166
|
+
waits behind a loading state or an empty section.
|
|
167
|
+
- **Seeded rows mirror the backend's rows field for field**: the `channel`
|
|
168
|
+
document is `loadChannel`'s shape and each `video` document is `loadVideos`'
|
|
169
|
+
shape, counts as numbers, `published` as an ISO string, so the next pass
|
|
170
|
+
changes the source and keeps every component.
|
|
171
|
+
- **The capability is labeled, never hidden**: a Badge on the header reads
|
|
172
|
+
"sample data until the key is set", and the setup card names YouTube Data API
|
|
173
|
+
v3 and `YOUTUBE_API_KEY` in so many words.
|
|
174
|
+
- **The key is the owner's**: the setup steps are for the owner, once. No input
|
|
175
|
+
asks a visitor for a key or a token, and no visitor is sent to a console. An
|
|
176
|
+
app where each visitor sees their own channel is a connection, not this.
|
|
177
|
+
- **Honest numbers**: a hidden subscriber count reads "hidden by the channel",
|
|
178
|
+
a missing like or comment count reads "hidden", and the subscriber tile
|
|
179
|
+
carries the rounding caption. Nothing unknown renders as zero.
|
|
180
|
+
- **What waits for the next pass** is named in the `TODO(next pass)` comment and
|
|
181
|
+
the draft note: `backend.js`, reading the secret, resolving the handle, the
|
|
182
|
+
uploads playlist, daily snapshots and the chart. The draft writes no
|
|
183
|
+
`backend.js`, makes no network call and reads no secret.
|
package/llms/youtube.js
CHANGED
|
@@ -2,6 +2,7 @@ export const youtubeConfig = {
|
|
|
2
2
|
name: "youtube",
|
|
3
3
|
label: "YouTube channel stats",
|
|
4
4
|
description: "YouTube channel dashboards on the owner's own YouTube Data API v3 key: the setup steps the app shows when the key is missing, keeping the key in backend.js, resolving a channel from a handle or URL, reading videos through the uploads playlist instead of the 100-unit search endpoint, daily snapshots to chart change over time, and labeling the numbers an API key cannot see",
|
|
5
|
+
draftVariant: true,
|
|
5
6
|
cues: [
|
|
6
7
|
"youtube",
|
|
7
8
|
"my channel",
|
package/llms/youtube.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"youtube.js","sourceRoot":"","sources":["../../jsr/llms/youtube.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,uBAAuB;IAC9B,WAAW,EACT,uXAAuX;
|
|
1
|
+
{"version":3,"file":"youtube.js","sourceRoot":"","sources":["../../jsr/llms/youtube.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,uBAAuB;IAC9B,WAAW,EACT,uXAAuX;IAGzX,YAAY,EAAE,IAAI;IAClB,IAAI,EAAE;QACJ,SAAS;QACT,YAAY;QACZ,kBAAkB;QAClB,aAAa;QACb,aAAa;QACb,eAAe;QACf,mBAAmB;KACpB;CACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "14.3.
|
|
3
|
+
"version": "14.3.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"exports": {
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"license": "Apache-2.0",
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@adviser/cement": "~0.5.34",
|
|
40
|
-
"@vibes.diy/call-ai-v2": "14.3.
|
|
41
|
-
"@vibes.diy/identity": "14.3.
|
|
42
|
-
"@vibes.diy/use-vibes-types": "14.3.
|
|
40
|
+
"@vibes.diy/call-ai-v2": "14.3.38",
|
|
41
|
+
"@vibes.diy/identity": "14.3.38",
|
|
42
|
+
"@vibes.diy/use-vibes-types": "14.3.38",
|
|
43
43
|
"arktype": "~2.2.3",
|
|
44
44
|
"json-schema-faker": "~0.6.3"
|
|
45
45
|
},
|