From 1038d0845faeb6a27596a514009e07dd30208775 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 21 Oct 2026 16:50:13 -0700 Subject: [PATCH] bops.bot's Boppy listens like someone into the music: it shuts its eyes, hums or sings a little on its own, smiles and blushes when you point at it, and sings a song of its own length when tapped --- site/README.md | 2 +- site/index.html | 6 +- site/notes.js | 177 ++++++++++++++++++++++++++++++++++++------------ site/styles.css | 6 +- 4 files changed, 142 insertions(+), 49 deletions(-) diff --git a/site/README.md b/site/README.md index eb182f42..6247c49e 100644 --- a/site/README.md +++ b/site/README.md @@ -10,7 +10,7 @@ python3 -m http.server 4321 --directory site # then open http://localhost:4321 - `styles.css`: everything visual. Colors are the app's: ink `#0A0A0A`, highlighter `#E9FF3B`, paper white. - `main.js`: the illustrated scenes (hero cards, badge, thread) keep their designed size and scale down to fit smaller screens. Below 1180 px the fit is content-aware (it crops a scene's empty margins, so the scene draws larger), and on phones `styles.css` recomposes the hero and the thread into a narrower layout of the same parts. From 1180 px up the page renders exactly as before. - `lanyard.js`: the badge in "Hire a bot" drops onto its lanyard (with a little wind) the first time it scrolls into view, then swings when you hover, drag or tap it (a few springs and a CSS 3D transform). At rest, with reduced motion or without JavaScript it is exactly the static design. -- `notes.js`: music notes drift out of the hero Boppy's headphones; clicking or tapping it makes a burst of them. It sings along (its mouth, a flat line as it listens, opens with the notes), its eyes follow the pointer and blink, and it squints happily in a burst. Off with reduced motion. +- `notes.js`: music notes drift out of the hero Boppy's headphones. Boppy listens like someone into the music: now and then it shuts its eyes for a moment, hums, or sings a short phrase on its own; its eyes follow the pointer and blink; pointing at it makes it smile and blush. Clicking or tapping it makes a burst of notes and a song of its own length each time (its mouth, a flat line as it listens, sings each syllable), ending in a big smile. Off with reduced motion. - `download.js`: the three Download for Mac buttons go to `/download/Bops.dmg`. On a Mac they open Get First Access first: leave an email (it goes to PostHog) or skip, and either way the download starts and the card says so. At the card's foot, What's new in the newest release: its version and lines from `/download/latest.json` (written with each release by Orgo's `scripts/download-publish.sh`, in its private OrgoAI/bops-secrets), left out when that can't be read. Anywhere else (iPhone, iPad, Android, Windows, Linux) they open a short note to get Bops on a Mac instead, with Send to my Mac (the share sheet, so AirDrop or Messages) and Copy link. An Intel Mac in Chrome gets the note's Apple silicon version. Without JS the buttons simply download. Test it with `node scripts/test-site.mjs`. - Pricing (`#pricing`, linked from the nav): Free, Pro and Max in exactly the app's words (`PLAN_CARDS` in `lib/plan-includes.ts`, which the app's Settings shows); `scripts/test-site.mjs` fails when the two differ. Change the plans there and here together. - PostHog: the snippet in `index.html` sends to Orgo's production project, with `site: bops.bot` on every event. The card's events are `bops_download_click` (with which button: nav, hero or footer), `bops_download_email` (and an identify by the email), `bops_download_skip_email` and `bops_download_note` (the non-Mac or Intel note). Every call is guarded, so a blocker can't break the buttons. diff --git a/site/index.html b/site/index.html index cc3ee795..462d7764 100644 --- a/site/index.html +++ b/site/index.html @@ -24,7 +24,7 @@ - + - + diff --git a/site/notes.js b/site/notes.js index cb11a438..e5fdaa83 100644 --- a/site/notes.js +++ b/site/notes.js @@ -1,9 +1,9 @@ -// The hero's Boppy is listening to music: small notes drift out of its headphones now and then, and -// clicking (or tapping) it makes a burst of them while it bops. It sings along (its mouth, shut as it -// listens, opens with the notes), its eyes follow the pointer and blink, and it squints happily in a -// burst. Notes live in the scene's stage, so they scale with it, and move with the Web Animations API -// (transform and opacity only). Nothing runs while the hero is off screen or the tab is hidden, and -// nothing at all with reduced motion. +// The hero's Boppy is listening to music: small notes drift out of its headphones now and then, with a +// "la" now and again. Point at it and it smiles, blushing a little; click (or tap) it and it sings, a +// song of its own length each time, bopping along with notes flying, and ends with a big smile. Its eyes +// follow the pointer and blink. Notes live in the scene's stage, so they scale with it, and move with +// the Web Animations API (transform and opacity only). Nothing runs while the hero is off screen or the +// tab is hidden, and nothing at all with reduced motion. (() => { const boppy = document.querySelector(".hero .boppy-float"); const stage = boppy && boppy.closest(".stage"); @@ -23,25 +23,110 @@ return [(b.left - r.left + b.width * CUPS[i][0]) / k, (b.top - r.top + b.height * CUPS[i][1]) / k, b.width / k]; } - // Its mouth rests shut, a flat line (styles.css), and opens with a note: one "la" at a time, now and - // then while it listens, quicker and wider in a burst. - const mouth = boppy.querySelector(".hero-mouth"), SHUT = "scale(1.4, .28)"; - let singing = 0; - function sing(strong) { - const t = performance.now(); - if (!mouth || t < singing || (!strong && Math.random() < 0.3)) return; - const dur = strong ? 300 : 620, open = (strong ? 0.95 : 0.75) + Math.random() * 0.3; - singing = t + dur * 0.85; - mouth.animate( - [{ transform: SHUT }, { transform: `scale(1.05, ${open.toFixed(2)})`, offset: 0.3 }, { transform: `scale(1.1, ${(open * 0.8).toFixed(2)})`, offset: 0.7 }, { transform: SHUT }], - { duration: dur, easing: "ease-in-out" }, - ); + // Its mouth: one shape, redrawn each frame while it moves, easing toward a pose. A pose is its width, + // how far its corners lift, how far its top and bottom edges bow (down is +), and how far in from the + // corners they bend (0 gives round ends, as an "o" has). Drawn with a round stroke of its own white, + // so a mouth with no height is a flat line with round ends, as index.html draws it at rest. + const mouth = boppy.querySelector(".hero-mouth"), cheeks = boppy.querySelector(".hero-cheeks"); + const MX = 20.78, MY = 26.9, f = (n) => n.toFixed(2); + const SHUT = { w: 3, lift: 0, top: 0, bot: 0, inset: 0 }; + const SMILE = { w: 3.9, lift: 0.75, top: 0.75, bot: 1.25, inset: 0.22 }; + const SOFT = { w: 3.4, lift: 0.4, top: 0.4, bot: 0.62, inset: 0.2 }; // a small smile, mouth closed + const GRIN = { w: 4, lift: 0.7, top: 0.55, bot: 1.9, inset: 0.18 }; // a big one, mouth open, at a song's end + const mix = (a, b, k) => Object.fromEntries(Object.keys(a).map((key) => [key, a[key] + (b[key] - a[key]) * k])); + // A sung syllable: an "o" (wide and low to tall and round, by how open), or, happy, an open smile. + const sung = (o, happy) => + mix({ w: 3 - 0.8 * o, lift: 0, top: -1.4 * o, bot: 1.4 * o, inset: 0 }, { w: 3.4, lift: 0.4, top: 0.3, bot: 0.6 + 1.6 * o, inset: 0.12 }, happy); + function draw(p) { + const x0 = MX - p.w / 2, x1 = MX + p.w / 2, y = MY - p.lift, i = p.inset * p.w, t = y + (p.top * 4) / 3, b = y + (p.bot * 4) / 3; + mouth.setAttribute("d", `M${f(x0)} ${f(y)}C${f(x0 + i)} ${f(t)} ${f(x1 - i)} ${f(t)} ${f(x1)} ${f(y)}C${f(x1 - i)} ${f(b)} ${f(x0 + i)} ${f(b)} ${f(x0)} ${f(y)}Z`); + } + let pose = { ...SHUT }, want = SHUT, raf = 0, last = 0; + function frame(now) { + const k = 1 - Math.exp(-Math.min(0.05, (now - (last || now)) / 1000) * 26); + let moving = false; + for (const key in pose) { + const d = want[key] - pose[key]; + pose[key] += d * k; + if (Math.abs(d) > 0.004) moving = true; + } + draw(pose); + last = moving ? now : 0; + raf = moving ? requestAnimationFrame(frame) : 0; } + const aim = (p) => { + want = p; + if (mouth && !raf) raf = requestAnimationFrame(frame); + }; + // Its cheeks: pink or not (in between, pink on black looks muddy). + const blush = (on) => cheeks && (cheeks.style.opacity = on ? "1" : "0"); + + // What it's doing: just listening, smiling at the pointer over it, or singing or humming (song is the + // one going, so a tap starts a new one and the old one's steps stop). + let over = false, song = 0; + const rest = () => (aim(over ? SMILE : SHUT), blush(over)); + boppy.addEventListener("pointerenter", () => ((over = true), !song && rest())); + boppy.addEventListener("pointerleave", () => ((over = false), !song && rest())); - // Its eyes look toward the pointer (a little, easing there in styles.css), blink every few seconds - // (twice, now and then), and squint happily through a burst. + // A phrase, sung or hummed: n syllables, each opening its mouth by between lo and hi (some of them an + // open smile, by `happy`), now and then a held note that wavers (`held`), with a note off a cup and a + // bop of the head on some (`notes`, `bops`), blushing if `flush`; then a big smile for `smile` ms. + function phrase(n, { lo, hi, held = 0, happy = 0, notes = 0, bops = 0, flush = false, smile = 0 }) { + const id = ++song, later = (ms, step) => setTimeout(() => id === song && step(), ms); + if (flush) blush(flush); + let at = 120; + for (let s = 0; s < n; s++) { + const long = Math.random() < held, dur = long ? 520 + Math.random() * 380 : 150 + Math.random() * 260; + const o = long ? Math.max(hi - 0.2, lo) + Math.random() * 0.2 : lo + Math.random() * (hi - lo), smiling = Math.random() < happy ? 1 : 0; + later(at, () => { + aim(sung(o, smiling)); + if (Math.random() < notes) note((side ^= 1), true); + if (Math.random() < bops) bop(0.03, (s % 2 ? 1 : -1) * 2); + }); + for (let w = 1; long && w * 130 < dur * 0.62; w++) later(at + w * 130, () => aim(sung(o - (w % 2) * 0.12, smiling))); + later(at + dur * 0.62, () => aim(Math.random() < 0.3 ? sung(lo * 0.4, 0) : SHUT)); + at += dur + (Math.random() < 0.2 ? 120 + Math.random() * 160 : 20 + Math.random() * 60); + } + if (smile) later(at, () => (aim(GRIN), bop(0.05, 0))); + later(at + smile, () => ((song = 0), rest())); + } + const hum = () => phrase(2 + Math.floor(Math.random() * 4), { lo: 0.12, hi: 0.4 }); + + // Its eyes look toward the pointer (a little, easing there in styles.css) and blink every few seconds + // (twice, now and then): shut quickly, open a little slower. Getting into the music, it shuts them for + // a moment, the white ovals giving way to two soft closed lids. const eyes = boppy.querySelector(".hero-eyes"), lids = eyes ? [...eyes.querySelectorAll("ellipse")] : []; - let happy = 0; + const closed = boppy.querySelector(".hero-closed"); + let dreaming = 0; + function dream(ms) { + dreaming = performance.now() + ms; + const a = 200 / ms, b = 1 - 280 / ms, o = { duration: ms, easing: "ease-in-out" }; + lids.forEach((l) => + l.animate([{ transform: "scaleY(1)", opacity: 1 }, { transform: "scaleY(.15)", opacity: 0, offset: a }, { transform: "scaleY(.15)", opacity: 0, offset: b }, { transform: "scaleY(1)", opacity: 1 }], o), + ); + if (closed) closed.animate([{ opacity: 0 }, { opacity: 1, offset: a }, { opacity: 1, offset: b }, { opacity: 0 }], o); + } + + // On its own now and then, as someone listening does: it shuts its eyes for a moment (with a small + // smile and a little blush, or humming along), hums a few soft syllables, or sings a short phrase. + function daydream() { + setTimeout(daydream, 2600 + Math.random() * 3200); + if (!onscreen || document.hidden || song || over) return; + const r = Math.random(); + if (r < 0.35) { + const ms = 1400 + Math.random() * 1600; + dream(ms); + if (Math.random() < 0.5) hum(); + else { + aim(SOFT); + blush(true); + const id = ++song; + setTimeout(() => id === song && ((song = 0), rest()), ms - 200); + } + } else if (r < 0.65) hum(); + else if (r < 0.82) phrase(3 + Math.floor(Math.random() * 5), { lo: 0.3, hi: 0.8, happy: 0.3, notes: 0.5, bops: 0.4, flush: true, smile: 800 + Math.random() * 400 }); + } + setTimeout(daydream, 2200); addEventListener( "pointermove", (e) => { @@ -53,25 +138,21 @@ }, { passive: true }, ); - const close = (to, ms) => - lids.forEach((l) => l.animate([{ transform: "scaleY(1)" }, { transform: `scaleY(${to})`, offset: 0.5 }, { transform: "scaleY(1)" }], { duration: ms, easing: "ease-in-out" })); + const shut = () => + lids.forEach((l) => + l.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(.1)", offset: 0.35 }, { transform: "scaleY(.1)", offset: 0.5 }, { transform: "scaleY(1)" }], { + duration: 150 + Math.random() * 60, + easing: "ease-out", + }), + ); function blink() { - if (onscreen && !document.hidden && performance.now() > happy) { - close(0.1, 170); - if (Math.random() < 0.25) setTimeout(() => close(0.1, 170), 230); + if (onscreen && !document.hidden && performance.now() > dreaming) { + shut(); + if (Math.random() < 0.2) setTimeout(shut, 260); } - setTimeout(blink, 2400 + Math.random() * 3200); + setTimeout(blink, 2600 + Math.random() * 3800); } if (lids.length) setTimeout(blink, 1800); - function squint() { - happy = performance.now() + 900; - lids.forEach((l) => - l.animate( - [{ transform: "scaleY(1)" }, { transform: "translateY(.5px) scaleY(.4)", offset: 0.2 }, { transform: "translateY(.5px) scaleY(.4)", offset: 0.8 }, { transform: "scaleY(1)" }], - { duration: 900, easing: "ease-in-out" }, - ), - ); - } function note(i, strong, pace = 1) { if (live > (strong ? 24 : 6)) return; @@ -87,7 +168,6 @@ el.innerHTML = `${SHAPES[Math.random() < 0.6 ? 0 : 1]}`; boppy.after(el); live++; - sing(strong); // Out and up with a gentle sway; a burst goes further and faster. const out = dir * size * (strong ? 0.28 + Math.random() * 0.3 : 0.18 + Math.random() * 0.14); const up = -size * (strong ? 0.55 + Math.random() * 0.4 : 0.5 + Math.random() * 0.2); @@ -122,13 +202,26 @@ }).observe(boppy); document.addEventListener("visibilitychange", resume); - // Click or tap Boppy (or Enter / Space on it): a burst of notes, and it bops. Each burst has its own - // number of notes (8 to 15) and pace (up to 10% faster or slower); the notes in it vary only a little. + // A bop of the head on a beat, tipping one way or the other. None while one (or a tap's squash) is + // still going: the new one would cut it off with a jump. + let bopping = 0; + function bop(by, tip) { + const t = performance.now(); + if (t < bopping) return; + bopping = t + 280; + boppy.animate([{ scale: "1", rotate: "0deg" }, { scale: String(1 + by), rotate: `${tip}deg` }, { scale: "1", rotate: "0deg" }], { duration: 280, easing: "ease-out" }); + } + + // Click or tap Boppy (or Enter / Space on it): a burst of notes, and it sings, blushing. Each song is + // its own: 4 to 9 syllables (about 1.5 to 4.5 s), each a quick "la" or now and then a held note + // that wavers, more or less open, some of them an open smile, with a note and a bop on most. It + // ends with a big smile for a moment before going back to listening. function burst() { - const count = 8 + Math.floor(Math.random() * 8), pace = 0.9 + Math.random() * 0.2, first = Math.random() < 0.5 ? 0 : 1; + const count = 5 + Math.floor(Math.random() * 5), pace = 0.9 + Math.random() * 0.2, first = Math.random() < 0.5 ? 0 : 1; for (let n = 0, at = 0; n < count; n++, at += (38 + Math.random() * 16) * pace) setTimeout(() => note((n + first) % 2, true, pace), at); boppy.animate([{ scale: "1" }, { scale: "1.08" }, { scale: "0.97" }, { scale: "1" }], { duration: 450, easing: "ease-out" }); - squint(); + bopping = performance.now() + 450; + phrase(4 + Math.floor(Math.random() * 6), { lo: 0.35, hi: 1, held: 0.15, happy: 0.35, notes: 0.75, bops: 0.6, flush: true, smile: 1400 + Math.random() * 900 }); } boppy.classList.add("is-playing"); boppy.setAttribute("tabindex", "0"); diff --git a/site/styles.css b/site/styles.css index d3e66cca..ef0ca3f9 100644 --- a/site/styles.css +++ b/site/styles.css @@ -75,9 +75,9 @@ ul { list-style: none; } .hero .fit { flex: 1; } .boppy-float { animation: bob 3.2s ease-in-out infinite; } @keyframes bob { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-10px) rotate(-2deg); } } -/* notes.js: music notes drifting out of the hero Boppy's headphones; click it for a burst. It listens with its - mouth shut, a flat line that opens as it sings, and its eyes ease toward the pointer. */ -.hero-mouth { transform-box: fill-box; transform-origin: center; transform: scale(1.4, .28); } +/* notes.js: music notes drifting out of the hero Boppy's headphones; click it and it sings. It listens with its + mouth shut (a flat line), its eyes ease toward the pointer, and its cheeks blush as it sings and smiles. */ +.hero-cheeks { opacity: 0; transition: opacity .45s ease; } .hero-eyes { transition: transform .35s cubic-bezier(.3, .7, .4, 1); } .hero-eyes ellipse { transform-box: fill-box; transform-origin: center; } .boppy-float.is-playing { cursor: pointer; -webkit-tap-highlight-color: transparent; }