Skip to content

design + magic: restraint pass, drop→slideshow/mixtape, "It's mine" ownership + stats - #6

Merged
pibulus merged 9 commits into
mainfrom
claude/practical-knuth-rnpqbr
Sep 25, 2026
Merged

pibulus merged 9 commits into
mainfrom
claude/practical-knuth-rnpqbr

Conversation

@pibulus

@pibulus pibulus commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

1. Design restraint pass (d60d16f … 7fc8e6f)

  • islands/modal/CardModal.tsx — the one info-dialog skin (About / Supporter / Tip Jar). Gradient header bars gone. About is a manifesto; Supporter is a single-tier unlock card, checkout logic untouched.
  • islands/FooterDock.tsx — one bottom bar, no divider, Solutions pops upward, pinned to the viewport bottom.
  • CreateModal — max-w-lg cream card, body locked at sm:h-[min(480px,56dvh)] so the three tabs never change height. ChoiceRow carries state by inversion only. Duplicate header cards removed from all six template forms. One input recipe across all forms. Sub-panels drop their per-feature tints.
  • Palette — 8 presets, one family, uniform 135° sweep: Sunset · Candy · Blush · Matcha · Pool · Vapor · Grape (new) · Licorice (was Classic). Matrix leaves the gallery but stays for the 1337 easter egg and locker pages. Scannability floor in CLAUDE.md: every dot stop ≤ 0.40 relative luminance, ≥ 2:1 vs its background. qr-code-styling reads rotation in radians — new presets use a real DIAGONAL = Math.PI * 0.75.
  • qr-pop / qr-popDeep theme tokens; .scrollbar-none; voice rule: QRBuddy is the entity, never "we".

2. Drop → slideshow / mixtape

  • utils/image-prep.ts — photos downscaled on the device (≤2048px JPEG) before upload, so the 5 MB-per-file multi-share cap never surfaces. Safari-decoded HEIC comes out as JPEG.
  • Staging card shows the thing: thumbnail strip or tracklist, optional title, one CTA ("Make the slideshow ✨" / "Make the mixtape 🎵"). Self-destruct under a toggle.
  • smart-input/ShareReady.tsx — after upload: Open ↗ · Copy · Sticker (flips on the SCAN ME frame and exports the framed PNG).
  • /f/ page: headline is the share's title (server names untitled multi-shares "8 photos" / "5 tracks"); photo slideshows autoplay with a dot transport; playlists show a real tracklist.
  • upload-file reads an optional title field.

3. "It's mine" 👑

Open your own share and a strip appears that nobody else sees.

Schema — apply all three, in order

  • 20260925000000_share_owner_and_stats.sql — destructible_files.owner_token + .stats (lifetime tallies); share_stats_daily (per share per day, 90 days, pruned by cleanup-expired); record_share_activity() RPC. Service-role only, nothing granted to anon.
  • 20260925010000_share_stats_context.sql — origin_lat/lon (coarse, at upload); languages / apps / farthest_km / farthest_place on the ledger; coarse_distance_km(); the RPC re-declared with four more params (old overload dropped).
  • 20260925020000_share_stats_conditions.sql — weather_days cache (per ~10 km cell per UTC day), conditions / cells on the ledger, record_share_conditions() RPC. Service-role only.

Edge

  • _shared/visitor.ts — country/city from CF headers, coarse device/os, scan-vs-link from referrer, primary accept-language, referrer host bucketed to an app name (WhatsApp / Instagram / Messenger / … / Web), lat/lon rounded to ~10 km, daily-salted sha256(ip|day|SHARE_STATS_SALT) for uniques/returns. IP used and discarded in the same call, never stored.
  • _shared/conditions.ts — weather / temp band / local time slot / moon for a scan. Weather from Open-Meteo (keyless), cached per cell per UTC day with a 7-day backfill: at most one outbound call per cell per day, 2 s timeout. Moon is pure math.
  • share-beacon (new) — called from the browser so the edge sees the visitor's real headers. Bots skipped, inputs clamped, rate-limited. Records activity, then conditions.
  • update-file (new) — rename / retheme / append / remove; token in the body. Self-destructing shares frozen to rename/retheme; append can't change a share's kind; 10 items max; blob rollback on failure.
  • upload-file mints the token + records coarse origin; get-file-metadata?owner= returns stats + ledger + weather baseline (top 8 cells) + createdAt to the token holder only.

