Skip to content

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.


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 §4 references in code/migrations resolve; AC6 and AC7 (§13) are likewise retired in place. The dormant DisqusSlot.astro / ConsentBanner.astro / apps/cron archive lane and migration 0020_disqus_archive.sql remain 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).


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_keyENDE
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.


TriggerSourceWhenkindNotes
Outbid04 Queue bid.outbidAccepted bid displaces previous topoutbidSkip self-re-raise
Closing 2h / 30m / 5mCronT-120 / T-30 / T-5 minclosing_soonKV de-dupe push:closing:{date}:{slot}:{user_id}
Won05 sagasaga.charge_succeededwonRate-limit exempt
Lost05 sagasaga.cascaded → previous toplostTargets pre-cascade top
This-close05 sagaAuction close, ranks #2 / #3this_closevars.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).


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 §4 references (code comments, migration 0020_disqus_archive.sql, the dormant apps/cron archive lane) still resolve to an explanation.

Original design (no longer in effect): a <div id="disqus_thread"> on every /day/{date}, lazy-loaded via IntersectionObserver and gated on functional consent, with a nightly disqus-archive cron writing a JSON snapshot to D1 disqus_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, migration 0020) remain in the tree but are not wired into any render or schedule.


<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.


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.

  1. From D1 bids view (mirrored from 04), count active bids per hour for the 24 h ending at close.
  2. Compute max = max(counts); bucket each hour by r = count / max:
    • r === 0 → ⬜ · r ≤ 0.25 → 🟦 · r ≤ 0.5 → 🟩 · r ≤ 0.75 → 🟨 · r ≤ 0.9 → 🟧 · else → 🟥
  3. Lay out hour 0 (T-24 h) top-left → hour 23 (close) bottom-right.
  4. Header nowyourlink — {date}, footer nowyourlink.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.


  1. Web Share with file — if navigator.canShare?.({ files: [pngBlob] }), call navigator.share({ title, text, url, files: [pngBlob] }).
  2. Web Share without file — navigator.share({ title, text, url }).
  3. Twitter / X intent — https://twitter.com/intent/tweet?text={enc}.
  4. WhatsApp deep link — https://wa.me/?text={enc}.
  5. 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.


One component, three phases on secondsToClose:

PhaseWindowVisualSoundA11y
AmbientT-24 h → T-6 hCalm blue/green gradient, slow tickSilentaria-live="off", hourly polite announce
ActiveT-6 h → T-60 minAmber, faster tick; subtle highlight if push enabledSilentaria-live="polite" every 15 min
UrgentT-60 min → T-0Pulsing red; sub-second sweep <5 minOptional 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.


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.


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).


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.


  • 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 aud must match https://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.

  1. Subscribe on Chrome/Firefox/Safari-PWA writes push:{user_id}:{endpoint_hash} + index (verify via wrangler kv key get).
  2. Outbidding a user fires one push within 5 s with kind=outbid and correct locale body.
  3. 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/send dedupe is global, so without it only the first bidder per slot is notified).
  4. saga.charge_succeeded → won; saga.cascaded → lost to previous top — both rate-limit-exempt.
  5. this_close arrives for ranks 2 and 3 within 60 s of close; payload carries vars: { 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 relative gap_increments lives on the dashboard “You Were This Close” card + D1 row (AC12), not on the push.
  6. Disqus loads only after consent AND viewport intersection. Retired (2026-06-13) — Disqus removed (§4).
  7. Nightly archive Worker writes one row per active day into disqus_archive. Retired (2026-06-13) — Disqus removed (§4).
  8. /grid/{date}.txt is byte-identical across two consecutive requests.
  9. Share button: Android Chrome shares PNG file; desktop Firefox falls to Twitter intent; no-API browser shows copy-URL toast.
  10. Countdown classnames nyl-cd--ambient → --active → --urgent flip at correct boundaries; prefers-reduced-motion removes pulse.
  11. spotlight_revealed triggers animation once per seq; reload after activation replays via data-activated-at.
  12. “You Were This Close” card renders for ranks 2/3 with relative gap copy only.

  • Worker packages: push endpoints + send in apps/api as Hono routes (ADR-0001 / D40); closing-soon in apps/cron (the Disqus archive lane is removed — §4). UI islands in apps/web/src/islands/engagement/. apps/cron’s scheduled() 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’s LiveCountdown holds a per-auction socket, so the live path opens the ONE lobby subscription (the socket that actually receives spotlight_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-wasm OG 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.