diff --git a/.gitleaksignore b/.gitleaksignore index 8f0adb1b..7636c6d4 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,4 @@ 0e7070138c71b77563f40ed5835535bae02e6f0d:scripts/test-render.mjs:generic-api-key:400 7e52c8555c74565850efe78d41ad4a8fd676cdbf:scripts/test-render.mjs:generic-api-key:406 +52451f6cd78a942408602b8c1ca67eb85ba74eea:scripts/test-vm-identity.mjs:generic-api-key:505 +52451f6cd78a942408602b8c1ca67eb85ba74eea:scripts/test-vm-identity.mjs:generic-api-key:510 diff --git a/site/README.md b/site/README.md index 37c0ef1f..eb182f42 100644 --- a/site/README.md +++ b/site/README.md @@ -6,11 +6,11 @@ The marketing page for Bops: plain HTML and CSS (no build step), from the Paper python3 -m http.server 4321 --directory site # then open http://localhost:4321 ``` -- `index.html`: the page. Mascots and icons are SVG symbols defined once at the top. +- `index.html`: the page. Mascots and icons are SVG symbols defined once at the top. Boppy is drawn as the app icon draws it; the hero's and the badge's Boppy are their own copies, so their eyes (and the hero's mouth) can move. - `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. Off with reduced motion. +- `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. - `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/apple-touch-icon.png b/site/apple-touch-icon.png index 5acf70a1..8d9b2f61 100644 Binary files a/site/apple-touch-icon.png and b/site/apple-touch-icon.png differ diff --git a/site/boppy.svg b/site/boppy.svg index d58953c4..703c4ce1 100644 --- a/site/boppy.svg +++ b/site/boppy.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/site/download.js b/site/download.js index 408c769a..28232bbe 100644 --- a/site/download.js +++ b/site/download.js @@ -36,7 +36,7 @@ }; const CLOSE = ''; - const DISC = ''; + const DISC = ''; const canShare = typeof navigator.share === "function"; let note, title, body, share, copy, anyway, status, opener, copyTimer; let access, ask, form, email, error, done, doneTitle, from; diff --git a/site/index.html b/site/index.html index 80975ba6..cc3ee795 100644 --- a/site/index.html +++ b/site/index.html @@ -14,17 +14,17 @@ - + - + - + - +

Bops
dot
bot.

- +
NEEDS YOU
@@ -149,7 +149,7 @@

Hire a bot,
not a chatbot.

- +
BOPS · STAFF ID 0001BoppyChief of Staff · Acme Inc
@@ -282,7 +282,7 @@

Pick a plan.

- +

Hire your first Bop.

Download for Mac and say hi to Boppy. It takes it from there.

@@ -299,8 +299,8 @@

Hire your first Bop.