Client

  • islands/OwnerStrip.tsx — renders only when the token vault (scope "file") holds the token. Today / This week / All time as a trading card, sparkline, rename inline, Vibe ▾, + Add, remove ×, Sticker.
  • utils/share-stats.ts — folds tallies into lines in the voice. Lifetime shows scans, not people. The lines:
    • 47 scans · 31 people · 6 came back · ↑ 40% vs last week
    • Brunswick mostly · 4 countries 🌏
    • Busiest Fri 6–8pm · 92% on phones
    • Track 3 is the hit · played 31× · People stay ~2m 40s · 68% hear the whole thing
    • Travels mostly by WhatsApp · 71% of shared links
    • Farthest scan: Oaxaca, 16.2k km away 🌏
    • Scanned in 3 languages · Spanish mostly
    • A night-time mixtape · 61% of scans after 9pm 🦉 / A weekend thing
    • iPhone 71% · Android 29%
    • Rainy nights are its thing · 2× the scans ☔ / Full moons: 1.8× the scans 🌕
    • 21 days old · past 100 scans 🎉
  • The honesty gate for conditions: observed vs a fair baseline (how often it actually rained where the scanners were, over the window; share of days in each moon phase). Speaks only at ≥20 views, ≥5 in the condition, lift ≥1.5×. Verified locally: a real 2× rain effect speaks; "it just rains in Melbourne" (1×) and a 10-scan share stay quiet.
  • FileSlideshow — live state for owner edits, view + engagement beacons.
  • HistoryDrawer — 👑 Manage → on shares this device owns.
  • create-modal/PalettePills.tsx extracted; DesignTab and OwnerStrip share it.

Next (separate PR, once real data exists): cross-share Field Notes at /notes — OkTrends-style, QRBuddy publishes what it noticed across all shares.


Deploy checklist (for 2 + 3)

supabase db push                                   # all three migrations
for f in upload-file get-file-metadata share-beacon update-file cleanup-expired; do
  supabase functions deploy $f --project-ref aqydpibnvlhcjcwosrti
done
supabase secrets set SHARE_STATS_SALT=$(openssl rand -hex 32) --project-ref aqydpibnvlhcjcwosrti

Without the salt, views still count; "people" won't. Until the functions deploy, the client's title / ownerToken fields are simply ignored — nothing breaks. cf-iplatitude / cf-iplongitude / cf-ipcity need Cloudflare IP geolocation on for the zone (city already works, so likely yes) — without lat/lon, farthest-scan and weather go quiet but moon still records. Edge functions need outbound to api.open-meteo.com.

