- profileStore (MMKV) + pure profileLogic (types, options, isProfileComplete,
buildPersona). Rich first-run onboarding (app/onboarding.tsx): name/role,
industry, vibes (multi), goal, brand colors, social links, inspirations.
- First-launch gate in (tabs)/_layout via <Redirect> guarded by a hydration
flag; onboarding registered in the root stack.
- Bottom tab bar made taller with safe-area bottom padding.
- Backpack (Cards tab): Grid / Showcase view toggle + add-new entry.
- Business cards skip data entry: demo.tsx prefills name/title/links from the
profile (creative tools lead).
- Persona → AI: SceneBrief/refine_prompt gain an optional persona (role/vibe/
colors/goal); mobile generators pass buildPersona(profile). FakeAiClient
echoes the role. genwizard gains MultiChips.
- Tests: mobile 28 (new profileLogic), backend 121 (new refine_includes_persona).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Experience #9 (lite): a published card can carry a "Now" line shown with a live
pulsing dot, so a scanned profile reflects what the person is currently doing.
- Backend: demo publish accepts optional `now` -> definition.profile.now; test
asserts it. Gate green (120 tests).
- Web: renders a "Now: …" block with an animated green dot. astro check clean.
- Mobile: a "Now" field on the publish screen.
Verified live alongside a link payload (CTA button).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Experience #8: a published card can carry a payload that's shown prominently on
scan — a primary link CTA, or a discount/access code with tap-to-copy.
- Backend: demo publish accepts an optional payload {kind, label, value},
stored in definition.profile.payload (flows through the public profile). Test
extended to assert it. Gate green (120 tests).
- Web ([handle].astro): renders a "link" payload as a big CTA button and a
"code" payload as a dashed chip with copy-on-tap. astro check clean.
- Mobile (publish.tsx): payload kind chips (None/Link/Code) + label + value,
sent with publishToWeb.
Verified live: publish with a code payload -> profile shows a copyable CARD20
chip; link payload -> CTA button.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Turns a no-login demo card into a real, scannable web profile with an optional
AI welcome — completing the owner side of the welcome feature without auth.
Backend:
- POST /v1/demo/publish (public, rate-limited 10/h/IP): creates a fresh
password-less owner (so the profile shows the right name + fits the free-tier
1-card limit), builds a card (contact title + profile links), optionally
generates an AI welcome, and publishes. Returns { handle, profileUrl }.
- demo_service + handlers/demo; 2 tests (publish→public profile w/ welcome,
name required). Gate green (120 tests). Verified live: publish → Astro renders
the welcome hero + image (from MinIO) + We-Met form, 18KB page.
Mobile:
- publish.tsx: enter an optional AI welcome scene + message, Publish → shows the
live cardclaws URL (Open/Done) and points the card's QR at it.
- demo.tsx: "Publish to web · AI welcome" button (saves, then opens publish).
- api.publishToWeb; LocalCard.publishedUrl.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The welcome image was inlined as a ~2.6MB data URL in the card, bloating every
public-profile fetch. Now it's uploaded to S3-compatible object storage and the
profile carries just a URL (profile JSON dropped ~2.6MB -> ~575 bytes).
- ObjectStore: add put_object(key, bytes, content_type) + public_url(key).
R2Store uploads via a presigned PUT; InMemoryStore keeps bytes for tests.
- R2Config: add public_base (R2_PUBLIC_BASE); falls back to endpoint/bucket.
- card_service::set_welcome: decode the generated image, upload to
welcome/<id>.<ext>, store its public URL as welcome.imageUrl.
- docker-compose: add MinIO (S3-compatible, drop-in for R2) + a one-shot that
creates a public-read cardclaws-assets bucket for local dev.
- web: PublicProfile.welcome.imageUrl (renamed from imageDataUrl); WelcomeHero
unchanged. astro check clean.
Verified live against MinIO: real Gemini image uploaded, served publicly
(HTTP 200, image/png, 1024x1024), profile payload now tiny. Same code path runs
against Cloudflare R2 in production (endpoint/creds/public_base only). Gate green
(118 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Experience #1 from the scan-experiences brief: when someone scans a card and
shares back, capture the meeting (with coarse geo + time) so the owner gets a
"People I met" list.
Backend (cardclaws-backend):
- migration 0006_connections.sql: connections table (card_id, owner_id, name,
email, note, country, city, met_at). Raw IP never stored — only resolved geo.
- POST /v1/profile/:handle/connect (public, rate-limited 5/min): captures the
connection with geo, emails the owner best-effort.
- GET /v1/cards/:id/connections (owner-only): the People-I-met list.
- ConnectionRow model + connections queries (insert, list_by_card);
profile_service::submit_connection; card_service::list_connections.
- 2 tests (capture→owner-list + validation, owner-only); gate green (118 tests).
- Verified live end-to-end.
Web (cardclaws-profile):
- [handle].astro: a "We met?" share-back form posting to /connect; astro check
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
When a card is scanned, the public profile can open with a full-bleed,
AI-generated welcome hero before the contact info — experience #10 from the
scan-experiences brief, on the share-token experience selector foundation.
Backend (cardclaws-backend):
- migration 0005_welcome.sql: add cards.welcome JSONB; flows through
PublicProfile via serde flatten.
- POST /v1/cards/:id/welcome (owner-gated): refine + generate via the existing
AiClient (nano-banana), store {kind, message, imageDataUrl}.
- card_service::set_welcome + queries::update_welcome; CardRow gains `welcome`.
- 2 tests (generate→public-profile, ownership); workspace gate green (116 tests).
- Verified live with real Gemini end-to-end.
Web (cardclaws-profile):
- WelcomeHero.astro: full-bleed AI hero + greeting + "scroll to connect" cue.
- [handle].astro renders it when the card has a welcome; experience selector
(?x=profile suppresses) as the lever for the other experiences.
- PublicProfile gains an optional `welcome`; astro check clean.
MVP inlines the image as a data URL; production swaps to an R2 URL (isolated in
the welcome column).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Server-side proxy so the Google API key (from Infisical) never reaches the
client. Behind an AiClient trait with a fake for headless tests.
- ai.rs: GeminiClient — refine prompt (gemini-2.5-flash), generate image
(gemini-2.5-flash-image / "nano-banana"), and async video (veo-2.0:
predictLongRunning → poll → proxy the redirecting download). DisabledAiClient
when no key is configured.
- POST /v1/ai/refine, /v1/ai/image (rate-limited 60/h, 20/h)
- POST /v1/ai/video (submit, 5/h) + /v1/ai/video/status (poll → base64 mp4)
- config: GEMINI_API_KEY via SecretSource
- 5 endpoint/parse tests; full workspace gate green (114 tests)
Live-verified: refine + nano-banana return through the proxy; Veo submits,
polls to done in ~60s, and streams back a valid mp4.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Idempotent hourly rollup (run_rollup, INSERT ... ON CONFLICT DO UPDATE into
analytics_rollups_hourly) + Tokio background task (startup then hourly)
- GET /v1/cards/{id}/analytics/geo — country-grouped visit counts
- GeoResolver trait: real MaxMind under the `geoip` feature, no-op default;
raw IP resolved then discarded (only hash + coarse country/city persisted)
- analytics_events now store country/city; rollups read query for the dashboard
2 new tests (geo grouping, rollup aggregation + idempotency); 75 backend tests.
fmt + clippy clean; geoip feature compiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- share_links model/queries; opaque 12-char base62 tokens (PRD §17.1)
- POST /v1/cards/{id}/share — create share link (owner-scoped, modality-validated)
- GET /v1/s/{token} — record modality-attributed event (qr→qr_scan, nfc→nfc_tap)
and 302-redirect to the profile; unknown/expired → 404
- GET /v1/cards/{id}/share-links — list a card's links
- analytics_events now carry the share_token correlation
- GET /v1/cards/{id}/analytics/feed — chronological event feed (§6.7.2)
6 new integration tests; 73 backend tests total. fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>