09 — engagement-features (Design)
09 — engagement-features (Design)
Section titled “09 — engagement-features (Design)”Status: Draft · Date: 2026-05-23 · Owner: frontend Depends on: 04 (close trigger), 05 (saga events), 06 (WS envelope), 07 (markup host + like backend), 08 (sibling). Binding refs: master D22, D23, D28, §4.4, §10.2.
1. Purpose & scope
Section titled “1. Purpose & scope”Purpose. The cinematic / viral / retention surfaces that turn each daily auction into a recurring micro-event.
In scope (09 owns): Web Push (VAPID) — endpoints, KV, payload, EN+DE templates, trigger matrix, rate limiting; Like button widget — markup, optimistic UI, cookie (backend in 07); Emoji-Bid-Grid — 24-cell algorithm, /grid/{date}.txt + .png; Web Share API + fallback chain; 3-phase countdown timer; reveal-ceremony client wiring driven by 06’s spotlight_revealed; “You Were This Close” dashboard card for ranks #2/#3.
Removed (2026-06-13): Disqus. The day-page comment embed (consent-gated lazy-load + nightly cold archive to D1) was originally in scope but has been removed from the product and will not be re-added (see master decision log). §4 below is kept as a tombstone so existing
spec 09 §4references in code/migrations resolve; AC6 and AC7 (§13) are likewise retired in place. The dormantDisqusSlot.astro/ConsentBanner.astro/apps/cronarchive lane and migration0020_disqus_archive.sqlremain in the tree but are unwired.
NOT MVP (Phase 3 per D23, listed so future agents don’t re-scope): Predict-the-Price, Bid-Equity rollover, streaks, Discord / Outbid Club, Bidder Report Cards, runner-up slot.
Out of scope (other sub-systems): WS DO + envelope (06), saga events that fire pushes (05), like persistence + count read (07), conversion pixel (08).
2. Web Push (VAPID)
Section titled “2. Web Push (VAPID)”Keys. VAPID_PUBLIC_KEY shipped to client; VAPID_PRIVATE_KEY Wrangler secret. One pair across environments (master §4.7).
Endpoints on api.nowyourlink.com (both require magic-link session cookie from 02). Implemented as Hono routes on apps/api (ADR-0001 / D40) with the sessionAuth middleware chained at mount.
POST /push/subscribe body: { endpoint, keys: { p256dh, auth }, locale }POST /push/revoke body: { endpoint }On subscribe: validate endpoint host against allowlist (fcm.googleapis.com, web.push.apple.com, updates.push.services.mozilla.com); hash endpoint_hash = SHA-256(endpoint).slice(0,16); store push:{user_id}:{endpoint_hash} → { endpoint, p256dh, auth, locale, created_at, last_ok_at }; index push:idx:{user_id} → [endpoint_hash,...] (cap 5, oldest evicted). Revoke + sign-out delete both rows.
Payload schema (≤3 KB, well under the 4 KB Web Push limit):
type PushPayload = { kind: "outbid" | "closing_soon" | "won" | "lost" | "this_close"; auction_id: string; // "YYYY-MM-DD" url: string; // deep link body_key: string; // i18n key vars?: Record<string, string | number>; ts: number; // SW de-dupe};Templates (EN / DE in packages/i18n/push.<locale>.json):
| body_key | EN | DE |
|---|---|---|
push.outbid | ”You’ve been outbid on {date}." | "Du wurdest am {date} überboten.” |
push.closing.2h / .30m / .5m | ”Auction for {date} closes in 2 hours.” / “30 minutes left on {date}.” / “Final 5 minutes — {date}." | "Auktion für {date} endet in 2 Stunden.” / “Noch 30 Minuten für {date}.” / “Letzte 5 Minuten — {date}.” |
push.won | ”You won The Spotlight for {date}." | "Du hast den Spotlight für {date} gewonnen.” |
push.lost | ”{date} went to another bidder." | "{date} ging an einen anderen Bieter.” |
push.this_close | ”So close — you were #{rank} on {date}." | "Knapp daneben — du warst #{rank} am {date}.” |
Rate limit per user: KV push:rl:{user_id}:{yyyymmdd} (expiration_ttl=86400), cap 8 / 24h. won and lost are exempt (terminal events). Cleanup: web-push 404/410 → delete KV row + index entry; 5xx → retain, retry next trigger.
3. Push trigger matrix
Section titled “3. Push trigger matrix”| Trigger | Source | When | kind | Notes |
|---|---|---|---|---|
| Outbid | 04 Queue bid.outbid | Accepted bid displaces previous top | outbid | Skip self-re-raise |
| Closing 2h / 30m / 5m | Cron | T-120 / T-30 / T-5 min | closing_soon | KV de-dupe push:closing:{date}:{slot}:{user_id} |
| Won | 05 saga | saga.charge_succeeded | won | Rate-limit exempt |
| Lost | 05 saga | saga.cascaded → previous top | lost | Targets pre-cascade top |
| This-close | 05 saga | Auction close, ranks #2 / #3 | this_close | vars.rank ∈ {2,3} |
Seven distinct triggers; all sends go through apps/api/push/send — a Hono POST /push/send route on apps/api (ADR-0001 / D40) gated by a service-binding header check; not publicly exposed. Idempotent per {user_id, kind, auction_id} (see process 09-push-notification-pipeline).
4. Disqus — REMOVED (2026-06-13)
Section titled “4. Disqus — REMOVED (2026-06-13)”The day-page comment embed and its nightly cold archive were removed from the product and will not be re-added. This section is retained only so prior
spec 09 §4references (code comments, migration0020_disqus_archive.sql, the dormantapps/cronarchive lane) still resolve to an explanation.Original design (no longer in effect): a
<div id="disqus_thread">on every/day/{date}, lazy-loaded viaIntersectionObserverand gated on functional consent, with a nightlydisqus-archivecron writing a JSON snapshot to D1disqus_archive(date, snapshot_json, archived_at).Consequences: there is no longer any non-essential-cookie vendor, so no cookie consent is required (spec 12 §6); Disqus is no longer an Art. 28 sub-processor (spec 12 §5); the day page renders no comment thread. Dormant artefacts (
DisqusSlot.astro,ConsentBanner.astro,apps/web/src/lib/disqus-embed.ts,apps/cron/src/disqus-archive.ts, migration0020) remain in the tree but are not wired into any render or schedule.
5. Like button
Section titled “5. Like button”<button class="nyl-like" data-date="2026-06-04" aria-pressed="false"> <span class="nyl-like-count" data-count="0">0</span></button>Initial count rendered server-side (07 fetches /like/{date}/count). Click → optimistic increment → POST /like/{date} (or DELETE if aria-pressed=true); cookie nyl-like-{date}=1 for UX dedupe (backend remains source of truth via 07’s visitor hash). Non-blocking; on error revert UI + toast “Try again”. Backend lives in 07.
6. Emoji-Bid-Grid
Section titled “6. Emoji-Bid-Grid”A 24-cell grid (4 rows × 6 cols) representing the last 24 h of bidding. Generated server-side at close + on demand for archive.
Algorithm.
- From D1
bidsview (mirrored from 04), count active bids per hour for the 24 h ending at close. - Compute
max = max(counts); bucket each hour byr = count / max:r === 0→ ⬜ ·r ≤ 0.25→ 🟦 ·r ≤ 0.5→ 🟩 ·r ≤ 0.75→ 🟨 ·r ≤ 0.9→ 🟧 · else → 🟥
- Lay out hour 0 (T-24 h) top-left → hour 23 (close) bottom-right.
- Header
nowyourlink — {date}, footernowyourlink.com/day/{date}.
Outputs. Text at GET /grid/{date}.txt (edge-cached 1 d) for copy-to-clipboard; PNG at GET /grid/{date}.png (240×160, @resvg/resvg-wasm, cached 7 d) for Web Share file path. Clipboard via navigator.clipboard.writeText; insecure-context <textarea> fallback.
7. Web Share API — fallback chain
Section titled “7. Web Share API — fallback chain”- Web Share with file — if
navigator.canShare?.({ files: [pngBlob] }), callnavigator.share({ title, text, url, files: [pngBlob] }). - Web Share without file —
navigator.share({ title, text, url }). - Twitter / X intent —
https://twitter.com/intent/tweet?text={enc}. - WhatsApp deep link —
https://wa.me/?text={enc}. - Copy URL —
clipboard.writeText+ toast “Link copied”.
A popover under the button surfaces all five explicitly once the primary share is invoked at least once.
8. 3-phase countdown timer
Section titled “8. 3-phase countdown timer”One component, three phases on secondsToClose:
| Phase | Window | Visual | Sound | A11y |
|---|---|---|---|---|
| Ambient | T-24 h → T-6 h | Calm blue/green gradient, slow tick | Silent | aria-live="off", hourly polite announce |
| Active | T-6 h → T-60 min | Amber, faster tick; subtle highlight if push enabled | Silent | aria-live="polite" every 15 min |
| Urgent | T-60 min → T-0 | Pulsing red; sub-second sweep <5 min | Optional soft tick last 60 s (off by default) | aria-live="assertive" at 30/10/5/1 min |
prefers-reduced-motion: reduce → static color shifts (no gradient sweep, no pulse). Clock anchored via Date.now() - serverOffsetMs from a /api/time HEAD; recomputed on visibilitychange→visible.
9. Reveal-ceremony animation
Section titled “9. Reveal-ceremony animation”Trigger. 06 WS envelope { type: "spotlight_revealed"; seq; date; winning_ad_id } from HubDO on activation.
Sequence (~3.5 s): page dims to 90 % (200 ms) → centered curtain wipe revealing the winning creative (fetched via /spotlight/{date}/creative, CF-cached) → soft CSS confetti → restore brightness, creative now the Spotlight.
Constraints. GPU-cheap (transform + opacity only); prefers-reduced-motion: reduce → instant 300 ms cross-fade; fires once per seq; if WS not connected, fires on first render after activation via data-activated-at attribute.
10. “You Were This Close” surface
Section titled “10. “You Were This Close” surface”Dashboard card at /app for ranks #2 / #3 in the last 7 days. Read endpoint /app/api/this-close:
type ThisClose = { date: string; rank: 2 | 3; gap_increments: number; // RELATIVE only — count of 1€ increments to rank above};Privacy: never shows absolute amounts. Copy: “You were #{rank} on {date} — missed by {gap_increments} bid increments.” A kind: "this_close" push fires in tandem (§3).
11. Performance
Section titled “11. Performance”Push payloads ≤3 KB. Like button is one non-blocking fetch (revert on error). Countdown timer uses a single requestAnimationFrame loop across all instances. Reveal animation uses compositor-only properties (60 fps verified on mid-range Android). Bid-Grid PNG Cache-Control: public, max-age=604800, immutable.
12. Edge cases
Section titled “12. Edge cases”- Stale push sub → 404/410 deletes KV row + index entry (§2).
- Sign-out → magic-link sign-out worker iterates
push:idx:{user_id}and deletes every endpoint. - Safari Web Push → only macOS Ventura+ / iOS 16.4+ as installed PWA; prompt gated on
'pushManager' in registration && Notification.permission !== 'denied'. iOS Safari outside PWA falls back to email (02). - Apple device cert → VAPID
audmust matchhttps://web.push.apple.com; subscription serializer normalizes host. - No-script → countdown renders static “Closes at {ISO time}”; share is a plain link; like is
<form method="POST">to/like/{date}. - Reveal missed (backgrounded tab) → first-render trigger via
data-activated-at. - Push consent denied → email-equivalents from 02.
13. Acceptance criteria
Section titled “13. Acceptance criteria”- Subscribe on Chrome/Firefox/Safari-PWA writes
push:{user_id}:{endpoint_hash}+ index (verify viawrangler kv key get). - Outbidding a user fires one push within 5 s with
kind=outbidand correct locale body. - Closing-soon cron fires at T-120 / T-30 / T-5 min; each de-duped via
push:closing:{date}:{slot}:{user_id}(the per-user suffix is required —/push/senddedupe is global, so without it only the first bidder per slot is notified). saga.charge_succeeded→won;saga.cascaded→lostto previous top — both rate-limit-exempt.this_closearrives for ranks 2 and 3 within 60 s of close; payload carriesvars: { rank, date }per the §2 template (“So close — you were #{rank} on {date}.”). Neither field is an absolute amount, so the §10 privacy invariant (“never shows absolute amounts”) holds. The relativegap_incrementslives on the dashboard “You Were This Close” card + D1 row (AC12), not on the push.Disqus loads only after consent AND viewport intersection.Retired (2026-06-13) — Disqus removed (§4).Nightly archive Worker writes one row per active day intoRetired (2026-06-13) — Disqus removed (§4).disqus_archive./grid/{date}.txtis byte-identical across two consecutive requests.- Share button: Android Chrome shares PNG file; desktop Firefox falls to Twitter intent; no-API browser shows copy-URL toast.
- Countdown classnames
nyl-cd--ambient → --active → --urgentflip at correct boundaries;prefers-reduced-motionremoves pulse. spotlight_revealedtriggers animation once perseq; reload after activation replays viadata-activated-at.- “You Were This Close” card renders for ranks 2/3 with relative gap copy only.
14. Agent-team hints
Section titled “14. Agent-team hints”- Worker packages: push endpoints + send in
apps/apias Hono routes (ADR-0001 / D40); closing-soon inapps/cron(the Disqus archive lane is removed — §4). UI islands inapps/web/src/islands/engagement/.apps/cron’sscheduled()export does NOT go through Hono — Hono on cron only fronts the worker’s HTTP probe surface. - Library:
web-push(Node-compat shim) or@block65/webcrypto-web-push— verify CF Workers compat via Context7 before pinning. - i18n: Paraglide keys under
push.*(D19); build must emit EN + DE. - Reveal animation: import the 06 WS client wrapper from
packages/shared/ws-client; do not open a second WS. Precise reading (ADR-0007 §11, #424): do not open a redundant per-auction socket. The live reveal IS a lobby/HubDO broadcast and the homepage’sLiveCountdownholds a per-auction socket, so the live path opens the ONE lobby subscription (the socket that actually receivesspotlight_revealed), bfcache-managed. #424 ships the producer (saga →/internal/announce-reveal→ HubDO fan-out); the homepage consumer wiring is deferred as a forward seam pending the display-day/settlement-day timing reconciliation (ADR-0007 §11(b)). - Bid-Grid PNG: reuse 07’s
@resvg/resvg-wasmOG renderer. - Sentry: push send results (
sent/gone/error) as breadcrumbs (master §4.6). - No direct imports from 04 or 05: consume via Queue / Workflow signals only (§4.4).
- Phase-3 guard: reject any PR introducing Predict-the-Price, Bid-Equity, Streaks, Discord — reference D23.