A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge remote-tracking branch 'origin/trunk' into simplify-homepage-cta

Conflicts in the landing page, where trunk's masthead/OG-meta work
overlapped this branch's CTA redesign:

- src/pages/index.astro masthead: took trunk's account redesign
(Write link + AccountMenu) over this branch's AuthorPill + Dashboard
link. AccountMenu replaces the now-deleted AuthorPill and its
dropdown carries Dashboard/Write/Profile, so nothing is lost. Kept
this branch's HandleStart hero CTA and merged both reduced-motion
rules (handle spinner + account-menu dropdown).
- src/pages/preview.astro: kept deleted. This branch retires the
/preview sample route (and SAMPLE_TREE); trunk only maintained it
and the old index/lexicon 'Read a sample' links it depended on are
already gone here.
- src/layouts/Base.meta.test.ts: dropped trunk's 'preview page
metadata' block, which read the deleted preview.astro. The
remaining OG-meta guards are unaffected.

+3403 -212
+6
AGENTS.md
··· 40 40 In **dev you must serve on `http://127.0.0.1:<port>`, not `localhost`** (atproto 41 41 loopback requirement), and the loopback `client_id` must be path-less — see 42 42 `src/lib/auth/oauth.ts`. Auth + editor live in the `Studio` client-only island. 43 + 8. **Colocated tests under `src/pages/` MUST be underscore-prefixed** (e.g. 44 + `_index.meta.test.ts`). Astro's file router imports every `.ts` in `src/pages/` during 45 + static-path collection; a `*.test.ts` there runs its top-level `import … from 'vitest'`, 46 + which throws outside the vitest runner → the build's prerender server 500s 47 + (`Vitest failed to access its internal state`). A leading `_` makes Astro ignore the 48 + file as a route while vitest's `src/**/*.test.ts` glob still finds it. 43 49 44 50 ## Product guardrails (from the brief) 45 51
+5
docs/decisions/0005-lexicon-and-publish-model.md
··· 43 43 pre-chosen rkey (content + textContent + bskyPostRef)`. One document write, no follow-up 44 44 update. 45 45 46 + > **Superseded by Decision 0013.** To embed the standard.site link card, the post must 47 + > carry the document's strongRef, so the order is now `create document (no bskyPostRef) → 48 + > create post (facet + associatedRefs) → putRecord document (add bskyPostRef)`. Two 49 + > document writes; see 0013 for why the resulting stale ref is harmless. 50 + 46 51 ## The SkyPress content lexicon — `blog.skypress.content.gutenberg` 47 52 48 53 Goes inside the document's open `content` union (brief §3). The block tree is canonical
+25 -11
docs/decisions/0006-image-blob-pipeline.md
··· 19 19 20 20 1. Upload to the writer's PDS: `agent.uploadBlob(file, { encoding: file.type })` → 21 21 `BlobRef`. 22 - 2. Hand the editor a **local object URL** (`URL.createObjectURL(file)`) via 22 + 2. Hand the editor a **`data:` URL** (`FileReader.readAsDataURL(file)`) via 23 23 `onFileChange([{ url, alt }])` so the image previews immediately. **No `id`** — PDS 24 24 blobs aren't WP attachments; an `id` makes the Image block fetch `/wp/v2/media/<id>`, 25 25 which 404s. 26 26 3. **Retain the blob ref + the canonical `getBlob` URL** in an in-memory registry keyed 27 27 by the object URL. 28 28 29 - ### Preview via object URL, not the live `getBlob` URL (corrected 2026-06-08) 29 + ### Preview via a `data:` URL, not the live `getBlob` URL (corrected 2026-06-08) 30 30 A just-uploaded blob is **unreferenced** (temporary on the PDS) until a record commits a 31 31 reference to it; `com.atproto.sync.getBlob` fails for it (observed: **500** on a 32 32 bsky.network PDS). So the original "preview from the live `getBlob` URL" plan below is 33 - wrong for in-editor preview — it only resolves *after* publish. We preview from a local 34 - object URL instead, and `attachBlobRefs` rewrites that transient `blob:` URL to the 35 - canonical `getBlob` URL at publish (so the stored record stays portable, never a dead 36 - object URL). The reader (SP4) still reconstructs the URL from `skypressBlob` + the 37 - author's current PDS. 33 + wrong for in-editor preview — it only resolves *after* publish. We preview from a `data:` 34 + URL instead, and `attachBlobRefs` rewrites that transient `data:` URL to the canonical 35 + `getBlob` URL at publish (so the stored record stays portable, never a dead preview URL). 36 + The reader (SP4) still reconstructs the URL from `skypressBlob` + the author's current PDS. 38 37 39 - Each object URL pins its `File` in memory until revoked, so the registry releases them 40 - (`revokeBlobRegistry`) at the points where the editor is torn down and the previews leave 41 - the DOM — switching/starting an article and Studio unmount — never mid-edit, which would 42 - blank a still-displayed preview. 38 + ### Why a `data:` URL and NOT a `blob:` object URL (corrected 2026-06-09) 39 + The first cut used `URL.createObjectURL(file)`, a `blob:` URL. That **hangs the editor**: 40 + the Image block (`@wordpress/block-library@9.24.0`) treats *any* `blob:` URL as a 41 + still-uploading image — it sets `temporaryURL` (`is-transient` + a `<Spinner/>`) and runs 42 + `useUploadMediaFromBlobURL`, which re-invokes `mediaUpload` for the file behind that URL 43 + and only clears the transient state when `onChange` returns a **non-`blob:`** URL. Stock 44 + WordPress satisfies this by calling `onFileChange` twice (temporary `blob:`, then the 45 + final `http(s)` URL+id); our handler calls it once. Handing back another `blob:` URL means 46 + the spinner never clears (and `uploadBlob` may fire twice). A `data:` URL is not a `blob:` 47 + URL, so the block renders it as an ordinary inline image immediately — no transient state, 48 + no re-upload loop. (Unit tests assert the preview is a `data:` URL and never `blob:`.) 49 + 50 + A `data:` URL is a plain string (no `File` pinned in memory), so there is nothing to 51 + revoke; `revokeBlobRegistry` just clears the map at teardown — switching/starting an 52 + article and Studio unmount — so a later article can't resolve a stale preview at publish. 53 + The trade-off is that `data:` URLs are base64, so they bloat the block tree and the 54 + localStorage draft; a large image can approach the ~5MB localStorage quota. Acceptable for 55 + v1 — drafts-with-images is already a known stopgap (see Consequences) — and revisited if 56 + quota becomes a real problem (e.g. strip preview URLs from the persisted draft). 43 57 44 58 The "Media Library" button (Gutenberg's `editor.MediaUpload`, which opens the legacy 45 59 `wp.media` Backbone frame) is **disabled** via a filter override — SkyPress has no media
+14
docs/decisions/0010-publication-system-url-model.md
··· 73 73 name/bio/avatar/banner; those are rendered as **text**/images, never injected as HTML. 74 74 - Slug freezing means a renamed publication keeps a slug derived from its old name. Accepted: 75 75 URL stability beats prettiness, and there is no slug registry to rename against. 76 + 77 + ## Amendment (2026-06-09): foreign publications are displayed read-only 78 + 79 + Section 3 originally hid every non-owned `site.standard.publication` record from the 80 + dashboard. Writers found this confusing: Leaflet's home shows all their publications, so 81 + SkyPress appeared to have "lost" the ones created elsewhere. 82 + 83 + SkyPress now **displays** foreign publications (origin ≠ `SITE_BASE`) on the dashboard in a 84 + separate read-only "From other apps" section — name, logo, a hostname badge, and an outbound 85 + link to the publication's own `url`. They are modelled with a distinct `ForeignPublication` 86 + type so they are structurally incapable of reaching the manage/edit/delete paths. 87 + 88 + The ownership guards are **unchanged**: SkyPress still manages, renders, and resolves only 89 + publications it owns (`isSkyPressPublicationUrl`). Only listing/visibility expanded.
+98
docs/decisions/0012-publication-theme-presets.md
··· 1 + # 0012 — Publication theme presets ("sky phases") 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** publication system (SP10), public reader (SP4/SP7), brand (SP6) 6 + 7 + ## Context 8 + 9 + Publishers had no way to give a publication its own look — every SkyPress publication 10 + rendered with the single global brand palette. standard.site's `site.standard.publication` 11 + lexicon already defines an optional `basicTheme` (a `site.standard.theme.basic` object: 12 + `background`, `foreground`, `accent`, `accentForeground`, each an RGB triple) precisely so a 13 + publication can carry a visual identity that any standard.site reader can honour. SkyPress 14 + did not use it. 15 + 16 + Open questions: whether to store the standard field or a SkyPress-private one; whether to 17 + offer curated presets or arbitrary colour pickers; what component library the picker should 18 + use; and how a chosen theme interacts with SkyPress's light/dark (`prefers-color-scheme`) 19 + design tokens. 20 + 21 + ## Decision 22 + 23 + ### 1. Store the standard `basicTheme` field, not a private one 24 + 25 + The theme is written to `site.standard.publication.basicTheme` exactly as the community 26 + lexicon defines it. Because it is the *standard* field, other standard.site readers (Leaflet, 27 + etc.) honour the same colours — interop for free. This is an additions-only, optional field, 28 + consistent with the lexicon-discipline rule (AGENTS.md). The renderer treats stored colours as 29 + untrusted PDS data: `parseBasicTheme` validates every channel is an integer 0–255 and the 30 + renderer emits only app-built `rgb()` strings (AGENTS.md #6). 31 + 32 + **Each colour is a union member, not a bare RGB triple.** The canonical 33 + `site.standard.theme.basic` lexicon types `background`/`foreground`/`accent`/`accentForeground` 34 + as a `union` of `site.standard.theme.color#rgb` (the colour lexicon also defines `#rgba`), so 35 + atproto requires a `$type: "site.standard.theme.color#rgb"` discriminator on **every** colour 36 + object. Omitting it makes the publication record invalid against the lexicon — and Bluesky's 37 + AppView, which validates these records when hydrating the post embed's `associatedRefs`, then 38 + silently drops the **enhanced standard.site link card** and falls back to a bare external embed 39 + (no avatar, theme, or reading time). `themes.ts` stamps `COLOR_TYPE` on every colour it builds 40 + (`rgb()`) or re-validates (`parseBasicTheme`), so the single write boundary always emits valid 41 + records. (Originally shipped without the discriminator; see the fix that added it.) 42 + 43 + ### 2. Curated presets only — eight "sky phases" 44 + 45 + Rather than a free-form colour picker, SkyPress ships eight presets derived from the Twenty 46 + Twenty-Five theme's colour style variations, which are named after times of day (evening, 47 + noon, dusk, afternoon, twilight, morning, sunrise, midnight). That maps directly onto 48 + SkyPress's "open sky" brand and the existing `data-phase` attribute, so the feature reads as 49 + **sky phases**, not a generic theme editor. Each preset's accent/accentForeground pair is 50 + chosen so button/link text clears **WCAG AA (≥ 4.5:1)** — locked by a unit test 51 + (`themes.test.ts`). A custom colour picker is deliberately out of scope (YAGNI) and would also 52 + reopen the contrast-safety question per publication. 53 + 54 + ### 3. Hand-rolled React picker, not `@wordpress/components` 55 + 56 + The picker lives in `PublicationForm` (so it appears on both creation and the settings tab) 57 + and is built from plain React + CSS matching the existing form. It does **not** use 58 + `@wordpress/components`, despite that being an option, because constraint #3 (Decision 0003) 59 + forbids importing `@wordpress/*` outside the editor/Studio island — the dashboard is a 60 + no-`@wordpress` zone, and pulling the editor stack in risks the duplicate `@wordpress/data` 61 + registry crash. The picker is a labelled radio group (`role="radiogroup"`), keyboard-navigable. 62 + 63 + ### 4. Pure, dependency-free theme module 64 + 65 + `src/lib/publish/themes.ts` holds the `BasicTheme`/`Rgb` types, the presets, `parseBasicTheme`, 66 + `findPresetByColors` (reverse-match stored colours so the picker highlights the current one), 67 + and `themeToCssVars`/`themeStyleBlock` (token mapping + the injected `<style>`). It has no 68 + `@atproto`, `@wordpress`, or DOM dependency, so the SSR reader can import it on the read path. 69 + 70 + ### 5. A publisher theme overrides both light and dark 71 + 72 + On the publication-home and article pages, when the publication has a `basicTheme` the page 73 + head injects a `<style>` overriding the design tokens (`--paper`←background, `--ink`←foreground, 74 + `--sun`/`--btn-primary`←accent, button text←accentForeground; borders/muted are deterministic 75 + blends so the editorial hierarchy survives any palette). Because a publisher's palette is a 76 + fixed identity (standard.site's "consistent across platforms" intent), it intentionally 77 + overrides both the light and dark `:root` defaults rather than only one. With no `basicTheme`, 78 + nothing is injected and the existing light/dark behaviour is untouched — the default for new 79 + publications is **no theme**. 80 + 81 + To let button text follow `accentForeground`, the previously hardcoded `.btn--primary { color: #fff }` 82 + was tokenised to `var(--btn-primary-fg)` (default `#fff` in both palettes — a no-op for the 83 + default look). 84 + 85 + ## Consequences 86 + 87 + - Themes are interoperable: other standard.site readers render the same palette. 88 + - The contrast guarantee is owned and tested here; adding a preset means adding its assertion. 89 + - The reader gains a tiny inline `<style>` per themed page; unthemed pages are unchanged. 90 + - Presets-only means no per-publication contrast risk and no custom-colour UI to maintain. 91 + 92 + ## Alternatives considered 93 + 94 + - **SkyPress-private theme field** — rejected; loses cross-reader interop for no benefit. 95 + - **Arbitrary colour pickers** — rejected (YAGNI + per-publication contrast safety burden). 96 + - **`@wordpress/components` for the picker** — rejected; violates constraint #3 / Decision 0003. 97 + - **Theme only one of light/dark** — rejected; a publication identity should be stable 98 + regardless of the reader's OS colour-scheme preference.
+62
docs/decisions/0013-bsky-post-link-facet-and-associated-refs.md
··· 1 + # 0013 — Clickable link + standard.site card in the companion Bluesky post 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** The `app.bsky.feed.post` SkyPress writes alongside each published document 6 + (Decision 0005 publish flow) 7 + 8 + ## Context 9 + 10 + Publishing creates a companion `app.bsky.feed.post` whose `embed` is an 11 + `app.bsky.embed.external` card pointing at the article (Decision 0005). Two things were 12 + missing versus a reference standard.site post (`bsky.app/profile/pckt.blog`): 13 + 14 + 1. **The article URL in the post text was not clickable.** Bluesky renders URLs in `text` 15 + as plain text unless the record carries a richtext **facet** 16 + (`app.bsky.richtext.facet#link`) marking the URL's byte range. 17 + 2. **The link card was bare — no standard.site enrichment.** The richer card (publication 18 + source, theme, reading time, associated profiles) is driven by 19 + `embed.external.associatedRefs`: an array of `com.atproto.repo.strongRef` pointing at 20 + the `site.standard.document` and `site.standard.publication` records. The Bluesky 21 + AppView resolves those to render the enhanced card. We emitted none. 22 + 23 + ## Decision 24 + 25 + `buildBskyPost` (`src/lib/publish/records.ts`) now always emits a **link facet** over the 26 + article URL, and embeds **`associatedRefs: [documentRef, publicationRef]`** when those 27 + strongRefs are available. 28 + 29 + - **Facet offsets are UTF-8 byte ranges**, not JS string indices. The URL is the trailing 30 + segment of `text` (`"<title>\n\n<url>"`), so `byteStart = utf8len("<title>\n\n")` and 31 + `byteEnd = byteStart + utf8len(url)`. Computed with `TextEncoder` — a multibyte title 32 + (emoji) shifts the offsets correctly. `records.ts` stays pure (no `@atproto/*`). 33 + - **`associatedRefs` order is `[document, publication]`**, matching the reference post. 34 + 35 + ### Record ordering: three writes 36 + 37 + The post needs the document's strongRef (uri + **cid**), and the document keeps a 38 + `bskyPostRef` back at the post (Decision 0008, for unpublish / cascade-delete). That's a 39 + mutual reference. `publish` (`src/lib/publish/publisher.ts`) now writes in this order: 40 + 41 + 1. **Create the document** (no `bskyPostRef`) → yields its strongRef. 42 + 2. **Create the post** with the link facet + `associatedRefs` to the document & publication. 43 + 3. **`putRecord` the document** to add `bskyPostRef`. 44 + 45 + Step 3 re-cids the document, leaving the post's document ref one version stale. **This is 46 + harmless**: the AppView resolves `associatedRefs` by URI (verified against the reference 47 + post, whose own document ref is already stale after an edit yet still renders the rich 48 + card). Keeping `bskyPostRef` preserves the existing unpublish/cascade-delete paths 49 + unchanged. 50 + 51 + ## Consequences 52 + 53 + - `PublishInput` gains `publicationCid`; `Publication` gains `cid` (captured from 54 + `listRecords`/`createRecord`/`putRecord` responses), threaded from `PublishPanel`. 55 + - Publish is now three PDS writes instead of two (publish is not a hot path). 56 + - Edit (`updateDocument`) is unchanged: it never created a post and still doesn't; the 57 + original post's facet + refs keep pointing at the stable URL. 58 + - **Thumbnail (`thumb`) was deferred here** and is now added in **Decision 0014** (it reuses 59 + the first uploaded content image's in-repo blob ref). The standard.site card renders with 60 + or without it. 61 + - No SkyPress lexicon change: facets/`associatedRefs` are Bluesky-native fields on 62 + `app.bsky.feed.post`, not part of the `site.standard.*` schema.
+67
docs/decisions/0014-bsky-post-thumb.md
··· 1 + # 0014 — Thumbnail (`embed.external.thumb`) on the companion Bluesky post 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** The `app.bsky.feed.post` SkyPress writes alongside each published document 6 + (Decision 0005 publish flow; link facet + `associatedRefs` added in Decision 0013). 7 + 8 + ## Context 9 + 10 + Decision 0013 gave the companion post a clickable link facet and `associatedRefs` for the 11 + rich standard.site card, but left `embed.external.thumb` unset (explicitly deferred). Without 12 + a `thumb`, clients that don't resolve standard.site cards — and the default card before the 13 + AppView resolves the refs — show no image. The reference post (`bsky.app/profile/pckt.blog`) 14 + includes a `thumb` blob. 15 + 16 + ### What the references do (researched against live records + source, 2026-06-09) 17 + 18 + - **pckt.blog**: `thumb` is a **dedicated, OG-shaped blob** (JPEG, 2000×1050, 1.91:1, 805 KB) 19 + — a *different CID* from the document's own `coverImage` (JPEG, 1200×630, 45 KB). The thumb 20 + CID is not present anywhere inside the referenced document. It is a purpose-built share card. 21 + - **Leaflet** (`actions/.../publishBskyPost.ts`): sources a **cover image** (re-fetched from 22 + the PDS by CID) or a **server-rendered page screenshot**, then always `sharp`-resizes to 23 + 1200×630 (`fit: cover`), re-encodes to webp q85, and `uploadBlob`s a **fresh** blob. 24 + 25 + Shared pattern: a fresh, OG-cropped (1.91:1), re-encoded blob from a designated cover image, 26 + with a screenshot fallback — done **server-side** (`sharp` + a screenshot service). 27 + 28 + ## Decision 29 + 30 + Set `embed.external.thumb` to the **first uploaded content image's existing in-repo blob ref** 31 + — `image/*`, `0 < size ≤ 1,000,000` bytes — selected depth-first from the document's block 32 + tree. Omit `thumb` entirely when there is no usable uploaded image; the standard.site card 33 + still renders via `associatedRefs`. 34 + 35 + - `firstImageBlobRef(blocks)` (`src/lib/media/blob.ts`, pure) selects the ref; `BSKY_THUMB_MAX_BYTES` 36 + (1,000,000) encodes the post lexicon's thumb size cap. 37 + - `buildBskyPost` (`src/lib/publish/records.ts`) gains an optional `thumb` field, included only 38 + when provided — `records.ts` stays free of `@atproto/*` (`thumb` is just a typed `BlobRefJson`). 39 + - `publish` (`src/lib/publish/publisher.ts`) computes the thumb from the prepared blocks and 40 + passes it through. `updateDocument` is unchanged — edits never create a post. 41 + 42 + ### Why reuse the existing blob ref, not re-upload 43 + 44 + atproto blobs are **content-addressed and repo-scoped**: re-uploading identical bytes yields 45 + the *same CID*, so there is no separate "post-owned" copy — "ownership" is per-repo-by-CID, not 46 + per-record. The Decision 0013 write order creates the **document first**, committing/retaining 47 + the image blob, *before* the post — so the post can reference that same CID and the AppView 48 + resolves `thumb` by `did + cid` from the PDS. (Leaflet re-uploads only to **OG-reshape**; its 49 + cover blob already lives in the same PDS, confirming ownership is not the constraint.) Reuse 50 + needs no `uploadBlob`, no byte-fetch, and therefore no SSRF guard. 51 + 52 + ## Consequences 53 + 54 + - **Divergence from the references:** the thumb is the image at its native aspect ratio, which 55 + bsky.app center-crops into the ~1.91:1 card frame — no OG-shaping in v1. Acceptable; the card 56 + still gets a real image. 57 + - **External images** (remote URL, no `skypressBlob`) are skipped — making a thumb from one 58 + would need a byte-fetch + the SSRF guard (`src/lib/net/safe-fetch.ts`), deferred. 59 + - No SkyPress lexicon change: `thumb` is a Bluesky-native field on `app.bsky.feed.post`. 60 + - Publish is still three PDS writes (Decision 0013); no extra round-trip for the thumb. 61 + 62 + ## Follow-ups (out of scope) 63 + 64 + - Browser-canvas OG-crop (first image → 1200×630 → webp → fresh `uploadBlob`) for a proper 65 + 1.91:1 card, matching pckt/Leaflet. 66 + - A designated **cover-image** picker (the source the references prefer). 67 + - Publication-`icon` fallback when no content image exists.
+4 -3
docs/specs/sp2-lexicon-and-publish.md
··· 16 16 2. **Pure record builders** produce valid `site.standard.publication`, 17 17 `site.standard.document` (with the Gutenberg content object + `textContent`), and 18 18 `app.bsky.feed.post` records — unit-tested. 19 - 3. **Publisher** orchestrates via the `Agent`: ensure-publication (list/reuse or create), 20 - create post, create document with `bskyPostRef`. Returns the document URI, post URI, 21 - and canonical article URL. 19 + 3. **Publisher** orchestrates via the `Agent`: pick the target publication, create the 20 + document, create the post (clickable link facet + standard.site `associatedRefs`), then 21 + `putRecord` the document with `bskyPostRef` (order/why: Decision 0013). Returns the 22 + document URI, post URI, and canonical article URL. 22 23 4. **Write scope**: the dev loopback client requests `atproto transition:generic` so 23 24 `createRecord` is authorized (one re-auth). 24 25 5. **Publish UI**: title field + publish action that **unmistakably states it also posts
+3 -3
docs/specs/sp3-image-blob-pipeline.md
··· 11 11 ## Success criteria 12 12 13 13 1. Inserting an image in the editor uploads it to the writer's PDS and previews via a 14 - local object URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet 15 - — see Decision 0006). 14 + `data:` URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet, and a 15 + `blob:` object URL would make the Image block spin forever — see Decision 0006). 16 16 2. On publish, image blocks carry a proper `skypressBlob` ref (`{$type:'blob',…}`) in the 17 17 stored `content`, keeping the blob in-use (not GC'd) and portable. 18 18 3. Pure helpers (`getBlob` URL builder, blob-ref attach transform) unit-tested. ··· 52 52 | Check | Result | 53 53 |---|---| 54 54 | 25 unit tests (incl. blob helpers) | pass | 55 - | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a local object URL | 55 + | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a `data:` URL | 56 56 | Stored content | image block carries `skypressBlob` = `{$type:'blob', ref:{$link:'bafkrei…'}, mimeType:'image/png', size:70}` | 57 57 | Blob served | `getBlob` returns the bytes (200, 70 bytes) | 58 58 | **Blob in-use** | the CID appears in `com.atproto.sync.listBlobs` — referenced by the record, so **not garbage-collected** (the load-bearing reason to store a typed ref, Decision 0006) |
+114
docs/superpowers/specs/2026-06-09-bsky-post-thumb-design.md
··· 1 + # Bluesky post embed thumbnail (`embed.external.thumb`) — design 2 + 3 + - **Date:** 2026-06-09 4 + - **Scope:** The companion `app.bsky.feed.post` SkyPress writes alongside each published 5 + document (Decision 0005 publish flow, extended by Decision 0013). 6 + - **Decision doc:** `docs/decisions/0014-bsky-post-thumb.md` (to be written with the code). 7 + 8 + ## Problem 9 + 10 + The companion Bluesky post already carries a clickable link facet + `associatedRefs` for 11 + the rich standard.site card (Decision 0013), but no `embed.external.thumb` blob. Clients 12 + that don't understand standard.site cards (and the default card before the AppView resolves 13 + the refs) show no image. The reference post (`pckt.blog`) includes a `thumb` blob. 14 + 15 + ## What the references actually do (research, 2026-06-09) 16 + 17 + - **pckt.blog**: `embed.external.thumb` is a **dedicated, OG-shaped blob** (JPEG, 2000×1050, 18 + 1.91:1, 805 KB) — a *different CID* from the document's own `coverImage` (JPEG, 1200×630, 19 + 45 KB). The thumb CID appears nowhere inside the referenced document. It is a purpose-built 20 + social-card image. 21 + - **Leaflet** (`actions/.../publishBskyPost.ts`): sources a **cover image** (re-fetched from 22 + the PDS by CID) or, failing that, a **server-rendered page screenshot**, then *always* 23 + `sharp`-resizes to **1200×630 `fit: cover`**, re-encodes to **webp q85**, and 24 + `uploadBlob`s a **fresh** blob — `thumb: blob.data.blob`. 25 + 26 + **Shared pattern:** a fresh, OG-cropped (1.91:1), re-encoded blob from a *designated cover 27 + image*, with a screenshot fallback. Both do this **server-side** (Next.js + `sharp` + 28 + screenshot service). 29 + 30 + ## Why SkyPress diverges (constraints) 31 + 32 + SkyPress is a **browser public client** that publishes from the client, plus a **static 33 + Cloudflare Workers renderer**. It has no server-side image pipeline (`sharp`, screenshots), 34 + no `coverImage` concept on the document yet (Decision 0006 deferred it), and OG-image 35 + generation is explicitly out of scope for v1. Full pckt/Leaflet parity = a cover-image 36 + picker **and** an OG-card pipeline — a separate project. 37 + 38 + Therefore v1 follows the *spirit* (give the card a real image blob), not the letter 39 + (OG-shaped, freshly processed). 40 + 41 + ## Decision (v1) 42 + 43 + Set `embed.external.thumb` to the **first uploaded content image's existing blob ref** — 44 + no re-upload, no OG-shaping. Omit `thumb` entirely when the document has no usable uploaded 45 + image (the card still renders via `associatedRefs`). 46 + 47 + ### Why reuse the existing blob ref (not re-upload) 48 + 49 + atproto blobs are **content-addressed and repo-scoped**: re-uploading identical bytes yields 50 + the *same CID*, so there is no separate "post-owned" copy — "ownership" is per-repo-by-CID, 51 + not per-record. The publish order (Decision 0013) writes the **document first**, which 52 + commits/retains the image blob, *before* the post is created — so the post can reference that 53 + same CID and the AppView resolves `thumb` by `did + cid` from the PDS. (Leaflet re-uploads 54 + only to **OG-reshape**, not for ownership; its cover blob already lives in the same PDS.) 55 + 56 + The single divergence from the references: the thumb is the image at its native aspect ratio, 57 + which bsky.app center-crops into the ~1.91:1 card frame. Acceptable for v1. 58 + 59 + ## Components 60 + 61 + ### 1. `src/lib/media/blob.ts` — `firstImageBlobRef` (pure, new) 62 + 63 + ```ts 64 + /** bsky `app.bsky.embed.external` thumb constraint: image/*, ≤ 1,000,000 bytes. */ 65 + export const BSKY_THUMB_MAX_BYTES = 1_000_000; 66 + 67 + /** 68 + * The blob ref to use as a companion Bluesky post's `embed.external.thumb`: the first 69 + * uploaded `core/image` block's `skypressBlob` (depth-first, innerBlocks included) that the 70 + * post lexicon will accept (`image/*`, 0 < size ≤ 1 MB). Returns undefined when there is no 71 + * usable uploaded image. Reuses `IMAGE_BLOCKS` + `normalizeBlobRefJson`. 72 + */ 73 + export function firstImageBlobRef( blocks: BlockNode[] ): BlobRefJson | undefined; 74 + ``` 75 + 76 + - Walks the tree depth-first. For each `core/image` block, normalises `attributes.skypressBlob` 77 + via `normalizeBlobRefJson` (defensive: handles both JSON and `BlobRef` shapes; drops refs 78 + with no CID). Accepts it only if `mimeType` starts with `image/` **and** 79 + `0 < size ≤ BSKY_THUMB_MAX_BYTES`. Returns the first acceptable ref. 80 + - External images (no `skypressBlob`) and non-image blocks are skipped — making a thumb from a 81 + remote URL would need byte-fetch + the SSRF guard, deferred. 82 + 83 + ### 2. `src/lib/publish/records.ts` — typed `thumb` field (stays pure) 84 + 85 + - `BskyPostRecord.embed.external.thumb?: BlobRefJson`. 86 + - `buildBskyPost` input gains `thumb?: BlobRefJson`; the field is included in `external` only 87 + when provided (same conditional-spread style as `associatedRefs`). No `@atproto/*` import. 88 + 89 + ### 3. `src/lib/publish/publisher.ts` — wiring (no `uploadBlob`) 90 + 91 + - In `publish()`: `const thumb = firstImageBlobRef( input.blocks );` then pass `thumb` to 92 + `buildBskyPost`. `input.blocks` is already `attachBlobRefs`-prepared by `PublishPanel`, so 93 + `skypressBlob` is present on session-uploaded images. 94 + - `updateDocument()` unchanged — edits never create a post. 95 + 96 + ## Testing (TDD — failing test first) 97 + 98 + - **`blob.test.ts` / `firstImageBlobRef`:** first `core/image` ref returned; recurses into 99 + `innerBlocks`; picks the *first* usable when several; skips images without `skypressBlob`; 100 + skips non-image blocks; skips `size > 1 MB`; skips non-`image/*` mimeType; `undefined` when 101 + none usable. 102 + - **`records.test.ts` / `buildBskyPost`:** includes `embed.external.thumb` when a ref is 103 + passed; omits the `thumb` key otherwise. 104 + - **`publisher.test.ts` / `publish`:** with blocks containing a usable uploaded image, the 105 + created post's `embed.external.thumb` equals that image's blob ref; with no usable image, the 106 + created post has no `thumb` key. 107 + 108 + ## Out of scope / follow-ups 109 + 110 + - Browser-canvas OG-crop (draw first image → 1200×630 → webp → fresh `uploadBlob`) for a 111 + proper 1.91:1 card. 112 + - A designated **cover-image** picker (the source pckt/Leaflet prefer). 113 + - Publication-`icon` fallback when no content image exists. 114 + - No SkyPress lexicon change: `thumb` is a Bluesky-native field on `app.bsky.feed.post`.
+163
docs/superpowers/specs/2026-06-09-masthead-account-menu-design.md
··· 1 + # Masthead account menu 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (design) 5 + 6 + ## Summary 7 + 8 + The home page masthead (`/`) currently shows three things on the right: 9 + `<AuthorPill>` (a signed-in avatar pill that links to the public author page), 10 + a static **Dashboard** link, and a static **Studio** link (`/editor`). That is 11 + three items when signed in and two when signed out. 12 + 13 + This project consolidates the right side of the masthead into a single, 14 + auth-aware control: 15 + 16 + - **Signed out** (and during the pre-session loading beat, and with no JS): a 17 + single **Write** link → `/editor`. 18 + - **Signed in**: a single **account menu** whose trigger shows the viewer's 19 + avatar + name + `@handle`, and whose dropdown contains **Dashboard** 20 + (`/dashboard`), **Write** (`/editor`), and **Profile** (`/@{handle}`). 21 + 22 + It replaces the read-only `AuthorPill` with an interactive `AccountMenu` island 23 + that reuses the same OAuth-session + profile-fetch flow. 24 + 25 + ## Constraints honored 26 + 27 + - **Reading pages must never import `@wordpress/*`** (AGENTS.md rule 3). The auth 28 + chain (`createOAuthClient` → `profile.ts`) is already `@wordpress`-free; 29 + `AccountMenu` imports only it, so the editor bundle stays off the landing page. 30 + - **OAuth is a browser public client** (AGENTS.md rule 7). The menu restores the 31 + session client-side only; it never runs server-side. 32 + - The menu is **read-only navigation** — it does not sign in or sign out. 33 + Sign-out stays in the editor where it already lives. 34 + 35 + ## Decisions (from brainstorming) 36 + 37 + - **Logged-out item:** a single **Write** link → `/editor`. The old static 38 + **Dashboard** and **Studio** links are removed from the masthead. 39 + - **Profile destination:** the public author page `/@{handle}` (same target the 40 + current pill uses), via `authorPath()`. 41 + - **Loading / no-JS behavior (chosen Option A):** the **Write** link is 42 + server-rendered in `index.astro`, so it appears instantly and works without 43 + JS. Signed-in users briefly see "Write", then the island swaps it for the 44 + account menu once the session resolves. A minor one-time morph is acceptable; 45 + an empty top-right or a skeleton is not. 46 + - **Trigger interaction (chosen Option B):** the trigger **toggles the dropdown** 47 + rather than navigating. Dashboard is therefore an explicit menu item, and every 48 + destination is reachable on touch. (The brief's original "click the trigger → 49 + /dashboard" idea was dropped in favor of full touch reachability.) 50 + - **Open/close affordances:** mouse hover (desktop enhancement), click/tap toggle 51 + (works on touch), and keyboard (focus trigger + Enter/Space). Closes on outside 52 + click, on `Escape`, and when focus leaves the menu. 53 + - **Implementation approach:** static SSR Write link + a client-only island that, 54 + when signed in, renders the menu and marks its container so CSS hides the static 55 + Write link. (Not: making the whole right side one client-only component — that 56 + would drop the no-JS Write link. Not: a custom popover library — the project has 57 + no dropdown component and this is small enough to build directly.) 58 + 59 + ## Components & files 60 + 61 + ### New — `src/components/AccountMenu.tsx` (`client:only="react"`) 62 + 63 + Replaces `AuthorPill.tsx`. 64 + 65 + - On mount: `createOAuthClient()` → `client.init()`. No session / `init()` throws 66 + / profile fetch throws → render nothing (the static Write link stays; this is 67 + non-critical chrome, exactly as the pill behaves today). 68 + - Session restored → build `Agent`, call `fetchViewerProfile`, render the menu. 69 + - **Trigger:** a `<button>` showing avatar (initial-letter circle fallback when 70 + `avatar` is null), display name (`displayNameFor`), and `@handle`. Carries 71 + `aria-haspopup="menu"` and `aria-expanded`. Styled to match today's frosted 72 + `.authorpill` chip. 73 + - **Dropdown:** `role="menu"` panel of `role="menuitem"` links built from 74 + `accountMenuItems()` (see helper). Opens on hover / click / keyboard; closes on 75 + outside click, `Escape`, and focus-out. 76 + - On signed-in render, sets `data-signed-in` on `.masthead__right` (via a ref to 77 + the parent, or a documented effect) so CSS hides the sibling static Write link. 78 + 79 + ### New — pure helper in `src/lib/auth/profile.ts` (or a small sibling module) 80 + 81 + ```ts 82 + export interface MenuItem { 83 + label: string; 84 + href: string; 85 + } 86 + 87 + // Builds the dropdown items for a signed-in viewer: 88 + // Dashboard → /dashboard, Write → /editor, Profile → /@{handle}. 89 + // The Profile item is omitted when no handle is known (authorPath → null), 90 + // rather than rendering a broken link. 91 + export function accountMenuItems(profile: ViewerProfile): MenuItem[]; 92 + ``` 93 + 94 + Keeps the navigation model in tested pure code; the island stays thin. 95 + `displayNameFor` and `authorPath` already exist and are tested. 96 + 97 + ### Changed — `src/pages/index.astro` 98 + 99 + - In `.masthead__right`: render a static `<a class="masthead-write" href="/editor">Write</a>` 100 + and `<AccountMenu client:only="react" />`. Remove the old `<AuthorPill>`, the 101 + Dashboard link, and the Studio link. 102 + - Add styles to the existing `<style>` block: the `.masthead-write` chip, the 103 + trigger/dropdown (frosted panel consistent with `.btn--ghost` + per-phase 104 + `--sky-*` tokens), and `.masthead__right[data-signed-in] .masthead-write { display: none }`. 105 + - Dropdown open/close transition respects `prefers-reduced-motion`. 106 + 107 + ### Removed — `src/components/AuthorPill.tsx` 108 + 109 + Superseded by `AccountMenu`. Confirm no other importer before deleting 110 + (home masthead is the only known consumer). 111 + 112 + ## Data flow & states 113 + 114 + **Home (`/`):** 115 + 1. Static HTML renders instantly — logo + **Write** link. Works with no JS. 116 + 2. `AccountMenu` hydrates → `createOAuthClient()` → `client.init()`. 117 + 3. No session → renders nothing; Write link stays (signed-out state). 118 + 4. Session restored → `fetchViewerProfile` → menu renders, `data-signed-in` set, 119 + static Write link hidden (brief morph, no skeleton). 120 + 121 + **Menu interaction (signed in):** 122 + - Hover trigger → open. Mouse leaves menu region → close. 123 + - Click/tap trigger → toggle. 124 + - Focus trigger + Enter/Space → open; Tab moves through items; `Escape` closes 125 + and returns focus to the trigger. 126 + - Click outside / focus-out → close. 127 + 128 + ## Error handling & edge cases 129 + 130 + - `getProfile` fails → `fetchViewerProfile` returns nulls (keeps `did`); name 131 + falls back displayName → handle → DID; avatar → initial circle. Menu still works. 132 + - No handle known → `authorPath` returns null → **Profile** item omitted; 133 + Dashboard and Write remain. 134 + - `client.init()` throws → renders nothing; static Write link stays. 135 + - Avatar `<img>` fails to load → `onError` fallback to the initial circle 136 + (same pattern as today's pill). 137 + 138 + ## Testing (test-first, repo convention) 139 + 140 + This repo has **no React component-test setup**: vitest (jsdom) runs only 141 + `src/**/*.test.ts` pure-logic tests; there is no `@testing-library`, and we do 142 + **not** add one. We follow the established pattern — push testable logic into 143 + pure helpers, unit-test those, and keep the React island thin (verified manually 144 + + type-check). 145 + 146 + - `profile.test.ts` (extend existing): 147 + - `accountMenuItems` returns Dashboard (`/dashboard`), Write (`/editor`), and 148 + Profile (`/@{handle}`) in order for a profile with a handle. 149 + - `accountMenuItems` omits the Profile item when the handle is null. 150 + - Manual verification (`npm run dev`): signed-out shows only Write → `/editor`; 151 + signed-in shows the trigger; hover/click/keyboard open the dropdown; the three 152 + items navigate correctly; `Escape` / outside-click close it; no-JS still shows 153 + the Write link. 154 + - `npm run check` passes (types + lint). 155 + - Guard: no `@wordpress` import reaches the home bundle (`AccountMenu` imports 156 + only the already-clean auth chain). 157 + 158 + ## Out of scope 159 + 160 + - Sign-out / sign-in from the masthead (stays in the editor). 161 + - Arrow-key roving focus inside the menu (Tab is sufficient for v1). 162 + - Any change to the dashboard, editor, or public author pages themselves. 163 + - Caching the profile across pages / a shared cross-page session store.
+109
docs/superpowers/specs/2026-06-09-open-graph-meta-tags-design.md
··· 1 + # Open Graph meta tags for the static pages 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (brainstorm) 5 + 6 + ## Goal 7 + 8 + Give SkyPress's public, shareable static pages rich link previews on social 9 + platforms (Open Graph + Twitter Cards), with an on-brand default share image. 10 + 11 + ## Scope 12 + 13 + In scope — the three public-facing static pages a logged-out visitor would share: 14 + 15 + - `/` — home (`src/pages/index.astro`) 16 + - `/lexicon` — `src/pages/lexicon.astro` 17 + - `/preview` — `src/pages/preview.astro` 18 + 19 + Out of scope (follow-ups): 20 + 21 + - The app screens `/editor` and `/dashboard` — tools behind sign-in, not share targets. 22 + - The dynamic `[author]` publication/article pages — they need per-record titles, 23 + descriptions, and images; a different shape, tracked separately. 24 + - Per-page custom OG images — all three pages share one default image for now. 25 + 26 + ## Approach 27 + 28 + Extend the shared `Base.astro` layout, which already owns `<head>` and takes 29 + `title`/`description`. The pages then inherit OG/Twitter tags from data they 30 + already pass. (Rejected: a separate `<Seo>` component or inline per-page meta — 31 + both duplicate what `Base` already centralises.) 32 + 33 + ## Design 34 + 35 + ### A. Tag-building helper — `src/lib/seo/meta.ts` 36 + 37 + A pure function that returns the list of `<meta>` attribute objects, kept out of 38 + the `.astro` template so it is unit-testable (the project is TDD-locked). 39 + 40 + ```ts 41 + interface MetaInput { 42 + title: string; 43 + description?: string; 44 + url: string; // absolute canonical URL 45 + image: string; // absolute URL 46 + siteName: string; // "SkyPress" 47 + type?: string; // default "website" 48 + imageAlt?: string; 49 + } 50 + // returns Array<{ property?: string; name?: string; content: string }> 51 + buildMetaTags(input): MetaTag[] 52 + ``` 53 + 54 + Tags produced: 55 + 56 + - `og:title`, `og:url`, `og:type`, `og:site_name` 57 + - `og:description` — **omitted entirely when `description` is absent** (no empty tag) 58 + - `og:image`, `og:image:width` (`1200`), `og:image:height` (`630`), `og:image:alt` 59 + - `twitter:card` (`summary_large_image`), `twitter:title`, `twitter:image` 60 + - `twitter:description` — omitted when `description` is absent 61 + 62 + `og:*` use the `property` attribute; `twitter:*` and `name`-style use `name`. 63 + 64 + ### B. `Base.astro` renders them 65 + 66 + New optional props (additions only — existing `title`/`description`/`phase` unchanged): 67 + 68 + - `image?: string` — default `/og-default.png` 69 + - `ogType?: string` — default `"website"` 70 + - `imageAlt?: string` — default a brand-appropriate string 71 + 72 + `Base` computes the canonical absolute URL from `Astro.url` resolved against 73 + `Astro.site` (`https://skypress.blog`, overridable via `PUBLIC_SITE_URL`), resolves 74 + the image to an absolute URL the same way, calls `buildMetaTags`, and renders the 75 + tags in `<head>`. It also emits `<link rel="canonical" href={url} />` since the 76 + absolute URL is already computed. 77 + 78 + ### C. The three static pages 79 + 80 + - `index.astro` and `lexicon.astro` already pass `title` + `description` — no change 81 + needed; they inherit the default image automatically. 82 + - `preview.astro` currently passes only `title`; add a `description` so its card has 83 + body text (e.g. "A sample SkyPress article, rendered server-side from stored AT 84 + Protocol blocks — zero editor JavaScript."). 85 + 86 + ### D. Shared OG image — `public/og-default.png` 87 + 88 + 1200×630 PNG, committed as a static asset. On-brand: the SkyPress sky gradient (a 89 + warm sun/golden phase), the logo sun-mark + **SkyPress** wordmark, and the tagline 90 + "A writing studio for the open social web." Authored as an HTML/SVG poster using the 91 + real palette (`global.css`) and self-hosted fonts (Overused Grotesk, IBM Plex Mono), 92 + then rasterized to PNG at exactly 1200×630 via a headless browser so the custom 93 + fonts render. The authoring source need not ship; only the PNG is required. 94 + 95 + ### E. Testing & verification 96 + 97 + - **Unit (TDD, red first):** `src/lib/seo/meta.test.ts` for `buildMetaTags` — 98 + asserts the full tag set, absolute image URL passthrough, `summary_large_image`, 99 + and clean omission of description tags when absent. 100 + - **Build/type:** `npm run test`, `npm run check`, `npm run build`. 101 + - **Rendered output:** confirm each of the three built pages' `<head>` contains the 102 + OG/Twitter tags with absolute `og:url` and `og:image`. 103 + 104 + ## Constraints honoured 105 + 106 + - Reading pages must not import `@wordpress/*` — the helper is dependency-free and 107 + the rendering lives in `Base.astro`. ✓ 108 + - Additions-only to `Base.astro`'s prop interface; no behaviour change for callers 109 + that don't pass the new props. ✓
+116
docs/superpowers/specs/2026-06-09-open-graph-meta-tags-pub-doc-design.md
··· 1 + # Open Graph meta tags for publication & document pages 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (brainstorm) 5 + 6 + ## Goal 7 + 8 + Extend the rich link previews (Open Graph + Twitter Cards) already shipped on the 9 + static pages to the two dynamic public reading surfaces a visitor actually 10 + shares: a **publication** (`/@handle/slug`) and a **document/article** 11 + (`/@handle/slug/rkey`). After this, both surfaces get a complete OG/Twitter tag 12 + set with a publication-branded share image. 13 + 14 + ## Current state 15 + 16 + - **Static pages** (`/`, `/lexicon`, `/preview`): full OG/Twitter set via 17 + `Base.astro` + `buildMetaTags`. Done (prior brief). 18 + - **Publication page** (`src/pages/[author]/[slug]/index.astro`): renders 19 + `<Base title description>` *without* opting out, so it already emits the full 20 + set — but always with the generic default image (`/og-default.png`) and 21 + `og:type=website`. It never uses the publication's own logo. 22 + - **Document page** (`src/pages/[author]/[slug]/[rkey].astro`): opts out 23 + (`socialMeta={false}`) because it owns a special canonical (the atproto 24 + `canonicalArticleUrl`) and `og:type=article`. It hand-rolls only four tags — 25 + `og:type`, `og:title`, `og:description`, `og:url`. **Missing:** `og:site_name`, 26 + `og:image`, all `og:image:*`, and the entire Twitter card set. This is the 27 + biggest gap: shared articles have no image and no Twitter card. 28 + 29 + ## Image source (decided) 30 + 31 + Articles have **no** cover-image field in the `site.standard.document` lexicon, 32 + and the only imagery lives inside body blocks. The agreed chain for **both** 33 + pages is: 34 + 35 + > **publication logo (`publication.icon` blob) → shared default `/og-default.png`** 36 + 37 + A publication and its articles then share one recognizable, on-brand image with 38 + near-zero complexity. 39 + 40 + **Out of scope (YAGNI):** a per-article cover-image lexicon field, scanning body 41 + blocks for a representative image, and using the author's Bluesky banner/avatar. 42 + 43 + ## Approach 44 + 45 + ### 1. `buildMetaTags` helper — `src/lib/seo/meta.ts` 46 + 47 + The dimensions `1200×630` are currently hardcoded. That is true for the default 48 + share image but a lie for a square publication logo. 49 + 50 + - Add optional `imageWidth?: number` and `imageHeight?: number` to 51 + `MetaTagInput`. 52 + - Emit `og:image:width` / `og:image:height` **only when both are present**. 53 + - Everything else is unchanged. 54 + 55 + ### 2. `Base.astro` 56 + 57 + - Add optional `imageWidth` / `imageHeight` props. 58 + - Derive defaults so existing static pages are byte-for-byte unchanged: when the 59 + resolved image is the built-in default (`/og-default.png`), use `1200/630`; 60 + when a caller passes a custom image and no dimensions, omit them. 61 + - Pass the resolved dimensions through to `buildMetaTags`. 62 + 63 + ### 3. Publication page — `src/pages/[author]/[slug]/index.astro` 64 + 65 + - Build the logo's absolute blob URL via the existing `buildGetBlobUrl` 66 + (`publication.icon`, `pdsUrl`, `did` are already in scope). 67 + - Pass `image={logoUrl ?? undefined}` and `imageAlt={publication.name}` to 68 + `Base`. No dimensions when a logo is used; when there's no logo it transparently 69 + falls back to the default image at `1200×630`. 70 + - `og:type` stays `website`. No opt-out — `Base` keeps emitting the full set. 71 + 72 + ### 4. Document page — `src/pages/[author]/[slug]/[rkey].astro` 73 + 74 + Keeps `socialMeta={false}` (it owns the atproto canonical + `og:type=article`). 75 + 76 + - Import `buildMetaTags` and `buildGetBlobUrl`. 77 + - Resolve the publication-logo blob URL, else the default `/og-default.png` 78 + resolved absolute via `new URL( image, Astro.site )`. 79 + - Call: 80 + 81 + ```ts 82 + buildMetaTags( { 83 + title, 84 + description, 85 + url: canonical, 86 + image, 87 + siteName: 'SkyPress', 88 + type: 'article', 89 + imageAlt: publication.name, 90 + } ) 91 + ``` 92 + 93 + and render the full set in the head slot — replacing the four partial tags. 94 + The existing `standard.site` link tags + canonical link stay untouched. 95 + - Result: articles gain `og:image`, `og:site_name`, and the full Twitter card. 96 + 97 + ## Testing (TDD — failing test first) 98 + 99 + - `src/lib/seo/meta.test.ts`: `og:image:width/height` emitted only when both 100 + passed; omitted otherwise. 101 + - `src/layouts/Base.meta.test.ts`: a custom image without dimensions omits the 102 + `og:image:width/height` tags; the default image still emits `1200×630`. 103 + - Source-level page assertions for the publication + document pages, matching the 104 + repo's existing pure-logic / source-pin convention (the runner is jsdom-pinned, 105 + so page rendering through `astro/container` is not viable — see the note atop 106 + `Base.meta.test.ts`): 107 + - Publication page passes `image=` (a resolved logo URL) and `imageAlt=` to 108 + `Base`. 109 + - Document page calls `buildMetaTags` with `type: 'article'` and renders the 110 + resulting tags, and no longer hand-rolls a partial set. 111 + 112 + ## Risks / notes 113 + 114 + - Blob image URLs point at the writer's PDS (an external host). OG scrapers fetch 115 + them directly — expected and fine. The logo is square, which is why we drop the 116 + `1200×630` assertion for it rather than mis-declaring dimensions.
+154
docs/superpowers/specs/2026-06-09-publication-themes-design.md
··· 1 + # Publication theme presets ("sky phases") 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (brainstorm) — ready for plan 5 + **Area:** publication system (SP10), reader/renderer (SP4/SP7), brand (SP6) 6 + 7 + ## Summary 8 + 9 + Let a publisher pick a colour theme for their publication from a small set of 10 + curated **sky-phase** presets. The chosen palette is stored on the publication 11 + record using the standard.site `basicTheme` field, surfaces on publication 12 + creation and in publication settings, and is applied when the publication and 13 + its articles are rendered publicly. Default is **no theme** (today's SkyPress 14 + look, unchanged). 15 + 16 + ## Motivation 17 + 18 + standard.site's `site.standard.publication` lexicon defines an optional 19 + `basicTheme` (a `site.standard.theme.basic` object) so a publication can carry a 20 + visual identity that any standard.site reader can honour. SkyPress does not use 21 + it yet. Adding it lets publishers personalise their space, and — because we 22 + store the *standard* field rather than a SkyPress-private one — other readers 23 + (Leaflet, etc.) honour the same colours. 24 + 25 + The Twenty Twenty-Five theme's colour style variations are named after times of 26 + day (evening, noon, dusk, afternoon, twilight, morning, sunrise, midnight). That 27 + is a direct fit for SkyPress's "open sky" brand and the existing `data-phase` 28 + attribute on `<html>`, so the presets ship as **sky phases**. 29 + 30 + ## Data model 31 + 32 + ### `site.standard.theme.basic` (embedded object) 33 + 34 + Four required RGB colours (each `{ r, g, b }`, integers 0–255; alpha not used): 35 + 36 + | field | meaning | 37 + |---|---| 38 + | `background` | content background | 39 + | `foreground` | content text | 40 + | `accent` | links + button backgrounds | 41 + | `accentForeground` | button text | 42 + 43 + Represented in TypeScript as: 44 + 45 + ```ts 46 + interface Rgb { r: number; g: number; b: number; } 47 + interface BasicTheme { 48 + $type: 'site.standard.theme.basic'; 49 + background: Rgb; 50 + foreground: Rgb; 51 + accent: Rgb; 52 + accentForeground: Rgb; 53 + } 54 + ``` 55 + 56 + ### Changes to existing types (additions only — lexicon discipline) 57 + 58 + - `PublicationRecord` (`src/lib/publish/records.ts`): add optional `basicTheme?: BasicTheme`. 59 + - `buildPublicationRecord`: accept + conditionally include `basicTheme`. 60 + - `PublicationInput` / `Publication` (`src/lib/publish/publications.ts`): add optional `basicTheme`. 61 + - `ReaderPublication` (`src/lib/reader/publications.ts`): surface `basicTheme`. 62 + 63 + No field is ever removed or made required. `basicTheme` absent ⇒ default look. 64 + 65 + ## Presets — `src/lib/publish/themes.ts` (pure, tested) 66 + 67 + A new dependency-free module exporting the 8 sky-phase presets derived from the 68 + Twenty Twenty-Five colour palettes: 69 + 70 + ```ts 71 + interface ThemePreset { slug: string; label: string; colors: BasicTheme; } 72 + export const THEME_PRESETS: readonly ThemePreset[]; 73 + ``` 74 + 75 + Mapping from each TT5 palette: `background ← base`, `foreground ← contrast`, and 76 + an `accent` / `accentForeground` pair chosen so button/link text clears 77 + **WCAG AA (contrast ≥ 4.5:1)**. The accent is the palette's most vivid usable 78 + accent; `accentForeground` is whichever of the palette's light/dark colours 79 + passes against it. 80 + 81 + Phases (TT5 source): Evening, Noon, Dusk, Afternoon, Twilight, Morning, 82 + Sunrise, Midnight. 83 + 84 + Helpers: 85 + - `findPresetByColors(theme): ThemePreset | null` — reverse-match stored colours 86 + to a preset so the settings UI can highlight the current selection (returns 87 + `null` for none/custom). 88 + - `themeToCssVars(theme): Record<string, string>` — map a `BasicTheme` to the 89 + SkyPress design tokens it overrides. Pure, unit-tested. 90 + 91 + ## UI — preset picker in `PublicationForm` 92 + 93 + A swatch-grid radio group added to `PublicationForm.tsx`, so it appears 94 + identically on **creation** and in the **Settings tab** (which already reuses 95 + `PublicationForm`). Each option is a small preview card showing the preset's 96 + background/text/accent, plus a "No theme" option (default, selected when the 97 + publication has no `basicTheme`). 98 + 99 + **Hand-rolled React + plain CSS — not `@wordpress/components`.** AGENTS.md 100 + constraint #3 forbids `@wordpress/*` outside the editor/Studio island; the 101 + dashboard is an explicit no-`@wordpress` zone (importing it risks the duplicate 102 + `@wordpress/data` registry crash from Decision 0003). The picker matches the 103 + existing `PublicationForm` plain-React/CSS style. 104 + 105 + Accessibility: a labelled radio group (`role="radiogroup"`), each preset an 106 + `<input type="radio">` with a visible label, keyboard-navigable, with a visible 107 + focus/selected state. 108 + 109 + ## Public rendering 110 + 111 + In the publication-home (`[author]/[slug]/index.astro`) and article 112 + (`[author]/[slug]/[rkey].astro`) pages, when the resolved publication has a 113 + `basicTheme`, inject a small `<style>` into the existing `<Fragment slot="head">` 114 + that overrides the design tokens on `:root` via `themeToCssVars` (e.g. 115 + `--paper`←background, `--ink`←foreground, `--sun`/`--btn-primary`←accent, 116 + button text←accentForeground). 117 + 118 + A publisher's palette is a **fixed identity**, so it intentionally overrides 119 + both the light and dark `prefers-color-scheme` defaults (standard.site's 120 + "consistent across platforms" intent). When there is no `basicTheme`, nothing is 121 + injected and the existing light/dark behaviour is untouched. 122 + 123 + The injected values come from a trusted, app-controlled preset table keyed by 124 + the stored colours — but since the record is PDS-sourced, the renderer validates 125 + each channel is an integer 0–255 and emits only `rgb(...)` strings (never raw 126 + record text) to avoid CSS injection. 127 + 128 + ## Tests (TDD, failing-test-first) 129 + 130 + - `themes.test.ts`: all 8 presets present; each accent/accentForeground pair 131 + clears WCAG AA; `findPresetByColors` round-trips; `themeToCssVars` output. 132 + - `records.test.ts`: `buildPublicationRecord` includes `basicTheme` when given, 133 + omits when not; RGB shape preserved. 134 + - `publications.test.ts`: create + update round-trip `basicTheme`. 135 + - `reader/publications.test.ts`: `basicTheme` surfaces (and malformed/absent → 136 + undefined). 137 + - Renderer mapping covered by `themeToCssVars` test + channel validation test. 138 + - Picker: radio-group semantics / selection state. 139 + 140 + ## Docs & decisions 141 + 142 + - New decision doc `docs/decisions/0012-publication-theme-presets.md`: why the 143 + standard `basicTheme` field, why hand-rolled UI (not `@wordpress`), and the 144 + light/dark override semantics. 145 + - Update `lexicons/README.md` — add `basicTheme` to the 146 + `site.standard.publication` fields table. 147 + 148 + ## Out of scope (YAGNI) 149 + 150 + - Custom colour pickers / arbitrary palettes (presets only). 151 + - Theming the authenticated dashboard/editor chrome (public reader only). 152 + - Fonts, layout/spacing, or per-article overrides. 153 + - `labels` / `preferences` publication fields. 154 + ```
+1
lexicons/README.md
··· 62 62 | `name` | ✓ | publication name (defaults from the handle) | 63 63 | `description` | | optional | 64 64 | `icon` | | optional blob (≤1MB) | 65 + | `basicTheme` | | optional `site.standard.theme.basic` object — a sky-phase colour theme (`background`/`foreground`/`accent`/`accentForeground` as RGB); applied on the public reader and honoured by other standard.site readers (Decision 0012) | 65 66 66 67 ## `app.bsky.feed.post` — the social signal 67 68
public/og-default.png