- - - + + + diff --git a/site/lanyard.js b/site/lanyard.js index 6b3795e1..30edccbb 100644 --- a/site/lanyard.js +++ b/site/lanyard.js @@ -71,11 +71,13 @@ const trails = TRAIL.map(() => []); let gust = 0, windAt = 0, OFF = TRAIL.map(([off]) => off); // OFF: the offsets in use, pulled in on narrow screens const calm = () => trails.forEach((t) => (t.length = 0)); - // Boppy on the badge is its own copy of the symbol, so only these two eyes ever move. + // Boppy on the badge is its own copy of the symbol, so only these two eyes ever move. EYE: where they + // sit at rest (as index.html draws them) and their size. const eyeEls = [...badge.querySelectorAll(".photo .eye")]; + const EYE = { cx: [15.6, 24.4], cy: 20, rx: 2.4, ry: 3.15 }; const drawEyes = (dx, cy, rx, ry) => eyeEls.forEach((el, i) => { - const at = { cx: dx === 0 ? [15.5, 24.5][i] : f2([15.5, 24.5][i] + dx), cy, rx, ry }; + const at = { cx: dx === 0 ? EYE.cx[i] : f2(EYE.cx[i] + dx), cy, rx, ry }; for (const k in at) el.setAttribute(k, at[k]); }); @@ -116,7 +118,7 @@ } else { badge.style.removeProperty("transform"); badge.style.removeProperty("transform-origin"); - drawEyes(0, "22.3", "1.9", "2.6"); + drawEyes(0, EYE.cy, EYE.rx, EYE.ry); eyes.drawn = ""; } stage.classList.toggle("is-moving", on); @@ -220,7 +222,8 @@ let lx = 0, ly = falling ? -1 : 0; if (ptr.on && !falling) { const dx = ptr.x - eyeAt[0], dy = ptr.y - eyeAt[1], d = Math.hypot(dx, dy) || 1, k = (1.1 * Math.min(1, d / 160)) / d; - lx = dx * k; + // Not as far right: the right eye, already glancing that way (and wider in a squint), would slip under its cup's outline. + lx = Math.min(0.3, dx * k); ly = dy * k; } const k = 1 - Math.exp(-dt * 16), happy = grab || now < eyes.happy ? 1 : 0; @@ -232,7 +235,7 @@ eyes.next = now + 2 + Math.random() * 3; } else if (eyes.blink < 0 && now > eyes.next && !falling) eyes.blink = 0; const open = eyes.blink < 0 ? 1 : Math.max(0.12, Math.abs(1 - eyes.blink / 0.08)), sq = eyes.squint; - const key = [eyes.x, 22.3 + eyes.y + 0.6 * sq, 1.9 * (1 + 0.12 * sq), 2.6 * open * (1 - 0.62 * sq)].map(f2); + const key = [eyes.x, EYE.cy + eyes.y + 0.6 * sq, EYE.rx * (1 + 0.12 * sq), EYE.ry * open * (1 - 0.62 * sq)].map(f2); if (key.join() !== eyes.drawn) drawEyes(eyes.x, key[1], key[2], key[3]); eyes.drawn = key.join(); return eyes.blink >= 0 || Math.abs(lx - eyes.x) + Math.abs(ly - eyes.y) > 0.02 || Math.abs(happy - sq) > 0.01; diff --git a/site/notes.js b/site/notes.js index 5e6473d2..cb11a438 100644 --- a/site/notes.js +++ b/site/notes.js @@ -1,7 +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. 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. +// 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. (() => { const boppy = document.querySelector(".hero .boppy-float"); const stage = boppy && boppy.closest(".stage"); @@ -12,7 +14,7 @@ '', '', ]; - const CUPS = [[0.14, 0.5], [0.86, 0.5]]; // the ear cups, as fractions of Boppy's box + const CUPS = [[0.23, 0.6], [0.78, 0.51]]; // the ear cups, as fractions of Boppy's box (its head tilts) let side = 0, live = 0, onscreen = false, timer = 0; // Where a cup is now, in stage px (Boppy bobs, and is smaller on phones). @@ -21,6 +23,56 @@ 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 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. + const eyes = boppy.querySelector(".hero-eyes"), lids = eyes ? [...eyes.querySelectorAll("ellipse")] : []; + let happy = 0; + addEventListener( + "pointermove", + (e) => { + if (!eyes || !onscreen) return; + const b = boppy.getBoundingClientRect(), dx = e.clientX - (b.left + b.width * 0.52), dy = e.clientY - (b.top + b.height * 0.45); + const d = Math.hypot(dx, dy) || 1, k = (0.9 * Math.min(1, d / b.width)) / d; + // Not as far right: the right eye, already glancing that way, would slip under its cup's outline. + eyes.style.transform = `translate(${Math.min(0.5, dx * k).toFixed(2)}px, ${(dy * k).toFixed(2)}px)`; + }, + { 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" })); + function blink() { + if (onscreen && !document.hidden && performance.now() > happy) { + close(0.1, 170); + if (Math.random() < 0.25) setTimeout(() => close(0.1, 170), 230); + } + setTimeout(blink, 2400 + Math.random() * 3200); + } + 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; const [x, y, size] = cup(i), dir = i ? 1 : -1, px = Math.round(size * (strong ? 0.1 : 0.08) * (0.8 + Math.random() * 0.5)); @@ -35,6 +87,7 @@ 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); @@ -75,6 +128,7 @@ const count = 8 + Math.floor(Math.random() * 8), 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(); } boppy.classList.add("is-playing"); boppy.setAttribute("tabindex", "0"); diff --git a/site/og.png b/site/og.png index b36ced41..1c15796b 100644 Binary files a/site/og.png and b/site/og.png differ diff --git a/site/styles.css b/site/styles.css index 24700ab4..d3e66cca 100644 --- a/site/styles.css +++ b/site/styles.css @@ -75,7 +75,11 @@ 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. */ +/* 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); } +.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; } .boppy-float.is-playing:focus { outline: none; } .boppy-float.is-playing:focus-visible { outline: 2px solid var(--ink); outline-offset: 6px; border-radius: 50%; } @@ -196,7 +200,7 @@ html.lanyard-grabbing, html.lanyard-grabbing * { cursor: grabbing !important; } /* 6. Close */ .s5 { gap: 30px; padding: 120px var(--gutter) 130px; } -.final-disc { display: flex; align-items: center; justify-content: center; width: 132px; height: 132px; border-radius: 999px; background: var(--lime); } +.final-disc { display: flex; align-items: center; justify-content: center; width: 132px; height: 132px; border-radius: 22.5%; background: var(--lime); } .h2-final { font-weight: 900; font-size: clamp(52px, 6.7vw, 96px); line-height: 0.96; letter-spacing: -0.06em; color: #fff; } .final-lede { color: #a3a3a0; } .s5 .ctas { justify-content: center; padding-top: 8px; } @@ -329,7 +333,7 @@ button.pill { border: 0; font-family: inherit; cursor: pointer; } .mac-note:focus { outline: none; } .mac-note::backdrop { background: rgba(10, 10, 10, 0.55); } .mac-note [hidden] { display: none !important; } -.mac-note-disc { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 84px; height: 84px; margin-bottom: 18px; border-radius: 999px; background: var(--lime); } +.mac-note-disc { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 84px; height: 84px; margin-bottom: 18px; border-radius: 22.5%; background: var(--lime); } .mac-note h2 { font-weight: 800; font-size: 28px; line-height: 32px; letter-spacing: -0.035em; } .mac-note p { max-width: 320px; margin-top: 10px; font-size: 16px; line-height: 23px; color: var(--graphite); } .mac-note-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; width: 100%; margin-top: 24px; } @@ -376,7 +380,7 @@ button.pill { border: 0; font-family: inherit; cursor: pointer; } .access-new { display: none; } .mac-note { padding-top: 22px; } .mac-note-disc { width: 64px; height: 64px; margin-bottom: 12px; } - .mac-note-disc svg { width: 46px; height: 46px; } + .mac-note-disc svg { width: 61px; height: 61px; } } @media (prefers-reduced-motion: reduce) { .mac-note[open], .access-done { animation: none; }