Repository navigation
design + magic: restraint pass, drop→slideshow/mixtape, "It's mine" ownership + stats - #6
Merged
Merged
Conversation
…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
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
…, 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.max-w-lgcream card, body locked atsm:h-[min(480px,56dvh)]so the three tabs never change height.ChoiceRowcarries 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.1337easter egg and locker pages. Scannability floor in CLAUDE.md: every dot stop ≤ 0.40 relative luminance, ≥ 2:1 vs its background.qr-code-stylingreadsrotationin radians — new presets use a realDIAGONAL = Math.PI * 0.75.qr-pop/qr-popDeeptheme 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.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-filereads an optionaltitlefield.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 bycleanup-expired);record_share_activity()RPC. Service-role only, nothing granted toanon.20260925010000_share_stats_context.sql—origin_lat/lon(coarse, at upload);languages/apps/farthest_km/farthest_placeon the ledger;coarse_distance_km(); the RPC re-declared with four more params (old overload dropped).20260925020000_share_stats_conditions.sql—weather_dayscache (per ~10 km cell per UTC day),conditions/cellson 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, primaryaccept-language, referrer host bucketed to an app name (WhatsApp / Instagram / Messenger / … / Web), lat/lon rounded to ~10 km, daily-saltedsha256(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-filemints 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 weekBrunswick mostly · 4 countries 🌏Busiest Fri 6–8pm·92% on phonesTrack 3 is the hit · played 31×·People stay ~2m 40s · 68% hear the whole thingTravels mostly by WhatsApp · 71% of shared linksFarthest scan: Oaxaca, 16.2k km away 🌏Scanned in 3 languages · Spanish mostlyA night-time mixtape · 61% of scans after 9pm 🦉/A weekend thingiPhone 71% · Android 29%Rainy nights are its thing · 2× the scans ☔/Full moons: 1.8× the scans 🌕21 days old · past 100 scans 🎉FileSlideshow— live state for owner edits, view + engagement beacons.HistoryDrawer— 👑 Manage → on shares this device owns.create-modal/PalettePills.tsxextracted; 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)
Without the salt, views still count; "people" won't. Until the functions deploy, the client's
title/ownerTokenfields are simply ignored — nothing breaks.cf-iplatitude/cf-iplongitude/cf-ipcityneed 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 toapi.open-meteo.com.Verification
deno task check): Executed locally across all 154 files. Clean pass, 0 type errors, 0 lint warnings.deno task test): 37/37 tests passed cleanly (supporter token roundtrips, file validators, URL normalizers, crypto passkeys, chaos harness).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 withPrepping photos…, client-side downscale to 251 KB total with tilted thumbnail strip and title input.20260925000000,20260925010000,20260925020000. Exercisedrecord_share_activityandrecord_share_conditionswith test shares. Confirmed unique vs return tallies,coarse_distance_km(13,622 km Melbourne to Oaxaca), weather & moon conditions.aqydpibnvlhcjcwosrti): all 3 migrations pushed withsupabase db push.upload-file,get-file-metadata,share-beacon,update-file,cleanup-expired.SHARE_STATS_SALTgenerated and set via Supabase CLI.f95ed580-4939-4448-9795-fd6a7ba7720dand minted owner token82afee2c1f9847c48c29532ba166acc7.get-file-metadata: public view returned clean shape; owner token returnedisOwner: true,stats,ledger, andweather.share-beacon: recorded visitor hash, view activity, and queried Open-Meteo populating 8 rows inweather_daysfor cell-37.8,145.0.update-file: renamed share to "Live Test Mixtape" and changed theme to "grape" in place.fresh.gen.ts) regenerated with all routes and islands, committed and pushed (2bb6385).https://qrbuddy.app), responding HTTP 200 with full ahead-of-time build snapshot.Eyeball on first run
Solutions ▴on a small laptop; tab-flip rigidity in CreateModal; Grape/Licorice as siblings of Sunset./f/share → 👑 strip appears; incognito → it doesn't. Rename, re-vibe, add a track, remove one — page updates in place, address unchanged.select cell, day from weather_daysshould show ~8 rows for your cell (the backfill) — that's the weather collector working.🤖 Generated with Claude Code
https://claude.ai/code/session_01FPzBAXN9jpnvWncMrM6vet