{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "@id": "https://pointcast.xyz/sparrow",
  "name": "Sparrow",
  "description": "A hosted reader client for PointCast. Tune in at dawn — the broadcast arrives at your perch. Sibling of Magpie (the publisher); Sparrow reads what Magpie and others have pushed into the PointCast graph.",
  "url": "https://pointcast.xyz/sparrow",
  "applicationCategory": "CommunicationApplication",
  "operatingSystem": "Any (web)",
  "license": "MIT",
  "version": "0.38",
  "protocol_version": "0.38",
  "sibling_of": "https://pointcast.xyz/magpie",
  "routes": {
    "home": "/sparrow",
    "about": "/sparrow/about",
    "deck": "/sparrow/deck",
    "connect": "/sparrow/connect",
    "channel": "/sparrow/ch/<slug>",
    "block_reader": "/sparrow/b/<id>",
    "saved": "/sparrow/saved",
    "friends": "/sparrow/friends",
    "friends_activity": "/sparrow/friends/activity",
    "signals": "/sparrow/signals",
    "tv": "/sparrow/tv",
    "tv_channel": "/sparrow/tv/ch/<slug>",
    "tv_friends": "/sparrow/tv/friends",
    "federation_json": "/sparrow/federation.json",
    "digest_subscribe_api": "/api/sparrow/digest-subscribe",
    "manifest": "/sparrow.json",
    "atom": "/sparrow/feed.xml",
    "latest_api": "/sparrow/api/latest.json",
    "pwa_manifest": "/sparrow/manifest.webmanifest",
    "service_worker": "/sparrow/sw.js"
  },
  "companion_app": {
    "name": "Sparrow.app",
    "role": "native macOS menu-bar companion",
    "platform": "macOS 13+",
    "language": "Swift 5.9",
    "distribution": "source (github.com/mhoydich/sparrow-app)",
    "landing": "https://pointcast.xyz/sparrow/connect",
    "repository": "https://github.com/mhoydich/sparrow-app",
    "polls": "/sparrow/api/latest.json",
    "default_poll_interval_seconds": 300,
    "poll_range_seconds": [
      30,
      3600
    ],
    "features": [
      "Menu-bar ✦ glyph; ember new-count appears when fresh blocks arrive",
      "Notification Center alerts (one per block up to 3; digest beyond)",
      "Preferences panel — feed URL, poll interval, notifications toggle",
      "LSUIElement: no Dock icon, no Cmd-Tab presence",
      "First-run seeds last-seen store from archive (no alert avalanche)"
    ],
    "privacy": "Opens one network connection to the configured feed URL. No telemetry, no phone-home."
  },
  "deck": {
    "url": "/sparrow/deck",
    "format": "single-page technical memorandum",
    "typography": "EB Garamond · Courier Prime · Space Mono",
    "palette": "cream paper · oxblood stamp · CRT phosphor · weathered ink",
    "sections": [
      "abstract",
      "1.0 introduction",
      "2.0 system architecture",
      "3.0 user interface",
      "4.0 keyboard protocol",
      "5.0 data model",
      "6.0 implementation notes",
      "7.0 future work",
      "references",
      "appendix A · manifest surface",
      "appendix B · AI image prompts for figure plates"
    ],
    "print_ready": true,
    "document_code": "SPA-TM-26-0421"
  },
  "pwa": {
    "installable": true,
    "manifest": "/sparrow/manifest.webmanifest",
    "service_worker": {
      "url": "/sparrow/sw.js",
      "scope": "/sparrow/",
      "version": "sparrow-v0.19.0"
    },
    "cache_policy": {
      "shell": "stale-while-revalidate (home, about, saved, 9 channel pages, manifest, atom feed)",
      "block_readers": "cache-first, max 48 entries, LRU-style trim",
      "assets": "cache-first, max 120 entries (Astro hashed assets + per-block OG images + Google Fonts)",
      "non_sparrow": "network-only (SW does not shadow the rest of pointcast.xyz)"
    },
    "offline_fallback": {
      "mode": "navigation-only",
      "chrome": "inline Sparrow-styled HTML shipped with the SW so cold cache first-visit still lands"
    },
    "install_triggers": [
      "browser-native beforeinstallprompt event surfaces \"install ↓\" button in the HUD + a larger CTA on /sparrow/about",
      "app shortcuts pre-seeded in the manifest: Front Door, Saved, About"
    ]
  },
  "reading_list": {
    "storage": "localStorage",
    "key": "sparrow:saved",
    "shape": "string[] — array of block IDs, newest-added first",
    "sync": "none in v0.3; v0.6 plans Nostr kind-7 cross-device sync via NIP-44"
  },
  "visited_list": {
    "storage": "localStorage",
    "key": "sparrow:visited",
    "shape": "string[] — array of block IDs, newest-visited first, max 120",
    "ui_signal": ".is-visited class on [data-sp-block-id] — softens title + adds \"read\" chip"
  },
  "reactions": {
    "storage": "localStorage",
    "key": "sparrow:reactions",
    "shape": "{ [blockId: string]: Array<\"ember\" | \"moss\" | \"lilac\"> }",
    "kinds": [
      {
        "id": "ember",
        "label": "lit",
        "glyph": "🔥",
        "accent": "var(--sp-ember)"
      },
      {
        "id": "moss",
        "label": "evergreen",
        "glyph": "🌿",
        "accent": "var(--sp-moss)"
      },
      {
        "id": "lilac",
        "label": "rare",
        "glyph": "💜",
        "accent": "var(--sp-lilac)"
      }
    ],
    "ui": "Three-chip toolbar on /sparrow/b/<id>, below the article body. Each chip toggles; active picks pulse their accent ring.",
    "emit_policy": "On ADD only (v0.8). Deletion does not emit a kind-5 delete event yet — deferred to v0.9 alongside cross-device sync."
  },
  "nostr": {
    "client_protocol": "NIP-07 (window.nostr.signEvent)",
    "event_kind": 7,
    "tag_convention": [
      [
        "r",
        "https://pointcast.xyz/b/<id>",
        "canonical block URL the reaction targets"
      ],
      [
        "t",
        "sparrow"
      ],
      [
        "t",
        "sparrow-<kind>"
      ],
      [
        "client",
        "sparrow",
        "https://pointcast.xyz/sparrow"
      ]
    ],
    "content": "Unicode glyph for the chosen kind (🔥 | 🌿 | 💜).",
    "relay_pool": {
      "storage_key": "sparrow:nostr-relays",
      "default": [
        "wss://relay.damus.io",
        "wss://relay.primal.net",
        "wss://nos.lol"
      ],
      "transport": "WebSocket — one connection per emit, fire-and-forget with 4s timeout"
    },
    "pubkey_cache": {
      "storage_key": "sparrow:nostr-pubkey",
      "note": "Cached hex x-only pubkey after first getPublicKey(); Sparrow never persists secret material."
    },
    "emitted_log": {
      "storage_key": "sparrow:nostr-emitted",
      "shape": "{ [`${blockId}:${kind}`]: { id: string, at: number } }",
      "purpose": "Prevents accidental re-broadcast on hydrate/reload. v0.9 also uses this as the target for kind-5 retractions (points at the original event id)."
    },
    "ui_states": [
      "local",
      "available",
      "connected",
      "emitting"
    ],
    "aggregation": {
      "enabled": true,
      "modes": {
        "reader": {
          "mechanism": "One REQ per [data-sp-reactions] panel",
          "filter": {
            "kinds": [
              7
            ],
            "#r": [
              "https://pointcast.xyz/b/<id>"
            ],
            "limit": 200
          },
          "count_ui": ".sp-react-count badge appended to each reaction chip",
          "since": "v0.9"
        },
        "reel": {
          "mechanism": "One REQ per relay with every visible block URL in #r (bulk fan-in)",
          "filter": {
            "kinds": [
              7
            ],
            "#r": [
              "https://pointcast.xyz/b/<id1>",
              "https://pointcast.xyz/b/<id2>",
              "..."
            ],
            "limit": 500
          },
          "count_ui": "Compact \"🔥 3 · 🌿 1\" row painted into .sp-r-foot on each receipt (.sp-r-react-counts). Hidden until at least one pick lands.",
          "scope": "Any page with [data-sp-block-id] receipts outside a [data-sp-reactions] panel — index, channel, saved, drawer, etc.",
          "since": "v0.10"
        }
      },
      "deduplication": "By Nostr event.id (Set<string> per blockId × kind), shared state between reader + reel paint paths",
      "kind_resolution": "Glyph-in-content first (🔥/🌿/💜); t=sparrow-<kind> tag as fallback for non-glyph reactions",
      "cleanup": "beforeunload closes every open WebSocket — no leaks across navigation",
      "signer_required": false
    },
    "unreact": {
      "event_kind": 5,
      "tag_convention": [
        [
          "e",
          "<original_event_id>"
        ],
        [
          "k",
          "7"
        ],
        [
          "t",
          "sparrow"
        ],
        [
          "client",
          "sparrow",
          "https://pointcast.xyz/sparrow"
        ]
      ],
      "trigger": "On reaction REMOVE, only when sparrow:nostr-emitted has a prior event id for this blockId:kind",
      "content": "sparrow: unreact"
    },
    "cross_device_sync": {
      "since": "v0.21",
      "event_kind": 30078,
      "d_tag": "sparrow-reader-state-v1",
      "encryption": "NIP-44 self-encryption (window.nostr.nip44.encrypt(selfPubkey, plaintext)); only the same npub can decrypt",
      "payload": "JSON body — same shape as /reader-state POST: { saved, visited, reactions }, each with { value, updated_at }",
      "opt_in_storage": "localStorage[\"sparrow:sync-enabled\"] (\"1\" | \"0\")",
      "throttle": "at most one relay push per 4 seconds (sparrow:sync-last-emitted-at floor) plus the 1.2s debounce stacked on top of the 600ms LAN mirror debounce",
      "pull_strategy": "On page load (when enabled) + on signer connect, one REQ per relay filtered by {kinds:[30078], authors:[self], #d:[sparrow-reader-state-v1], limit:1}. Newest created_at across relays wins; merge applied per top-level key via the same MIRROR_KEYS apply callbacks the LAN mirror uses.",
      "coexistence": "Nostr sync and the LAN peer-node mirror are orthogonal — both run when enabled. On save/visit/react, scheduleReaderMirror() fires both the LAN push and (if enabled) the Nostr push. On load, mirrorPull() populates from the LAN peer; subscribeNostrSync() then merges in anything newer from the relay pool.",
      "requires": [
        "NIP-07 signer connected (window.nostr.getPublicKey)",
        "NIP-44 support on the signer (window.nostr.nip44.{encrypt,decrypt}) — nos2x, Alby recent builds"
      ],
      "ui_toggle": ".sp-sync-toggle pill in the HUD · states: unavailable (no signer / no NIP-44) · off · on",
      "not_shipped_yet": "Cross-device conflict UX beyond newest-wins; per-key opt-out (e.g. \"sync saved only\"); key rotation after an NIP-44 change"
    },
    "participation": {
      "since": "v0.31",
      "surface": "/sparrow/friends (top of page, before publisher + following list)",
      "components": {
        "getting_started": {
          "steps": [
            "signer: has a NIP-07 pubkey cached (sparrow:nostr-pubkey)",
            "public: sparrow:public-saved-enabled === \"1\"",
            "follow: sparrow:friends[] has at least one entry",
            "ambient: sparrow:ambient-enabled === \"1\" (optional)"
          ],
          "ui": "ordered list · ○ default, ✓ on done · moss color when complete · \"N of 4 steps done\" meta · repaints on any storage change via window.addEventListener(\"storage\",…) cross-tab"
        },
        "share_invite": {
          "surface": "card below getting-started, shown only when signer is connected",
          "link_shape": "${origin}/sparrow/friends?follow=<npub1…>",
          "copy_button": "navigator.clipboard.writeText with execCommand fallback; ✓ copied feedback for 1.6s"
        },
        "starters": {
          "surface": "card below invite",
          "storage": "hard-coded STARTERS array in the page — scaffold only, edit this array to curate",
          "one_click_follow": "+ follow button writes to sparrow:friends[] with the starter alias",
          "hides_when": "every starter is already followed, or the entire list is empty"
        },
        "follow_deep_link": {
          "param": "?follow=<npub1… | hex-64>",
          "accepts_both": "parsePubkey normalizes to hex — same codec the add-form uses",
          "behavior": "pre-fills the add-form pubkey input + scrolls into view + pulses the submit button (\"follow <short-pk> →\") for 2 cycles. User still clicks to confirm — no silent follow.",
          "no_op_when": "already following, or param is malformed"
        },
        "opml_round_trip": {
          "surface": "dashed panel inside the \"people I'm following\" card",
          "export": {
            "filename": "sparrow-friends-<YYYY-MM-DD>.opml",
            "element_shape": "<outline type=\"nostr\" text=\"<alias>\" xmlUrl=\"nostr:<npub1>\" htmlUrl=\"<origin>/sparrow/friends?follow=<npub1>\" x-sparrow-pubkey=\"<hex>\" x-sparrow-muted=\"true|absent\" />",
            "root": "<opml version=\"2.0\"> with <head> carrying title/dateCreated/ownerName/docs → /sparrow.json"
          },
          "import": {
            "accepts": ".opml, .xml, application/xml, text/xml",
            "parser": "DOMParser · walks every <outline> · prefers x-sparrow-pubkey attr, falls back to npubToHex(xmlUrl match)",
            "merge": "union with existing sparrow:friends[]; preserves mute state via x-sparrow-muted",
            "privacy": "entirely client-side — no upload leaves the browser beyond the user-initiated file selection"
          }
        }
      },
      "why_this_sprint": "The federation surface shipped end-to-end from v0.22 → v0.30 but had no obvious on-ramp. v0.31 collapses the \"now what?\" gap between first page load and actually seeing friends' reads."
    },
    "federated_lists": {
      "since": "v0.22",
      "surface": "/sparrow/friends",
      "publisher": {
        "opt_in_storage": "localStorage[\"sparrow:public-saved-enabled\"] (\"1\" | \"0\")",
        "event_kind": 30078,
        "d_tag": "sparrow-public-saved-v1",
        "encryption": "none — content is clear JSON so any follower can read without a signer",
        "payload_shape": {
          "schema": "sparrow-public-saved-v1",
          "saved": {
            "value": "string[] · saved block ids (newest-saved first, capped at 240)",
            "updated_at": "ISO 8601 — mirrors sparrow:saved:updated_at"
          },
          "profile": {
            "client": "\"sparrow\"",
            "client_url": "\"https://pointcast.xyz/sparrow\"",
            "version": "\"0.22\""
          }
        },
        "throttle": "shares the 4s floor with private sync (sparrow:public-saved-last-emitted-at), runs on the same scheduleReaderMirror() debounce",
        "scope_guarantee": "ONLY the saved list is in this event — visited state, reactions, and private kind-30078 (sparrow-reader-state-v1) stay untouched. Visibility is a separate consent from cross-device sync."
      },
      "consumer": {
        "friends_storage": "localStorage[\"sparrow:friends\"] · Array<{ pubkey: \"64-hex\", alias?: string (≤40 chars) }>",
        "subscribe_filter": "{ kinds: [30078], authors: <friends>, \"#d\": [\"sparrow-public-saved-v1\"], limit: friends.length * 2 }",
        "dedup_policy": "newest created_at per author wins; earlier events ignored",
        "title_resolution": "server ships a { [id]: {id, title, dek, channel, type} } lookup for every non-draft block in the Astro content collection; client hydrates each saved id against it. Blocks absent from the current Sparrow build render as \"unknown block · not in this Sparrow build\" with a dead-but-valid link.",
        "render_cap": "first 24 block receipts per author; \"+ N more\" footer if the list is longer"
      },
      "accepted_formats": "v0.23 · pubkey input accepts both 64-hex AND npub1… (NIP-19 bech32). parsePubkey() in /sparrow/friends normalizes to lowercase hex. Self-pubkey display in the HUD + publisher signature line also renders as short-npub1 instead of hex.",
      "privacy_story": "Two distinct opt-ins: cross-device sync (v0.21, NIP-44 self-encrypted) and public saved list (v0.22, unencrypted). Either can be on without the other. No visited/reaction data ever leaves the device unencrypted.",
      "profile_lookup": {
        "since": "v0.23",
        "event_kind": 0,
        "subscribe_filter": "{ kinds: [0], authors: <friends>, limit: friends.length }",
        "cache_storage": "localStorage[\"sparrow:profiles\"] — { [hex]: { name?, display_name?, picture?, nip05?, nip05_verified?, nip05_verified_at?, fetched_at } }",
        "ttl_ms": 86400000,
        "fields_read": [
          "name",
          "display_name",
          "picture",
          "nip05"
        ],
        "alias_priority": "local alias (sparrow:friends[].alias) > kind-0 display_name > kind-0 name > nothing",
        "render": "friends list + feed card header both show the resolved display name; a 🛰 glyph appears next to names sourced from the relay so users can tell what's local vs federated"
      },
      "picture_rendering": {
        "since": "v0.24",
        "storage_gate": "kind-0 `picture` accepted only when /^https?:\\/\\//i.test(value), capped at 400 chars",
        "render_element": "<img class=\"sp-friends-pic\" loading=\"lazy\" referrerpolicy=\"no-referrer\" decoding=\"async\" onerror=\"this.style.display='none'\">",
        "placeholder": "sp-friends-pic-placeholder span with a ✦ glyph when no picture is known",
        "sizes": {
          "list": "28px circle",
          "feed_card": "18px inline"
        },
        "why_no_proxy": "Loading via Cloudflare/image proxy would add a TTL layer but also cost bandwidth + privacy. Lazy + no-referrer gets 90% of the benefit."
      },
      "friends_motion": {
        "since": "v0.26",
        "watcher": {
          "transport": "persistent WebSocket REQ per relay (never limited, never closed until beforeunload)",
          "filter": "{ kinds:[30078], authors:<non-muted friends>, \"#d\":[\"sparrow-public-saved-v1\"], since: <bootTime seconds> }",
          "since_policy": "bootTime = Math.floor(Date.now() / 1000) on script run → only events newer than the current tab fire toasts",
          "dedup": "Set<event.id> so the same event seen across multiple relays only toasts once",
          "gated_on": [
            "sparrow:friends non-empty",
            "sparrow:friends-motion-disabled !== \"1\"",
            "friend not flagged muted"
          ]
        },
        "toast": {
          "container": "aside.sp-motion-toasts fixed bottom-right; column-reverse stack",
          "max_visible": 3,
          "ttl_ms": 7000,
          "shape": "avatar + \"<name> · just saved · № <id>\" — click opens the block, × dismisses",
          "animation": "sp-motion-in 260ms (slide-from-right + fade) on mount, sp-motion-out 300ms on leave",
          "profile_source": "same sparrow:profiles cache populated by /sparrow/friends (one-pass hydration)"
        },
        "chime": {
          "opt_in_storage": "localStorage[\"sparrow:friends-chime-enabled\"] (\"1\" | \"0\")",
          "sound": "two overlapping sine oscillators (A5 880Hz + E6 1318.5Hz) · exponential fade · ~450ms total · Web Audio only · no asset request",
          "default": "off",
          "scope": "plays only alongside a toast — never on historical events, never when friend is muted"
        },
        "disable_all": "localStorage[\"sparrow:friends-motion-disabled\"] === \"1\" short-circuits the watcher entirely (keeps toasts off for privacy-minded sessions without unfollowing)"
      },
      "per_friend_mute": {
        "since": "v0.26",
        "storage_shape_change": "sparrow:friends[] now accepts an optional `muted: boolean` field on each entry; legacy entries without the field are treated as unmuted",
        "applied_in": [
          "/sparrow/friends subscribe filter",
          "/sparrow/friends feed render",
          "/sparrow dashboard lane",
          "SparrowLayout live motion watcher"
        ],
        "ui": "🔈 mute / 🔇 unmute button per row · row fades to 45% opacity when muted · \"muted\" pill next to the pubkey",
        "why_not_unfollow": "unfollow is destructive (loses the alias, loses future reinstate) — mute lets users quiet noisy signers without losing track of them"
      },
      "signals": {
        "since": "v0.28",
        "surface": "/sparrow/signals",
        "subscribe_filter": "{ kinds:[30078], authors:<non-muted friends>, \"#d\":[\"sparrow-public-saved-v1\"], limit: friends.length * 2 }",
        "newest_per_author": "Map keyed by pubkey — only latest created_at per friend is aggregated, matching the rest of the federation surface",
        "panels": {
          "top_co_saved": {
            "purpose": "blocks saved by 2+ followed signers — overlap is the signal",
            "min_count": 2,
            "cap": 12,
            "sort": "count desc, then block id asc for stability",
            "shows_savers": "up to 4 saver names inline + \"+N\" overflow"
          },
          "recent_adds": {
            "purpose": "friends who published a fresh saved list in the last 7 days",
            "window_seconds": 604800,
            "row": "avatar · name · \"published N · Xh ago\" · freshest saved receipt",
            "sort": "event created_at desc"
          },
          "channel_distribution": {
            "purpose": "where attention is aggregated right now",
            "computation": "sum distinct saves per channel across all newest-per-author lists; proportional bar against total",
            "row": "channel code · name · bar · count → links to /sparrow/ch/<slug>"
          }
        },
        "shared_state": "reads the same sparrow:profiles cache + block lookup the rest of /sparrow federation surfaces use; no extra round-trips",
        "first_picker_attribution": {
          "since": "v0.29",
          "computation": "among current savers of a block, the one whose newest kind-30078 event has the earliest created_at wins the ⭐ attribution",
          "caveat": "kind-30078 is replaceable — this reflects \"most recently re-published\" per author, not the true first-save moment; across a group re-publishing regularly, the pattern stabilizes",
          "surface": "top co-saved panel: ⭐ <name> chip in the savers row"
        },
        "export_json": {
          "since": "v0.29",
          "trigger": "⤓ export JSON button in the signals nav",
          "filename": "sparrow-signals-<YYYY-MM-DD>.json",
          "schema": "sparrow-signals-v1",
          "payload_keys": [
            "generated_at",
            "boot_origin",
            "friends[]",
            "relays[]",
            "newest_events[]",
            "top_co_saved[]",
            "notes"
          ],
          "privacy_posture": "bundle contains pubkeys + aliases + profile metadata the user already has cached locally; no secret material. Entirely client-side Blob download."
        },
        "not_yet": "opt-in email digest (still requires sidecar worker infra — now the ONLY remaining v0.28 bundle item)"
      },
      "native_ambient": {
        "since": "v0.33",
        "source": "/Users/michaelhoydich/sparrow-app/Sources/SparrowApp/{NostrRelayClient.swift, FriendsService.swift}",
        "reads_from": "UserDefaults[\"sparrow.readerState\"] (the SparrowServer peer-node storage; v0.32 mirror extension writes friends + profiles here)",
        "subscriptions": {
          "ambient": "kinds:[20078] · authors:<non-muted friends> · #t:[\"sparrow-presence\"] · since: <now - 120s>",
          "saved": "kinds:[30078] · authors:<non-muted friends> · #d:[\"sparrow-public-saved-v1\"] · limit: authors*2 (closed on EOSE)"
        },
        "freshness_window_s": 90,
        "decay_cadence_s": 20,
        "tray_ui": "MenuBarController.setFriendsPresence(count, aliases) adds a \"✦ N here · alias1, alias2, +K\" menu item above the separator. Hidden when count is zero.",
        "not_yet": "NIP-01 kind-0 profile lookup inside the native app (so aliases can auto-populate); NIP-05 verification; avatar image rendering in the tray",
        "trust_model": "Relay events are not signature-verified inside the native app yet — same posture as the web client. Future sprint can add secp256k1 verification in Swift."
      },
      "ambient_friends": {
        "since": "v0.30",
        "opt_in_storage": "localStorage[\"sparrow:ambient-enabled\"] (\"1\" | \"0\")",
        "publisher": {
          "event_kind": 20078,
          "tags": "[[\"t\",\"sparrow-presence\"], [\"client\",\"sparrow\",...], [\"expiration\", <now+120s>]]",
          "content": "empty — presence is the fact of emission, not the payload",
          "cadence": "broadcast every 60s (AMBIENT_BROADCAST_MS) while the tab is foregrounded; re-emits on visibilitychange → visible",
          "fan_out": "short-lived WebSocket per relay, EVENT frame, 3s timeout",
          "why_ephemeral": "kind 20078 is in the 20000-29999 NIP-16 ephemeral range — relays relay in real-time but don't persist. Presence is moot the instant it's stale."
        },
        "subscriber": {
          "filter": "{ kinds:[20078], authors:<non-muted friends>, \"#t\":[\"sparrow-presence\"], since: <bootTime - 60s> }",
          "fresh_window_ms": 90000,
          "render": "fixed bottom-left .sp-ambient strip · ✦ here now label + up to 8 avatars + \"+N\" overflow; hidden when nobody's fresh",
          "decay_tick": "setInterval(15s) repaints so avatars drop off as they exceed AMBIENT_FRESH_MS",
          "mute_respected": "friend.muted filtered at ingest + paint"
        },
        "privacy_story": "Publishing announces your liveness to the public relay pool; off by default. Not publishing still lets you see ambient friends (subscription is passive)."
      },
      "digest_sidecar": {
        "since": "v0.30",
        "status": "v0.32: endpoint live (ack only · no cron yet)",
        "signup_ui": "/sparrow/signals weekly-digest panel",
        "storage_key": "sparrow:digest-subscription",
        "subscription_shape": {
          "schema": "sparrow-digest-subscription-v1",
          "email": "string · user-supplied",
          "frequency": "\"weekly\" | \"biweekly\" | \"monthly\"",
          "npub": "hex pubkey or null (from sparrow:nostr-pubkey)",
          "relays": "string[] · copies getRelays() so the worker can re-aggregate from the same relay pool",
          "created_at": "ISO 8601"
        },
        "endpoint": "POST /api/sparrow/digest-subscribe (v0.32 · Cloudflare Pages Function)",
        "responses": {
          "202": "intent accepted · stored in SPARROW_DIGEST_KV if bound",
          "400": "malformed body (invalid JSON, unknown schema, bad email, bad frequency)",
          "413": "body exceeds 8 KB"
        },
        "kv_binding": "SPARROW_DIGEST_KV · optional · when missing, responses still 202 with { stored: false, note } so the client stops retrying",
        "unsubscribe": {
          "since": "v0.33",
          "endpoint": "DELETE /api/sparrow/digest-subscribe",
          "modes": {
            "local-clear": {
              "header": "x-unsub-intent: local-clear",
              "body": "{ email }",
              "response": "200 { ok, removed, mode, note }",
              "auth": "none — web-initiated from a browser that just cleared sparrow:digest-subscription; same-user implicit trust"
            },
            "token-verified": {
              "since": "v0.34",
              "header": "x-unsub-token: <email>.<expires_at>.<hex-hmac> · also accepted as ?unsub_token query param so email-footer links work from any browser",
              "token_shape": "<urlencoded email>.<unix-seconds expiry>.<lowercase-hex HMAC-SHA256 over \"<email>.<expires_at>\">",
              "ttl_seconds": 2592000,
              "response": "200 { ok, removed, email, mode } on verify · 410 on expired · 400 on malformed/bad-hmac/missing-secret",
              "signing_key_env": "SPARROW_DIGEST_SIGNING_KEY · must be bound with the same value on BOTH the Pages Function and the cron worker so the footer links the worker signs verify here",
              "helpers": "workers/sparrow-digest/src/signing.ts is the canonical implementation (signUnsubToken, verifyUnsubToken, buildUnsubUrl). The Pages Function inlines a byte-identical verify helper because Pages + Workers are separate deploy targets.",
              "rotation": "rotating SPARROW_DIGEST_SIGNING_KEY invalidates all outstanding tokens; subscribers get a new token on the next digest send",
              "constant_time_compare": "HMAC comparison uses a constant-time equal to avoid timing-oracle leakage of the correct HMAC prefix"
            }
          }
        },
        "cron_worker": {
          "since": "v0.33",
          "status": "v0.36 · dead-letter bucket live (3-strike threshold + immediate on non-retriable); /ops/dead-letter + /ops/release routes (bearer-token gated). Full signals aggregation (co-saves + channel distribution) planned for v0.37 after it got rolled back during branch churn.",
          "location": "workers/sparrow-digest/ (wrangler.toml + src/{index,signing,send,nostr}.ts + README.md)",
          "schedule": "0 8 * * 1 (Mondays 08:00 UTC) — biweekly/monthly gates applied per-subscriber based on last_sent_at",
          "transport": "MailChannels · retry-with-jitter: 3 attempts max, exponential backoff with full jitter (800ms/1600ms/3200ms ceilings). 5xx + 429 + 408 retriable; 401/403/422 fail-fast and do not touch last_sent_at (ops can redeploy and the next cron tick retries).",
          "kv_binding": "SPARROW_DIGEST_KV (shared with the Pages Function)",
          "signing_binding": "SPARROW_DIGEST_SIGNING_KEY (shared with the Pages Function)",
          "nostr_client": "workers/sparrow-digest/src/nostr.ts — URLSessionWebSocketTask-style WebSocket client ported from sparrow-app/Sources/SparrowApp/NostrRelayClient.swift. collectFromRelay + collectAcrossRelays + newestPerAuthorByDTag helpers. 6s timeout per relay, 500-event safety cap. No signature verification yet — downstream re-filters by author.",
          "email_body_v035": "Subscribers with npub + relays: live fetch of their kind-3 contact list + kind-30078 saved events across their relays; surfaces \"N of M followed signers published public saved lists · K total saved-block references.\" Subscribers without npub get a short \"open signals\" prompt.",
          "dry_run": "GET /dry-run on the worker returns {total, due, now, worker_version:\"v0.35\"} without sending",
          "not_yet": "full signals aggregation (co-saves + channel distribution mirror) · block-lookup prefetch · native Sparrow.app NIP-01 kind-0 profile lookup · /sparrow/digest preview page",
          "dead_letter": {
            "since": "v0.36",
            "module": "workers/sparrow-digest/src/deadletter.ts",
            "kv_keys": {
              "fail:<email>": "consecutive-failure counter · 60-day TTL · cleared on success",
              "dl:<email>": "dead-letter record · 1-year TTL · persists until /ops/release"
            },
            "thresholds": {
              "retriable": "3 consecutive retriable failures (5xx/429/408) → dead-letter",
              "permanent": "any non-retriable failure (401/403/422) → immediate dead-letter"
            },
            "sub_row_untouched": "main sub:<email> subscription row is never deleted; scheduled() just skips dead-lettered entries",
            "release_path": "POST /ops/release?email=<addr> with Bearer SPARROW_OPS_TOKEN clears both dl: and fail: entries"
          },
          "ops_endpoints": {
            "since": "v0.36",
            "auth": "Bearer <SPARROW_OPS_TOKEN> on the Authorization header · missing secret returns 503 ops-not-configured",
            "routes": {
              "GET  /ops/dead-letter": "{ ok, count, records[], now } · records sorted by dead_lettered_at desc",
              "POST /ops/release?email=<a>": "{ ok, released } · clears dl:<email> + fail:<email>"
            },
            "token_setup": "wrangler secret put SPARROW_OPS_TOKEN · not bound on the Pages Function (ops is a worker-only concern)"
          }
        },
        "worker_security_posture": "never holds secret material — stores email + npub + relay list + frequency only. Recomputes signals bundle at send time by subscribing to the same public kind-30078 d-tag the web client uses.",
        "why_client_first": "We shipped the intent capture + schema doc in v0.30 before the worker went live so early users were queued, not blocked. v0.32 lands the endpoint; v0.33 lands the cron scaffold + DELETE modes; v0.34 lands the actual email dispatch + token signing."
      },
      "federation_json": {
        "since": "v0.32",
        "endpoint": "GET /sparrow/federation.json",
        "schema": "sparrow-federation-v1",
        "cache_control": "public, max-age=900",
        "shape": {
          "schema": "sparrow-federation-v1",
          "version": "0.32",
          "curated_at": "ISO date",
          "starters": "Array<{ hex: 64-hex, alias: string (≤40), note?: string (≤160) }>",
          "notes": "object · scope + caveat + editing instructions"
        },
        "consumer": "/sparrow/friends fetches on boot; sanitizes entries (requires valid hex); falls back to a 2-item internal seed on fetch failure",
        "curation_flow": "add a line to starters[] in src/pages/sparrow/federation.json.ts, ship. No friends.astro change needed.",
        "not_a_whitelist": "Starter seeds are suggestions, not gatekeeping. Follow-/-unfollow is always one click."
      },
      "channel_friends": {
        "since": "v0.29",
        "surface": "/sparrow/ch/<slug> (above the main reel)",
        "scope": "filter friends' saved ids to the server-shipped channelBlockSet for the current channel",
        "subscribe_filter": "{ kinds:[30078], authors:<non-muted friends>, \"#d\":[\"sparrow-public-saved-v1\"], limit: friends.length * 2 }",
        "sort": "count desc, then block id asc for stability",
        "max_rows": 6,
        "row": "badge ×N · block № · title · ⭐ first-picker chip",
        "hides_when": "no followed friends, all muted, or no saves intersect this channel",
        "opt_out_storage": "localStorage[\"sparrow:ch-friends-hidden\"] === \"1\" hides the panel across every channel page until re-enabled",
        "lookup_shipped": "{ [id]: { title } } for every block in the current channel only — keeps per-page payload minimal"
      },
      "friends_activity": {
        "since": "v0.27",
        "surface": "/sparrow/friends/activity",
        "subscriptions": {
          "initial": "{ kinds:[30078], authors:<non-muted friends>, \"#d\":[\"sparrow-public-saved-v1\"], limit: 50 } — closed on EOSE",
          "live": "{ kinds:[30078], authors:<non-muted friends>, \"#d\":[\"sparrow-public-saved-v1\"], since: <bootTime seconds> } — kept open until beforeunload"
        },
        "dedup_strategy": "per-event.id Map so the same event across multiple relays only renders once; author replaceable events land as distinct entries by their event id",
        "render_policy": "newest-wins sort by created_at desc, capped at 50 visible entries",
        "per_event_card": "avatar + display name + \"saved N blocks\" + relative timestamp + first 3 saved-block receipts (title + channel chip) + \"+N more\" footer",
        "new_badge": "events with created_at >= bootTime get .is-new styling (moss border + shadow pulse animation + \"new\" pill). Interval tick demotes them 12s after arrival.",
        "title_resolution": "server-shipped lookup { [id]: { title, channel, channelName } } for every non-draft block",
        "share_with": "/sparrow/friends + /sparrow dashboard lane + SparrowLayout motion watcher all read the same sparrow:profiles cache, so avatars/names are one-pass",
        "mute_respected": "muted friends filtered out of the authors list AND re-checked on ingest (mute can happen mid-subscribe)"
      },
      "friends_lane": {
        "since": "v0.25",
        "surface": "/sparrow (dashboard) · between rosette and reel",
        "max_rows": 6,
        "sort": "freshest friend event first (created_at desc)",
        "row_contents": "avatar (kind-0 picture) · display name · \"saved\" · block № · title · channel chip · \"+N more\" (when friend has >1 saved)",
        "lookup": "inline <script id=\"sp-friends-lane-lookup\" type=\"application/json\"> carrying { [id]: { title, channel, channelName } } for every non-draft block",
        "opt_out_storage": "localStorage[\"sparrow:friends-lane-hidden\"] === \"1\" hides the section until re-enabled from /sparrow/friends",
        "empty_state": "when friends list is non-empty but no public-saved events arrived, a prompt links to /sparrow/friends",
        "when_hidden_by_default": "when sparrow:friends localStorage is empty (no one followed yet) — no nag",
        "shared_logic_with": "/sparrow/friends (subscribe filter identical; profile picture/name resolution reads the same sparrow:profiles cache written by /sparrow/friends)"
      },
      "nip05_verification": {
        "since": "v0.24",
        "protocol": "NIP-05",
        "endpoint_template": "https://<domain>/.well-known/nostr.json?name=<user>",
        "request": "GET · no credentials · cache: force-cache",
        "cache_fields": [
          "nip05_verified (bool)",
          "nip05_verified_at (epoch ms)",
          "nip05_verify_in_flight (optional bool)"
        ],
        "cache_ttl_ms": 604800000,
        "outcomes": {
          "ok": "pubkey matches names[user] (case-insensitive)",
          "mismatch": "resolved to a different pubkey, non-2xx, parse error, CORS failure, or malformed nip05",
          "pending": "verify in-flight",
          "unchecked": "no nip05 in profile, or pending verification not yet started"
        },
        "render": "✓ (moss pill) beside the nip05 string + name when verified; ! (oxblood pill) when mismatch; … (mute) while pending",
        "trigger_points": [
          "commitProfiles() when nip05 changed or stale",
          "boot scan of cached friends whose nip05_verified_at is >7d"
        ]
      }
    },
    "future": "v1.0: Federated reading lists across more surfaces (reel lane injection, \"signals\" digest), agent-mode view at /sparrow/llms.txt for machine readers, full offline archive in IndexedDB."
  },
  "compose": {
    "surface": "Collapsible <details> panel on /sparrow/b/<id>, below the reactions toolbar",
    "transport": "POST https://pointcast.xyz/api/ping",
    "schema": "pc-ping-v1",
    "payload_shape": {
      "type": "pc-ping-v1",
      "subject": "optional string (≤120 chars)",
      "body": "string (≤3800 chars)",
      "expand": true,
      "channel": "<parent block channel code>",
      "blockType": "NOTE",
      "sourceUrl": "https://pointcast.xyz/b/<parent-id>",
      "sourceApp": "sparrow",
      "from": "sparrow-reader",
      "timestamp": "ISO 8601",
      "dek": "\"Re: <parent-id>\" when subject is empty"
    },
    "result_states": [
      "pending",
      "ok",
      "error"
    ],
    "collapses_on": "successful post (2.2s after ok state shows)",
    "future": "v0.13 routes multi-destination replies through Magpie /compose when any non-PointCast destination is selected (this sprint); when Magpie is unreachable or /compose 4xx/5xx, Sparrow automatically falls back to direct /api/ping so the reply always lands in PointCast."
  },
  "bridge_discovery": {
    "probe_order": [
      {
        "rank": 1,
        "source": "localStorage[\"sparrow:magpie-origin\"]",
        "why": "user override — LAN, VM, reverse proxy, non-default port"
      },
      {
        "rank": 2,
        "source": "http://magpie.local:38473",
        "why": "mDNS-published .local name once Magpie advertises via Bonjour (v0.19)"
      },
      {
        "rank": 3,
        "source": "http://127.0.0.1:38473",
        "why": "Magpie hardcoded loopback default — always present when Magpie is running locally"
      },
      {
        "rank": 4,
        "source": "http://sparrow.local:38474",
        "why": "Sparrow.app peer-node Bonjour name — v0.20 fallback when Magpie is absent but the menu-bar companion is running"
      },
      {
        "rank": 5,
        "source": "http://127.0.0.1:38474",
        "why": "Sparrow.app hardcoded loopback default — last-resort fallback before giving up on peer mirror"
      }
    ],
    "health_endpoint": "/health",
    "probe_timeout_ms": 1200,
    "resolved_cache": "window.__sparrow.magpieOrigin + window.__sparrow.magpiePeerKind (\"magpie\" | \"sparrow-app\") so the bridge pill can render the right peer label",
    "override_setter": "set localStorage[\"sparrow:magpie-origin\"] to e.g. \"http://magpie.box.lan:38473\" and reload",
    "why_not_direct_mdns": "Browsers do not expose Bonjour / mDNS-SD APIs; .local name resolution is handled by the OS stack and works transparently once the service advertises. v0.19 shipped the Swift-side NWListener advertisement on the Magpie side; v0.20 ships the parallel advertisement on Sparrow.app.",
    "shared_by": [
      "reader_state_mirror (GET/POST, either peer)",
      "magpie_bridge (GET /health + /config.json — Magpie-only routes naturally 404 on Sparrow.app)",
      "compose (POST /compose — Magpie-only; falls back to direct /api/ping on Sparrow.app)"
    ],
    "bonjour_advertisement": {
      "service_type": "_magpie._tcp",
      "service_name": "Magpie",
      "port": 38473,
      "txt_record": {
        "version": "0.19",
        "path": "/health",
        "schema": "sparrow-reader-state-v1",
        "composer": "/compose",
        "mirror": "/reader-state"
      },
      "listener_binding": "loopback — advertisement is metadata-only; traffic still flows over 127.0.0.1",
      "publisher": "Magpie v0.19 via NWListener.service + NWTXTRecord",
      "discovery_ui": "Any `dns-sd -B _magpie._tcp` on macOS shows the active Magpie instance"
    },
    "sparrow_peer_advertisement": {
      "service_type": "_sparrow._tcp",
      "service_name": "Sparrow",
      "port": 38474,
      "txt_record": {
        "version": "0.20",
        "path": "/health",
        "schema": "sparrow-reader-state-v1",
        "mirror": "/reader-state",
        "peer": "sparrow-app"
      },
      "listener_binding": "loopback — same posture as Magpie: advertisement is metadata-only, traffic is 127.0.0.1-bound",
      "publisher": "Sparrow.app v0.20 via NWListener.service + NWTXTRecord (Sources/SparrowApp/SparrowServer.swift)",
      "discovery_ui": "`dns-sd -B _sparrow._tcp` shows the active Sparrow.app instance; running both peers simultaneously is fine",
      "enabled_toggle": "Settings.peerServerEnabled (defaults to true); port override via Settings.peerServerPort",
      "when_active": "Resolver falls through to Sparrow.app as rank 4/5 only when Magpie isn't responding — so both can coexist without thrashing"
    }
  },
  "opml": {
    "surface": "toolbar above the receipts list on /sparrow/saved",
    "export": {
      "filename": "sparrow-saved-<YYYY-MM-DD>.opml",
      "outlines": [
        {
          "group": "Sparrow",
          "items": [
            "Sparrow Atom feed (/sparrow/feed.xml)",
            "PointCast generic feed (/feed.xml)"
          ]
        },
        {
          "group": "Saved blocks",
          "items": "one <outline type=\"link\" htmlUrl=\"/b/<id>\"> per saved ID, title prefixed with \"№ <id> — <title> · <channel>\""
        },
        {
          "group": "Channels",
          "items": "all 9 channel RSS feeds (/c/<slug>.rss) with htmlUrl pointing at /sparrow/ch/<slug>"
        }
      ]
    },
    "import": {
      "accepts": ".opml, .xml, application/xml, text/xml",
      "parser": "DOMParser over the uploaded text; scrapes any outline's htmlUrl or xmlUrl for /b/<id> matches",
      "merge": "Union with existing sparrow:saved (newest-added first). Reloads the page after a short flash so the list re-renders from the updated state. Updates sparrow:saved:updated_at to current time so the mirror picks the import up on the next debounce.",
      "tolerates": "Non-block outlines (channel feeds, arbitrary feeds) are ignored silently — the import is additive, never destructive."
    },
    "privacy": "Entirely client-side. No upload leaves the browser except when the user themselves submits the file via a normal <input type=\"file\">."
  },
  "reader_state_mirror": {
    "ships_in": "v0.15",
    "shared_storage_key": "magpie.sparrowReaderState (UserDefaults on the Magpie side)",
    "endpoints": {
      "get": "GET http://127.0.0.1:38473/reader-state.json → { ok, state, schema: \"sparrow-reader-state-v1\", served_at }",
      "post": "POST http://127.0.0.1:38473/reader-state · body: { [key]: { value, updated_at } } → { ok, state, updated_at }"
    },
    "payload_shape": {
      "saved": {
        "value": "string[] · block IDs (newest-saved first)",
        "updated_at": "ISO 8601",
        "since": "v0.15"
      },
      "visited": {
        "value": "string[] · block IDs (last 120, newest-visited first)",
        "updated_at": "ISO 8601",
        "since": "v0.16"
      },
      "reactions": {
        "value": "{ [blockId: string]: Array<\"ember\" | \"moss\" | \"lilac\"> }",
        "updated_at": "ISO 8601",
        "since": "v0.16"
      },
      "friends": {
        "value": "Array<{ pubkey: hex-64, alias?: string (≤40), muted?: bool }>",
        "updated_at": "ISO 8601",
        "since": "v0.32"
      },
      "profiles": {
        "value": "{ [hex: string]: { name?, display_name?, picture?, nip05?, nip05_verified?, nip05_verified_at?, fetched_at } }",
        "updated_at": "ISO 8601",
        "since": "v0.32"
      }
    },
    "merge_policy": "Newest-wins per top-level key. On the Magpie server, mergeReaderState() compares stored updated_at against incoming and incoming-newer replaces wholesale. On the Sparrow client, mirrorPull() writes only when remote updated_at is later than the local sparrow:<key>:updated_at. Apply callbacks use skipMirror: true to avoid push-loops, then re-paint the DOM (hydrateReactions + applyVisited) so the UI follows the remote state immediately.",
    "signer_required": false,
    "auth": "none · localhost-bound only; Magpie never exposes this port externally",
    "debounce_ms": 600,
    "web_state_keys": [
      "sparrow:saved",
      "sparrow:saved:updated_at",
      "sparrow:visited",
      "sparrow:visited:updated_at",
      "sparrow:reactions",
      "sparrow:reactions:updated_at",
      "sparrow:friends",
      "sparrow:friends:updated_at",
      "sparrow:profiles",
      "sparrow:profiles:updated_at"
    ],
    "scheduler_handle": "v0.32: window.__sparrow.scheduleReaderMirror() is exposed by SparrowLayout so route-local pages (e.g. /sparrow/friends) can trigger a push after mutating a mirror-tracked key without reaching into the layout module."
  },
  "magpie_bridge": {
    "probe": {
      "url": "http://127.0.0.1:38473/health",
      "timeout_ms": 1200,
      "method": "GET",
      "purpose": "non-blocking — composer still works when Magpie is offline"
    },
    "config_source": {
      "url": "http://127.0.0.1:38473/config.json",
      "shape": "{ pointcast: {...}, publishers: { [id]: { ready, enabled, ... } } }",
      "purpose": "derive the destination readiness chips the composer displays"
    },
    "pill_states": [
      "probing",
      "connected",
      "offline"
    ],
    "ui_surface": "sp-compose-bridge row inside the reply composer on /sparrow/b/<id> — pill + destination checkboxes (PC locked on) + \"compose in magpie\" deep link",
    "submit_path": "v0.13: POST http://127.0.0.1:38473/compose when any non-PointCast destination is checked AND the pill is in \"connected\" state. Any non-2xx or network error falls back to direct https://pointcast.xyz/api/ping (pc-ping-v1) so the reply still lands in PointCast. PointCast-only posts always use the direct path — no Magpie round-trip needed.",
    "destinations_surfaced": [
      "pointcast",
      "mastodon",
      "farcaster",
      "bitchat",
      "bluesky",
      "twitter",
      "linkedin",
      "instagram",
      "zora",
      "objkt",
      "opensea"
    ],
    "endpoint_contract": {
      "status": "shipped · magpie v0.14",
      "native_source": "Magpie/Services/MagpieServer.swift (handleCompose) + Magpie/App/AppState.swift (handleComposeRequest)",
      "method": "POST",
      "path": "/compose",
      "origin": "http://127.0.0.1:38473",
      "request_body": {
        "body": "string · required · plain text",
        "title": "string · optional · ≤120 chars",
        "dek": "string · optional · one-line framing",
        "destinations": "string[] · required · PublisherID raw values (pointcast, mastodon, farcaster, bitchat, bluesky, twitter, linkedin, instagram, zora, objkt, opensea)",
        "channel": "string · optional · PointCast channel code (FD/CRT/SPN/GF/GDN/ESC/FCT/VST/BTL)",
        "blockType": "string · optional · READ/NOTE/LISTEN/WATCH/LINK/VISIT/MINT/FAUCET",
        "sourceUrl": "string · optional · canonical URL of the thing being replied to",
        "sourceApp": "string · optional · \"sparrow\" by default",
        "subject": "string · optional · alias of title",
        "timestamp": "ISO 8601 · optional · client-side wall clock"
      },
      "response_body": {
        "ok": "bool · true if at least one destination succeeded",
        "clipID": "Int64? · ephemeral clip id if Magpie created one for bookkeeping",
        "results": "Array<{ publisher, success, summary?, permalink?, remoteID?, error? }>"
      },
      "native_side_shape": "Create an ephemeral ClipItem (not persisted), build a PublishDraft with the given body/title/dek/hints, call PublisherRegistry.publish(draft, to: destinations.compactMap(PublisherID.init(rawValue:))). Return per-destination outcomes — same result envelope shape as /broadcast so downstream clients can reuse parsing.",
      "fallback_contract": "When /compose 404s (Magpie version pre-v0.13) or any network error, Sparrow posts the body to https://pointcast.xyz/api/ping directly. PointCast always lands; extras may be missed. The composer surfaces this clearly: \"direct (magpie fallback: <reason>)\"."
    }
  },
  "data_sources": {
    "blocks_index": {
      "url": "/blocks.json",
      "shape": "{ total, blocks: [{ id, channel, type, title, dek, timestamp, author, mood, companions }] }",
      "cadence": "refreshed on every site build; cache 5 min"
    },
    "block_detail": {
      "url_pattern": "/b/<id>.json",
      "shape": "Full block body + meta. See BLOCKS.md for schema."
    },
    "beacon": {
      "url": "/beacon.json",
      "purpose": "Live location pulse — powers the Sparrow beacon strip."
    },
    "atom_feed": {
      "url": "/sparrow/feed.xml",
      "note": "Sparrow-branded Atom feed (same content, sparrow kicker). Generic feed at /feed.xml."
    },
    "polling_api": {
      "url": "/sparrow/api/latest.json",
      "shape": "{ total, updated_at, window, origin, blocks: [{ id, title, dek, channel, type, mood, timestamp, author, url, sparrow_url }] }",
      "purpose": "Polling-shaped companion feed for Sparrow.app and other lightweight clients. Top 24 blocks, snake_case keys, summary-only. Cache: public, max-age=120."
    }
  },
  "ui_primitives": {
    "tuning_dial": {
      "purpose": "Conic-gradient ring over the latest 24 blocks, sliced by channel. Visual center-of-mass of recent activity.",
      "computation": "Σ blocks by channel over last 24; percent share → conic stops."
    },
    "broadcast_reel": {
      "purpose": "Horizontal/grid of the latest 12 blocks, channel-tinted, sorted newest-first.",
      "per_card": [
        "stamp (channel+type)",
        "no. + date",
        "title",
        "dek",
        "signal-strength bars",
        "mood chip"
      ]
    },
    "channel_rosette": {
      "purpose": "9 channels as a compass; each tile shows count-in-last-24 + purpose blurb.",
      "source": "/lib/channels.ts"
    },
    "beacon_strip": {
      "purpose": "Thin HUD bar with location pulse + CTA to /beacon."
    },
    "command_palette": {
      "shortcut": [
        "⌘K",
        "Ctrl+K"
      ],
      "sources": [
        "routes",
        "channels",
        "60 most recent blocks"
      ]
    },
    "now_tuned": {
      "purpose": "IntersectionObserver marks whichever receipt is centered in the viewport as \"on-air\" — the glow ring + S save-to-list key both target it.",
      "signal": "adds .is-now-tuned to the receipt; consumer css draws the ring"
    },
    "reader_aside": {
      "purpose": "Right rail on /sparrow/b/<id> — channel chip, save button, companions list, external link, canonical permalink"
    },
    "saved_list": {
      "purpose": "/sparrow/saved — local-first reading list rendered from localStorage"
    },
    "cheatsheet": {
      "purpose": "Global keyboard cheatsheet modal — opens on `?`, grouped by Discovery / Reading / Display / Reader extras. Read-only reference; palette is for doing."
    },
    "reading_progress": {
      "purpose": "Thin ember bar fixed below the tuning progress on /sparrow/b/<id>, filled via CSS view-timeline on .sp-article-body. Degrades silently on browsers without scroll-timeline."
    },
    "copy_as_quote": {
      "purpose": "Floating chip near a text selection inside .sp-article-body. Click: copies a formatted quote block with title + № + canonical URL via navigator.clipboard."
    },
    "prefetch": {
      "hover": "mouseover / focusin on [data-sp-block-id] a[href^='/sparrow/b/'] injects <link rel=\"prefetch\"> — works with the SW runtime cache so J/K and click feel instant.",
      "idle": "on /sparrow/b/<id>, requestIdleCallback prefetches prev + next block readers with a 2s timeout fallback."
    },
    "hud_lane_indicator": {
      "purpose": "v0.37 — wordmark sub-line shows which \"lane\" of the agentic web this surface belongs to (reading / federation / session / artifact / capability / meta) instead of a hard-coded label.",
      "source": "/lib/sparrow-lane.ts · laneFor(pathname) → { lane, label }; SparrowLayout renders {label} · v{SPARROW_VERSION}.",
      "styling": "[data-sp-lane=\"<lane>\"] selector tints the sub-line per lane (federation → moss, artifact → ember, session → lilac, etc)."
    },
    "hud_federation_pulse": {
      "purpose": "v0.37 — moss dot inside the wordmark blinks when a friend's kind-20078 ambient presence event lands. Ambient watcher (v0.30) dispatches a CustomEvent; the dot listens.",
      "event": "sparrow:fed-pulse · detail { pubkey, at }",
      "css": ".sp-fed-dot ↔ .sp-fed-dot.is-pulse · 800ms class toggle."
    },
    "hud_density_toggle": {
      "purpose": "v0.37 — per-device toggle between \"comfortable\" (default) and \"compact\" HUD chrome. Persists under sparrow:density; writes <html data-density>; surfaces opt in via [data-density=\"compact\"] selectors.",
      "baseline": "SparrowLayout itself tightens HUD padding, wordmark size, sub-line size, action gaps in compact mode for free."
    }
  },
  "keyboard": {
    "navigate_prev": "K",
    "navigate_next": "J",
    "focus_search": "/",
    "theme_toggle": "T",
    "save_toggle": "S",
    "friends_open": "F",
    "scroll_top": "0",
    "scroll_bottom": "$",
    "cheatsheet": "?",
    "channel_jump_by_letter": "G then <first letter of channel>",
    "channel_jump_by_number": "1=FD, 2=CRT, 3=SPN, 4=GF, 5=GDN, 6=ESC, 7=FCT, 8=VST, 9=BTL",
    "palette_open": [
      "⌘K",
      "Ctrl+K"
    ],
    "palette_confirm": "Enter",
    "palette_close": "Escape"
  },
  "design_system": {
    "palette_space": "OKLCH",
    "themes": [
      "blue-hour (default dark)",
      "dawn (light)"
    ],
    "typography": {
      "display": "Gloock (Didone, Google Fonts)",
      "ui": "Inter Tight (geometric grotesque, Google Fonts)",
      "mono": "Departure Mono (pixelated retro, Google Fonts)"
    },
    "tokens": {
      "core": [
        "--sp-ink",
        "--sp-blue-hour",
        "--sp-slate",
        "--sp-rule",
        "--sp-bone",
        "--sp-ash",
        "--sp-mute"
      ],
      "accents": [
        "--sp-ember",
        "--sp-oxblood",
        "--sp-moss",
        "--sp-lilac"
      ],
      "channels": [
        "--ch-fd",
        "--ch-crt",
        "--ch-spn",
        "--ch-gf",
        "--ch-gdn",
        "--ch-esc",
        "--ch-fct",
        "--ch-vst",
        "--ch-btl",
        "--ch-bdy"
      ]
    },
    "motifs": [
      "tuning dial",
      "signal receipts",
      "compass rosette",
      "radar sweep"
    ],
    "motion": {
      "respects_reduced_motion": true,
      "animations": [
        "needle sweep",
        "beacon radar",
        "pulse",
        "scroll-driven tuning progress"
      ]
    }
  },
  "channels": [
    {
      "code": "FD",
      "slug": "front-door",
      "name": "Front Door",
      "purpose": "AI, interfaces, agent-era thinking.",
      "recent_count": 2,
      "url": "https://pointcast.xyz/sparrow/ch/front-door",
      "color_hex_600": "#185FA5"
    },
    {
      "code": "CRT",
      "slug": "court",
      "name": "Court",
      "purpose": "Pickleball — matches, paddles, drills.",
      "recent_count": 0,
      "url": "https://pointcast.xyz/sparrow/ch/court",
      "color_hex_600": "#3B6D11"
    },
    {
      "code": "SPN",
      "slug": "spinning",
      "name": "Spinning",
      "purpose": "Music, playlists, listening notes.",
      "recent_count": 5,
      "url": "https://pointcast.xyz/sparrow/ch/spinning",
      "color_hex_600": "#993C1D"
    },
    {
      "code": "GF",
      "slug": "good-feels",
      "name": "Good Feels",
      "purpose": "Cannabis/hemp, product drops, brand ops.",
      "recent_count": 0,
      "url": "https://pointcast.xyz/sparrow/ch/good-feels",
      "color_hex_600": "#993556"
    },
    {
      "code": "GDN",
      "slug": "garden",
      "name": "Garden",
      "purpose": "Balcony, birds, wildlife, quiet noticing.",
      "recent_count": 1,
      "url": "https://pointcast.xyz/sparrow/ch/garden",
      "color_hex_600": "#0F6E56"
    },
    {
      "code": "ESC",
      "slug": "el-segundo",
      "name": "El Segundo",
      "purpose": "ESCU fiction, local, community.",
      "recent_count": 1,
      "url": "https://pointcast.xyz/sparrow/ch/el-segundo",
      "color_hex_600": "#534AB7"
    },
    {
      "code": "FCT",
      "slug": "faucet",
      "name": "Faucet",
      "purpose": "Free daily claims, giveaways.",
      "recent_count": 0,
      "url": "https://pointcast.xyz/sparrow/ch/faucet",
      "color_hex_600": "#BA7517"
    },
    {
      "code": "VST",
      "slug": "visit",
      "name": "Visit",
      "purpose": "Human and agent visit log entries.",
      "recent_count": 6,
      "url": "https://pointcast.xyz/sparrow/ch/visit",
      "color_hex_600": "#5F5E5A"
    },
    {
      "code": "BTL",
      "slug": "battler",
      "name": "Battler",
      "purpose": "Nouns Battler — deterministic duels. Every match is a block.",
      "recent_count": 9,
      "url": "https://pointcast.xyz/sparrow/ch/battler",
      "color_hex_600": "#8A2432"
    },
    {
      "code": "BDY",
      "slug": "birthday",
      "name": "Birthday",
      "purpose": "Birthdays celebrated on PointCast — one block per person per year, one Noun per person forever. Indexed at /cake.",
      "recent_count": 0,
      "url": "https://pointcast.xyz/sparrow/ch/birthday",
      "color_hex_600": "#D86440"
    }
  ],
  "snapshot": {
    "total_blocks": 186,
    "latest_block_id": "0409",
    "latest_block_url": "https://pointcast.xyz/b/0409",
    "reel_size": 12,
    "recent_window": 24,
    "distribution": {
      "BTL": 9,
      "SPN": 5,
      "VST": 6,
      "ESC": 1,
      "FD": 2,
      "GDN": 1
    },
    "generated_at": "2026-04-29T22:09:13.731Z"
  },
  "roadmap": {
    "v0.1": "Reader home + rosette + reel + palette + keyboard shortcuts. Atom feed. Theme toggle.",
    "v0.2": "Per-channel pages /sparrow/ch/<slug>. Block reader /sparrow/b/<id> with view-transition morph from the reel. Reading list /sparrow/saved (localStorage). Numeric channel shortcuts 1-9. Mood filter chips. Now-tuned IntersectionObserver. Save-toggle via S.",
    "v0.3": "Scoped service worker at /sparrow/sw.js — precache shell + 9 channels + manifest + feed, cache-first block readers (48-entry cap). PWA install via /sparrow/manifest.webmanifest with Front Door / Saved / About shortcuts. Offline pill in HUD. Last-visited indicator on receipts. Offline fallback page.",
    "v0.4": "Technical-memorandum overview at /sparrow/deck in 1980s Bell Labs / Xerox PARC styling (EB Garamond + Courier Prime on cream paper, numbered sections, ASCII architecture diagram, figure plates, references, and a prompt appendix for generating hero images). Precached for offline.",
    "v0.5": "Reader finesse — reading-progress bar (CSS view-timeline on .sp-article-body), keyboard cheatsheet overlay on `?`, copy-as-quote floating chip with attribution, hover + idle prefetch of block readers, drop caps on first paragraph, text-wrap: pretty for body copy, 0 / $ jump-to-top/bottom.",
    "v0.6": "Native macOS Sparrow.app companion shipped at github.com/mhoydich/sparrow-app (Swift 5.9, AppKit + URLSession + UserNotifications, no external deps). Menu-bar ✦ glyph with ember new-count, Notification Center alerts, preferences (feed URL, poll interval, notifications toggle). Paired with /sparrow/api/latest.json polling endpoint + /sparrow/connect landing.",
    "v0.7": "Named reactions — three-chip toolbar on every block reader (🔥 lit · 🌿 evergreen · 💜 rare) backed by localStorage:sparrow:reactions. Local-only picks hydrate from storage on load; active states pulse their accent ring.",
    "v0.8": "Reaction fan-out via NIP-07. Detects window.nostr, offers a \"connect signer\" pill next to the reactions toolbar; on reaction ADD, signs a kind-7 event r-tagged to https://pointcast.xyz/b/<id> and fire-and-forget publishes to a configurable relay pool (default: damus, primal, nos.lol). Emitted log prevents duplicate re-broadcasts on reload. Signer status surface states: local · available · connected · emitting.",
    "v0.9": "Reaction aggregation (read side). Per-reader REQ subscription against the relay pool filtered by {kinds:[7], #r:[canonical-block-url]}. Client-side dedupe by event id; count badges paint on each chip as events arrive or from the last 200 stored per relay. Reading works without a signer. Kind-5 delete events fire on unreact when the local emitted log has the original event id, with optimistic local state.",
    "v0.10": "Cross-reel count badges. One REQ per relay filtered by every visible block URL in #r, paints a compact \"🔥 3 · 🌿 1\" row into each receipt footer as events arrive. Shared state with reader aggregation so reader + reel stay in sync. Works on index, channel, saved — anywhere receipts render.",
    "v0.11": "Inline reply composer on /sparrow/b/<id>. Collapsible panel below the reactions toolbar; body + optional subject; POSTs pc-ping-v1 to https://pointcast.xyz/api/ping with the parent block as sourceUrl, channel + expand=true. Shows pending/ok/error states and auto-collapses after a successful post.",
    "v0.12": "Magpie bridge awareness. Composer probes 127.0.0.1:38473/health on load; when alive, fetches /config.json and paints a status pill + destination chips (reading state from publishers[id].ready). Deep link opens a fresh /magpie tab. Submit path stays on direct /api/ping — real multi-destination routing lands in v0.13 once the Magpie native side has a clip-less compose endpoint.",
    "v0.13": "Magpie-routed multi-destination reply (web side). Destination chips are now checkboxes; PointCast is locked on. When any non-PC box is checked AND the Magpie bridge is connected, Sparrow POSTs to http://127.0.0.1:38473/compose with { body, title, destinations[], channel, hints }. Graceful fallback: any /compose failure (404 on older Magpie, network error, or all-destinations-failed) routes the reply to direct /api/ping instead. Per-destination result painted in the composer result span. Endpoint contract spec'd in sparrow.json.magpie_bridge.endpoint_contract for the native side to implement.",
    "v0.14": "Magpie native /compose endpoint shipped. Swift-side handler in Magpie/Services/MagpieServer.swift decodes ComposeRequest { body, title?, dek?, destinations[], channel?, blockType?, sourceUrl?, sourceApp?, subject?, timestamp?, overrides? }; AppState.handleComposeRequest builds an ephemeral ClipItem (id: nil, not persisted) + PublishDraft and fans out via PublisherRegistry. Results envelope mirrors /broadcast so existing parsers work unchanged. Sparrow's v0.13 /compose attempts now succeed on the first hop when Magpie is ≥v0.14; older versions still graceful-fallback.",
    "v0.15": "Reading-list mirror via the Magpie peer-node. MagpieServer adds GET /reader-state.json + POST /reader-state (UserDefaults-backed blob store). SparrowLayout debounces POSTs on saved-list writes + pulls on load. Newest-wins per top-level key via updated_at timestamps. Single-machine today; Sparrow.app HTTP server for true native ↔ web mirror lands later alongside the v0.6 app reshape.",
    "v0.16": "Visited + reactions extend the sparrow-reader-state-v1 schema. writeVisited + writeReactions both debounce into the same 600ms scheduleReaderMirror(); on pull, Sparrow repaints .is-visited and rehydrates reaction chips from remote state. Magpie-side merge logic unchanged — its per-key newest-wins already handled any shape.",
    "v0.17": "OPML import/export on /sparrow/saved. Export bundles Sparrow's Atom feed + the nine channel RSS feeds + one <outline> per saved block (/b/<id>) into an OPML 2.0 file any feed reader can swallow. Import DOMParses the uploaded text and unions /b/<id> matches with sparrow:saved (additive, never destructive; unknown outlines ignored). Entirely client-side.",
    "v0.18": "Bridge discovery (web side). Shared resolveMagpieOrigin() probes a ranked ladder — localStorage[\"sparrow:magpie-origin\"] override → http://magpie.local:38473 → http://127.0.0.1:38473 — and caches the first /health responder on window.__sparrow. Used by every Magpie-bound fetch (mirror, bridge awareness, composer). Sets the stage for Magpie v0.19's Bonjour advertisement: the `.local` host just starts resolving once Magpie advertises via NWListener.",
    "v0.19": "Magpie Swift-side Bonjour advertisement shipped. NWListener.service publishes \"Magpie\" of type _magpie._tcp on port 38473 with a TXT record carrying version, /health path, schema id, composer + mirror endpoint hints. macOS mDNS responder transparently resolves magpie.local to the host, so Sparrow's v0.18 ladder picks it up on the second rung without any web-side changes. Listener stays loopback-bound — advertisement is metadata-only.",
    "v0.20": "Sparrow.app peer-node shipped. Sources/SparrowApp/SparrowServer.swift runs a loopback NWListener on port 38474 exposing GET /health + GET /reader-state.json + POST /reader-state with byte-identical sparrow-reader-state-v1 merge logic to Magpie's. NWListener.service advertises _sparrow._tcp \"Sparrow\" with a TXT record (version/path/schema/mirror/peer). Web resolver ladder extended to 5 rungs: user override → magpie.local → 127.0.0.1:38473 → sparrow.local:38474 → 127.0.0.1:38474. window.__sparrow.magpiePeerKind records which peer answered so the bridge pill can label it accurately. Composer stays Magpie-only and falls back to direct /api/ping when Sparrow.app is the resolved peer.",
    "v0.21": "Cross-device sync of saved + visited + reactions via NIP-44-encrypted kind-30078 addressable events. Opt-in HUD pill (sync · on/off/n/a); self-encryption via window.nostr.nip44 means only the same npub can decrypt. Runs alongside the LAN peer-node mirror — both fire on the same scheduleReaderMirror() debounce, both use the same newest-wins-per-key merge policy so state stays coherent across device + peer. 4s throttle between relay pushes; on page load subscribes with limit:1 against each relay to pull latest.",
    "v0.22": "Federated reading lists. New /sparrow/friends route. Opt-in \"publish my saved list publicly\" flag emits a separate kind-30078 event with d-tag sparrow-public-saved-v1 (unencrypted, narrow scope — just the saved ids + a client-id profile; no visited/reaction data). Friends management UI stores {pubkey,alias?}[] in sparrow:friends; on load, REQs the relay pool for each friend's latest public saved event and renders their list with server-shipped block lookups so titles and channels resolve. Hex-only for now; npub1… bech32 decode lands in a polish pass.",
    "v0.23": "Federation polish. Self-contained NIP-19 bech32 codec in /sparrow/friends — npubToHex/hexToNpub/parsePubkey — so the add-form accepts npub1… alongside hex and the HUD self-pubkey display renders as a short npub. NIP-01 kind-0 profile lookup REQs {kinds:[0], authors:<friends>} on load, caches {name, display_name, picture, nip05, fetched_at} in sparrow:profiles (24h TTL), and uses display_name/name as auto-alias when the local alias is empty. Friends list + feed cards show a 🛰 glyph on names pulled from the relay so federated vs local is legible. Picture rendering + NIP-05 verification round-trip are explicitly v0.24.",
    "v0.24": "Federation finish. Profile pictures render as 28px circles on /sparrow/friends list (18px inline on feed cards) with lazy loading + referrerpolicy=no-referrer + onerror hide. NIP-05 verification hits https://<domain>/.well-known/nostr.json?name=<user> and checks names[user] equals the pubkey; results cached on the profile with a 7-day TTL. Moss-pill ✓ when verified, oxblood-pill ! on mismatch, dot while pending. Keyboard shortcut F jumps to /sparrow/friends from any page; palette + cheatsheet entries added. Friends reel-lane on /sparrow dashboard is explicitly deferred to v0.25 to keep this sprint coherent.",
    "v0.25": "Friends lane on the dashboard. New compact section between /sparrow rosette and reel shows freshest save from each followed npub — avatar · name · block № · title · channel chip — sorted by event created_at desc, capped at 6 rows. Shares the kind-30078 consumer flow with /sparrow/friends and reads the same sparrow:profiles cache so hydration stays one-pass. Server inlines a block lookup (id → title + channel + channelName) so titles resolve without per-block fetches. Opt-out via localStorage[\"sparrow:friends-lane-hidden\"]; dismiss button lives on the lane header. Hidden entirely by default when no friends have been added.",
    "v0.26": "Friends in motion. SparrowLayout gains a persistent streaming kind-30078 REQ with `since: bootTime` so only events published after the tab loaded fire a bottom-right \"just saved\" toast (avatar + name + № id · click opens). Max 3 visible, 7s TTL, fades on leave. Opt-in Web Audio chime (two-note fifth, ~450ms, no asset) via sparrow:friends-chime-enabled. Per-friend mute — sparrow:friends entries gain an optional `muted` field that drops the friend from every consumer path (subscribe, feed, lane, toasts) without unfollowing. New 🔈/🔇 mute button in /sparrow/friends. Global motion opt-out via sparrow:friends-motion-disabled.",
    "v0.27": "Friends activity timeline at /sparrow/friends/activity. Dual subscription — bounded initial pull (limit 50) for history + `since: bootTime` live pull kept open until beforeunload. Events render newest-first as per-event cards (avatar + name + \"saved N blocks\" + relative timestamp + first 3 saved-block receipts with title/channel chips + \"+N more\"). New events splice at the top with a moss pulse animation + \"new\" pill that fades after 12 seconds. Respects mute — muted friends filtered from the authors list and re-checked on ingest. CTA added from /sparrow/friends feed head; palette + SW + routes + kicker + feature card updated.",
    "v0.28": "Signals recap at /sparrow/signals. Three-panel aggregation over the same kind-30078 corpus the rest of the federation surface reads. Panel 1 — most co-saved (blocks hit by 2+ signers, count-sorted, top 12, inline saver chips). Panel 2 — recent adds (friends who published a fresh saved list in the last 7 days, newest first, with avatar + freshest receipt). Panel 3 — channel distribution (proportional bars across all 9 channels derived from saved-block channel codes, each linking to its /sparrow/ch/<slug>). Client-side aggregation only; reuses sparrow:profiles cache + server-shipped block lookup. Signals nav links added from /sparrow/friends feed head; palette + SW + routes + kicker + feature card updated. Stats bumped to 10 routes.",
    "v0.29": "Signals, extended. Three of the four v0.29-bundled items land today; opt-in email digest deferred to v0.30 (needs sidecar worker infra). (1) First-picker attribution — in /sparrow/signals Panel 1, each co-saved block now surfaces ⭐ <name> — the friend with the earliest created_at among current savers. (2) Export JSON — new ⤓ button in signals nav dumps the current recap as sparrow-signals-<date>.json with schema sparrow-signals-v1 (friends, relays, newest_events, top_co_saved with saver lists + first_picker_at, notes documenting caveats). (3) Channel friends panel — new client-side strip on every /sparrow/ch/<slug> above the main reel showing which blocks in this channel your followed friends have co-saved, count-sorted, top 6, with first-picker chip. Hides when irrelevant; opt-out via localStorage[\"sparrow:ch-friends-hidden\"].",
    "v0.30": "Ambient friends + digest sidecar scaffold. (1) Ambient presence — SparrowLayout now publishes an ephemeral kind-20078 event tagged t:sparrow-presence every 60s while the tab is foregrounded (opt-in via sparrow:ambient-enabled). A streaming subscriber tracks friends' presence via kinds:[20078] #t:sparrow-presence and paints a fixed bottom-left \"✦ here now\" avatar strip of friends seen in the last 90s. Uses NIP-16 ephemeral-kind range so relays never persist presence. (2) Digest sidecar scaffold — new panel in /sparrow/signals for email-digest subscription (schema sparrow-digest-subscription-v1). Worker isn't live yet; intents stored locally in sparrow:digest-subscription and POSTed to /api/sparrow/digest-subscribe with 501 handled as \"queued, worker pending.\" Contract fully documented in /sparrow.json.nostr.federated_lists.digest_sidecar so the worker can pick up the shape when infra is ready. Toggle for ambient added to /sparrow/friends publisher panel.",
    "v0.31": "Participation onramps. Turns the federation surface into a joinable layer instead of a feature people have to discover. /sparrow/friends gains (1) a \"join the federation\" checklist — 4 live-updating steps (signer connected, public list on, at least one follow, ambient on) with ✓/○ ticks that repaint across tabs via storage events. (2) An invite card — builds a shareable URL (/sparrow/friends?follow=<npub1>) with a one-click copy button, shown once the signer is connected. (3) Follow-by-URL — ?follow=<npub|hex> pre-fills the add-form, scrolls, pulses the submit button. (4) Starter seeds — a scaffolded list of suggested pubkeys (edit STARTERS to curate) with one-click follow. (5) OPML friends round-trip — export bundles sparrow:friends as <outline type=\"nostr\" xmlUrl=\"nostr:npub1…\" x-sparrow-pubkey=\"<hex>\"> elements; import parses either attribute and unions with existing. Schema documented at /sparrow.json.nostr.participation.",
    "v0.32": "Digest worker live + federation.json + friends/profiles in reader-state. (1) POST /api/sparrow/digest-subscribe (Cloudflare Pages Function) validates sparrow-digest-subscription-v1 shape, returns 202 Accept with { stored, key, echo } — stores in SPARROW_DIGEST_KV if bound, otherwise still acks so the v0.30 client stops retrying. Cron dispatch ships separately. (2) GET /sparrow/federation.json carries an editorial starters list (sparrow-federation-v1 · hex + alias + optional note) so curation is a data edit in src/pages/sparrow/federation.json.ts; friends.astro fetches on boot, falls back to a 2-item internal seed. (3) Reader-state mirror gains two new keys: friends + profiles, each with their own :updated_at timestamp — cross-device federation follows the same newest-wins contract the other MIRROR_KEYS use. window.__sparrow.scheduleReaderMirror is exposed so route-local pages can trigger a push after mutating those keys. Sparrow.app ambient pickup deferred to v0.33 — native Swift Nostr client + tray UI deserves focus.",
    "v0.33": "Native ambient pickup + digest cron scaffold + unsubscribe. (1) Sparrow.app gains NostrRelayClient.swift (URLSessionWebSocketTask · ws:// REQ/EVENT/EOSE) + FriendsService.swift (reads friends from SparrowServer's sparrow.readerState UserDefaults, opens kind-20078 presence + kind-30078 saved subscriptions across damus/primal/nos.lol, tracks freshness over 90s window, decays every 20s). MenuBarController.setFriendsPresence(count, aliases) adds a \"✦ N here · alias1, alias2, +K\" menu item above the separator, hidden when zero. (2) workers/sparrow-digest/ scaffold: wrangler.toml (weekly cron Mon 08:00 UTC, shared SPARROW_DIGEST_KV binding, MailChannels transport via DKIM+DMARC lockdown), src/index.ts (listAllSubscriptions + isDue frequency gate + placeholder HTML email + /dry-run test route), README (deploy recipe + MailChannels DNS requirements + what's deferred to v0.34). (3) DELETE /api/sparrow/digest-subscribe shipped with two modes: x-unsub-intent: local-clear for web-initiated clears (no auth, same-user trust), x-unsub-token for email-footer links (501 in v0.33; signing lands with the cron worker).",
    "v0.34": "HMAC unsubscribe tokens live. workers/sparrow-digest/src/signing.ts — Web Crypto HMAC-SHA256, 30-day TTL, shape `<email>.<expires_at>.<hex-hmac>`, constant-time compare, buildUnsubUrl helper for the cron email footer. DELETE /api/sparrow/digest-subscribe now verifies the token against SPARROW_DIGEST_SIGNING_KEY (400 on malformed/bad-hmac, 410 on expired, 200 on verify + KV delete). Token arrives via either x-unsub-token header OR ?unsub_token query param so email-footer clicks work from any browser. Worker renderDigestEmail wires the signed URL into the email body/text. Signing key must be bound to both the Pages Function AND the cron worker with the same value; rotating invalidates outstanding tokens. Cron dispatch + signals aggregation + native NIP-01 profile lookup still pending.",
    "v0.35": "Cron worker gains a Nostr client + retry-with-jitter + live friends-saved summary. workers/sparrow-digest/src/nostr.ts ports NostrRelayClient.swift to TypeScript (Web WebSocket + JSON frames, no deps). collectFromRelay + collectAcrossRelays + newestPerAuthorByDTag helpers · 6s timeout per relay · 500-event safety cap. workers/sparrow-digest/src/send.ts extracts MailChannels transport with retry (3 attempts max, 800ms/1600ms/3200ms exponential-jitter backoff), 5xx/429/408 retriable, 401/403/422 fail-fast without touching last_sent_at so ops sees them + next cron retries. renderDigestEmail now fetches each subscriber's kind-3 contact list + their friends' kind-30078 public saved events, surfacing \"N of M followed signers published public saved lists · K total saved-block references\" in the email body (text + HTML). Subscribers without npub get the existing short prompt.",
    "v0.36": "Dead-letter bucket + ops endpoints. workers/sparrow-digest/src/deadletter.ts carries KV-backed failure counter (`fail:<email>`, 60-day TTL) + dead-letter record (`dl:<email>`, 1-year TTL). Threshold: 3 consecutive retriable failures OR any non-retriable failure (401/403/422) → dead-letter immediately. scheduled() skips dead-lettered subs every tick, clears the counter on success. Two new fetch routes, bearer-token gated via SPARROW_OPS_TOKEN: GET /ops/dead-letter lists every dead-letter record newest-first; POST /ops/release?email=<addr> clears both dl: and fail: entries (sub row untouched) so the next cron tick retries. Missing token → 503 ops-not-configured. Full signals aggregation (co-saves + channel distribution) from prior v0.35/v0.36 work got rolled back during branch churn — re-lands in a later sprint.",
    "v0.37": "HUD layout pass · ship-this-week items 1, 2, 4 from the v0.36 hud plan (docs/plans/2026-04-28-sparrow-hud.md). (1) Lane indicator. New src/lib/sparrow-lane.ts maps Astro.url.pathname → lane (reading / federation / session / artifact / capability / meta) with a short label; SparrowLayout's sub-line drops the hard-coded \"pointcast reader · v0.x\" string and renders {laneInfo.label} · v{SPARROW_VERSION} with a per-lane tint via [data-sp-lane=\"…\"] selectors. (2) Federation pulse dot. The wordmark gains a moss <span class=\"sp-fed-dot\"> that sits dim by default and pulses for 800ms whenever the v0.30 ambient watcher's onEvent receives a friend's kind-20078 presence event — the watcher dispatches a CustomEvent(\"sparrow:fed-pulse\"), the dot listens. Cheap to dispatch even on surfaces where no one's listening. (3) Density toggle. New compact button next to the theme toggle persists \"comfortable\" / \"compact\" under sparrow:density and writes the choice to <html data-density>; surfaces opt in via [data-density=\"compact\"] selectors. Layout-level baseline tightens HUD chrome (smaller wordmark, tighter gap, smaller pills) for free. SW_VERSION → sparrow-v0.37.0; SPARROW_VERSION = \"0.37\" exported from sparrow-lane so the sub-line and the lane lib never drift.",
    "v0.38": "TV mode · Phase 2 (federation channel). New /sparrow/tv/friends — same ten-foot ambient chrome as /sparrow/tv (slide rotation, channel-tinted wash, surface-detect, here-strip, vmin-scaled type), but the slide pool is hydrated from friends' kind-30078 sparrow-public-saved-v1 events. Pipeline: server pre-renders 12 fallback slides from the latest broadcast so the surface never looks broken; server inlines a 60-block lookup map (id → title/dek/channel/type/mood/timestamp) as application/json; client opens a WS REQ across the relay pool with { kinds:[30078], authors:<friends>, '#d':['sparrow-public-saved-v1'] }, collects newest-per-author, flattens saved IDs into a time-sorted slide list with friend-attribution chips (\"✦ saved by Alice\"), and replaces the fallback in place. New federation strip (.sptv-fed-strip) replaces the rosette: avatars of follows currently feeding the rotation + total signer count. Empty state when no friends configured: a calm CTA pointing at /sparrow/friends. Privacy default: avatars hidden until sparrow:tv-private-ok=\"1\" (the count-only mode is intentional — leaks \"N friends are publishing\" without \"who\"). SW_VERSION → sparrow-v0.38.0; /sparrow/tv + /sparrow/tv/friends added to SHELL_URLS for offline-first ambient broadcast. (current)",
    "v1.0": "Full offline archive (300+ blocks) in IndexedDB. Cross-client read state via Nostr addressable events. /sparrow/llms.txt for machine readers. Federated reading lists."
  },
  "install_steps": [
    "Open https://pointcast.xyz/sparrow — no install required.",
    "Press ⌘K to see the palette; / to filter the reel; 1-9 to jump to channels; T to flip the theme; ? for the cheatsheet.",
    "Open any block (via the reel, a channel page, or the palette) — press S to save it. Saved list lives at /sparrow/saved.",
    "(Optional) install as a PWA when your browser offers it — Sparrow becomes a standalone app and keeps working offline.",
    "(Optional) get the native companion at /sparrow/connect — a menu-bar ✦ that pulses when new blocks land. macOS 13+.",
    "(Optional) subscribe to /sparrow/feed.xml in your feed reader of choice.",
    "(Optional, v0.22) connect a NIP-07 signer (Alby / nos2x), flip the sync pill in the HUD, and add friends at /sparrow/friends to see what other readers are saving."
  ],
  "companions": {
    "magpie": {
      "role": "publisher",
      "url": "https://pointcast.xyz/magpie",
      "manifest": "https://pointcast.xyz/magpie.json"
    },
    "pointcast": {
      "role": "canonical broadcast",
      "url": "https://pointcast.xyz",
      "blocks_manifest": "https://pointcast.xyz/blocks.json"
    }
  }
}