This is a binary file and will not be displayed.

+150
src/components/AccountMenu.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { Agent } from '@atproto/api'; 3 + import { createOAuthClient } from '../lib/auth/oauth'; 4 + import { 5 + fetchViewerProfile, 6 + displayNameFor, 7 + accountMenuItems, 8 + type ViewerProfile, 9 + } from '../lib/auth/profile'; 10 + 11 + /** 12 + * Signed-in account menu for the home masthead. Restores the browser OAuth 13 + * session and, when signed in, renders an avatar + name + handle trigger with a 14 + * Dashboard / Write / Profile dropdown. Renders nothing when signed out or if 15 + * anything fails — it is non-critical chrome (the static "Write" link in the 16 + * masthead remains), so it never surfaces errors. While signed in it sets 17 + * `data-signed-in` on `.masthead__right` so CSS hides that static link. 18 + */ 19 + export default function AccountMenu() { 20 + const [ profile, setProfile ] = useState< ViewerProfile | null >( null ); 21 + const [ open, setOpen ] = useState( false ); 22 + const [ avatarOk, setAvatarOk ] = useState( true ); 23 + const [ canHover, setCanHover ] = useState( false ); 24 + const rootRef = useRef< HTMLDivElement >( null ); 25 + const triggerRef = useRef< HTMLButtonElement >( null ); 26 + 27 + // Hover is a desktop-only enhancement. On hover-capable devices the trigger 28 + // opens on pointer enter (and closes on leave), so its click only ever opens — 29 + // a toggle would close the menu the hover just opened. On touch there is no 30 + // hover, so the click acts as a tap toggle instead. 31 + useEffect( () => { 32 + setCanHover( window.matchMedia?.( '(hover: hover)' ).matches ?? false ); 33 + }, [] ); 34 + 35 + // Restore the OAuth session and fetch the viewer's profile. 36 + useEffect( () => { 37 + let cancelled = false; 38 + ( async () => { 39 + try { 40 + const client = await createOAuthClient(); 41 + const result = await client.init(); 42 + if ( cancelled || ! result?.session ) { 43 + return; 44 + } 45 + const next = await fetchViewerProfile( new Agent( result.session ), result.session.did ); 46 + if ( ! cancelled ) { 47 + setProfile( next ); 48 + } 49 + } catch { 50 + // Non-critical chrome: stay hidden on any failure. 51 + } 52 + } )(); 53 + return () => { 54 + cancelled = true; 55 + }; 56 + }, [] ); 57 + 58 + // While signed in, mark the masthead so the static "Write" link is hidden. 59 + useEffect( () => { 60 + const right = rootRef.current?.closest( '.masthead__right' ); 61 + if ( ! right ) { 62 + return; 63 + } 64 + right.setAttribute( 'data-signed-in', '' ); 65 + return () => right.removeAttribute( 'data-signed-in' ); 66 + }, [ profile ] ); 67 + 68 + // Close the dropdown on outside click and on Escape. 69 + useEffect( () => { 70 + if ( ! open ) { 71 + return; 72 + } 73 + function onDown( event: MouseEvent ) { 74 + if ( rootRef.current && ! rootRef.current.contains( event.target as Node ) ) { 75 + setOpen( false ); 76 + } 77 + } 78 + function onKey( event: KeyboardEvent ) { 79 + if ( event.key === 'Escape' ) { 80 + setOpen( false ); 81 + triggerRef.current?.focus(); 82 + } 83 + } 84 + document.addEventListener( 'mousedown', onDown ); 85 + document.addEventListener( 'keydown', onKey ); 86 + return () => { 87 + document.removeEventListener( 'mousedown', onDown ); 88 + document.removeEventListener( 'keydown', onKey ); 89 + }; 90 + }, [ open ] ); 91 + 92 + if ( ! profile ) { 93 + return null; 94 + } 95 + 96 + const name = displayNameFor( profile ); 97 + const items = accountMenuItems( profile ); 98 + 99 + return ( 100 + <div 101 + className="account-menu" 102 + ref={ rootRef } 103 + onMouseEnter={ canHover ? () => setOpen( true ) : undefined } 104 + onMouseLeave={ canHover ? () => setOpen( false ) : undefined } 105 + onBlur={ ( event ) => { 106 + if ( ! rootRef.current?.contains( event.relatedTarget as Node ) ) { 107 + setOpen( false ); 108 + } 109 + } } 110 + > 111 + <button 112 + ref={ triggerRef } 113 + type="button" 114 + className="account-menu__trigger" 115 + aria-haspopup="menu" 116 + aria-expanded={ open } 117 + aria-controls="account-menu-dropdown" 118 + onClick={ () => setOpen( ( value ) => ( canHover ? true : ! value ) ) } 119 + > 120 + { profile.avatar && avatarOk ? ( 121 + <img 122 + className="account-menu__avatar" 123 + src={ profile.avatar } 124 + alt="" 125 + width={ 30 } 126 + height={ 30 } 127 + onError={ () => setAvatarOk( false ) } 128 + /> 129 + ) : ( 130 + <span className="account-menu__avatar account-menu__avatar--fallback" aria-hidden="true"> 131 + { name.charAt( 0 ).toUpperCase() } 132 + </span> 133 + ) } 134 + <span className="account-menu__who"> 135 + <span className="account-menu__name">{ name }</span> 136 + { profile.handle && <span className="account-menu__handle">@{ profile.handle }</span> } 137 + </span> 138 + </button> 139 + { open && ( 140 + <div id="account-menu-dropdown" className="account-menu__dropdown" role="menu"> 141 + { items.map( ( item ) => ( 142 + <a key={ item.href } className="account-menu__item" role="menuitem" href={ item.href }> 143 + { item.label } 144 + </a> 145 + ) ) } 146 + </div> 147 + ) } 148 + </div> 149 + ); 150 + }
-75
src/components/AuthorPill.tsx
··· 1 - import { useEffect, useState } from 'react'; 2 - import { Agent } from '@atproto/api'; 3 - import { createOAuthClient } from '../lib/auth/oauth'; 4 - import { fetchViewerProfile, displayNameFor, authorPath, type ViewerProfile } from '../lib/auth/profile'; 5 - 6 - /** 7 - * Read-only signed-in badge for the home masthead. Restores the browser OAuth 8 - * session and shows the viewer's avatar + name + handle, linking to their public 9 - * page. Renders nothing when signed out or if anything fails — it is non-critical 10 - * chrome, so it never surfaces errors (the editor is where auth errors live). 11 - */ 12 - export default function AuthorPill() { 13 - const [ profile, setProfile ] = useState< ViewerProfile | null >( null ); 14 - const [ avatarOk, setAvatarOk ] = useState( true ); 15 - 16 - useEffect( () => { 17 - let cancelled = false; 18 - ( async () => { 19 - try { 20 - const client = await createOAuthClient(); 21 - const result = await client.init(); 22 - if ( cancelled || ! result?.session ) { 23 - return; 24 - } 25 - const next = await fetchViewerProfile( new Agent( result.session ), result.session.did ); 26 - if ( ! cancelled ) { 27 - setProfile( next ); 28 - } 29 - } catch { 30 - // Non-critical chrome: stay hidden on any failure. 31 - } 32 - } )(); 33 - return () => { 34 - cancelled = true; 35 - }; 36 - }, [] ); 37 - 38 - if ( ! profile ) { 39 - return null; 40 - } 41 - 42 - const name = displayNameFor( profile ); 43 - const href = authorPath( profile.handle ); 44 - 45 - const inner = ( 46 - <> 47 - { profile.avatar && avatarOk ? ( 48 - <img 49 - className="authorpill__avatar" 50 - src={ profile.avatar } 51 - alt="" 52 - width={ 30 } 53 - height={ 30 } 54 - onError={ () => setAvatarOk( false ) } 55 - /> 56 - ) : ( 57 - <span className="authorpill__avatar authorpill__avatar--fallback" aria-hidden="true"> 58 - { name.charAt( 0 ).toUpperCase() } 59 - </span> 60 - ) } 61 - <span className="authorpill__who"> 62 - <span className="authorpill__name">{ name }</span> 63 - { profile.handle && <span className="authorpill__handle">@{ profile.handle }</span> } 64 - </span> 65 - </> 66 - ); 67 - 68 - return href ? ( 69 - <a className="authorpill" href={ href } aria-label={ `${ name } — view your public page` }> 70 - { inner } 71 - </a> 72 - ) : ( 73 - <span className="authorpill">{ inner }</span> 74 - ); 75 - }
+81 -21
src/components/Dashboard.tsx
··· 5 5 import LoginForm from '../lib/auth/LoginForm'; 6 6 import PublicationForm from './PublicationForm'; 7 7 import { 8 - listPublications, 8 + listAllPublications, 9 9 deletePublication, 10 10 type Publication, 11 + type ForeignPublication, 11 12 } from '../lib/publish/publications'; 12 13 import { 13 14 listPublicationArticles, 14 15 unpublish, 15 16 type MyArticle, 16 17 } from '../lib/publish/publisher'; 17 - import { buildGetBlobUrl } from '../lib/media/blob'; 18 + import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 18 19 19 20 type View = 20 21 | { kind: 'list' } ··· 24 25 /** The publication dashboard (SP10, step D). Authed, client-only — no `@wordpress/*`. */ 25 26 function DashboardGate() { 26 27 const { status, agent, handle, did, pdsUrl, error, signOut } = useAuth(); 27 - const [ publications, setPublications ] = useState< Publication[] | null >( null ); 28 + const [ data, setData ] = useState< 29 + { owned: Publication[]; foreign: ForeignPublication[] } | null 30 + >( null ); 28 31 const [ view, setView ] = useState< View >( { kind: 'list' } ); 29 32 30 33 useEffect( () => { ··· 32 35 return; 33 36 } 34 37 let cancelled = false; 35 - listPublications( agent, did ) 36 - .then( ( list ) => ! cancelled && setPublications( list ) ) 37 - .catch( () => ! cancelled && setPublications( [] ) ); 38 + listAllPublications( agent, did ) 39 + .then( ( result ) => ! cancelled && setData( result ) ) 40 + .catch( () => ! cancelled && setData( { owned: [], foreign: [] } ) ); 38 41 return () => { 39 42 cancelled = true; 40 43 }; ··· 60 63 const writerHandle = handle ?? did; 61 64 62 65 const reload = () => { 63 - listPublications( agent, did ) 64 - .then( setPublications ) 65 - .catch( () => setPublications( [] ) ); 66 + listAllPublications( agent, did ) 67 + .then( setData ) 68 + .catch( () => setData( { owned: [], foreign: [] } ) ); 66 69 }; 67 70 68 71 return ( ··· 121 124 122 125 { view.kind === 'list' && ( 123 126 <PublicationList 124 - publications={ publications } 127 + publications={ data ? data.owned : null } 128 + foreign={ data?.foreign ?? [] } 125 129 did={ did } 126 130 handle={ writerHandle } 127 131 pdsUrl={ pdsUrl } ··· 133 137 ); 134 138 } 135 139 140 + /** A publication's 48px logo, or a letter-fallback square when it has no icon. Shared by the owned and foreign lists. */ 141 + function PublicationLogo( { 142 + icon, 143 + name, 144 + pdsUrl, 145 + did, 146 + }: { 147 + icon?: BlobRefJson; 148 + name: string; 149 + pdsUrl: string | null; 150 + did: string; 151 + } ) { 152 + const logoUrl = icon && pdsUrl ? buildGetBlobUrl( pdsUrl, did, icon.ref.$link ) : null; 153 + return logoUrl ? ( 154 + <img className="dash__publogo" src={ logoUrl } alt="" width={ 48 } height={ 48 } /> 155 + ) : ( 156 + <span className="dash__publogo dash__publogo--fallback" aria-hidden="true"> 157 + { name.charAt( 0 ).toUpperCase() } 158 + </span> 159 + ); 160 + } 161 + 136 162 function PublicationList( { 137 163 publications, 164 + foreign, 138 165 did, 139 166 handle, 140 167 pdsUrl, ··· 142 169 onManage, 143 170 }: { 144 171 publications: Publication[] | null; 172 + foreign: ForeignPublication[]; 145 173 did: string; 146 174 handle: string; 147 175 pdsUrl: string | null; ··· 167 195 ) : ( 168 196 <ul className="dash__pubs"> 169 197 { publications.map( ( pub ) => { 170 - const logoUrl = 171 - pub.icon && pdsUrl 172 - ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 173 - : null; 174 198 return ( 175 199 <li className="dash__pub" key={ pub.uri }> 176 - { logoUrl ? ( 177 - <img className="dash__publogo" src={ logoUrl } alt="" width={ 48 } height={ 48 } /> 178 - ) : ( 179 - <span className="dash__publogo dash__publogo--fallback" aria-hidden="true"> 180 - { pub.name.charAt( 0 ).toUpperCase() } 181 - </span> 182 - ) } 200 + <PublicationLogo 201 + icon={ pub.icon } 202 + name={ pub.name } 203 + pdsUrl={ pdsUrl } 204 + did={ did } 205 + /> 183 206 <span className="dash__pubtext"> 184 207 <span className="dash__pubname">{ pub.name }</span> 185 208 <span className="dash__pubslug">/{ pub.slug }</span> ··· 196 219 ); 197 220 } ) } 198 221 </ul> 222 + ) } 223 + 224 + { foreign.length > 0 && ( 225 + <div className="dash__foreign"> 226 + <h2 className="dash__h2">From other apps</h2> 227 + <p className="dash__foreign-note"> 228 + These publications live in your repository but are managed by another app. 229 + </p> 230 + <ul className="dash__pubs"> 231 + { foreign.map( ( pub ) => { 232 + return ( 233 + <li className="dash__pub" key={ pub.uri }> 234 + <PublicationLogo 235 + icon={ pub.icon } 236 + name={ pub.name } 237 + pdsUrl={ pdsUrl } 238 + did={ did } 239 + /> 240 + <span className="dash__pubtext"> 241 + <span className="dash__pubname">{ pub.name }</span> 242 + <span className="dash__pubhost">{ pub.hostname }</span> 243 + </span> 244 + <span className="dash__pubactions"> 245 + <a 246 + className="dash__link" 247 + href={ pub.url } 248 + target="_blank" 249 + rel="noopener noreferrer" 250 + > 251 + Visit ↗ 252 + </a> 253 + </span> 254 + </li> 255 + ); 256 + } ) } 257 + </ul> 258 + </div> 199 259 ) } 200 260 </section> 201 261 );
+74
src/components/MyArticles.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 + import { createElement, act } from 'react'; 3 + import { createRoot, type Root } from 'react-dom/client'; 4 + import type { Agent } from '@atproto/api'; 5 + import type { MyArticle } from '../lib/publish/publisher'; 6 + 7 + // React needs this flag to run effects synchronously inside act(). 8 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 9 + 10 + const sampleArticle: MyArticle = { 11 + rkey: '3kabc123', 12 + title: 'Hello world', 13 + siteUri: 'at://did:plc:alice/site.standard.publication/pub1', 14 + siteSlug: 'my-blog', 15 + blocks: [], 16 + }; 17 + 18 + const listAllMyArticles = vi.fn(); 19 + vi.mock( '../lib/publish/publisher', () => ( { 20 + listAllMyArticles: () => listAllMyArticles(), 21 + unpublish: vi.fn().mockResolvedValue( undefined ), 22 + } ) ); 23 + 24 + // Imported after the mock so the component picks up the mocked module. 25 + const { default: MyArticles } = await import( './MyArticles' ); 26 + 27 + let container: HTMLDivElement; 28 + let root: Root; 29 + 30 + beforeEach( () => { 31 + listAllMyArticles.mockResolvedValue( [ sampleArticle ] ); 32 + container = document.createElement( 'div' ); 33 + document.body.appendChild( container ); 34 + root = createRoot( container ); 35 + } ); 36 + 37 + afterEach( () => { 38 + act( () => root.unmount() ); 39 + container.remove(); 40 + vi.clearAllMocks(); 41 + } ); 42 + 43 + async function renderList( handle: string | null ): Promise< void > { 44 + await act( async () => { 45 + root.render( 46 + createElement( MyArticles, { 47 + agent: {} as Agent, 48 + did: 'did:plc:alice', 49 + handle, 50 + refreshKey: 0, 51 + onEdit: () => {}, 52 + } ) 53 + ); 54 + } ); 55 + } 56 + 57 + describe( 'MyArticles view link', () => { 58 + it( 'links each article to its public page when a handle is known', async () => { 59 + await renderList( 'alice.test' ); 60 + 61 + const link = container.querySelector< HTMLAnchorElement >( 'a.myarticles__view' ); 62 + expect( link ).not.toBeNull(); 63 + expect( link?.getAttribute( 'href' ) ).toBe( '/@alice.test/my-blog/3kabc123' ); 64 + expect( link?.textContent ).toContain( 'View' ); 65 + } ); 66 + 67 + it( 'omits the view link when no handle is available', async () => { 68 + await renderList( null ); 69 + 70 + expect( container.querySelector( 'a.myarticles__view' ) ).toBeNull(); 71 + // The other actions still render. 72 + expect( container.textContent ).toContain( 'Edit' ); 73 + } ); 74 + } );
+13 -1
src/components/MyArticles.tsx
··· 5 5 interface Props { 6 6 agent: Agent; 7 7 did: string; 8 + /** Writer's handle, for building public article URLs. Null if unresolved. */ 9 + handle: string | null; 8 10 /** Bump to re-fetch after a publish/update. */ 9 11 refreshKey: number; 10 12 onEdit: ( article: MyArticle ) => void; 11 13 } 12 14 13 15 /** Lists the signed-in writer's SkyPress articles across all their publications (SP5/SP10). */ 14 - export default function MyArticles( { agent, did, refreshKey, onEdit }: Props ) { 16 + export default function MyArticles( { agent, did, handle, refreshKey, onEdit }: Props ) { 15 17 const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 16 18 const [ busy, setBusy ] = useState< string | null >( null ); 17 19 ··· 63 65 { article.updatedAt && <em className="myarticles__edited"> · edited</em> } 64 66 </span> 65 67 <span className="myarticles__actions"> 68 + { handle && ( 69 + <a 70 + className="myarticles__view" 71 + href={ `/@${ handle }/${ article.siteSlug }/${ article.rkey }` } 72 + target="_blank" 73 + rel="noopener noreferrer" 74 + > 75 + View 76 + </a> 77 + ) } 66 78 <button type="button" onClick={ () => onEdit( article ) }> 67 79 Edit 68 80 </button>
+161
src/components/PublicationForm.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import { createRoot } from 'react-dom/client'; 5 + import type { Agent } from '@atproto/api'; 6 + 7 + // react-dom/client + act need this flag so React treats vitest's jsdom as a test environment. 8 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 9 + import PublicationForm from './PublicationForm'; 10 + import { THEME_PRESETS } from '../lib/publish/themes'; 11 + import type { Publication } from '../lib/publish/publications'; 12 + 13 + const baseProps = { 14 + agent: {} as Agent, 15 + did: 'did:plc:alice', 16 + pdsUrl: null, 17 + handle: 'alice.test', 18 + onSaved: () => {}, 19 + onCancel: () => {}, 20 + }; 21 + 22 + function renderForm( existing?: Publication ): string { 23 + return renderToStaticMarkup( createElement( PublicationForm, { ...baseProps, existing } ) ); 24 + } 25 + 26 + /** Every `<input type="radio">` tag in the markup. */ 27 + function radios( markup: string ): string[] { 28 + return markup.match( /<input[^>]*type="radio"[^>]*>/g ) ?? []; 29 + } 30 + 31 + /** The `value=""` of the single checked radio, or null. */ 32 + function checkedValue( markup: string ): string | null { 33 + const checked = radios( markup ).find( ( tag ) => /\schecked\b/.test( tag ) ); 34 + if ( ! checked ) { 35 + return null; 36 + } 37 + return checked.match( /value="([^"]*)"/ )?.[ 1 ] ?? null; 38 + } 39 + 40 + describe( 'PublicationForm theme picker', () => { 41 + it( 'renders a "no theme" option plus every preset as radios in one group', () => { 42 + const markup = renderForm(); 43 + expect( markup ).toContain( 'role="radiogroup"' ); 44 + expect( markup ).toContain( 'aria-label="Theme"' ); 45 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 46 + for ( const preset of THEME_PRESETS ) { 47 + expect( markup ).toContain( `value="${ preset.slug }"` ); 48 + expect( markup ).toContain( preset.label ); 49 + } 50 + } ); 51 + 52 + it( 'defaults a new publication to "no theme" (empty value checked)', () => { 53 + expect( checkedValue( renderForm() ) ).toBe( '' ); 54 + } ); 55 + 56 + it( 'pre-selects the matching preset when editing a themed publication', () => { 57 + const existing: Publication = { 58 + uri: 'at://x', 59 + cid: 'bafyx', 60 + rkey: 'r', 61 + slug: 'blog', 62 + name: 'Blog', 63 + basicTheme: THEME_PRESETS[ 2 ].colors, // dusk 64 + }; 65 + expect( checkedValue( renderForm( existing ) ) ).toBe( 'dusk' ); 66 + } ); 67 + 68 + it( 'surfaces and pre-selects a "Current" option when the stored theme matches no preset', () => { 69 + // A valid theme whose colours match no preset (e.g. a preset whose values later changed). 70 + // It must remain visible + selected so an unrelated edit doesn't silently erase it. 71 + const existing: Publication = { 72 + uri: 'at://x', 73 + cid: 'bafyx', 74 + rkey: 'r', 75 + slug: 'blog', 76 + name: 'Blog', 77 + basicTheme: { 78 + $type: 'site.standard.theme.basic', 79 + background: { r: 10, g: 20, b: 30 }, 80 + foreground: { r: 240, g: 240, b: 240 }, 81 + accent: { r: 100, g: 50, b: 200 }, 82 + accentForeground: { r: 255, g: 255, b: 255 }, 83 + }, 84 + }; 85 + const markup = renderForm( existing ); 86 + expect( markup ).toContain( 'Current' ); 87 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 2 ); // none + custom + presets 88 + expect( checkedValue( markup ) ).toBe( 'custom' ); 89 + } ); 90 + 91 + it( 'shows no "Current" option for an unthemed publication', () => { 92 + const existing: Publication = { uri: 'at://x', cid: 'bafyx', rkey: 'r', slug: 'blog', name: 'Blog' }; 93 + const markup = renderForm( existing ); 94 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 95 + expect( checkedValue( markup ) ).toBe( '' ); 96 + } ); 97 + } ); 98 + 99 + describe( 'PublicationForm save lifecycle', () => { 100 + it( 'clears the "Saving…" state after a successful save even when the form stays mounted', async () => { 101 + // The Settings tab keeps the SAME PublicationForm instance mounted across a save: 102 + // onSaved just re-renders the manager on the same tab, so the form never unmounts. 103 + // onSubmit must therefore reset `saving` on the success path — relying on unmount left 104 + // the button stuck on "Saving…" forever despite the PDS returning 200 (the reported bug). 105 + const existing: Publication = { 106 + uri: 'at://did:plc:alice/site.standard.publication/abc', 107 + cid: 'bafypub', 108 + rkey: 'abc', 109 + slug: 'blog', 110 + name: 'Blog', 111 + }; 112 + let resolvePut: ( ( value: unknown ) => void ) | null = null; 113 + const agent = { 114 + com: { 115 + atproto: { 116 + repo: { 117 + putRecord: () => 118 + new Promise( ( resolve ) => { 119 + resolvePut = resolve; 120 + } ), 121 + }, 122 + }, 123 + }, 124 + } as unknown as Agent; 125 + 126 + const container = document.createElement( 'div' ); 127 + document.body.appendChild( container ); 128 + const root = createRoot( container ); 129 + await act( async () => { 130 + root.render( 131 + createElement( PublicationForm, { ...baseProps, agent, existing, onSaved: () => {} } ) 132 + ); 133 + } ); 134 + 135 + const form = container.querySelector( 'form' ) as HTMLFormElement; 136 + const button = container.querySelector( '.pubform__save' ) as HTMLButtonElement; 137 + expect( button.textContent ).toBe( 'Save changes' ); 138 + 139 + await act( async () => { 140 + form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ); 141 + } ); 142 + expect( button.textContent ).toBe( 'Saving…' ); 143 + expect( button.disabled ).toBe( true ); 144 + 145 + // The PDS write resolves (the 200 the user saw). The button must return to normal. 146 + // Flush the whole async chain (putRecord resolve → updatePublication resolve → onSubmit 147 + // continuation → setSaving) inside act. A macrotask drains every queued microtask first, 148 + // so the setSaving update lands inside the act() scope rather than escaping it. 149 + await act( async () => { 150 + resolvePut?.( { data: {} } ); 151 + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); 152 + } ); 153 + expect( button.textContent ).toBe( 'Save changes' ); 154 + expect( button.disabled ).toBe( false ); 155 + 156 + await act( async () => { 157 + root.unmount(); 158 + } ); 159 + container.remove(); 160 + } ); 161 + } );
+116
src/components/PublicationForm.tsx
··· 11 11 PUBLICATION_ICON_MAX_BYTES, 12 12 } from '../lib/media/uploadImage'; 13 13 import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 14 + import { 15 + THEME_PRESETS, 16 + findPresetByColors, 17 + resolveSelectedTheme, 18 + CUSTOM_THEME_SLUG, 19 + type Rgb, 20 + } from '../lib/publish/themes'; 14 21 15 22 interface Props { 16 23 agent: Agent; ··· 48 55 ? buildGetBlobUrl( pdsUrl, did, existing.icon.ref.$link ) 49 56 : null 50 57 ); 58 + // A stored theme that matches no current preset (e.g. a preset whose colours later changed): 59 + // keep it verbatim and offer it as a selectable "Custom" option, so editing an unrelated field 60 + // never silently erases the publisher's colours. 61 + const customTheme = 62 + existing?.basicTheme && ! findPresetByColors( existing.basicTheme ) 63 + ? existing.basicTheme 64 + : null; 65 + const [ themeSlug, setThemeSlug ] = useState< string | null >( () => { 66 + const matched = findPresetByColors( existing?.basicTheme )?.slug; 67 + if ( matched ) { 68 + return matched; 69 + } 70 + return customTheme ? CUSTOM_THEME_SLUG : null; 71 + } ); 51 72 const [ uploading, setUploading ] = useState( false ); 52 73 const [ saving, setSaving ] = useState( false ); 53 74 const [ error, setError ] = useState< string | null >( null ); ··· 106 127 name: name.trim(), 107 128 description: description.trim() || undefined, 108 129 icon: icon ?? undefined, 130 + basicTheme: resolveSelectedTheme( themeSlug, customTheme ), 109 131 }; 110 132 const saved = existing 111 133 ? await updatePublication( agent, did, handle, existing, input ) 112 134 : await createPublication( agent, did, handle, input ); 135 + // Clear `saving` before handing off: the Settings tab keeps this form mounted across a 136 + // save (onSaved just re-renders the manager on the same tab), so relying on unmount left 137 + // the button stuck on "Saving…" forever despite a successful write. 138 + setSaving( false ); 113 139 onSaved( saved ); 114 140 } catch ( err ) { 115 141 setError( err instanceof Error ? err.message : String( err ) ); ··· 168 194 disabled={ saving } 169 195 /> 170 196 </label> 197 + 198 + <fieldset className="pubform__themes" role="radiogroup" aria-label="Theme"> 199 + <legend>Theme</legend> 200 + <div className="pubform__theme-grid"> 201 + <label className={ `pubform__theme${ themeSlug === null ? ' is-selected' : '' }` }> 202 + <input 203 + type="radio" 204 + name="pub-theme" 205 + value="" 206 + checked={ themeSlug === null } 207 + onChange={ () => setThemeSlug( null ) } 208 + disabled={ saving } 209 + /> 210 + <span 211 + className="pubform__theme-swatch pubform__theme-swatch--none" 212 + aria-hidden="true" 213 + /> 214 + <span className="pubform__theme-label">No theme</span> 215 + </label> 216 + { customTheme && ( 217 + <label 218 + className={ `pubform__theme${ 219 + themeSlug === CUSTOM_THEME_SLUG ? ' is-selected' : '' 220 + }` } 221 + > 222 + <input 223 + type="radio" 224 + name="pub-theme" 225 + value={ CUSTOM_THEME_SLUG } 226 + checked={ themeSlug === CUSTOM_THEME_SLUG } 227 + onChange={ () => setThemeSlug( CUSTOM_THEME_SLUG ) } 228 + disabled={ saving } 229 + /> 230 + <span 231 + className="pubform__theme-swatch" 232 + style={ { 233 + background: `rgb(${ customTheme.background.r }, ${ customTheme.background.g }, ${ customTheme.background.b })`, 234 + color: `rgb(${ customTheme.foreground.r }, ${ customTheme.foreground.g }, ${ customTheme.foreground.b })`, 235 + } } 236 + aria-hidden="true" 237 + > 238 + <span 239 + className="pubform__theme-dot" 240 + style={ { 241 + background: `rgb(${ customTheme.accent.r }, ${ customTheme.accent.g }, ${ customTheme.accent.b })`, 242 + } } 243 + /> 244 + Aa 245 + </span> 246 + <span className="pubform__theme-label">Current</span> 247 + </label> 248 + ) } 249 + { THEME_PRESETS.map( ( preset ) => { 250 + const swatch = ( color: Rgb ) => `rgb(${ color.r }, ${ color.g }, ${ color.b })`; 251 + return ( 252 + <label 253 + key={ preset.slug } 254 + className={ `pubform__theme${ 255 + themeSlug === preset.slug ? ' is-selected' : '' 256 + }` } 257 + > 258 + <input 259 + type="radio" 260 + name="pub-theme" 261 + value={ preset.slug } 262 + checked={ themeSlug === preset.slug } 263 + onChange={ () => setThemeSlug( preset.slug ) } 264 + disabled={ saving } 265 + /> 266 + <span 267 + className="pubform__theme-swatch" 268 + style={ { 269 + background: swatch( preset.colors.background ), 270 + color: swatch( preset.colors.foreground ), 271 + } } 272 + aria-hidden="true" 273 + > 274 + <span 275 + className="pubform__theme-dot" 276 + style={ { background: swatch( preset.colors.accent ) } } 277 + /> 278 + Aa 279 + </span> 280 + <span className="pubform__theme-label">{ preset.label }</span> 281 + </label> 282 + ); 283 + } ) } 284 + </div> 285 + <small>Sets the colours readers see on your publication.</small> 286 + </fieldset> 171 287 172 288 { isEditing && ( 173 289 <p className="pubform__note">
+8 -2
src/components/PublishPanel.tsx
··· 126 126 } ); 127 127 setResultUrl( res.articleUrl ); 128 128 } else { 129 + // Non-editing: the target is always a full Publication picked from `pubs`. 130 + const pub = pubs.find( ( candidate ) => candidate.uri === targetUri ); 131 + if ( ! pub ) { 132 + return; 133 + } 129 134 const res = await publish( agent, identity, { 130 135 title: title.trim(), 131 136 blocks: prepared, 132 - publicationUri: target.uri, 133 - publicationSlug: target.slug, 137 + publicationUri: pub.uri, 138 + publicationCid: pub.cid, 139 + publicationSlug: pub.slug, 134 140 } ); 135 141 setResultUrl( res.articleUrl ); 136 142 }
+1
src/components/Studio.tsx
··· 118 118 <MyArticles 119 119 agent={ agent } 120 120 did={ did } 121 + handle={ handle } 121 122 refreshKey={ refreshKey } 122 123 onEdit={ startEdit } 123 124 />
+66 -1
src/layouts/Base.astro
··· 1 1 --- 2 2 import '../styles/global.css'; 3 + import { buildMetaTags } from '../lib/seo/meta'; 3 4 4 5 interface Props { 5 6 title: string; ··· 10 11 * from the visitor's clock. `<html>` is the single phase carrier — see index.astro. 11 12 */ 12 13 phase?: string; 14 + /** Share image path (resolved to an absolute URL). Defaults to the shared OG image. */ 15 + image?: string; 16 + /** Open Graph object type. Defaults to "website". */ 17 + ogType?: string; 18 + /** Alt text for the share image. */ 19 + imageAlt?: string; 20 + /** 21 + * Intrinsic share-image dimensions. Defaulted to 1200x630 only for the 22 + * built-in default image; a caller passing a custom image (e.g. a square 23 + * publication logo) leaves these unset so we don't mis-declare dimensions. 24 + */ 25 + imageWidth?: number; 26 + imageHeight?: number; 27 + /** 28 + * Whether Base emits the canonical link + Open Graph/Twitter tags. Defaults 29 + * to true. Pages that manage their own head tags (e.g. the article page, 30 + * which sets article-specific `og:*` + canonical) opt out to avoid emitting 31 + * duplicate, conflicting tags. 32 + */ 33 + socialMeta?: boolean; 13 34 } 14 - const { title, description, phase } = Astro.props; 35 + const { 36 + title, 37 + description, 38 + phase, 39 + image = '/og-default.png', 40 + ogType = 'website', 41 + imageAlt = 'SkyPress — a writing studio for the open social web', 42 + imageWidth, 43 + imageHeight, 44 + socialMeta = true, 45 + } = Astro.props; 46 + 47 + // Absolute URLs for OG/Twitter, resolved against the configured public origin 48 + // (astro.config.mjs `site`). Astro.url.pathname drops any query string so the 49 + // canonical URL stays stable. 50 + const canonicalUrl = new URL( Astro.url.pathname, Astro.site ).href; 51 + const imageUrl = new URL( image, Astro.site ).href; 52 + const isDefaultImage = image === '/og-default.png'; 53 + const ogImageWidth = imageWidth ?? ( isDefaultImage ? 1200 : undefined ); 54 + const ogImageHeight = imageHeight ?? ( isDefaultImage ? 630 : undefined ); 55 + const metaTags = buildMetaTags( { 56 + title, 57 + description, 58 + url: canonicalUrl, 59 + image: imageUrl, 60 + siteName: 'SkyPress', 61 + type: ogType, 62 + imageAlt, 63 + imageWidth: ogImageWidth, 64 + imageHeight: ogImageHeight, 65 + } ); 15 66 --- 16 67 17 68 <!doctype html> ··· 22 73 <meta name="generator" content={Astro.generator} /> 23 74 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 24 75 {description && <meta name="description" content={description} />} 76 + { 77 + socialMeta && ( 78 + <> 79 + <link rel="canonical" href={canonicalUrl} /> 80 + {metaTags.map( ( tag ) => 81 + tag.property ? ( 82 + <meta property={tag.property} content={tag.content} /> 83 + ) : ( 84 + <meta name={tag.name} content={tag.content} /> 85 + ) 86 + )} 87 + </> 88 + ) 89 + } 25 90 <title>{title}</title> 26 91 <slot name="head" /> 27 92 </head>
+77
src/layouts/Base.meta.test.ts
··· 1 + /** 2 + * Source-level guard for the Open Graph wiring in Base.astro. 3 + * 4 + * Rendering the layout through astro/container is not viable in this suite (the 5 + * runner is pinned to jsdom for the WordPress block tests — see 6 + * src/pages/index.phase.test.ts). So, like that test, we pin the wiring at the 7 + * source level: Base must compute absolute URLs, call buildMetaTags, render the 8 + * tags, and emit a canonical link. The tag *shapes* are unit-tested in 9 + * src/lib/seo/meta.test.ts. 10 + */ 11 + import { readFileSync } from 'node:fs'; 12 + import { dirname, join } from 'node:path'; 13 + import { fileURLToPath } from 'node:url'; 14 + import { describe, expect, it } from 'vitest'; 15 + 16 + const here = dirname( fileURLToPath( import.meta.url ) ); 17 + const read = ( rel: string ) => readFileSync( join( here, rel ), 'utf8' ); 18 + 19 + describe( 'Base.astro Open Graph wiring', () => { 20 + const base = read( './Base.astro' ); 21 + 22 + it( 'imports the buildMetaTags helper', () => { 23 + expect( base ).toMatch( /import\s*\{\s*buildMetaTags\s*\}\s*from\s*'\.\.\/lib\/seo\/meta'/ ); 24 + } ); 25 + 26 + it( 'derives an absolute canonical URL and image URL from Astro.site', () => { 27 + expect( base ).toMatch( /new URL\(\s*Astro\.url\.pathname,\s*Astro\.site\s*\)/ ); 28 + expect( base ).toMatch( /new URL\(\s*image,\s*Astro\.site\s*\)/ ); 29 + } ); 30 + 31 + it( 'renders the built meta tags into <head>', () => { 32 + expect( base ).toMatch( /metaTags\.map/ ); 33 + expect( base ).toMatch( /<meta\s+property=\{/ ); 34 + expect( base ).toMatch( /<meta\s+name=\{/ ); 35 + } ); 36 + 37 + it( 'emits a canonical link', () => { 38 + expect( base ).toMatch( /<link\s+rel="canonical"/ ); 39 + } ); 40 + 41 + it( 'defaults the share image to the shared og-default.png', () => { 42 + expect( base ).toMatch( /\/og-default\.png/ ); 43 + } ); 44 + 45 + it( 'guards the canonical link + social tags behind an opt-out socialMeta prop', () => { 46 + // Defaults on, so callers that pass nothing are unaffected. 47 + expect( base ).toMatch( /socialMeta\s*=\s*true/ ); 48 + // The canonical + meta block only renders when socialMeta is truthy, so 49 + // pages that manage their own head tags (e.g. articles) can opt out. 50 + expect( base ).toMatch( /socialMeta\s*&&/ ); 51 + } ); 52 + 53 + it( 'accepts optional imageWidth/imageHeight props', () => { 54 + expect( base ).toMatch( /imageWidth\??:\s*number/ ); 55 + expect( base ).toMatch( /imageHeight\??:\s*number/ ); 56 + } ); 57 + 58 + it( 'only defaults the 1200x630 dimensions for the built-in default image', () => { 59 + // A flag distinguishes the default image from a caller-supplied one. 60 + expect( base ).toMatch( /og-default\.png/ ); 61 + // The 1200x630 defaults must be gated behind the isDefaultImage guard, so 62 + // caller-supplied images don't silently inherit the default dimensions. 63 + expect( base ).toMatch( /isDefaultImage\s*\?\s*1200/ ); 64 + expect( base ).toMatch( /isDefaultImage\s*\?\s*630/ ); 65 + // Dimensions are threaded into buildMetaTags. 66 + expect( base ).toMatch( /imageWidth:/ ); 67 + expect( base ).toMatch( /imageHeight:/ ); 68 + } ); 69 + } ); 70 + 71 + describe( 'article page metadata', () => { 72 + const article = read( '../pages/[author]/[slug]/[rkey].astro' ); 73 + 74 + it( 'opts out of Base social meta so its own OG + canonical tags are not duplicated', () => { 75 + expect( article ).toMatch( /<Base[^>]*\ssocialMeta=\{\s*false\s*\}/s ); 76 + } ); 77 + } );
+20 -1
src/lib/auth/profile.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 import type { Agent } from '@atproto/api'; 3 - import { fetchViewerProfile, displayNameFor, authorPath } from './profile'; 3 + import { fetchViewerProfile, displayNameFor, authorPath, accountMenuItems } from './profile'; 4 4 5 5 /** Minimal Agent stub: only getProfile is exercised. */ 6 6 function agentReturning( data: Record< string, unknown > ): Agent { ··· 69 69 expect( authorPath( null ) ).toBeNull(); 70 70 } ); 71 71 } ); 72 + 73 + describe( 'accountMenuItems', () => { 74 + const base = { did: 'did:plc:abc', displayName: 'Jane Rivera', avatar: null }; 75 + 76 + it( 'returns Dashboard, Write and Profile in order for a profile with a handle', () => { 77 + expect( accountMenuItems( { ...base, handle: 'jane.bsky.social' } ) ).toEqual( [ 78 + { label: 'Dashboard', href: '/dashboard' }, 79 + { label: 'Write', href: '/editor' }, 80 + { label: 'Profile', href: '/@jane.bsky.social' }, 81 + ] ); 82 + } ); 83 + 84 + it( 'omits the Profile item when no handle is known', () => { 85 + expect( accountMenuItems( { ...base, handle: null } ) ).toEqual( [ 86 + { label: 'Dashboard', href: '/dashboard' }, 87 + { label: 'Write', href: '/editor' }, 88 + ] ); 89 + } ); 90 + } );
+23
src/lib/auth/profile.ts
··· 40 40 export function authorPath( handle: string | null ): string | null { 41 41 return handle ? `/@${ handle }` : null; 42 42 } 43 + 44 + /** One entry in the signed-in account dropdown. */ 45 + export interface MenuItem { 46 + label: string; 47 + href: string; 48 + } 49 + 50 + /** 51 + * Dropdown items for a signed-in viewer: Dashboard, Write, then Profile. The 52 + * Profile entry links to the public author page and is omitted when no handle 53 + * is known (so we never render a broken link). 54 + */ 55 + export function accountMenuItems( profile: ViewerProfile ): MenuItem[] { 56 + const items: MenuItem[] = [ 57 + { label: 'Dashboard', href: '/dashboard' }, 58 + { label: 'Write', href: '/editor' }, 59 + ]; 60 + const profileHref = authorPath( profile.handle ); 61 + if ( profileHref ) { 62 + items.push( { label: 'Profile', href: profileHref } ); 63 + } 64 + return items; 65 + }
+75
src/lib/media/blob.test.ts
··· 4 4 attachBlobRefs, 5 5 resolveBlobImageUrls, 6 6 normalizeBlobRefJson, 7 + firstImageBlobRef, 8 + BSKY_THUMB_MAX_BYTES, 7 9 type BlobRefJson, 8 10 } from './blob'; 9 11 import type { BlockNode } from '../blocks/render'; ··· 102 104 attachBlobRefs( blocks, lookup ); 103 105 expect( 'skypressBlob' in ( blocks[ 0 ].attributes ?? {} ) ).toBe( false ); 104 106 expect( blocks[ 0 ].attributes?.url ).toBe( 'blob:preview-1' ); 107 + } ); 108 + } ); 109 + 110 + describe( 'firstImageBlobRef', () => { 111 + const img = ( skypressBlob?: unknown, extra: Record< string, unknown > = {} ): BlockNode => ( { 112 + name: 'core/image', 113 + attributes: { ...( skypressBlob ? { skypressBlob } : {} ), ...extra }, 114 + innerBlocks: [], 115 + } ); 116 + 117 + it( 'returns the first uploaded core/image ref (depth-first, into innerBlocks)', () => { 118 + const blocks: BlockNode[] = [ 119 + { name: 'core/paragraph', attributes: { content: 'Intro' }, innerBlocks: [] }, 120 + { 121 + name: 'core/gallery', 122 + attributes: {}, 123 + innerBlocks: [ img( REF ) ], 124 + }, 125 + ]; 126 + expect( firstImageBlobRef( blocks ) ).toEqual( REF ); 127 + } ); 128 + 129 + it( 'picks the first usable image when several are present', () => { 130 + const second: BlobRefJson = { 131 + $type: 'blob', 132 + ref: { $link: 'bafysecond' }, 133 + mimeType: 'image/jpeg', 134 + size: 100, 135 + }; 136 + expect( firstImageBlobRef( [ img( REF ), img( second ) ] ) ).toEqual( REF ); 137 + } ); 138 + 139 + it( 'skips external images (no skypressBlob) and non-image blocks', () => { 140 + const blocks: BlockNode[] = [ 141 + { name: 'core/heading', attributes: { content: 'Title' }, innerBlocks: [] }, 142 + img( undefined, { url: 'https://example.com/cat.jpg' } ), 143 + ]; 144 + expect( firstImageBlobRef( blocks ) ).toBeUndefined(); 145 + } ); 146 + 147 + it( 'skips images whose blob exceeds the bsky thumb size limit', () => { 148 + const tooBig: BlobRefJson = { 149 + $type: 'blob', 150 + ref: { $link: 'bafytoobig' }, 151 + mimeType: 'image/png', 152 + size: BSKY_THUMB_MAX_BYTES + 1, 153 + }; 154 + const ok: BlobRefJson = { 155 + $type: 'blob', 156 + ref: { $link: 'bafyok' }, 157 + mimeType: 'image/png', 158 + size: BSKY_THUMB_MAX_BYTES, 159 + }; 160 + expect( firstImageBlobRef( [ img( tooBig ) ] ) ).toBeUndefined(); 161 + // The oversized one is skipped, the in-limit one after it is picked. 162 + expect( firstImageBlobRef( [ img( tooBig ), img( ok ) ] ) ).toEqual( ok ); 163 + } ); 164 + 165 + it( 'skips non-image mime types', () => { 166 + const pdf: BlobRefJson = { 167 + $type: 'blob', 168 + ref: { $link: 'bafypdf' }, 169 + mimeType: 'application/pdf', 170 + size: 10, 171 + }; 172 + expect( firstImageBlobRef( [ img( pdf ) ] ) ).toBeUndefined(); 173 + } ); 174 + 175 + it( 'returns undefined when there are no usable images', () => { 176 + expect( firstImageBlobRef( [] ) ).toBeUndefined(); 177 + expect( 178 + firstImageBlobRef( [ { name: 'core/paragraph', attributes: {}, innerBlocks: [] } ] ) 179 + ).toBeUndefined(); 105 180 } ); 106 181 } ); 107 182
+41 -2
src/lib/media/blob.ts
··· 13 13 14 14 /** 15 15 * What a session upload records for each previewed image. The editor previews from a 16 - * transient object URL (`blob:…`); `attachBlobRefs` looks that key up at publish and 17 - * persists the portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`). 16 + * transient `data:` URL; `attachBlobRefs` looks that key up at publish and persists the 17 + * portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`). 18 18 */ 19 19 export interface BlobUpload { 20 20 ref: BlobRefJson; ··· 23 23 24 24 /** Block names whose `url` attribute may reference an uploaded blob. */ 25 25 const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 26 + 27 + /** 28 + * The `app.bsky.embed.external` thumb blob constraint: `image/*`, ≤ 1,000,000 bytes. A blob 29 + * larger than this is rejected by the post lexicon, so we skip it rather than fail the publish. 30 + */ 31 + export const BSKY_THUMB_MAX_BYTES = 1_000_000; 32 + 33 + /** 34 + * The blob ref to use as a companion Bluesky post's `embed.external.thumb` (Decision 0014): 35 + * the first uploaded `core/image` block's `skypressBlob` (depth-first, `innerBlocks` included) 36 + * that the post lexicon will accept — `image/*` and `0 < size ≤ BSKY_THUMB_MAX_BYTES`. 37 + * 38 + * Returns undefined when the document has no usable uploaded image; the caller then omits 39 + * `thumb` (the standard.site card still renders via `associatedRefs`). External images (no 40 + * `skypressBlob`) are skipped — turning a remote URL into a thumb would need a byte-fetch + 41 + * the SSRF guard, deferred. Reuses the in-repo blob ref (no re-upload): atproto blobs are 42 + * content-addressed and repo-scoped, and the publish flow commits the document (which 43 + * references this blob) before the post, so the post can reference the same CID. 44 + */ 45 + export function firstImageBlobRef( blocks: BlockNode[] ): BlobRefJson | undefined { 46 + for ( const block of blocks ) { 47 + if ( IMAGE_BLOCKS.has( block.name ) ) { 48 + const ref = normalizeBlobRefJson( block.attributes?.skypressBlob ); 49 + if ( 50 + ref && 51 + ref.mimeType.startsWith( 'image/' ) && 52 + ref.size > 0 && 53 + ref.size <= BSKY_THUMB_MAX_BYTES 54 + ) { 55 + return ref; 56 + } 57 + } 58 + const nested = firstImageBlobRef( block.innerBlocks ?? [] ); 59 + if ( nested ) { 60 + return nested; 61 + } 62 + } 63 + return undefined; 64 + } 26 65 27 66 /** 28 67 * Reader-side (SP4): rewrite each blob-backed image's `url` to a fresh `getBlob` URL
+15 -28
src/lib/media/mediaUpload.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 1 + import { describe, expect, it, vi } from 'vitest'; 2 2 import type { Agent } from '@atproto/api'; 3 3 import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from './mediaUpload'; 4 4 ··· 8 8 } 9 9 10 10 describe( 'createMediaUpload', () => { 11 - beforeEach( () => { 12 - // jsdom doesn't implement object URLs; stub a deterministic one per call. 13 - let n = 0; 14 - vi.stubGlobal( 'URL', { 15 - ...URL, 16 - createObjectURL: vi.fn( () => `blob:preview-${ ++n }` ), 17 - } ); 18 - } ); 19 - afterEach( () => vi.unstubAllGlobals() ); 20 - 21 11 function setup() { 22 12 const registry: BlobRegistry = new Map(); 23 13 const uploadBlob = vi ··· 33 23 return { registry, uploadBlob, handler }; 34 24 } 35 25 36 - it( 'uploads to the PDS, previews via an object URL, and omits any attachment id', async () => { 26 + it( 'uploads to the PDS, previews via a data URL (never a blob: URL), and omits any attachment id', async () => { 37 27 const { uploadBlob, handler } = setup(); 38 28 const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 39 29 const onFileChange = vi.fn(); ··· 43 33 expect( uploadBlob ).toHaveBeenCalledWith( file, { encoding: 'image/png' } ); 44 34 expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 45 35 const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 46 - // Preview src is the local object URL — never the live getBlob URL (unreferenced 47 - // blobs 500 on com.atproto.sync.getBlob until a record commits them). 48 - expect( media.url ).toBe( 'blob:preview-1' ); 36 + // Preview src is a data: URL — NEVER a blob: URL. The Image block treats any blob: 37 + // URL as "still uploading" (is-transient + Spinner) and re-runs its upload hook 38 + // expecting a non-blob URL back; a blob: preview hangs the editor forever. 39 + expect( media.url.startsWith( 'data:image/png;base64,' ) ).toBe( true ); 40 + expect( media.url.startsWith( 'blob:' ) ).toBe( false ); 49 41 // No `id`: PDS blobs are not WP attachments, so the image block must not try to 50 42 // fetch /wp/v2/media/<id> (that 404s). 51 43 expect( 'id' in media ).toBe( false ); 52 44 } ); 53 45 54 - it( 'registers the blob ref + canonical getBlob URL keyed by the preview URL', async () => { 46 + it( 'registers the blob ref + canonical getBlob URL keyed by the preview (data) URL', async () => { 55 47 const { registry, handler } = setup(); 56 48 const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 49 + const onFileChange = vi.fn(); 57 50 58 - await handler( { filesList: [ file ], onFileChange: vi.fn() } ); 51 + await handler( { filesList: [ file ], onFileChange } ); 59 52 60 - expect( registry.get( 'blob:preview-1' ) ).toEqual( { 53 + const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ]; 54 + expect( registry.get( media.url ) ).toEqual( { 61 55 ref: { 62 56 $type: 'blob', 63 57 ref: { $link: 'bafycid123' }, ··· 88 82 } ); 89 83 90 84 describe( 'revokeBlobRegistry', () => { 91 - it( 'revokes every preview object URL and empties the registry', () => { 92 - const revoke = vi.fn(); 93 - vi.stubGlobal( 'URL', { ...URL, revokeObjectURL: revoke } ); 85 + it( 'empties the registry', () => { 94 86 const ref = { $type: 'blob' as const, ref: { $link: 'cid' }, mimeType: 'image/png', size: 1 }; 95 87 const registry: BlobRegistry = new Map( [ 96 - [ 'blob:preview-1', { ref, url: 'https://pds.example.com/a' } ], 97 - [ 'blob:preview-2', { ref, url: 'https://pds.example.com/b' } ], 88 + [ 'data:image/png;base64,aaa', { ref, url: 'https://pds.example.com/a' } ], 89 + [ 'data:image/png;base64,bbb', { ref, url: 'https://pds.example.com/b' } ], 98 90 ] ); 99 91 100 92 revokeBlobRegistry( registry ); 101 93 102 - expect( revoke ).toHaveBeenCalledTimes( 2 ); 103 - expect( revoke ).toHaveBeenCalledWith( 'blob:preview-1' ); 104 - expect( revoke ).toHaveBeenCalledWith( 'blob:preview-2' ); 105 94 expect( registry.size ).toBe( 0 ); 106 - 107 - vi.unstubAllGlobals(); 108 95 } ); 109 96 } );
+27 -15
src/lib/media/mediaUpload.ts
··· 1 1 import type { Agent } from '@atproto/api'; 2 2 import { buildGetBlobUrl, type BlobUpload } from './blob'; 3 3 4 - /** Maps a preview (object) URL → its blob ref + canonical getBlob URL for this session. */ 4 + /** Maps a preview (data) URL → its blob ref + canonical getBlob URL for this session. */ 5 5 export type BlobRegistry = Map< string, BlobUpload >; 6 6 7 7 /** 8 - * Release the preview object URLs held by `registry` and empty it. Each preview URL was 9 - * minted with `URL.createObjectURL`, which pins its `File` in memory until revoked — so 10 - * an editing session that uploads many images would otherwise leak them all until the 11 - * page unloads. Call this only when those previews are no longer on screen (the editor is 12 - * being torn down for a new/other article, or unmounted), never mid-edit. 8 + * Forget the previews held by `registry` and empty it. Previews are `data:` URLs (plain 9 + * strings, no `File` pinned), so there is nothing to revoke — but the map must still be 10 + * cleared so a later article can't accidentally resolve a stale preview at publish. Call 11 + * this only when those previews are no longer on screen (the editor is being torn down for 12 + * a new/other article, or unmounted), never mid-edit. 13 13 */ 14 14 export function revokeBlobRegistry( registry: BlobRegistry ): void { 15 - for ( const previewUrl of registry.keys() ) { 16 - URL.revokeObjectURL( previewUrl ); 17 - } 18 15 registry.clear(); 19 16 } 20 17 18 + /** Read a file into a `data:` URL (base64) for inline preview. */ 19 + function readAsDataUrl( file: File ): Promise< string > { 20 + return new Promise( ( resolve, reject ) => { 21 + const reader = new FileReader(); 22 + reader.onload = () => resolve( reader.result as string ); 23 + reader.onerror = () => reject( reader.error ?? new Error( `Could not read "${ file.name }".` ) ); 24 + reader.readAsDataURL( file ); 25 + } ); 26 + } 27 + 21 28 interface MediaFile { 22 29 url: string; 23 30 alt: string; ··· 33 40 34 41 /** 35 42 * A Gutenberg `mediaUpload` handler backed by atproto blobs (Decision 0006): 36 - * upload each file to the writer's PDS, hand back a local object URL for preview, and 37 - * record the blob ref + canonical `getBlob` URL in `registry` so publish can persist them. 43 + * upload each file to the writer's PDS, hand back a `data:` URL for preview, and record 44 + * the blob ref + canonical `getBlob` URL in `registry` so publish can persist them. 38 45 * 39 - * Preview uses a local object URL rather than the live `getBlob` URL because a 40 - * just-uploaded blob is unreferenced (temporary on the PDS) until a record commits it — 46 + * Preview uses a `data:` URL rather than the live `getBlob` URL because a just-uploaded 47 + * blob is unreferenced (temporary on the PDS) until a record commits it — 41 48 * `com.atproto.sync.getBlob` fails for it, so an inline `getBlob` preview would 500. 49 + * 50 + * Crucially the preview must NOT be a `blob:` object URL: the Image block treats any 51 + * `blob:` URL as a still-uploading image (`is-transient` + a Spinner) and re-runs its own 52 + * upload hook expecting a non-`blob:` URL back — so a `blob:` preview spins forever and 53 + * never commits. A `data:` URL reads as an ordinary inline image and renders immediately. 42 54 */ 43 55 export type MediaUploadHandler = ( args: MediaUploadArgs ) => Promise< void >; 44 56 ··· 65 77 const res = await agent.uploadBlob( file, { encoding: file.type } ); 66 78 const { blob } = res.data; 67 79 const cid = blob.ref.toString(); 68 - // Preview from a local object URL; persist the portable getBlob URL on publish. 69 - const previewUrl = URL.createObjectURL( file ); 80 + // Preview from a data: URL; persist the portable getBlob URL on publish. 81 + const previewUrl = await readAsDataUrl( file ); 70 82 registry.set( previewUrl, { 71 83 ref: { 72 84 $type: 'blob',
+118
src/lib/publish/publications.test.ts
··· 2 2 import type { Agent } from '@atproto/api'; 3 3 import { 4 4 listPublications, 5 + listAllPublications, 5 6 createPublication, 6 7 updatePublication, 7 8 deletePublication, 8 9 } from './publications'; 9 10 import { SITE_BASE } from './records'; 11 + import { THEME_PRESETS } from './themes'; 10 12 import type { BlobRefJson } from '../media/blob'; 11 13 12 14 const DID = 'did:plc:me'; ··· 134 136 } ); 135 137 } ); 136 138 139 + describe( 'listAllPublications', () => { 140 + it( 'partitions owned publications from foreign ones', async () => { 141 + const { agent } = mockAgent( { 142 + 'site.standard.publication': [ 143 + pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog` ), 144 + pubRecord( 'b', 'https://leaflet.pub/lish/did:plc:me/xyz' ), 145 + ], 146 + } ); 147 + const { owned, foreign } = await listAllPublications( agent, DID ); 148 + expect( owned ).toHaveLength( 1 ); 149 + expect( owned[ 0 ] ).toMatchObject( { rkey: 'a', slug: 'my-blog' } ); 150 + expect( foreign ).toHaveLength( 1 ); 151 + expect( foreign[ 0 ] ).toMatchObject( { 152 + uri: `at://${ DID }/site.standard.publication/b`, 153 + name: 'Pub b', 154 + hostname: 'leaflet.pub', 155 + url: 'https://leaflet.pub/lish/did:plc:me/xyz', 156 + } ); 157 + } ); 158 + 159 + it( 'drops a slugless SkyPress-origin record from BOTH buckets', async () => { 160 + const { agent } = mockAgent( { 161 + 'site.standard.publication': [ pubRecord( 'c', `${ SITE_BASE }/@me.bsky.social` ) ], 162 + } ); 163 + const { owned, foreign } = await listAllPublications( agent, DID ); 164 + expect( owned ).toHaveLength( 0 ); 165 + expect( foreign ).toHaveLength( 0 ); 166 + } ); 167 + 168 + it( 'excludes records with a missing or non-http(s) url from foreign', async () => { 169 + const { agent } = mockAgent( { 170 + 'site.standard.publication': [ 171 + pubRecord( 'd', 'at://did:plc:other/site.standard.publication/d' ), 172 + { 173 + uri: `at://${ DID }/site.standard.publication/e`, 174 + cid: 'bafy-e', 175 + value: { $type: 'site.standard.publication', name: 'No URL' }, 176 + }, 177 + ], 178 + } ); 179 + const { owned, foreign } = await listAllPublications( agent, DID ); 180 + expect( owned ).toHaveLength( 0 ); 181 + expect( foreign ).toHaveLength( 0 ); 182 + } ); 183 + 184 + it( 'normalises a foreign icon to the portable $link shape', async () => { 185 + const cidObject = { toString: () => 'bafyforeign' }; 186 + const { agent } = mockAgent( { 187 + 'site.standard.publication': [ 188 + pubRecord( 'f', 'https://leaflet.pub/lish/did:plc:me/abc', { 189 + icon: { $type: 'blob', ref: cidObject, mimeType: 'image/png', size: 9 }, 190 + } ), 191 + ], 192 + } ); 193 + const { foreign } = await listAllPublications( agent, DID ); 194 + expect( foreign[ 0 ].icon ).toEqual( { 195 + $type: 'blob', 196 + ref: { $link: 'bafyforeign' }, 197 + mimeType: 'image/png', 198 + size: 9, 199 + } ); 200 + } ); 201 + } ); 202 + 137 203 describe( 'createPublication', () => { 138 204 it( 'derives the slug from the name and writes the record under a fresh rkey', async () => { 139 205 const { agent, created } = mockAgent(); ··· 171 237 await createPublication( agent, DID, 'me.bsky.social', { name: 'Logo Blog', icon } ); 172 238 expect( created[ 0 ].record.icon ).toEqual( icon ); 173 239 } ); 240 + 241 + it( 'writes and returns basicTheme when provided', async () => { 242 + const { agent, created } = mockAgent(); 243 + const pub = await createPublication( agent, DID, 'me.bsky.social', { 244 + name: 'Themed Blog', 245 + basicTheme: THEME_PRESETS[ 4 ].colors, // twilight 246 + } ); 247 + expect( created[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 248 + expect( pub.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 249 + } ); 250 + 251 + it( 'omits basicTheme when none is chosen', async () => { 252 + const { agent, created } = mockAgent(); 253 + const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'Plain Blog' } ); 254 + expect( 'basicTheme' in created[ 0 ].record ).toBe( false ); 255 + expect( pub.basicTheme ).toBeUndefined(); 256 + } ); 174 257 } ); 175 258 176 259 describe( 'updatePublication', () => { ··· 178 261 const { agent, put } = mockAgent(); 179 262 const existing = { 180 263 uri: `at://${ DID }/site.standard.publication/a`, 264 + cid: 'bafy-a', 181 265 rkey: 'a', 182 266 slug: 'my-blog', 183 267 name: 'My Blog', ··· 193 277 expect( updated.slug ).toBe( 'my-blog' ); 194 278 expect( updated.name ).toBe( 'Renamed Blog' ); 195 279 expect( updated.description ).toBe( 'Now with a description' ); 280 + } ); 281 + 282 + it( 'writes and returns a newly chosen basicTheme', async () => { 283 + const { agent, put } = mockAgent(); 284 + const existing = { 285 + uri: `at://${ DID }/site.standard.publication/a`, 286 + cid: 'bafy-a', 287 + rkey: 'a', 288 + slug: 'my-blog', 289 + name: 'My Blog', 290 + }; 291 + const updated = await updatePublication( agent, DID, 'me.bsky.social', existing, { 292 + name: 'My Blog', 293 + basicTheme: THEME_PRESETS[ 1 ].colors, // noon 294 + } ); 295 + expect( put[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 296 + expect( updated.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 297 + } ); 298 + } ); 299 + 300 + describe( 'toPublication basicTheme', () => { 301 + it( 'surfaces a valid stored basicTheme and drops a malformed one', async () => { 302 + const good = THEME_PRESETS[ 2 ].colors; // dusk 303 + const { agent } = mockAgent( { 304 + 'site.standard.publication': [ 305 + pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/a`, { basicTheme: good } ), 306 + pubRecord( 'b', `${ SITE_BASE }/@me.bsky.social/b`, { 307 + basicTheme: { background: { r: 999 } }, 308 + } ), 309 + ], 310 + } ); 311 + const pubs = await listPublications( agent, DID ); 312 + expect( pubs.find( ( p ) => p.slug === 'a' )?.basicTheme ).toEqual( good ); 313 + expect( pubs.find( ( p ) => p.slug === 'b' )?.basicTheme ).toBeUndefined(); 196 314 } ); 197 315 } ); 198 316
+85 -6
src/lib/publish/publications.ts
··· 16 16 type PublicationRecord, 17 17 } from './records'; 18 18 import { normalizeBlobRefJson, type BlobRefJson } from '../media/blob'; 19 + import { parseBasicTheme, type BasicTheme } from './themes'; 19 20 20 21 const PUBLICATION_COLLECTION = 'site.standard.publication'; 21 22 const DOCUMENT_COLLECTION = 'site.standard.document'; ··· 34 35 /** A SkyPress publication, resolved to the shape the dashboard + editor consume. */ 35 36 export interface Publication { 36 37 uri: string; 38 + /** Current record cid — needed for the post's `associatedRefs` strongRef (Decision 0013). */ 39 + cid: string; 37 40 rkey: string; 38 41 slug: string; 39 42 name: string; 40 43 description?: string; 41 44 icon?: BlobRefJson; 45 + basicTheme?: BasicTheme; 46 + } 47 + 48 + /** A publication record written by another app sharing the lexicon (Leaflet, …). Read-only. */ 49 + export interface ForeignPublication { 50 + uri: string; 51 + name: string; 52 + hostname: string; 53 + url: string; 54 + icon?: BlobRefJson; 42 55 } 43 56 44 57 /** Fields a writer can set/change on a publication (the slug is derived, never entered). */ ··· 46 59 name: string; 47 60 description?: string; 48 61 icon?: BlobRefJson; 62 + basicTheme?: BasicTheme; 49 63 } 50 64 51 65 /** Map a raw repo record to a Publication, or null if it isn't a usable SkyPress publication. */ 52 66 function toPublication( record: { 53 67 uri: string; 68 + cid: string; 54 69 value: unknown; 55 70 } ): Publication | null { 56 71 const value = record.value as Partial< PublicationRecord > | undefined; ··· 64 79 // The agent deserialises the icon blob into a BlobRef (ref = CID object); normalise back to 65 80 // the portable `{ ref: { $link } }` shape the dashboard + form read from. 66 81 const icon = normalizeBlobRefJson( value.icon ); 82 + const basicTheme = parseBasicTheme( value.basicTheme ); 67 83 return { 68 84 uri: record.uri, 85 + cid: record.cid, 69 86 rkey: rkeyFromUri( record.uri ), 70 87 slug, 71 88 name: value.name ?? slug, 72 89 ...( value.description ? { description: value.description } : {} ), 90 + ...( icon ? { icon } : {} ), 91 + ...( basicTheme ? { basicTheme } : {} ), 92 + }; 93 + } 94 + 95 + /** Map a raw repo record to a ForeignPublication, or null if its url isn't a usable http(s) link. */ 96 + function toForeignPublication( record: { 97 + uri: string; 98 + value: unknown; 99 + } ): ForeignPublication | null { 100 + const value = record.value as Partial< PublicationRecord > | undefined; 101 + if ( ! value?.url ) { 102 + return null; 103 + } 104 + let parsed: URL; 105 + try { 106 + parsed = new URL( value.url ); 107 + } catch { 108 + return null; 109 + } 110 + if ( parsed.protocol !== 'http:' && parsed.protocol !== 'https:' ) { 111 + return null; 112 + } 113 + const icon = normalizeBlobRefJson( value.icon ); 114 + return { 115 + uri: record.uri, 116 + name: value.name ?? parsed.hostname, 117 + hostname: parsed.hostname, 118 + url: value.url, 73 119 ...( icon ? { icon } : {} ), 74 120 }; 75 121 } 76 122 77 - /** List the writer's SkyPress publications (newest first), filtered to this app's origin. */ 78 - export async function listPublications( agent: Agent, did: string ): Promise< Publication[] > { 123 + /** 124 + * Fetch the writer's publication records once and partition them: those SkyPress OWNS 125 + * (origin === SITE_BASE, with a slug) versus FOREIGN records another app wrote into the same 126 + * collection (Leaflet, …), which SkyPress only ever displays read-only (Decision 0010). 127 + */ 128 + export async function listAllPublications( 129 + agent: Agent, 130 + did: string 131 + ): Promise< { owned: Publication[]; foreign: ForeignPublication[] } > { 79 132 const res = await agent.com.atproto.repo.listRecords( { 80 133 repo: did, 81 134 collection: PUBLICATION_COLLECTION, 82 135 limit: 100, 83 136 } ); 84 - return res.data.records 85 - .map( ( record ) => toPublication( record ) ) 86 - .filter( ( pub ): pub is Publication => pub !== null ); 137 + const owned: Publication[] = []; 138 + const foreign: ForeignPublication[] = []; 139 + for ( const record of res.data.records ) { 140 + const url = ( record.value as Partial< PublicationRecord > | undefined )?.url; 141 + if ( url && isSkyPressPublicationUrl( url ) ) { 142 + // Ours: owned if it has a slug, otherwise a malformed/legacy own-record we drop entirely. 143 + const pub = toPublication( record ); 144 + if ( pub ) { 145 + owned.push( pub ); 146 + } 147 + continue; 148 + } 149 + const fp = toForeignPublication( record ); 150 + if ( fp ) { 151 + foreign.push( fp ); 152 + } 153 + } 154 + return { owned, foreign }; 155 + } 156 + 157 + /** List the writer's SkyPress publications (newest first), filtered to this app's origin. */ 158 + export async function listPublications( agent: Agent, did: string ): Promise< Publication[] > { 159 + return ( await listAllPublications( agent, did ) ).owned; 87 160 } 88 161 89 162 /** ··· 110 183 name: input.name, 111 184 description: input.description, 112 185 icon: input.icon, 186 + basicTheme: input.basicTheme, 113 187 } ); 114 188 const res = await agent.com.atproto.repo.createRecord( { 115 189 repo: did, ··· 119 193 } ); 120 194 return { 121 195 uri: res.data.uri, 196 + cid: res.data.cid, 122 197 rkey, 123 198 slug, 124 199 name: record.name, 125 200 ...( record.description ? { description: record.description } : {} ), 126 201 ...( record.icon ? { icon: record.icon } : {} ), 202 + ...( record.basicTheme ? { basicTheme: record.basicTheme } : {} ), 127 203 }; 128 204 } 129 205 ··· 144 220 name: input.name, 145 221 description: input.description, 146 222 icon: input.icon, 223 + basicTheme: input.basicTheme, 147 224 } ); 148 - await agent.com.atproto.repo.putRecord( { 225 + const res = await agent.com.atproto.repo.putRecord( { 149 226 repo: did, 150 227 collection: PUBLICATION_COLLECTION, 151 228 rkey: existing.rkey, ··· 153 230 } ); 154 231 return { 155 232 uri: existing.uri, 233 + cid: res.data.cid, 156 234 rkey: existing.rkey, 157 235 slug: existing.slug, 158 236 name: record.name, 159 237 ...( record.description ? { description: record.description } : {} ), 160 238 ...( record.icon ? { icon: record.icon } : {} ), 239 + ...( record.basicTheme ? { basicTheme: record.basicTheme } : {} ), 161 240 }; 162 241 } 163 242
+70
src/lib/publish/publisher.test.ts
··· 58 58 59 59 const TARGET = { 60 60 publicationUri: `at://${ DID }/site.standard.publication/pub1`, 61 + publicationCid: 'bafy-pub1', 61 62 publicationSlug: 'my-blog', 62 63 }; 63 64 ··· 84 85 85 86 // It does NOT auto-create a publication (no ensurePublication anymore). 86 87 expect( created.some( ( c ) => c.collection === 'site.standard.publication' ) ).toBe( false ); 88 + } ); 89 + 90 + it( 'sets embed.external.thumb to the first uploaded image blob (Decision 0014)', async () => { 91 + const thumb = { 92 + $type: 'blob', 93 + ref: { $link: 'bafyimg' }, 94 + mimeType: 'image/jpeg', 95 + size: 5000, 96 + }; 97 + const blocksWithImage: BlockNode[] = [ 98 + { name: 'core/paragraph', attributes: { content: 'Intro' }, innerBlocks: [] }, 99 + { name: 'core/image', attributes: { url: 'getblob://x', skypressBlob: thumb }, innerBlocks: [] }, 100 + ]; 101 + const { agent, created } = mockAgent(); 102 + await publish( 103 + agent, 104 + { did: DID, handle: HANDLE }, 105 + { title: 'Hello', blocks: blocksWithImage, ...TARGET } 106 + ); 107 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 108 + embed: { external: { thumb?: unknown } }; 109 + }; 110 + expect( post.embed.external.thumb ).toEqual( thumb ); 111 + } ); 112 + 113 + it( 'omits thumb when no usable image is present', async () => { 114 + const { agent, created } = mockAgent(); 115 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } ); 116 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 117 + embed: { external: Record< string, unknown > }; 118 + }; 119 + expect( 'thumb' in post.embed.external ).toBe( false ); 120 + } ); 121 + 122 + it( 'writes the document first so the post can embed its strongRef (standard.site card)', async () => { 123 + const { agent, created, put } = mockAgent(); 124 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } ); 125 + 126 + // 3-write order: document (no ref yet) → post → putRecord document with bskyPostRef. 127 + expect( created.map( ( c ) => c.collection ) ).toEqual( [ 128 + 'site.standard.document', 129 + 'app.bsky.feed.post', 130 + ] ); 131 + expect( created[ 0 ].record.bskyPostRef ).toBeUndefined(); 132 + 133 + const docUri = `at://${ DID }/site.standard.document/${ created[ 0 ].rkey }`; 134 + const postUri = `at://${ DID }/app.bsky.feed.post/gen-2`; 135 + 136 + // The post embeds associatedRefs to the document AND the publication. 137 + const post = created[ 1 ].record as { 138 + facets: unknown[]; 139 + embed: { external: { associatedRefs: Array< { uri: string } > } }; 140 + }; 141 + expect( post.embed.external.associatedRefs ).toEqual( [ 142 + { $type: 'com.atproto.repo.strongRef', uri: docUri, cid: 'bafy-new' }, 143 + { 144 + $type: 'com.atproto.repo.strongRef', 145 + uri: TARGET.publicationUri, 146 + cid: TARGET.publicationCid, 147 + }, 148 + ] ); 149 + // And the link is clickable. 150 + expect( post.facets ).toHaveLength( 1 ); 151 + 152 + // The document is then updated to point back at the post (preserved for unpublish). 153 + expect( put ).toHaveLength( 1 ); 154 + expect( put[ 0 ].collection ).toBe( 'site.standard.document' ); 155 + expect( put[ 0 ].rkey ).toBe( created[ 0 ].rkey ); 156 + expect( put[ 0 ].record.bskyPostRef ).toEqual( { uri: postUri, cid: 'bafy-new' } ); 87 157 } ); 88 158 } ); 89 159
+39 -8
src/lib/publish/publisher.ts
··· 7 7 type StrongRef, 8 8 } from './records'; 9 9 import { listPublications } from './publications'; 10 + import { firstImageBlobRef } from '../media/blob'; 10 11 import { blocksToText, type BlockNode } from '../blocks/render'; 11 12 12 13 const DOCUMENT_COLLECTION = 'site.standard.document'; ··· 34 35 description?: string; 35 36 /** The chosen target publication's AT-URI (Decision 0010 — no more auto-create). */ 36 37 publicationUri: string; 38 + /** Its current cid, for the post's `associatedRefs` strongRef (Decision 0013). */ 39 + publicationCid: string; 37 40 /** Its frozen slug, needed to build the article URL. */ 38 41 publicationSlug: string; 39 42 } ··· 46 49 } 47 50 48 51 /** 49 - * The two-record publish (Decision 0005), now targeting a CHOSEN publication (Decision 0010). 50 - * Order avoids a circular dependency: the article URL is known from handle+slug+rkey, so we 51 - * create the Bluesky post first, then the document with `bskyPostRef`. 52 + * The publish flow (Decision 0005), targeting a CHOSEN publication (Decision 0010) and embedding 53 + * the standard.site link card refs in the Bluesky post (Decision 0013). 54 + * 55 + * Three writes, in an order that satisfies the mutual references: 56 + * 1. Create the DOCUMENT (no `bskyPostRef` yet) — yields its strongRef (uri + cid). 57 + * 2. Create the POST with a link facet + `associatedRefs` to the document & publication. 58 + * 3. `putRecord` the document to add `bskyPostRef` (kept for unpublish/cascade-delete). 59 + * Step 3 re-cids the document, so the post's document ref is one version stale — harmless, as the 60 + * AppView resolves `associatedRefs` by URI (this is how standard.site itself behaves). 52 61 * 53 62 * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that unmistakable to 54 63 * the user first (brief §10). ··· 67 76 const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, rkey ); 68 77 const textContent = blocksToText( input.blocks ); 69 78 79 + // 1. Document first (no bskyPostRef yet) so the post can embed its strongRef. 80 + const docRes = await agent.com.atproto.repo.createRecord( { 81 + repo: did, 82 + collection: DOCUMENT_COLLECTION, 83 + rkey, 84 + record: asRecord( 85 + buildDocumentRecord( { 86 + title: input.title, 87 + rkey, 88 + blocks: input.blocks, 89 + textContent, 90 + siteUri: input.publicationUri, 91 + publishedAt: now, 92 + description: input.description, 93 + } ) 94 + ), 95 + } ); 96 + const documentRef: StrongRef = { uri: docRes.data.uri, cid: docRes.data.cid }; 97 + const publicationRef: StrongRef = { uri: input.publicationUri, cid: input.publicationCid }; 98 + 99 + // 2. Post with the clickable link facet + standard.site associatedRefs (document, publication). 70 100 const postRes = await agent.com.atproto.repo.createRecord( { 71 101 repo: did, 72 102 collection: POST_COLLECTION, ··· 76 106 articleUrl, 77 107 description: input.description, 78 108 createdAt: now, 109 + // Reuse the first uploaded image's in-repo blob as the card thumb (Decision 0014). 110 + thumb: firstImageBlobRef( input.blocks ), 111 + associatedRefs: [ documentRef, publicationRef ], 79 112 } ) 80 113 ), 81 114 } ); 82 - const bskyPostRef: StrongRef = { 83 - uri: postRes.data.uri, 84 - cid: postRes.data.cid, 85 - }; 115 + const bskyPostRef: StrongRef = { uri: postRes.data.uri, cid: postRes.data.cid }; 86 116 87 - const docRes = await agent.com.atproto.repo.createRecord( { 117 + // 3. Point the document back at its companion post (preserved for unpublish/cascade-delete). 118 + await agent.com.atproto.repo.putRecord( { 88 119 repo: did, 89 120 collection: DOCUMENT_COLLECTION, 90 121 rkey,
+129 -4
src/lib/publish/records.test.ts
··· 18 18 } from './records'; 19 19 import type { BlockNode } from '../blocks/render'; 20 20 import type { BlobRefJson } from '../media/blob'; 21 + import { THEME_PRESETS } from './themes'; 21 22 22 23 const BLOCKS: BlockNode[] = [ 23 24 { name: 'core/heading', attributes: { level: 1, content: 'Hi' }, innerBlocks: [] }, ··· 159 160 expect( full.description ).toBe( 'A blog' ); 160 161 expect( full.icon ).toEqual( icon ); 161 162 } ); 163 + 164 + it( 'includes basicTheme when provided and omits it otherwise', () => { 165 + const themed = buildPublicationRecord( { 166 + handle: 'a.b', 167 + slug: 's', 168 + name: 'N', 169 + basicTheme: THEME_PRESETS[ 0 ].colors, 170 + } ); 171 + expect( themed.basicTheme ).toEqual( THEME_PRESETS[ 0 ].colors ); 172 + 173 + const bare = buildPublicationRecord( { handle: 'a.b', slug: 's', name: 'N' } ); 174 + expect( 'basicTheme' in bare ).toBe( false ); 175 + } ); 176 + 177 + it( 'stamps the colour union $type so the stored basicTheme validates against the lexicon', () => { 178 + // `site.standard.theme.basic` colours are a union of `site.standard.theme.color#rgb`; without 179 + // the `$type` discriminator the record is invalid and Bluesky drops the enhanced link card. 180 + const themed = buildPublicationRecord( { 181 + handle: 'a.b', 182 + slug: 's', 183 + name: 'N', 184 + basicTheme: THEME_PRESETS[ 0 ].colors, 185 + } ); 186 + for ( const color of [ 187 + themed.basicTheme!.background, 188 + themed.basicTheme!.foreground, 189 + themed.basicTheme!.accent, 190 + themed.basicTheme!.accentForeground, 191 + ] ) { 192 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 193 + } 194 + } ); 195 + 196 + it( 'drops an invalid basicTheme rather than persisting it (write-boundary validation)', () => { 197 + const record = buildPublicationRecord( { 198 + handle: 'a.b', 199 + slug: 's', 200 + name: 'N', 201 + // Out-of-range channel — must never reach storage. 202 + basicTheme: { 203 + $type: 'site.standard.theme.basic', 204 + background: { r: 300, g: 0, b: 0 }, 205 + foreground: { r: 0, g: 0, b: 0 }, 206 + accent: { r: 0, g: 0, b: 0 }, 207 + accentForeground: { r: 0, g: 0, b: 0 }, 208 + } as never, 209 + } ); 210 + expect( 'basicTheme' in record ).toBe( false ); 211 + } ); 162 212 } ); 163 213 164 214 describe( 'buildDocumentRecord', () => { ··· 197 247 } ); 198 248 199 249 describe( 'buildBskyPost', () => { 250 + const ARTICLE_URL = 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey'; 251 + 200 252 it( 'creates a post with an external embed pointing at the article', () => { 201 253 const post = buildBskyPost( { 202 254 title: 'Hello, World!', 203 - articleUrl: 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey', 255 + articleUrl: ARTICLE_URL, 204 256 description: 'An excerpt', 205 257 createdAt: '2026-06-08T12:00:00.000Z', 206 258 } ); ··· 208 260 expect( post.text ).toContain( 'Hello, World!' ); 209 261 expect( post.createdAt ).toBe( '2026-06-08T12:00:00.000Z' ); 210 262 expect( post.embed.$type ).toBe( 'app.bsky.embed.external' ); 211 - expect( post.embed.external.uri ).toBe( 212 - 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey' 213 - ); 263 + expect( post.embed.external.uri ).toBe( ARTICLE_URL ); 214 264 expect( post.embed.external.title ).toBe( 'Hello, World!' ); 215 265 expect( typeof post.embed.external.description ).toBe( 'string' ); 266 + } ); 267 + 268 + it( 'marks the article URL as a clickable link facet over its byte range', () => { 269 + const post = buildBskyPost( { 270 + title: 'Hello, World!', 271 + articleUrl: ARTICLE_URL, 272 + createdAt: '2026-06-08T12:00:00.000Z', 273 + } ); 274 + const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 275 + const byteStart = bytes( `Hello, World!\n\n` ); 276 + expect( post.facets ).toEqual( [ 277 + { 278 + $type: 'app.bsky.richtext.facet', 279 + index: { byteStart, byteEnd: byteStart + bytes( ARTICLE_URL ) }, 280 + features: [ { $type: 'app.bsky.richtext.facet#link', uri: ARTICLE_URL } ], 281 + }, 282 + ] ); 283 + } ); 284 + 285 + it( 'computes facet byte offsets in UTF-8 (multibyte title)', () => { 286 + const post = buildBskyPost( { 287 + title: '🍔 Burgers', 288 + articleUrl: ARTICLE_URL, 289 + createdAt: '2026-06-08T12:00:00.000Z', 290 + } ); 291 + const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 292 + // The emoji is 4 UTF-8 bytes, so the link starts well past its JS string index. 293 + expect( post.facets[ 0 ].index.byteStart ).toBe( bytes( '🍔 Burgers\n\n' ) ); 294 + expect( post.facets[ 0 ].index.byteEnd ).toBe( 295 + bytes( '🍔 Burgers\n\n' ) + bytes( ARTICLE_URL ) 296 + ); 297 + } ); 298 + 299 + it( 'embeds associatedRefs (document, publication strongRefs) when provided', () => { 300 + const documentRef = { uri: 'at://did:plc:abc/site.standard.document/d1', cid: 'bafydoc' }; 301 + const publicationRef = { 302 + uri: 'at://did:plc:abc/site.standard.publication/p1', 303 + cid: 'bafypub', 304 + }; 305 + const post = buildBskyPost( { 306 + title: 'Hello, World!', 307 + articleUrl: ARTICLE_URL, 308 + createdAt: '2026-06-08T12:00:00.000Z', 309 + associatedRefs: [ documentRef, publicationRef ], 310 + } ); 311 + expect( post.embed.external.associatedRefs ).toEqual( [ 312 + { $type: 'com.atproto.repo.strongRef', ...documentRef }, 313 + { $type: 'com.atproto.repo.strongRef', ...publicationRef }, 314 + ] ); 315 + } ); 316 + 317 + it( 'omits associatedRefs when none are provided', () => { 318 + const post = buildBskyPost( { 319 + title: 'Hello, World!', 320 + articleUrl: ARTICLE_URL, 321 + createdAt: '2026-06-08T12:00:00.000Z', 322 + } ); 323 + expect( 'associatedRefs' in post.embed.external ).toBe( false ); 324 + } ); 325 + 326 + it( 'includes embed.external.thumb only when a blob ref is provided (Decision 0014)', () => { 327 + const base = { 328 + title: 'Hello, World!', 329 + articleUrl: ARTICLE_URL, 330 + createdAt: '2026-06-08T12:00:00.000Z', 331 + }; 332 + expect( 'thumb' in buildBskyPost( base ).embed.external ).toBe( false ); 333 + 334 + const thumb: BlobRefJson = { 335 + $type: 'blob', 336 + ref: { $link: 'bafythumb' }, 337 + mimeType: 'image/png', 338 + size: 4242, 339 + }; 340 + expect( buildBskyPost( { ...base, thumb } ).embed.external.thumb ).toEqual( thumb ); 216 341 } ); 217 342 } );
+62 -1
src/lib/publish/records.ts
··· 6 6 */ 7 7 import type { BlockNode } from '../blocks/render'; 8 8 import type { BlobRefJson } from '../media/blob'; 9 + import { parseBasicTheme, type BasicTheme } from './themes'; 9 10 10 11 /** 11 12 * Public origin for the stored publication + article URLs (and the Bluesky post link). ··· 148 149 description?: string; 149 150 /** The publication logo (≤1MB, image/*) — the lexicon's `icon` blob (Decision 0010). */ 150 151 icon?: BlobRefJson; 152 + /** Optional sky-phase colour theme — the lexicon's `basicTheme` object (Decision 0012). */ 153 + basicTheme?: BasicTheme; 151 154 } 152 155 153 156 export function buildPublicationRecord( input: { ··· 157 160 name?: string; 158 161 description?: string; 159 162 icon?: BlobRefJson; 163 + basicTheme?: BasicTheme; 160 164 } ): PublicationRecord { 161 165 const trimmedName = input.name?.trim(); 162 166 const description = input.description?.trim(); 167 + // Validate the theme at the write boundary too (symmetric with the read path): an invalid 168 + // theme is dropped rather than persisted, so no out-of-range channel can ever reach storage. 169 + const basicTheme = parseBasicTheme( input.basicTheme ); 163 170 return { 164 171 $type: 'site.standard.publication', 165 172 url: publicationHomeUrl( input.handle, input.slug ), 166 173 name: trimmedName || input.handle, 167 174 ...( description ? { description } : {} ), 168 175 ...( input.icon ? { icon: input.icon } : {} ), 176 + ...( basicTheme ? { basicTheme } : {} ), 169 177 }; 170 178 } 171 179 ··· 208 216 }; 209 217 } 210 218 219 + /** A richtext link facet (`app.bsky.richtext.facet#link`) over a UTF-8 byte range of `text`. */ 220 + export interface BskyLinkFacet { 221 + $type: 'app.bsky.richtext.facet'; 222 + index: { byteStart: number; byteEnd: number }; 223 + features: Array< { $type: 'app.bsky.richtext.facet#link'; uri: string } >; 224 + } 225 + 226 + /** A `com.atproto.repo.strongRef` as embedded in `external.associatedRefs` (Decision 0013). */ 227 + export interface AssociatedRef extends StrongRef { 228 + $type: 'com.atproto.repo.strongRef'; 229 + } 230 + 211 231 export interface BskyPostRecord { 212 232 $type: 'app.bsky.feed.post'; 213 233 text: string; 214 234 createdAt: string; 235 + /** Always present: marks the article URL in `text` as a clickable link (Decision 0013). */ 236 + facets: BskyLinkFacet[]; 215 237 embed: { 216 238 $type: 'app.bsky.embed.external'; 217 239 external: { 218 240 uri: string; 219 241 title: string; 220 242 description: string; 243 + /** An image blob (≤1MB, image/*) shown on the link card — the og:image fallback (Decision 0014). */ 244 + thumb?: BlobRefJson; 245 + /** strongRefs to the document + publication; drives the standard.site link card. */ 246 + associatedRefs?: AssociatedRef[]; 221 247 }; 222 248 }; 223 249 } 224 250 251 + /** UTF-8 byte length — facet offsets are byte-based, not JS string-index based. */ 252 + function utf8ByteLength( value: string ): number { 253 + return new TextEncoder().encode( value ).length; 254 + } 255 + 256 + /** 257 + * Build the companion Bluesky post (Decision 0005). The article URL is appended to the text and 258 + * marked as a clickable link via a richtext facet; `associatedRefs` (the document + publication 259 + * strongRefs) are embedded so Bluesky renders the rich standard.site link card (Decision 0013). 260 + */ 225 261 export function buildBskyPost( input: { 226 262 title: string; 227 263 articleUrl: string; 228 264 createdAt: string; 229 265 description?: string; 266 + /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */ 267 + thumb?: BlobRefJson; 268 + /** Document + publication strongRefs, in that order, for the standard.site card. */ 269 + associatedRefs?: StrongRef[]; 230 270 } ): BskyPostRecord { 271 + const text = `${ input.title }\n\n${ input.articleUrl }`; 272 + // The URL is the trailing segment of `text`; mark its byte range as a link facet. 273 + const byteStart = utf8ByteLength( `${ input.title }\n\n` ); 274 + const byteEnd = byteStart + utf8ByteLength( input.articleUrl ); 231 275 return { 232 276 $type: 'app.bsky.feed.post', 233 - text: `${ input.title }\n\n${ input.articleUrl }`, 277 + text, 234 278 createdAt: input.createdAt, 279 + facets: [ 280 + { 281 + $type: 'app.bsky.richtext.facet', 282 + index: { byteStart, byteEnd }, 283 + features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ], 284 + }, 285 + ], 235 286 embed: { 236 287 $type: 'app.bsky.embed.external', 237 288 external: { 238 289 uri: input.articleUrl, 239 290 title: input.title, 240 291 description: input.description ?? '', 292 + ...( input.thumb ? { thumb: input.thumb } : {} ), 293 + ...( input.associatedRefs && input.associatedRefs.length 294 + ? { 295 + associatedRefs: input.associatedRefs.map( ( ref ) => ( { 296 + $type: 'com.atproto.repo.strongRef' as const, 297 + uri: ref.uri, 298 + cid: ref.cid, 299 + } ) ), 300 + } 301 + : {} ), 241 302 }, 242 303 }, 243 304 };
+241
src/lib/publish/themes.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + THEME_PRESETS, 4 + parseBasicTheme, 5 + findPresetByColors, 6 + themeToCssVars, 7 + themeStyleBlock, 8 + resolveSelectedTheme, 9 + CUSTOM_THEME_SLUG, 10 + type BasicTheme, 11 + } from './themes'; 12 + 13 + const luminance = ( { r, g, b }: { r: number; g: number; b: number } ) => { 14 + const ch = ( c: number ) => { 15 + const s = c / 255; 16 + return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; 17 + }; 18 + return 0.2126 * ch( r ) + 0.7152 * ch( g ) + 0.0722 * ch( b ); 19 + }; 20 + const ratio = ( a: BasicTheme[ 'background' ], b: BasicTheme[ 'background' ] ) => { 21 + const la = luminance( a ) + 0.05; 22 + const lb = luminance( b ) + 0.05; 23 + return la > lb ? la / lb : lb / la; 24 + }; 25 + 26 + describe( 'THEME_PRESETS', () => { 27 + it( 'ships the 8 sky phases with unique slugs', () => { 28 + expect( THEME_PRESETS ).toHaveLength( 8 ); 29 + expect( THEME_PRESETS.map( ( p ) => p.slug ) ).toEqual( [ 30 + 'evening', 31 + 'noon', 32 + 'dusk', 33 + 'afternoon', 34 + 'twilight', 35 + 'morning', 36 + 'sunrise', 37 + 'midnight', 38 + ] ); 39 + expect( new Set( THEME_PRESETS.map( ( p ) => p.slug ) ).size ).toBe( 8 ); 40 + } ); 41 + 42 + it( 'every preset clears WCAG AA for body text and button text', () => { 43 + for ( const preset of THEME_PRESETS ) { 44 + const { background, foreground, accent, accentForeground } = preset.colors; 45 + expect( ratio( background, foreground ) ).toBeGreaterThanOrEqual( 4.5 ); 46 + expect( ratio( accent, accentForeground ) ).toBeGreaterThanOrEqual( 4.5 ); 47 + } 48 + } ); 49 + 50 + it( 'every preset colour carries the site.standard.theme.color#rgb union $type', () => { 51 + // `site.standard.theme.basic` types each colour as a union of `site.standard.theme.color#rgb`, 52 + // and atproto requires a `$type` discriminator on union members. Without it the publication 53 + // record is invalid and Bluesky's AppView can't hydrate the enhanced standard.site link card. 54 + for ( const preset of THEME_PRESETS ) { 55 + for ( const color of [ 56 + preset.colors.background, 57 + preset.colors.foreground, 58 + preset.colors.accent, 59 + preset.colors.accentForeground, 60 + ] ) { 61 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 62 + } 63 + } 64 + } ); 65 + 66 + it( 'every preset color channel is an integer 0–255', () => { 67 + for ( const preset of THEME_PRESETS ) { 68 + for ( const color of [ 69 + preset.colors.background, 70 + preset.colors.foreground, 71 + preset.colors.accent, 72 + preset.colors.accentForeground, 73 + ] ) { 74 + for ( const channel of [ color.r, color.g, color.b ] ) { 75 + expect( Number.isInteger( channel ) ).toBe( true ); 76 + expect( channel ).toBeGreaterThanOrEqual( 0 ); 77 + expect( channel ).toBeLessThanOrEqual( 255 ); 78 + } 79 + } 80 + } 81 + } ); 82 + } ); 83 + 84 + describe( 'parseBasicTheme', () => { 85 + it( 'accepts a valid theme and stamps $type', () => { 86 + const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors ); 87 + expect( parsed ).not.toBeNull(); 88 + expect( parsed!.$type ).toBe( 'site.standard.theme.basic' ); 89 + expect( parsed!.accent ).toEqual( THEME_PRESETS[ 0 ].colors.accent ); 90 + } ); 91 + 92 + it( 'stamps the union $type discriminator on every colour (lexicon requires it)', () => { 93 + const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors )!; 94 + for ( const color of [ parsed.background, parsed.foreground, parsed.accent, parsed.accentForeground ] ) { 95 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 96 + } 97 + } ); 98 + 99 + it( 'rejects undefined, non-objects, and out-of-range / non-integer channels', () => { 100 + expect( parseBasicTheme( undefined ) ).toBeNull(); 101 + expect( parseBasicTheme( 'nope' ) ).toBeNull(); 102 + expect( parseBasicTheme( { background: { r: 0, g: 0, b: 0 } } ) ).toBeNull(); 103 + expect( 104 + parseBasicTheme( { 105 + background: { r: 300, g: 0, b: 0 }, 106 + foreground: { r: 0, g: 0, b: 0 }, 107 + accent: { r: 0, g: 0, b: 0 }, 108 + accentForeground: { r: 0, g: 0, b: 0 }, 109 + } ) 110 + ).toBeNull(); 111 + expect( 112 + parseBasicTheme( { 113 + background: { r: 1.5, g: 0, b: 0 }, 114 + foreground: { r: 0, g: 0, b: 0 }, 115 + accent: { r: 0, g: 0, b: 0 }, 116 + accentForeground: { r: 0, g: 0, b: 0 }, 117 + } ) 118 + ).toBeNull(); 119 + } ); 120 + } ); 121 + 122 + describe( 'findPresetByColors', () => { 123 + it( 'reverse-matches stored colours to a preset', () => { 124 + const preset = findPresetByColors( THEME_PRESETS[ 3 ].colors ); 125 + expect( preset?.slug ).toBe( 'afternoon' ); 126 + } ); 127 + 128 + it( 'returns null for none/custom colours', () => { 129 + expect( findPresetByColors( null ) ).toBeNull(); 130 + expect( findPresetByColors( undefined ) ).toBeNull(); 131 + expect( 132 + findPresetByColors( { 133 + $type: 'site.standard.theme.basic', 134 + background: { r: 1, g: 2, b: 3 }, 135 + foreground: { r: 4, g: 5, b: 6 }, 136 + accent: { r: 7, g: 8, b: 9 }, 137 + accentForeground: { r: 10, g: 11, b: 12 }, 138 + } ) 139 + ).toBeNull(); 140 + } ); 141 + 142 + it( 'round-trips a parsed theme back to its preset (the edit-prefill path)', () => { 143 + // Production prefill reads `existing.basicTheme`, which came through parseBasicTheme. 144 + const parsed = parseBasicTheme( THEME_PRESETS[ 6 ].colors ); // sunrise 145 + expect( findPresetByColors( parsed )?.slug ).toBe( 'sunrise' ); 146 + } ); 147 + } ); 148 + 149 + describe( 'resolveSelectedTheme', () => { 150 + const custom: BasicTheme = { 151 + $type: 'site.standard.theme.basic', 152 + background: { r: 1, g: 2, b: 3 }, 153 + foreground: { r: 4, g: 5, b: 6 }, 154 + accent: { r: 7, g: 8, b: 9 }, 155 + accentForeground: { r: 10, g: 11, b: 12 }, 156 + }; 157 + 158 + it( 'maps null to undefined (no theme)', () => { 159 + expect( resolveSelectedTheme( null, null ) ).toBeUndefined(); 160 + expect( resolveSelectedTheme( null, custom ) ).toBeUndefined(); 161 + } ); 162 + 163 + it( 'maps a preset slug to that preset’s colours', () => { 164 + expect( resolveSelectedTheme( 'dusk', null ) ).toEqual( THEME_PRESETS[ 2 ].colors ); 165 + } ); 166 + 167 + it( 'keeps the custom theme verbatim for the custom sentinel', () => { 168 + expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, custom ) ).toEqual( custom ); 169 + // No custom theme to keep → no theme rather than a dangling sentinel. 170 + expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, null ) ).toBeUndefined(); 171 + } ); 172 + 173 + it( 'maps an unknown slug to undefined (preset removed) rather than crashing', () => { 174 + expect( resolveSelectedTheme( 'no-such-preset', null ) ).toBeUndefined(); 175 + } ); 176 + } ); 177 + 178 + describe( 'themeToCssVars', () => { 179 + const vars = themeToCssVars( THEME_PRESETS[ 0 ].colors ); // evening 180 + 181 + it( 'maps the core tokens to rgb() strings', () => { 182 + expect( vars[ '--paper' ] ).toBe( 'rgb(27, 27, 27)' ); 183 + expect( vars[ '--ink' ] ).toBe( 'rgb(240, 240, 240)' ); 184 + expect( vars[ '--sun' ] ).toBe( 'rgb(68, 35, 105)' ); 185 + expect( vars[ '--btn-primary' ] ).toBe( 'rgb(68, 35, 105)' ); 186 + expect( vars[ '--btn-primary-fg' ] ).toBe( 'rgb(255, 255, 255)' ); 187 + } ); 188 + 189 + it( 'derives intermediate tokens as in-range rgb() strings', () => { 190 + for ( const key of [ '--muted', '--line', '--line-strong', '--sun-tint', '--btn-primary-hover' ] ) { 191 + expect( vars[ key ] ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ ); 192 + } 193 + } ); 194 + 195 + it( 'blends muted as foreground OVER background (direction locked)', () => { 196 + // evening: bg 27, fg 240 → mix(fg, bg, 0.55) = round(240*.55 + 27*.45) = round(144.15) = 144. 197 + // If the blend direction inverted (bg over fg) this would be ~123 — caught here. 198 + expect( vars[ '--muted' ] ).toBe( 'rgb(144, 144, 144)' ); 199 + } ); 200 + 201 + it( 'carries accentForeground into --btn-primary-fg', () => { 202 + const noon = themeToCssVars( THEME_PRESETS[ 1 ].colors ); // accentForeground = black 203 + expect( noon[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' ); 204 + } ); 205 + } ); 206 + 207 + describe( 'themeStyleBlock', () => { 208 + it( 'returns a :root override style tag for a theme', () => { 209 + const html = themeStyleBlock( THEME_PRESETS[ 1 ].colors ); // noon 210 + expect( html ).toContain( '<style>' ); 211 + expect( html ).toContain( ':root' ); 212 + expect( html ).toContain( '--paper: rgb(248, 247, 245)' ); 213 + expect( html ).toContain( '.btn--primary' ); 214 + } ); 215 + 216 + it( 'returns an empty string when there is no theme', () => { 217 + expect( themeStyleBlock( null ) ).toBe( '' ); 218 + expect( themeStyleBlock( undefined ) ).toBe( '' ); 219 + } ); 220 + 221 + it( 'outranks the global :root defaults so the theme wins regardless of load order', () => { 222 + // The light + dark design tokens live on `:root` in global.css, which Astro bundles and 223 + // links into the head AFTER the page's injected <style>. A bare `:root` override has 224 + // identical specificity (0,1,0), so the cascade falls to source order — and the later 225 + // global.css wins, silently dropping the publication theme in BOTH colour schemes 226 + // (verified on production; Decision 0012). The override must therefore outrank a single 227 + // `:root`: `:root:root` (0,2,0) beats both the plain and the 228 + // `@media (prefers-color-scheme: dark) :root` defaults (0,1,0) in any order. 229 + const html = themeStyleBlock( THEME_PRESETS[ 4 ].colors ); // twilight 230 + expect( html ).toMatch( /<style>:root:root\s*\{/ ); 231 + } ); 232 + 233 + it( 'never emits anything but digits/commas inside rgb() (no injection surface)', () => { 234 + const html = themeStyleBlock( THEME_PRESETS[ 2 ].colors ); 235 + const matches = html.match( /rgb\([^)]*\)/g ) ?? []; 236 + expect( matches.length ).toBeGreaterThan( 0 ); 237 + for ( const m of matches ) { 238 + expect( m ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ ); 239 + } 240 + } ); 241 + } );
+221
src/lib/publish/themes.ts
··· 1 + /** 2 + * Publication colour themes ("sky phases"). Pure + dependency-free (no `@atproto`, 3 + * no `@wordpress`, no DOM) so the SSR reader can import it. Presets are derived from 4 + * the Twenty Twenty-Five colour style variations; each accent/accentForeground pair 5 + * is chosen to clear WCAG AA. Stored on `site.standard.publication.basicTheme` 6 + * (lexicon `site.standard.theme.basic`) — the standard field, so other readers honour it. 7 + * (Decision 0012.) 8 + */ 9 + 10 + /** 11 + * The lexicon type id for an RGB colour. `site.standard.theme.basic` types each colour as a 12 + * UNION of `site.standard.theme.color#rgb`, and atproto requires a `$type` discriminator on every 13 + * union member. Omitting it makes the publication record invalid, so Bluesky's AppView can't 14 + * hydrate it and silently drops the enhanced standard.site link card — falling back to a bare 15 + * external embed (no avatar / theme / reading time). Every stored colour must carry this. */ 16 + export const COLOR_TYPE = 'site.standard.theme.color#rgb'; 17 + 18 + export interface Rgb { 19 + /** Union discriminator required by the lexicon; always set on stored/built colours. */ 20 + $type?: typeof COLOR_TYPE; 21 + r: number; 22 + g: number; 23 + b: number; 24 + } 25 + 26 + export interface BasicTheme { 27 + $type: 'site.standard.theme.basic'; 28 + /** content background */ 29 + background: Rgb; 30 + /** content text */ 31 + foreground: Rgb; 32 + /** links + button backgrounds */ 33 + accent: Rgb; 34 + /** button text */ 35 + accentForeground: Rgb; 36 + } 37 + 38 + export interface ThemePreset { 39 + slug: string; 40 + label: string; 41 + colors: BasicTheme; 42 + } 43 + 44 + const rgb = ( r: number, g: number, b: number ): Rgb => ( { $type: COLOR_TYPE, r, g, b } ); 45 + 46 + const preset = ( 47 + slug: string, 48 + label: string, 49 + background: Rgb, 50 + foreground: Rgb, 51 + accent: Rgb, 52 + accentForeground: Rgb 53 + ): ThemePreset => ( { 54 + slug, 55 + label, 56 + colors: { $type: 'site.standard.theme.basic', background, foreground, accent, accentForeground }, 57 + } ); 58 + 59 + /** 60 + * The eight sky-phase presets (Twenty Twenty-Five colour palettes). Mapping per palette: 61 + * `background ← base`, `foreground ← contrast`, `accent ←` the palette's signature accent, 62 + * `accentForeground ←` whichever of black/white clears WCAG AA on that accent. 63 + */ 64 + export const THEME_PRESETS: readonly ThemePreset[] = [ 65 + preset( 'evening', 'Evening', rgb( 27, 27, 27 ), rgb( 240, 240, 240 ), rgb( 68, 35, 105 ), rgb( 255, 255, 255 ) ), 66 + preset( 'noon', 'Noon', rgb( 248, 247, 245 ), rgb( 25, 25, 25 ), rgb( 245, 182, 132 ), rgb( 0, 0, 0 ) ), 67 + preset( 'dusk', 'Dusk', rgb( 226, 226, 226 ), rgb( 59, 59, 59 ), rgb( 101, 13, 212 ), rgb( 255, 255, 255 ) ), 68 + preset( 'afternoon', 'Afternoon', rgb( 218, 231, 189 ), rgb( 81, 96, 40 ), rgb( 199, 246, 66 ), rgb( 0, 0, 0 ) ), 69 + preset( 'twilight', 'Twilight', rgb( 19, 19, 19 ), rgb( 255, 255, 255 ), rgb( 75, 82, 255 ), rgb( 255, 255, 255 ) ), 70 + preset( 'morning', 'Morning', rgb( 223, 220, 215 ), rgb( 25, 25, 25 ), rgb( 122, 155, 219 ), rgb( 0, 0, 0 ) ), 71 + preset( 'sunrise', 'Sunrise', rgb( 51, 6, 22 ), rgb( 255, 255, 255 ), rgb( 219, 154, 177 ), rgb( 0, 0, 0 ) ), 72 + preset( 'midnight', 'Midnight', rgb( 68, 51, 166 ), rgb( 121, 243, 177 ), rgb( 232, 183, 255 ), rgb( 0, 0, 0 ) ), 73 + ]; 74 + 75 + const isChannel = ( v: unknown ): v is number => 76 + typeof v === 'number' && Number.isInteger( v ) && v >= 0 && v <= 255; 77 + 78 + const isRgb = ( v: unknown ): v is Rgb => { 79 + if ( ! v || typeof v !== 'object' ) { 80 + return false; 81 + } 82 + const c = v as Record< string, unknown >; 83 + return isChannel( c.r ) && isChannel( c.g ) && isChannel( c.b ); 84 + }; 85 + 86 + /** 87 + * Validate a PDS-sourced value into a `BasicTheme`, or null. Untrusted input: every 88 + * channel must be an integer 0–255 (the renderer relies on this to emit safe `rgb()`). 89 + */ 90 + export function parseBasicTheme( value: unknown ): BasicTheme | null { 91 + if ( ! value || typeof value !== 'object' ) { 92 + return null; 93 + } 94 + const v = value as Record< string, unknown >; 95 + if ( 96 + ! isRgb( v.background ) || 97 + ! isRgb( v.foreground ) || 98 + ! isRgb( v.accent ) || 99 + ! isRgb( v.accentForeground ) 100 + ) { 101 + return null; 102 + } 103 + // Re-stamp the colour union `$type` on the way out: the lexicon requires it, and PDS-sourced 104 + // input may omit it (e.g. records written before this was fixed). This is the single boundary 105 + // both the write path (buildPublicationRecord) and read path (reader) funnel through. 106 + return { 107 + $type: 'site.standard.theme.basic', 108 + background: { $type: COLOR_TYPE, r: v.background.r, g: v.background.g, b: v.background.b }, 109 + foreground: { $type: COLOR_TYPE, r: v.foreground.r, g: v.foreground.g, b: v.foreground.b }, 110 + accent: { $type: COLOR_TYPE, r: v.accent.r, g: v.accent.g, b: v.accent.b }, 111 + accentForeground: { 112 + $type: COLOR_TYPE, 113 + r: v.accentForeground.r, 114 + g: v.accentForeground.g, 115 + b: v.accentForeground.b, 116 + }, 117 + }; 118 + } 119 + 120 + /** 121 + * Sentinel slug for "keep the publication's current colours" — used when a stored theme doesn't 122 + * match any preset (e.g. a future preset whose colours changed). Keeps the picker from silently 123 + * erasing a theme it can't name. Not a real preset; never written as a slug. 124 + */ 125 + export const CUSTOM_THEME_SLUG = 'custom'; 126 + 127 + /** 128 + * Resolve the picker's selection to the colours to store. `null` slug → no theme (omit); 129 + * the custom sentinel → keep `customTheme` verbatim; any other slug → that preset's colours. 130 + * Pure, so the form's submit mapping is unit-testable without rendering. 131 + */ 132 + export function resolveSelectedTheme( 133 + slug: string | null, 134 + customTheme: BasicTheme | null | undefined 135 + ): BasicTheme | undefined { 136 + if ( slug === null ) { 137 + return undefined; 138 + } 139 + if ( slug === CUSTOM_THEME_SLUG ) { 140 + return customTheme ?? undefined; 141 + } 142 + return THEME_PRESETS.find( ( preset ) => preset.slug === slug )?.colors; 143 + } 144 + 145 + const sameRgb = ( a: Rgb, b: Rgb ): boolean => a.r === b.r && a.g === b.g && a.b === b.b; 146 + 147 + /** Reverse-match stored colours to a known preset (so the picker highlights it), or null. */ 148 + export function findPresetByColors( theme: BasicTheme | null | undefined ): ThemePreset | null { 149 + if ( ! theme ) { 150 + return null; 151 + } 152 + return ( 153 + THEME_PRESETS.find( 154 + ( p ) => 155 + sameRgb( p.colors.background, theme.background ) && 156 + sameRgb( p.colors.foreground, theme.foreground ) && 157 + sameRgb( p.colors.accent, theme.accent ) && 158 + sameRgb( p.colors.accentForeground, theme.accentForeground ) 159 + ) ?? null 160 + ); 161 + } 162 + 163 + const clamp255 = ( n: number ): number => Math.max( 0, Math.min( 255, Math.round( n ) ) ); 164 + 165 + /** Linear per-channel blend: `t` of `a` over `(1 - t)` of `b`. */ 166 + const mix = ( a: Rgb, b: Rgb, t: number ): Rgb => ( { 167 + r: clamp255( a.r * t + b.r * ( 1 - t ) ), 168 + g: clamp255( a.g * t + b.g * ( 1 - t ) ), 169 + b: clamp255( a.b * t + b.b * ( 1 - t ) ), 170 + } ); 171 + 172 + const css = ( { r, g, b }: Rgb ): string => `rgb(${ r }, ${ g }, ${ b })`; 173 + 174 + /** 175 + * Map a `BasicTheme` to the SkyPress design tokens it overrides. Background/foreground/accent 176 + * families map directly; borders + muted text are deterministic blends of fg over bg so the 177 + * editorial hierarchy survives any palette. A publisher's palette is a fixed identity, so this 178 + * intentionally overrides both the light and dark `:root` defaults. 179 + */ 180 + export function themeToCssVars( theme: BasicTheme ): Record< string, string > { 181 + const { background: bg, foreground: fg, accent, accentForeground } = theme; 182 + return { 183 + '--paper': css( bg ), 184 + '--paper-raised': css( mix( fg, bg, 0.04 ) ), 185 + '--panel': css( mix( fg, bg, 0.06 ) ), 186 + '--ink': css( fg ), 187 + '--ink-soft': css( mix( fg, bg, 0.8 ) ), 188 + '--muted': css( mix( fg, bg, 0.55 ) ), 189 + '--line': css( mix( fg, bg, 0.14 ) ), 190 + '--line-strong': css( mix( fg, bg, 0.26 ) ), 191 + '--sun': css( accent ), 192 + '--sun-strong': css( mix( accent, fg, 0.82 ) ), 193 + '--sun-tint': css( mix( accent, bg, 0.2 ) ), 194 + '--btn-primary': css( accent ), 195 + '--btn-primary-hover': css( mix( accent, fg, 0.85 ) ), 196 + '--btn-primary-fg': css( accentForeground ), 197 + '--ember': css( accent ), 198 + }; 199 + } 200 + 201 + /** 202 + * A `<style>` block overriding the design tokens for a themed publication, or '' when there is 203 + * no theme. Also re-points `.btn--primary` text at `--btn-primary-fg` so button text follows the 204 + * theme's accentForeground. All values are app-built `rgb()` strings — no record text reaches CSS. 205 + * 206 + * The token override targets `:root:root` (not a bare `:root`) on purpose: global.css defines the 207 + * light defaults on `:root` and the dark defaults on `@media (prefers-color-scheme: dark) :root`, 208 + * both specificity (0,1,0). Astro bundles global.css and links it into the head AFTER this injected 209 + * <style>, so a bare `:root` override — equal specificity, earlier in source — loses the cascade in 210 + * both colour schemes and the theme silently vanishes. `:root:root` (0,2,0) outranks both defaults 211 + * regardless of load order. (Decision 0012.) 212 + */ 213 + export function themeStyleBlock( theme: BasicTheme | null | undefined ): string { 214 + if ( ! theme ) { 215 + return ''; 216 + } 217 + const decls = Object.entries( themeToCssVars( theme ) ) 218 + .map( ( [ key, value ] ) => `${ key }: ${ value };` ) 219 + .join( ' ' ); 220 + return `<style>:root:root { ${ decls } } .btn--primary, .btn--primary:hover { color: var(--btn-primary-fg); }</style>`; 221 + }
+22
src/lib/reader/publications.test.ts
··· 2 2 import { listReaderPublications, resolveReaderPublication } from './publications'; 3 3 import { listRecords } from './records'; 4 4 import { SITE_BASE } from '../publish/records'; 5 + import { THEME_PRESETS } from '../publish/themes'; 5 6 6 7 vi.mock( './records', () => ( { listRecords: vi.fn() } ) ); 7 8 const mockedList = listRecords as unknown as ReturnType< typeof vi.fn >; ··· 48 49 expect( await resolveReaderPublication( PDS, DID, 'missing' ) ).toBeNull(); 49 50 } ); 50 51 } ); 52 + 53 + describe( 'reader basicTheme', () => { 54 + it( 'surfaces a valid stored basicTheme', async () => { 55 + mockedList.mockResolvedValue( [ 56 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog`, { 57 + basicTheme: THEME_PRESETS[ 5 ].colors, // morning 58 + } ), 59 + ] ); 60 + const pub = await resolveReaderPublication( PDS, DID, 'blog' ); 61 + expect( pub?.basicTheme ).toEqual( THEME_PRESETS[ 5 ].colors ); 62 + } ); 63 + 64 + it( 'sets basicTheme to null when absent or malformed', async () => { 65 + mockedList.mockResolvedValue( [ 66 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/plain` ), 67 + rec( 'b', `${ SITE_BASE }/@me.bsky.social/broken`, { basicTheme: { accent: 'red' } } ), 68 + ] ); 69 + expect( ( await resolveReaderPublication( PDS, DID, 'plain' ) )?.basicTheme ).toBeNull(); 70 + expect( ( await resolveReaderPublication( PDS, DID, 'broken' ) )?.basicTheme ).toBeNull(); 71 + } ); 72 + } );
+4
src/lib/reader/publications.ts
··· 12 12 publicationSlugFromUrl, 13 13 } from '../publish/records'; 14 14 import type { BlobRefJson } from '../media/blob'; 15 + import { parseBasicTheme, type BasicTheme } from '../publish/themes'; 15 16 16 17 export interface ReaderPublication { 17 18 uri: string; ··· 19 20 name: string; 20 21 description: string | null; 21 22 icon: BlobRefJson | null; 23 + basicTheme: BasicTheme | null; 22 24 } 23 25 24 26 interface RawPublication { ··· 26 28 name?: string; 27 29 description?: string; 28 30 icon?: BlobRefJson; 31 + basicTheme?: unknown; 29 32 } 30 33 31 34 function toReaderPublication( record: { uri: string; value: RawPublication } ): ReaderPublication | null { ··· 43 46 name: value.name ?? slug, 44 47 description: value.description?.trim() || null, 45 48 icon: value.icon ?? null, 49 + basicTheme: parseBasicTheme( value.basicTheme ), 46 50 }; 47 51 } 48 52
+47
src/lib/reader/theme-injection.test.ts
··· 1 + /** 2 + * Regression guard for the publication-theme injection (Decision 0012). 3 + * 4 + * The publication-home and article pages inject a `<style>` overriding the design tokens when a 5 + * publication has a `basicTheme`. Rendering these `.astro` pages through astro/container isn't 6 + * viable here (the runner is pinned to jsdom for the WordPress block suites, which breaks 7 + * esbuild's init invariant — see index.phase.test.ts), so these asserts pin the wiring at the 8 + * source level: each page must compute `themeStyleBlock(publication.basicTheme)` and inject it, 9 + * gated on a truthy result, via `set:html` (the value is app-built CSS, proven injection-safe by 10 + * themes.test.ts). A refactor that drops the gate or passes the wrong field ships green without it. 11 + * 12 + * Lives under src/lib/ (not src/pages/) on purpose: a `.test.ts` inside a `[param]` route dir is 13 + * treated by Astro as a prerendered endpoint and breaks the build. 14 + */ 15 + import { readFileSync } from 'node:fs'; 16 + import { fileURLToPath } from 'node:url'; 17 + import { describe, expect, it } from 'vitest'; 18 + 19 + const read = ( rel: string ) => 20 + readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' ); 21 + 22 + describe( 'publication theme injection wiring', () => { 23 + for ( const [ label, file ] of [ 24 + [ 'publication home', '../../pages/[author]/[slug]/index.astro' ], 25 + [ 'article', '../../pages/[author]/[slug]/[rkey].astro' ], 26 + ] as const ) { 27 + describe( label, () => { 28 + const src = read( file ); 29 + 30 + it( 'imports themeStyleBlock from the themes module', () => { 31 + expect( src ).toMatch( 32 + /import\s*\{\s*themeStyleBlock\s*\}\s*from\s*['"][^'"]*publish\/themes['"]/ 33 + ); 34 + } ); 35 + 36 + it( 'derives the style block from the publication’s basicTheme', () => { 37 + expect( src ).toMatch( /themeStyleBlock\(\s*publication\.basicTheme\s*\)/ ); 38 + } ); 39 + 40 + it( 'injects it via set:html, gated on a truthy theme', () => { 41 + expect( src ).toMatch( 42 + /\{\s*themeStyle\s*&&\s*<Fragment\s+set:html=\{\s*themeStyle\s*\}\s*\/>\s*\}/ 43 + ); 44 + } ); 45 + } ); 46 + } 47 + } );
+89
src/lib/seo/meta.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { buildMetaTags, type MetaTag } from './meta'; 3 + 4 + const BASE = { 5 + title: 'SkyPress', 6 + description: 'A writing studio for the open social web.', 7 + url: 'https://skypress.blog/', 8 + image: 'https://skypress.blog/og-default.png', 9 + siteName: 'SkyPress', 10 + imageAlt: 'SkyPress', 11 + imageWidth: 1200, 12 + imageHeight: 630, 13 + }; 14 + 15 + const byProperty = ( tags: MetaTag[], property: string ) => 16 + tags.find( ( t ) => t.property === property ); 17 + const byName = ( tags: MetaTag[], name: string ) => 18 + tags.find( ( t ) => t.name === name ); 19 + 20 + describe( 'buildMetaTags', () => { 21 + it( 'emits the core Open Graph tags', () => { 22 + const tags = buildMetaTags( BASE ); 23 + expect( byProperty( tags, 'og:title' )?.content ).toBe( 'SkyPress' ); 24 + expect( byProperty( tags, 'og:url' )?.content ).toBe( 'https://skypress.blog/' ); 25 + expect( byProperty( tags, 'og:type' )?.content ).toBe( 'website' ); 26 + expect( byProperty( tags, 'og:site_name' )?.content ).toBe( 'SkyPress' ); 27 + } ); 28 + 29 + it( 'emits the image tags with explicit dimensions and alt', () => { 30 + const tags = buildMetaTags( BASE ); 31 + expect( byProperty( tags, 'og:image' )?.content ).toBe( 32 + 'https://skypress.blog/og-default.png' 33 + ); 34 + expect( byProperty( tags, 'og:image:width' )?.content ).toBe( '1200' ); 35 + expect( byProperty( tags, 'og:image:height' )?.content ).toBe( '630' ); 36 + expect( byProperty( tags, 'og:image:alt' )?.content ).toBe( 'SkyPress' ); 37 + } ); 38 + 39 + it( 'emits a large-image Twitter card mirroring title and image', () => { 40 + const tags = buildMetaTags( BASE ); 41 + expect( byName( tags, 'twitter:card' )?.content ).toBe( 'summary_large_image' ); 42 + expect( byName( tags, 'twitter:title' )?.content ).toBe( 'SkyPress' ); 43 + expect( byName( tags, 'twitter:image' )?.content ).toBe( 44 + 'https://skypress.blog/og-default.png' 45 + ); 46 + } ); 47 + 48 + it( 'includes description tags when a description is given', () => { 49 + const tags = buildMetaTags( BASE ); 50 + expect( byProperty( tags, 'og:description' )?.content ).toBe( BASE.description ); 51 + expect( byName( tags, 'twitter:description' )?.content ).toBe( BASE.description ); 52 + } ); 53 + 54 + it( 'omits description tags entirely when no description is given', () => { 55 + const tags = buildMetaTags( { ...BASE, description: undefined } ); 56 + expect( byProperty( tags, 'og:description' ) ).toBeUndefined(); 57 + expect( byName( tags, 'twitter:description' ) ).toBeUndefined(); 58 + } ); 59 + 60 + it( 'honours a custom og:type', () => { 61 + const tags = buildMetaTags( { ...BASE, type: 'article' } ); 62 + expect( byProperty( tags, 'og:type' )?.content ).toBe( 'article' ); 63 + } ); 64 + 65 + it( 'omits og:image:alt when no alt is given', () => { 66 + const tags = buildMetaTags( { ...BASE, imageAlt: undefined } ); 67 + expect( byProperty( tags, 'og:image:alt' ) ).toBeUndefined(); 68 + } ); 69 + 70 + it( 'omits og:image dimensions when width/height are not given', () => { 71 + const tags = buildMetaTags( { 72 + ...BASE, 73 + imageWidth: undefined, 74 + imageHeight: undefined, 75 + } ); 76 + expect( byProperty( tags, 'og:image:width' ) ).toBeUndefined(); 77 + expect( byProperty( tags, 'og:image:height' ) ).toBeUndefined(); 78 + // The image itself is still present. 79 + expect( byProperty( tags, 'og:image' )?.content ).toBe( 80 + 'https://skypress.blog/og-default.png' 81 + ); 82 + } ); 83 + 84 + it( 'omits og:image dimensions when only one of width/height is given', () => { 85 + const tags = buildMetaTags( { ...BASE, imageHeight: undefined } ); 86 + expect( byProperty( tags, 'og:image:width' ) ).toBeUndefined(); 87 + expect( byProperty( tags, 'og:image:height' ) ).toBeUndefined(); 88 + } ); 89 + } );
+76
src/lib/seo/meta.ts
··· 1 + /** 2 + * Build the list of Open Graph + Twitter Card <meta> tags for a page. 3 + * 4 + * Pure and dependency-free so it is unit-testable and safe to call from the 5 + * server-rendered Base layout (the read path must not pull in browser-only deps). 6 + * `og:*` tags use the `property` attribute; `twitter:*` tags use `name`. 7 + */ 8 + export interface MetaTagInput { 9 + title: string; 10 + /** Page description. When absent, og:description / twitter:description are omitted. */ 11 + description?: string; 12 + /** Absolute canonical URL of the page. */ 13 + url: string; 14 + /** Absolute URL of the share image. */ 15 + image: string; 16 + /** Site name (e.g. "SkyPress"). */ 17 + siteName: string; 18 + /** Open Graph object type. Defaults to "website". */ 19 + type?: string; 20 + /** Alt text for the share image. Omitted when absent. */ 21 + imageAlt?: string; 22 + /** Intrinsic image width in px. Emitted only when both width AND height are given. */ 23 + imageWidth?: number; 24 + /** Intrinsic image height in px. Emitted only when both width AND height are given. */ 25 + imageHeight?: number; 26 + } 27 + 28 + export interface MetaTag { 29 + property?: string; 30 + name?: string; 31 + content: string; 32 + } 33 + 34 + export function buildMetaTags( input: MetaTagInput ): MetaTag[] { 35 + const { 36 + title, 37 + description, 38 + url, 39 + image, 40 + siteName, 41 + type = 'website', 42 + imageAlt, 43 + imageWidth, 44 + imageHeight, 45 + } = input; 46 + 47 + const tags: MetaTag[] = [ 48 + { property: 'og:title', content: title }, 49 + { property: 'og:type', content: type }, 50 + { property: 'og:url', content: url }, 51 + { property: 'og:site_name', content: siteName }, 52 + { property: 'og:image', content: image }, 53 + ]; 54 + 55 + if ( imageWidth !== undefined && imageHeight !== undefined ) { 56 + tags.push( { property: 'og:image:width', content: String( imageWidth ) } ); 57 + tags.push( { property: 'og:image:height', content: String( imageHeight ) } ); 58 + } 59 + 60 + tags.push( 61 + { name: 'twitter:card', content: 'summary_large_image' }, 62 + { name: 'twitter:title', content: title }, 63 + { name: 'twitter:image', content: image } 64 + ); 65 + 66 + if ( description ) { 67 + tags.push( { property: 'og:description', content: description } ); 68 + tags.push( { name: 'twitter:description', content: description } ); 69 + } 70 + 71 + if ( imageAlt ) { 72 + tags.push( { property: 'og:image:alt', content: imageAlt } ); 73 + } 74 + 75 + return tags; 76 + }
+32 -6
src/pages/[author]/[slug]/[rkey].astro
··· 4 4 import { resolveAuthor } from '../../../lib/reader/identity'; 5 5 import { getRecord } from '../../../lib/reader/records'; 6 6 import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 - import { resolveBlobImageUrls } from '../../../lib/media/blob'; 7 + import { resolveBlobImageUrls, buildGetBlobUrl } from '../../../lib/media/blob'; 8 8 import { renderBlocks, blocksToText, type BlockNode } from '../../../lib/blocks/render'; 9 9 import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 10 10 import { canonicalArticleUrl } from '../../../lib/publish/records'; 11 + import { themeStyleBlock } from '../../../lib/publish/themes'; 12 + import { buildMetaTags } from '../../../lib/seo/meta'; 11 13 12 14 // Frontend block styles only — no editor chrome, no JS. 13 15 import '@wordpress/block-library/build-style/common.css'; ··· 72 74 const readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 73 75 const publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 74 76 const updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 77 + const themeStyle = themeStyleBlock( publication.basicTheme ); 78 + 79 + // Share image: the publication logo, else the shared default. Only the default 80 + // image has known 1200x630 dimensions; a square logo omits them. 81 + const ogImage = publication.icon 82 + ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 83 + : new URL( '/og-default.png', Astro.site ).href; 84 + const ogDimensions = publication.icon 85 + ? {} 86 + : { imageWidth: 1200, imageHeight: 630 }; 87 + const metaTags = buildMetaTags( { 88 + title, 89 + description, 90 + url: canonical, 91 + image: ogImage, 92 + siteName: 'SkyPress', 93 + type: 'article', 94 + imageAlt: publication.name, 95 + ...ogDimensions, 96 + } ); 75 97 --- 76 98 77 - <Base title={`${ title } — ${ publication.name }`} description={description}> 99 + <Base title={`${ title } — ${ publication.name }`} description={description} socialMeta={false}> 78 100 <Fragment slot="head"> 79 101 {/* standard.site indexing tags (brief §3) — Bluesky cards + AppView pickup */} 80 102 <link rel="site.standard.document" href={docUri} /> 81 103 <link rel="site.standard.publication" href={publication.uri} /> 82 104 <link rel="alternate" type="application/rss+xml" title={`${ publication.name } — RSS`} href={feedHref} /> 83 105 <link rel="canonical" href={canonical} /> 84 - <meta property="og:type" content="article" /> 85 - <meta property="og:title" content={title} /> 86 - <meta property="og:description" content={description} /> 87 - <meta property="og:url" content={canonical} /> 106 + {metaTags.map( ( tag ) => 107 + tag.property ? ( 108 + <meta property={tag.property} content={tag.content} /> 109 + ) : ( 110 + <meta name={tag.name} content={tag.content} /> 111 + ) 112 + )} 113 + {themeStyle && <Fragment set:html={themeStyle} />} 88 114 </Fragment> 89 115 90 116 <header class="masthead">
+44
src/pages/[author]/[slug]/_[rkey].meta.test.ts
··· 1 + /** 2 + * Source-level guard for the document page's Open Graph wiring. 3 + * 4 + * The document page opts out of Base's social meta (it owns the atproto 5 + * canonical + og:type=article), so it must build the FULL tag set itself via 6 + * buildMetaTags — not just the handful of og:* tags it used to hand-roll. 7 + * jsdom-pinned suite, so this is a source pin (see Base.meta.test.ts). 8 + */ 9 + import { readFileSync } from 'node:fs'; 10 + import { dirname, join } from 'node:path'; 11 + import { fileURLToPath } from 'node:url'; 12 + import { describe, expect, it } from 'vitest'; 13 + 14 + const here = dirname( fileURLToPath( import.meta.url ) ); 15 + const page = readFileSync( join( here, './[rkey].astro' ), 'utf8' ); 16 + 17 + describe( 'document page Open Graph wiring', () => { 18 + it( 'imports buildMetaTags and buildGetBlobUrl', () => { 19 + expect( page ).toMatch( /import\s*\{\s*buildMetaTags\s*\}\s*from\s*'[^']*lib\/seo\/meta'/ ); 20 + expect( page ).toMatch( /buildGetBlobUrl/ ); 21 + } ); 22 + 23 + it( 'builds the meta tags as an article with the canonical URL', () => { 24 + expect( page ).toMatch( /buildMetaTags\(/ ); 25 + expect( page ).toMatch( /type:\s*'article'/ ); 26 + expect( page ).toMatch( /url:\s*canonical/ ); 27 + } ); 28 + 29 + it( 'falls back to the default share image when the publication has no logo', () => { 30 + expect( page ).toMatch( /og-default\.png/ ); 31 + } ); 32 + 33 + it( 'renders the built tag set instead of hand-rolled og:title/og:description', () => { 34 + expect( page ).toMatch( /metaTags\.map/ ); 35 + // The old hand-rolled tags are gone (buildMetaTags now owns og:title etc). 36 + expect( page ).not.toMatch( /<meta property="og:title"/ ); 37 + expect( page ).not.toMatch( /<meta property="og:description"/ ); 38 + } ); 39 + 40 + it( 'still opts out of Base social meta and keeps its own canonical link', () => { 41 + expect( page ).toMatch( /socialMeta=\{false\}/ ); 42 + expect( page ).toMatch( /<link\s+rel="canonical"\s+href=\{canonical\}/ ); 43 + } ); 44 + } );
+25
src/pages/[author]/[slug]/_index.meta.test.ts
··· 1 + /** 2 + * Source-level guard for the publication page's Open Graph wiring. 3 + * 4 + * Page rendering through astro/container isn't viable in this jsdom-pinned suite 5 + * (see src/layouts/Base.meta.test.ts), so we pin the wiring at the source level: 6 + * the publication page must hand Base its own logo as the share image, with an 7 + * alt, falling back to the default image when there is no logo. 8 + */ 9 + import { readFileSync } from 'node:fs'; 10 + import { dirname, join } from 'node:path'; 11 + import { fileURLToPath } from 'node:url'; 12 + import { describe, expect, it } from 'vitest'; 13 + 14 + const here = dirname( fileURLToPath( import.meta.url ) ); 15 + const page = readFileSync( join( here, './index.astro' ), 'utf8' ); 16 + 17 + describe( 'publication page Open Graph wiring', () => { 18 + it( 'passes the publication logo (or default) as the Base share image', () => { 19 + expect( page ).toMatch( /image=\{\s*logoUrl\s*\?\?\s*undefined\s*\}/ ); 20 + } ); 21 + 22 + it( 'passes the publication name as the image alt', () => { 23 + expect( page ).toMatch( /imageAlt=\{\s*publication\.name\s*\}/ ); 24 + } ); 25 + } );
+9 -1
src/pages/[author]/[slug]/index.astro
··· 6 6 import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 7 import { fetchActorProfile } from '../../../lib/reader/profile'; 8 8 import { buildGetBlobUrl } from '../../../lib/media/blob'; 9 + import { themeStyleBlock } from '../../../lib/publish/themes'; 9 10 10 11 export const prerender = false; 11 12 ··· 56 57 const authorName = profile.displayName ?? `@${ handle }`; 57 58 const initial = publication.name.charAt( 0 ).toUpperCase(); 58 59 const feedHref = `/${ author }/${ slug }/rss.xml`; 60 + const themeStyle = themeStyleBlock( publication.basicTheme ); 59 61 --- 60 62 61 - <Base title={`${ publication.name } — SkyPress`} description={publication.description ?? undefined}> 63 + <Base 64 + title={`${ publication.name } — SkyPress`} 65 + description={publication.description ?? undefined} 66 + image={logoUrl ?? undefined} 67 + imageAlt={publication.name} 68 + > 62 69 <Fragment slot="head"> 63 70 <link rel="site.standard.publication" href={publication.uri} /> 64 71 <link rel="alternate" type="application/rss+xml" title={`${ publication.name } — RSS`} href={feedHref} /> 72 + {themeStyle && <Fragment set:html={themeStyle} />} 65 73 </Fragment> 66 74 67 75 <header class="masthead">
+104
src/pages/dashboard.astro
··· 186 186 font-family: var(--font-mono); 187 187 font-size: 0.78rem; 188 188 } 189 + .dash__foreign { 190 + margin-top: 2.5rem; 191 + padding-top: 1.5rem; 192 + border-top: 1px solid var(--line); 193 + } 194 + .dash__h2 { 195 + font-size: 1.15rem; 196 + margin: 0 0 0.25rem; 197 + } 198 + .dash__foreign-note { 199 + color: var(--muted); 200 + font-size: 0.9rem; 201 + margin: 0 0 0.5rem; 202 + } 203 + .dash__foreign .dash__pub { 204 + opacity: 0.85; 205 + } 206 + .dash__pubhost { 207 + display: inline-block; 208 + align-self: flex-start; 209 + color: var(--muted); 210 + font-family: var(--font-mono); 211 + font-size: 0.72rem; 212 + padding: 0.05rem 0.4rem; 213 + border: 1px solid var(--line); 214 + border-radius: 999px; 215 + background: var(--paper-raised); 216 + } 189 217 .dash__pubactions { 190 218 display: flex; 191 219 align-items: center; ··· 350 378 .pubform__error { 351 379 color: var(--ember); 352 380 font-size: 0.9rem; 381 + } 382 + .pubform__themes { 383 + border: 0; 384 + margin: 0 0 1rem; 385 + padding: 0; 386 + min-width: 0; 387 + } 388 + .pubform__themes legend { 389 + font-size: 0.85rem; 390 + font-weight: 600; 391 + padding: 0; 392 + margin-bottom: 0.4rem; 393 + } 394 + .pubform__themes small { 395 + display: block; 396 + margin-top: 0.5rem; 397 + font-size: 0.82rem; 398 + color: var(--muted); 399 + } 400 + .pubform__theme-grid { 401 + display: grid; 402 + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); 403 + gap: 0.6rem; 404 + } 405 + .pubform__theme { 406 + display: flex; 407 + flex-direction: column; 408 + align-items: center; 409 + gap: 0.35rem; 410 + cursor: pointer; 411 + } 412 + .pubform__theme input { 413 + position: absolute; 414 + width: 1px; 415 + height: 1px; 416 + opacity: 0; 417 + pointer-events: none; 418 + } 419 + .pubform__theme-swatch { 420 + display: flex; 421 + align-items: center; 422 + justify-content: center; 423 + gap: 0.3rem; 424 + width: 100%; 425 + aspect-ratio: 16 / 10; 426 + border-radius: var(--radius-sm); 427 + border: 2px solid var(--line-strong); 428 + font-family: var(--font-display); 429 + font-weight: 600; 430 + font-size: 0.95rem; 431 + } 432 + .pubform__theme.is-selected .pubform__theme-swatch { 433 + border-color: var(--sun); 434 + box-shadow: 0 0 0 2px var(--sun); 435 + } 436 + .pubform__theme input:focus-visible + .pubform__theme-swatch { 437 + outline: 2px solid var(--sun); 438 + outline-offset: 2px; 439 + } 440 + .pubform__theme-swatch--none { 441 + background: repeating-linear-gradient( 442 + 45deg, 443 + var(--panel), 444 + var(--panel) 6px, 445 + var(--paper-raised) 6px, 446 + var(--paper-raised) 12px 447 + ); 448 + } 449 + .pubform__theme-dot { 450 + width: 0.7rem; 451 + height: 0.7rem; 452 + border-radius: 50%; 453 + } 454 + .pubform__theme-label { 455 + font-size: 0.8rem; 456 + color: var(--ink-soft); 353 457 } 354 458 .pubform__actions { 355 459 display: flex;
+71 -16
src/pages/index.astro
··· 2 2 import Base from '../layouts/Base.astro'; 3 3 import Logo from '../components/Logo.astro'; 4 4 import Footer from '../components/Footer.astro'; 5 - import AuthorPill from '../components/AuthorPill.tsx'; 5 + import AccountMenu from '../components/AccountMenu.tsx'; 6 6 import HandleStart from '../components/HandleStart'; 7 7 import { PHASES, DEFAULT_PHASE } from '../lib/landing/time-of-day'; 8 8 ··· 44 44 <header class="masthead"> 45 45 <Logo /> 46 46 <div class="masthead__right"> 47 - <AuthorPill client:only="react" /> 48 - <a class="btn btn--ghost" href="/dashboard">Dashboard</a> 47 + <a class="btn btn--ghost masthead-write" href="/editor">Write</a> 48 + <AccountMenu client:only="react" /> 49 49 </div> 50 50 </header> 51 51 ··· 242 242 border-color: var(--sky-ink); 243 243 } 244 244 245 - /* Signed-in author pill — shares the frosted sky-chip treatment + button radius. 246 - AuthorPill is a `client:only` React island, so Astro's scoped styles never 247 - reach its DOM; target it with `:global()`. The `.masthead` prefix stays scoped 248 - so the pill still inherits the per-phase `--sky-*` custom properties. */ 249 - .masthead :global(.authorpill) { 245 + /* Signed-in account menu — frosted sky-chip trigger + dropdown. AccountMenu is 246 + a `client:only` React island, so Astro's scoped styles never reach its DOM; 247 + target it with `:global()`. The `.masthead` prefix stays scoped so it still 248 + inherits the per-phase `--sky-*` custom properties. */ 249 + .masthead :global(.account-menu) { 250 + position: relative; 251 + } 252 + .masthead :global(.account-menu__trigger) { 250 253 display: inline-flex; 251 254 align-items: center; 252 255 gap: 0.55rem; ··· 255 258 background: var(--sky-chip); 256 259 border: 1px solid var(--sky-line); 257 260 color: var(--sky-ink); 258 - text-decoration: none; 261 + font: inherit; 262 + cursor: pointer; 263 + text-shadow: var(--sky-shadow); 259 264 backdrop-filter: blur(8px); 260 265 -webkit-backdrop-filter: blur(8px); 261 - text-shadow: var(--sky-shadow); 262 266 } 263 - .masthead :global(.authorpill:hover) { 267 + .masthead :global(.account-menu__trigger:hover) { 264 268 border-color: var(--sky-ink); 265 269 } 266 - .masthead :global(.authorpill__avatar) { 270 + .masthead :global(.account-menu__avatar) { 267 271 width: 30px; 268 272 height: 30px; 269 273 border-radius: 50%; 270 274 object-fit: cover; 271 275 flex: none; 272 276 } 273 - .masthead :global(.authorpill__avatar--fallback) { 277 + .masthead :global(.account-menu__avatar--fallback) { 274 278 display: inline-flex; 275 279 align-items: center; 276 280 justify-content: center; ··· 279 283 font-weight: 700; 280 284 font-size: 0.85rem; 281 285 } 282 - .masthead :global(.authorpill__who) { 286 + .masthead :global(.account-menu__who) { 283 287 display: flex; 284 288 flex-direction: column; 285 289 line-height: 1.1; 290 + text-align: left; 286 291 } 287 - .masthead :global(.authorpill__name) { 292 + .masthead :global(.account-menu__name) { 288 293 font-weight: 680; 289 294 font-size: 0.86rem; 290 295 } 291 - .masthead :global(.authorpill__handle) { 296 + .masthead :global(.account-menu__handle) { 292 297 font-size: 0.72rem; 293 298 opacity: 0.8; 294 299 } 300 + .masthead :global(.account-menu__dropdown) { 301 + position: absolute; 302 + top: calc(100% + 0.4rem); 303 + right: 0; 304 + min-width: 11rem; 305 + display: flex; 306 + flex-direction: column; 307 + padding: 0.3rem; 308 + border-radius: var(--radius-sm); 309 + background: var(--paper-raised); 310 + border: 1px solid var(--line-strong); 311 + box-shadow: var(--shadow); 312 + z-index: 5; 313 + animation: account-menu-in 0.14s ease; 314 + } 315 + /* Transparent bridge across the visual gap below the trigger, so moving the 316 + cursor from the trigger into the dropdown never leaves `.account-menu` (which 317 + would fire mouseleave and close the menu before the pointer arrives). */ 318 + .masthead :global(.account-menu__dropdown)::before { 319 + content: ''; 320 + position: absolute; 321 + left: 0; 322 + right: 0; 323 + top: -0.4rem; 324 + height: 0.4rem; 325 + } 326 + .masthead :global(.account-menu__item) { 327 + padding: 0.5rem 0.7rem; 328 + border-radius: calc(var(--radius-sm) - 2px); 329 + color: var(--ink); 330 + font-size: 0.88rem; 331 + text-decoration: none; 332 + } 333 + .masthead :global(.account-menu__item:hover) { 334 + background: var(--line); 335 + color: var(--ink); 336 + } 337 + @keyframes account-menu-in { 338 + from { 339 + opacity: 0; 340 + transform: translateY(-4px); 341 + } 342 + } 295 343 296 344 .masthead { 297 345 position: relative; ··· 305 353 display: flex; 306 354 align-items: center; 307 355 gap: 0.75rem; 356 + } 357 + /* AccountMenu sets data-signed-in once a session restores; hide the static link then. */ 358 + .masthead__right[data-signed-in] .masthead-write { 359 + display: none; 308 360 } 309 361 .hero { 310 362 position: relative; ··· 476 528 .shootingstar { animation: none; opacity: 0; } 477 529 .sky .stars, .sky .bloom, .sky .halo { transition: none; } 478 530 .hero :global(.handlestart__spinner) { animation: none; } 531 + .masthead :global(.account-menu__dropdown) { 532 + animation: none; 533 + } 479 534 } 480 535 </style>
src/pages/index.phase.test.ts src/pages/_index.phase.test.ts
+21 -4
src/styles/editor-chrome.css
··· 243 243 display: flex; 244 244 gap: 0.5rem; 245 245 } 246 - .myarticles__actions button { 246 + .myarticles__actions button, 247 + .myarticles__actions a { 247 248 border: 1px solid var(--line-strong); 248 249 background: var(--paper-raised); 249 250 border-radius: 6px; ··· 251 252 font: inherit; 252 253 font-size: 0.85rem; 253 254 cursor: pointer; 255 + } 256 + .myarticles__actions a { 257 + color: inherit; 258 + text-decoration: none; 254 259 } 255 260 .studio__mode { 256 261 display: flex; ··· 288 293 color: var(--ink); 289 294 box-shadow: var(--shadow); 290 295 } 296 + /* With `has-fixed-toolbar`, the fixed toolbar sits at the top of the layout, 297 + flush against the framed surface's top edge. Pad the header region (which 298 + holds the toolbar) so the tools aren't pinned to the border. Keeping the 299 + breathing room on the header — rather than on the layout root or the content 300 + below — gives a single, predictable source of top spacing. */ 301 + .skypress-editor .iso-editor .interface-interface-skeleton__header { 302 + padding-top: 1rem; 303 + } 291 304 .skypress-editor .iso-editor .edit-post-visual-editor { 292 305 background-color: transparent; 293 - /* Breathing room above the first block so the toolbar isn't flush to the 294 - top edge of the framed surface. */ 295 - padding-top: 0.5rem; 296 306 } 297 307 /* Gutenberg sets `background: white` inline on the device-preview wrapper; 298 308 only !important lets the paper surface show through (esp. in dark mode). */ ··· 325 335 .skypress-editor .iso-editor .block-editor-block-contextual-toolbar { 326 336 padding-left: 0.75rem; 327 337 padding-right: 0.75rem; 338 + } 339 + /* The "Show/Hide block tools" collapse toggle (`<<`/`>>`). The header toolbar 340 + stacks its rows vertically, so this toggle lands orphaned on its own line — 341 + and it's redundant here anyway: the block tools auto-expand whenever a block 342 + is selected, so the toggle never has a useful effect. Hide it. */ 343 + .skypress-editor .iso-editor .edit-post-header__block-tools-toggle { 344 + display: none; 328 345 } 329 346 330 347 .skypress-editor__status {
+4 -2
src/styles/global.css
··· 39 39 white text. Bare --sun is too light for white text (~2.5:1). */ 40 40 --btn-primary: #b85c12; 41 41 --btn-primary-hover: #9a4c0f; 42 + --btn-primary-fg: #fff; 42 43 --ember: #bb5a36; /* rare warm accent (the ink/press counterweight) */ 43 44 44 45 --radius: 12px; ··· 63 64 /* Dark mode's accents are light, so white text still needs a deeper fill. */ 64 65 --btn-primary: #aa6010; 65 66 --btn-primary-hover: #964f0c; 67 + --btn-primary-fg: #fff; 66 68 --ember: #e08a63; 67 69 --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 18px 40px -16px rgba(0, 0, 0, 0.6); 68 70 } ··· 142 144 } 143 145 .btn--primary { 144 146 background: var(--btn-primary); 145 - color: #fff; 147 + color: var(--btn-primary-fg); 146 148 } 147 149 .btn--primary:hover { 148 150 background: var(--btn-primary-hover); 149 - color: #fff; 151 + color: var(--btn-primary-fg); 150 152 } 151 153 .btn--ghost { 152 154 background: var(--paper-raised);
+1 -1
vitest.config.ts
··· 5 5 globals: true, 6 6 // jsdom: @wordpress/block-library block registration touches browser globals. 7 7 environment: 'jsdom', 8 - include: [ 'src/**/*.test.ts' ], 8 + include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx' ], 9 9 }, 10 10 resolve: { 11 11 dedupe: [ 'react', 'react-dom', '@wordpress/element' ],