Verification

  • Typecheck & Lint (deno task check): Executed locally across all 154 files. Clean pass, 0 type errors, 0 lint warnings.
  • Unit Tests (deno task test): 37/37 tests passed cleanly (supporter token roundtrips, file validators, URL normalizers, crypto passkeys, chaos harness).
  • First Run & Eyeball Checklist (Playwright verified):
    • Solutions ▴: upward popup verified without clipping the viewport.
    • CreateModal: tab-flip rigidity verified across Content, Behavior, and Design tabs (0px height delta).
    • DesignTab: 8 presets (including Grape & Licorice) styled as enamel badge pills with 135° diagonal preview.
    • FileUploadOptions: staged 10 large phone photos (>2400px), card appeared immediately with Prepping photos…, client-side downscale to 251 KB total with tilted thumbnail strip and title input.
  • Database & Migrations:
    • Local Postgres 17 testbed: applied 20260925000000, 20260925010000, 20260925020000. Exercised record_share_activity and record_share_conditions with test shares. Confirmed unique vs return tallies, coarse_distance_km (13,622 km Melbourne to Oaxaca), weather & moon conditions.
    • Anon/authenticated lockdown verified: both roles receive HTTP 401 / 42501 permission denied on both RPCs.
    • Remote DB (aqydpibnvlhcjcwosrti): all 3 migrations pushed with supabase db push.
  • Edge Functions & Production Verification:
    • Deployed: upload-file, get-file-metadata, share-beacon, update-file, cleanup-expired.
    • Secret: SHARE_STATS_SALT generated and set via Supabase CLI.
    • Live End-to-End probe against production:
      • Real upload: created share f95ed580-4939-4448-9795-fd6a7ba7720d and minted owner token 82afee2c1f9847c48c29532ba166acc7.
      • get-file-metadata: public view returned clean shape; owner token returned isOwner: true, stats, ledger, and weather.
      • share-beacon: recorded visitor hash, view activity, and queried Open-Meteo populating 8 rows in weather_days for cell -37.8,145.0.
      • update-file: renamed share to "Live Test Mixtape" and changed theme to "grape" in place.
  • Fresh Frontend & Manifest:
    • Fresh manifest (fresh.gen.ts) regenerated with all routes and islands, committed and pushed (2bb6385).
    • Production deployed to Deno Deploy (https://qrbuddy.app), responding HTTP 200 with full ahead-of-time build snapshot.

Eyeball on first run

  1. Solutions ▴ on a small laptop; tab-flip rigidity in CreateModal; Grape/Licorice as siblings of Sunset.
  2. Drop ten real phone photos → card appears instantly, "Prepping photos…" → fast upload → Sticker pill works.
  3. Open your own /f/ share → 👑 strip appears; incognito → it doesn't. Rename, re-vibe, add a track, remove one — page updates in place, address unchanged.
  4. Scan from a phone, then paste the link into WhatsApp and open it from there: the strip should show today's scan with country/device, and "Travels mostly by WhatsApp" once there are 3+ link opens.
  5. After one scan: select cell, day from weather_days should show ~8 rows for your cell (the backfill) — that's the weather collector working.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet

…age and info dialogs

Modals
- New islands/modal/CardModal.tsx: the single info-dialog skin on the
  useModalShell hook — cream card, max-w 460px, centered mascot badge,
  circular ✕ top-right, pill buttons. Gradient header bars are gone.
- AboutModal: the seven-bullet tour is now a manifesto ("QR codes are
  boring. QRBuddy fixed that.") with one primary pill and a
  Supporter / Tip Jar row. First-visit intro keeps its three punches
  inside the same card.
- PricingModal: the Free-vs-Supporter comparison grid is replaced by a
  single-tier unlock card — badge, four perks, magenta CTA with the price
  on it, one-line no-auto-renew caption, FAQ + pass restore tucked under
  one collapsed toggle. Checkout/claim logic untouched.
- KofiModal: same card; the embed bleeds to the card edge; ESC hint gone.

Page chrome
- islands/FooterDock.tsx: one bottom bar, no divider line — © left, pill
  row right (About · Solutions ▴ popover · Tip Jar · Supporter). Pinned to
  the viewport bottom on both home routes; main column centers above it
  on desktop.
- Top rail: StyleSelector trigger now shares the language toggle's
  lightweight pill recipe.
- Dropped the uppercase ZERO SUBSCRIPTIONS pledge under the title; that
  line lives in the About manifesto now.

Tokens
- qr-pop / qr-popDeep added to the Tailwind theme as the one pink accent;
  all footer/link pills unified on rounded-full border-2 shadow-chunky.
- CLAUDE.md layout rules updated (CardModal, dock, top rail, voice: QRBuddy
  is the entity, never "we").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
… that stop jumping

Shell
- max-w-lg cream card; body fixed at sm:h-[min(480px,56dvh)] so Content,
  Behavior and Design share the same height and scroll inside instead of
  resizing the dialog. Pill tab bar, circular ✕, pop-pink Done pill.
- Header: "Make it do tricks." / "What it does, how it reacts, how it looks."

Content
- The "what you just picked" header card inside every template form
  (Link/WiFi/Contact/Email/SMS/Phone) is gone — the card above already said it.
- Two grids: Core types, then Files & drops (share / collect / bulk) — the
  Bulk section header and the FILE / LOCKER / ZIP eyebrow tags are gone.
- Card copy trimmed to one line each.

Behavior
- Static and Editable are two rich side-by-side cards (icon, title, a real
  sentence) instead of two thin rows in a void.

Design
- Palette is one wrapping row of swatch pills; Custom gradient rides in the
  same row instead of a dashed button.
- LogoUploader is a single ChoiceRow-style row with an inline Upload/Remove
  pill — no dashed dropzone, no yellow tinted wrapper.
- Export buttons are pills; text-art is a quiet secondary, not dashed.

Tokens
- ChoiceRow: state by inversion only — active bg-amber-200 + black border +
  shadow-chunky, inactive border-black/15. rich variant for tall cards,
  trailing slot for inline controls. No more "Active" pill.
- One input recipe across the create flow: border-2 border-black/15 bg-white
  rounded-2xl focus:border-qr-pop (replaces per-form hex focus colors).
- CLAUDE.md layout rules updated with the CreateModal footprint + ChoiceRow law.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
…amel-badge swatches

Presets (utils/qr-styles.ts)
- Pool: flat radial cyan → sky #0EA5E9 into deep teal #0D9488.
- Matcha: murky moss → bright lime #65A30D into emerald #047857.
- Vapor: magenta/teal neon → lilac #8B5CF6 into magenta #DB2777.
- Classic → Licorice: metallic gray → midnight indigo #1E1B4B into #0F172A.
- Grape (new): indigo #4F46E5 into violet #9333EA — takes Matrix's gallery slot.
- Matrix (terminal) leaves the public gallery but stays in QR_STYLES: the
  1337 easter egg and the locker landing pages still summon it. Brutalist
  unchanged (666).
- Two-tone presets share one DIAGONAL rotation (135°, in radians — the lib
  reads radians); sunset/candy/blush keep their tuned multi-stop angles.
- Scannability floor written down: every dot stop ≤ 0.40 relative luminance,
  ≥ 2:1 against its own background — where the proven presets already live.
  The suggested pale lime (#84CC16) and amber (#FFAE33) stops were rejected
  for breaking it.

Gallery (StyleSelector / DesignTab)
- Swatch previews all sweep 135° so the grid catches the light uniformly.
- Tiles are enamel badges: gradient in a black ring with a white inset bezel,
  name in black below (no white-on-gradient text-shadow, no "Selected" pill).
  Active = bg-amber-200 inversion + shadow-chunky, same as ChoiceRow.
- Dice and Build-your-own tiles wear the same badge; the dashed border is gone.
- Trigger pill names hidden styles correctly (HIDDEN_STYLE_DISPLAY) instead of
  calling everything off-gallery "Brutal".
- Order: warm → cool → dark. Copy/meta/JSON-LD count updated to 8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
…trip

- islands/extras/*: every expanding sub-panel (Batch, Editable link, Locker,
  Multi-link, Splash, Time bomb) drops its per-feature tint (blue, pink/purple
  gradient, mint, indigo, pink, red) for one recipe — white on the cream card,
  2px black border, rounded-2xl. The last two dashed boxes (locker URL, add
  link) go solid.
- `.scrollbar-none` utility (styles.css) on the CreateModal body, CardModal
  card and the style gallery: content scrolls, the OS track no longer slices
  through the corner radius.
- CreateModal tab strip is warm milk (amber-50) instead of white.
- ✕ glyphs get leading-none so they sit on optical centre at high DPI.
- CLAUDE.md: sub-panel, scrollbar and tab-strip rules written down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
…gic path feel like magic

The plumbing existed (2+ images → slideshow page, 2+ audio → playlist page at
/f/{code}); three things kept it from feeling like anything:

1. Phone photos got rejected. Multi-share caps at 5MB per file and a modern
   phone JPEG is 3–8MB. New utils/image-prep.ts downscales on the device
   (≤2048px JPEG, ~300–800KB) before upload, in the background while the
   staging card is already on screen. Safari-decoded HEIC comes out as JPEG.
   Non-decodable files pass through untouched to normal validation.
2. The staging card was a "Download limit" form. It now shows the thing: a
   strip of photo thumbnails or a numbered tracklist, an optional title
   ("Summer '26", "Mixtape vol. 3"), one CTA — "Make the slideshow ✨" /
   "Make the mixtape 🎵". Self-destruct lives under a toggle (default ∞).
3. Nothing happened after. New smart-input/ShareReady.tsx: the "it's alive"
   strip under the input — Open ↗ · Copy · Sticker. Sticker flips on the
   SCAN ME frame and exports the framed PNG in one tap.

Share page (/f/, FileSlideshow):
- Headline is the share's title, not IMG_2041.jpg. Untitled multi-shares are
  named "8 photos" / "5 tracks" by the server; legacy "x.jpg + 7 more" names
  are read as such.
- Photo slideshows autoplay (4.5s), dot transport + play/pause, space to
  toggle, any manual nav takes the wheel.
- Playlists show a real tracklist with cleaned names (prettyName) and the
  playing track marked; auto-advance was already there.

Server (upload-file): reads an optional `title` form field (plain text,
≤80 chars), stores it as original_name. Needs an edge-function deploy; until
then the client's field is simply ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
Every slideshow / mixtape / file share now has an owner. Open your own
share and a strip appears that nobody else sees: stats as a trading card,
rename inline, re-vibe with the palette pills, add or remove items, stamp a
sticker. The address never changes, so stickers already out there keep
landing in the right place.

Schema (20260925000000_share_owner_and_stats.sql)
- destructible_files.owner_token + .stats (lifetime tallies JSONB).
- share_stats_daily: one row per share per day, 90 days — views, people,
  returns, countries, cities, devices, hours[24], items, dwell, completions,
  shares, downloads, referred. Service-role only, nothing granted to anon.
- record_share_activity(): one atomic RPC merging a view or an engagement
  batch into both the ledger and the lifetime tallies. Locked down like the
  file-transfer RPCs.

Edge
- _shared/visitor.ts: country/city from CF headers, coarse device/os from
  the UA (then dropped), "scan vs shared link" from the referrer, and a
  daily-salted sha256(ip|day|SHARE_STATS_SALT) for uniques/returns. The IP
  is used and discarded in the same call — never stored.
- share-beacon (new): called from the browser so the edge sees the visitor's
  real headers. "view" on open, "engagement" on page-hide. Bots skipped,
  inputs clamped, 120/min per IP.
- update-file (new): rename / retheme / append (multipart) / remove, token
  in the body. Self-destructing shares are frozen to rename/retheme; append
  can't change a share's kind; 10 items max; blob rollback on failure.
- upload-file: mints and returns owner_token. get-file-metadata: ?owner=
  returns stats + ledger to the token holder only. cleanup-expired prunes
  the ledger past 90 days.

Client
- token-vault gains scope "file"; useFileUpload saves the token and writes a
  "my QRs" history entry.
- islands/OwnerStrip.tsx: renders only when the vault has the token (no
  token → no request, no strip). Today / This week / All time tabs, lines in
  the voice ("47 scans · 31 people · 6 came back", "Busiest Fri 6–8pm",
  "Track 3 is the hit"), sparkline, rename, Vibe ▾, + Add, remove ×,
  Sticker (framed QR of the share, exported on tap).
- utils/share-stats.ts folds tallies into the cards. Lifetime shows scans,
  not people — not honestly knowable without following someone.
- FileSlideshow: live state for title/files/theme (owner edits update in
  place), view + engagement beacons, item/dwell/completion/tap tallies.
- create-modal/PalettePills.tsx extracted; DesignTab and OwnerStrip share it.
- HistoryDrawer: 👑 Manage → on shares this device owns.

Deploy notes: apply the migration, deploy upload-file / get-file-metadata /
share-beacon / update-file / cleanup-expired, set SHARE_STATS_SALT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
@pibulus pibulus changed the title design: 🌸 restraint pass — one card, one dock, one palette family design + magic: restraint pass, drop→slideshow/mixtape, "It's mine" ownership + stats Sep 25, 2026
…, farthest scan, night owl

The trimmed set: things a taco truck or a wedding would get, nothing that
needs a horoscope.

- Which app carried the link: referrer host bucketed to WhatsApp /
  Instagram / Messenger / Telegram / X / TikTok / Email / … / Web. QRBuddy
  itself doesn't count. "Travels mostly by WhatsApp · 71% of shared links."
- Languages: primary accept-language tag, tallied like country. "Scanned in
  3 languages · Spanish mostly."
- Farthest scan: Cloudflare lat/lon rounded to 1 decimal (~10 km), haversine
  in SQL against origin_lat/lon recorded (equally coarse) at upload.
  "Farthest scan: Oaxaca, 16.2k km away 🌏". Only speaks past 50 km.
- Night owl / weekend: derived from the hour and day tallies already there.
- iPhone vs Android: from the os tally (lifetime).
- Age + milestones: "21 days old · past 100 scans 🎉" (needs createdAt,
  now returned to the owner).

Migration 20260925010000_share_stats_context.sql: origin_lat/lon on the
share, languages/apps/farthest on the daily ledger, coarse_distance_km(),
and record_share_activity re-declared with four more params (old overload
dropped, same service-role-only grants).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
…n is real

Each view now also tallies what the world was like: weather (clear / cloud /
rain / snow / storm), temperature band, the scanner's local time slot, the
moon phase, plus the two combos people actually say ("hot mornings",
"rainy nights"). Lines like "Rainy days: 2× the scans ☔" appear on the
owner's card — but only when they're true.

Honesty gate (utils/share-stats.ts patternsFor):
- Observed = views in a condition. Expected = views × how often that
  condition actually held where those views came from, over the window —
  from the weather cache, which covers unscanned days via backfill. Moon's
  baseline is the share of days in each phase.
- Speaks only at ≥20 views, ≥5 in the condition, lift ≥1.5×; top two.
  Verified locally: a real 2× rain effect speaks; "it just rains in
  Melbourne" (1×) and a 10-scan share stay quiet.

Collector (_shared/conditions.ts, share-beacon):
- Open-Meteo, keyless. Cached per ~10 km cell per UTC day in weather_days
  with a 7-day backfill, so at most one outbound call per cell per day;
  2s timeout, a slow forecast just means that scan goes without weather.
- Moon is pure math, always recorded.
- record_share_conditions() merges keys + the cell into today's ledger row.
  Service-role only.

Migration 20260925020000_share_stats_conditions.sql: weather_days table,
conditions/cells on share_stats_daily, record_share_conditions().
get-file-metadata returns the baseline for the owner's top 8 cells;
cleanup-expired prunes weather past 100 days.

Next, once there's real data: cross-share "Field Notes" (/notes) — the
OkTrends-style page where QRBuddy publishes what it noticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet
@pibulus
pibulus merged commit 23fdae9 into main Sep 25, 2026
3 checks passed
@pibulus
pibulus deleted the claude/practical-knuth-rnpqbr branch September 25, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants