···191920201. Upload to the writer's PDS: `agent.uploadBlob(file, { encoding: file.type })` →
2121 `BlobRef`.
2222-2. Build a fetchable URL — `getBlob` on the writer's PDS for their DID + the blob CID —
2323- and hand it back to the editor via `onFileChange([{ id: cid, url, alt }])` so the
2424- image previews immediately.
2525-3. **Retain the full blob ref** in an in-memory registry keyed by that URL.
2222+2. Hand the editor a **local object URL** (`URL.createObjectURL(file)`) via
2323+ `onFileChange([{ url, alt }])` so the image previews immediately. **No `id`** — PDS
2424+ blobs aren't WP attachments; an `id` makes the Image block fetch `/wp/v2/media/<id>`,
2525+ which 404s.
2626+3. **Retain the blob ref + the canonical `getBlob` URL** in an in-memory registry keyed
2727+ by the object URL.
2828+2929+### Preview via object URL, not the live `getBlob` URL (corrected 2026-06-08)
3030+A just-uploaded blob is **unreferenced** (temporary on the PDS) until a record commits a
3131+reference to it; `com.atproto.sync.getBlob` fails for it (observed: **500** on a
3232+bsky.network PDS). So the original "preview from the live `getBlob` URL" plan below is
3333+wrong for in-editor preview — it only resolves *after* publish. We preview from a local
3434+object URL instead, and `attachBlobRefs` rewrites that transient `blob:` URL to the
3535+canonical `getBlob` URL at publish (so the stored record stays portable, never a dead
3636+object URL). The reader (SP4) still reconstructs the URL from `skypressBlob` + the
3737+author's current PDS.
3838+3939+Each object URL pins its `File` in memory until revoked, so the registry releases them
4040+(`revokeBlobRegistry`) at the points where the editor is torn down and the previews leave
4141+the DOM — switching/starting an article and Studio unmount — never mid-edit, which would
4242+blank a still-displayed preview.
4343+4444+The "Media Library" button (Gutenberg's `editor.MediaUpload`, which opens the legacy
4545+`wp.media` Backbone frame) is **disabled** via a filter override — SkyPress has no media
4646+library; uploads go straight to the PDS through the Upload/drop-zone path. See
4747+`src/lib/media/registerMediaUpload.ts`.
26482749At **publish**, `attachBlobRefs` walks the block tree and, for every image whose `url` is
2850one we uploaded, writes the blob ref (`BlobRef.ipld()` →
···3860 resolving the writer's *current* PDS — so the image survives a PDS migration.
39614062### In-editor URL strategy
4141-Live `getBlob` URL (not a SkyPress media proxy) for v1 — simplest, proves the upload. A
4242-caching/resizing proxy through the edge is a later optimisation (brief §5).
6363+Local object URL for preview; the canonical `getBlob` URL (not a SkyPress media proxy) is
6464+persisted at publish. A caching/resizing proxy through the edge is a later optimisation
6565+(brief §5).
43664467### Externally-URL'd images
4568Allowed **as-is** for v1 (no `skypressBlob`, just their `url`). Only files added through
+4-2
docs/specs/sp3-image-blob-pipeline.md
···1111## Success criteria
121213131. Inserting an image in the editor uploads it to the writer's PDS and previews via a
1414- `getBlob` URL.
1414+ local object URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet
1515+ — see Decision 0006).
15162. On publish, image blocks carry a proper `skypressBlob` ref (`{$type:'blob',…}`) in the
1617 stored `content`, keeping the blob in-use (not GC'd) and portable.
17183. Pure helpers (`getBlob` URL builder, blob-ref attach transform) unit-tested.
···2627 blob.test.ts Vitest unit tests (written first)
2728 pds.ts browser: resolvePdsUrl(did) from the DID document
2829 mediaUpload.ts browser: createMediaUpload({ agent, did, pdsUrl, registry })
3030+ registerMediaUpload.ts browser: disables the wp.media "Media Library" button
2931src/lib/auth/AuthProvider.tsx resolves + exposes `pdsUrl` on sign-in
3032src/components/Studio.tsx owns the blob registry; wires mediaUpload + publish
3133src/components/SkyEditor.tsx accepts `mediaUpload`, sets settings.editor.mediaUpload
···5052| Check | Result |
5153|---|---|
5254| 25 unit tests (incl. blob helpers) | pass |
5353-| Editor upload | `mediaUpload` → `uploadBlob` → image previews via the PDS `getBlob` URL |
5555+| Editor upload | `mediaUpload` → `uploadBlob` → image previews via a local object URL |
5456| Stored content | image block carries `skypressBlob` = `{$type:'blob', ref:{$link:'bafkrei…'}, mimeType:'image/png', size:70}` |
5557| Blob served | `getBlob` returns the bytes (200, 70 bytes) |
5658| **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) |
+9-1
src/components/SkyEditor.tsx
···11import { useCallback, useState } from 'react';
22import IsolatedBlockEditor from '@automattic/isolated-block-editor';
33+// Side-effect import: must run AFTER isolated-block-editor so our editor.MediaUpload
44+// filter registers last and replaces the bundled wp.media-based one (see the module).
55+import '../lib/media/registerMediaUpload';
36import { createBlock, type BlockInstance } from '@wordpress/blocks';
47import type { BlockNode } from '../lib/blocks/render';
58···4245 * reading pages (Decision 0001).
4346 */
4447export default function SkyEditor( { onChange, mediaUpload, initialBlocks }: SkyEditorProps ) {
4545- const [ status, setStatus ] = useState< string >( 'Start writing…' );
4848+ // Empty until the first autosave. The in-editor placeholder already prompts
4949+ // the writer; this line is reserved for save feedback ("Draft saved · …").
5050+ // Kept in the DOM as an `aria-live` region so updates are announced; the
5151+ // `:empty` rule clips it out of view (not `display: none`, which would drop
5252+ // it from the accessibility tree and suppress the announcement) while idle.
5353+ const [ status, setStatus ] = useState< string >( '' );
46544755 // Load existing content (editing) by rebuilding block instances from the stored tree.
4856 // Core blocks are registered globally by the time onLoad runs.
+8-1
src/components/Studio.tsx
···66import SkyEditor from './SkyEditor';
77import PublishPanel from './PublishPanel';
88import MyArticles from './MyArticles';
99-import { createMediaUpload, type BlobRegistry } from '../lib/media/mediaUpload';
99+import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload';
1010import { displayNameFor, authorPath } from '../lib/auth/profile';
1111import type { MyArticle } from '../lib/publish/publisher';
1212import { listPublications, type Publication } from '../lib/publish/publications';
···4040 };
4141 }, [ agent, did, refreshKey ] );
42424343+ // Release the preview object URLs this session minted when the Studio unmounts.
4444+ useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] );
4545+4346 const mediaUpload = useMemo( () => {
4447 if ( ! agent || ! did || ! pdsUrl ) {
4548 return undefined;
···5760 const viewerName = displayNameFor( { did, handle, displayName, avatar } );
5861 const publicPath = authorPath( handle );
59626363+ // Switching articles re-mounts the editor, so the current previews leave the DOM —
6464+ // safe to release the object URLs they held before loading the next article.
6065 const startEdit = ( article: MyArticle ) => {
6666+ revokeBlobRegistry( registry );
6167 setEditing( article );
6268 setBlocks( article.blocks as unknown as BlockInstance[] );
6369 };
6470 const startNew = () => {
7171+ revokeBlobRegistry( registry );
6572 setEditing( null );
6673 setBlocks( [] );
6774 };
+8-2
src/layouts/Base.astro
···44interface Props {
55 title: string;
66 description?: string;
77+ /**
88+ * Optional landing-page sky phase. Rendered as `data-phase` on `<html>` so the
99+ * no-JS / pre-paint default has a sky; the inline head script then overwrites it
1010+ * from the visitor's clock. `<html>` is the single phase carrier — see index.astro.
1111+ */
1212+ phase?: string;
713}
88-const { title, description } = Astro.props;
1414+const { title, description, phase } = Astro.props;
915---
10161117<!doctype html>
1212-<html lang="en">
1818+<html lang="en" data-phase={phase}>
1319 <head>
1420 <meta charset="utf-8" />
1521 <meta name="viewport" content="width=device-width, initial-scale=1" />
···1111 size: number;
1212}
13131414+/**
1515+ * What a session upload records for each previewed image. The editor previews from a
1616+ * transient object URL (`blob:…`); `attachBlobRefs` looks that key up at publish and
1717+ * persists the portable `url` (a `getBlob` URL) plus the typed `ref` (`skypressBlob`).
1818+ */
1919+export interface BlobUpload {
2020+ ref: BlobRefJson;
2121+ url: string;
2222+}
2323+1424/** Block names whose `url` attribute may reference an uploaded blob. */
1525const IMAGE_BLOCKS = new Set( [ 'core/image' ] );
1626···49595060/**
5161 * Attach the stored blob ref (`skypressBlob`) to every image block whose `url` was
5252- * uploaded this session (resolved via `lookup`). Returns a new tree; does not mutate
6262+ * uploaded this session (resolved via `lookup`), and rewrite that `url` to the canonical,
6363+ * portable `getBlob` URL — the editor previewed from a transient object URL which would
6464+ * otherwise be persisted as a dead `blob:` string. Returns a new tree; does not mutate
5365 * the input. Images without a matching upload (e.g. external URLs) are left as-is, so
5466 * the reader keeps their `url`.
5567 */
5668export function attachBlobRefs(
5769 blocks: BlockNode[],
5858- lookup: ( url: string ) => BlobRefJson | undefined
7070+ lookup: ( url: string ) => BlobUpload | undefined
5971): BlockNode[] {
6072 return blocks.map( ( block ) => {
6173 const url = block.attributes?.url;
6262- const ref = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string'
7474+ const upload = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string'
6375 ? lookup( url )
6476 : undefined;
6577 return {
6678 name: block.name,
6767- attributes: ref
6868- ? { ...block.attributes, skypressBlob: ref }
7979+ attributes: upload
8080+ ? { ...block.attributes, skypressBlob: upload.ref, url: upload.url }
6981 : { ...block.attributes },
7082 innerBlocks: attachBlobRefs( block.innerBlocks ?? [], lookup ),
7183 };
+109
src/lib/media/mediaUpload.test.ts
···11+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22+import type { Agent } from '@atproto/api';
33+import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from './mediaUpload';
44+55+/** A fake `uploadBlob` response shaped like the atproto BlobRef the agent returns. */
66+function fakeUploadResult( cid: string, mimeType: string, size: number ) {
77+ return { data: { blob: { ref: { toString: () => cid }, mimeType, size } } };
88+}
99+1010+describe( 'createMediaUpload', () => {
1111+ beforeEach( () => {
1212+ // jsdom doesn't implement object URLs; stub a deterministic one per call.
1313+ let n = 0;
1414+ vi.stubGlobal( 'URL', {
1515+ ...URL,
1616+ createObjectURL: vi.fn( () => `blob:preview-${ ++n }` ),
1717+ } );
1818+ } );
1919+ afterEach( () => vi.unstubAllGlobals() );
2020+2121+ function setup() {
2222+ const registry: BlobRegistry = new Map();
2323+ const uploadBlob = vi
2424+ .fn()
2525+ .mockResolvedValue( fakeUploadResult( 'bafycid123', 'image/png', 70 ) );
2626+ const agent = { uploadBlob } as unknown as Agent;
2727+ const handler = createMediaUpload( {
2828+ agent,
2929+ did: 'did:plc:abc',
3030+ pdsUrl: 'https://pds.example.com',
3131+ registry,
3232+ } );
3333+ return { registry, uploadBlob, handler };
3434+ }
3535+3636+ it( 'uploads to the PDS, previews via an object URL, and omits any attachment id', async () => {
3737+ const { uploadBlob, handler } = setup();
3838+ const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } );
3939+ const onFileChange = vi.fn();
4040+4141+ await handler( { filesList: [ file ], onFileChange } );
4242+4343+ expect( uploadBlob ).toHaveBeenCalledWith( file, { encoding: 'image/png' } );
4444+ expect( onFileChange ).toHaveBeenCalledTimes( 1 );
4545+ const [ media ] = onFileChange.mock.calls[ 0 ][ 0 ];
4646+ // Preview src is the local object URL — never the live getBlob URL (unreferenced
4747+ // blobs 500 on com.atproto.sync.getBlob until a record commits them).
4848+ expect( media.url ).toBe( 'blob:preview-1' );
4949+ // No `id`: PDS blobs are not WP attachments, so the image block must not try to
5050+ // fetch /wp/v2/media/<id> (that 404s).
5151+ expect( 'id' in media ).toBe( false );
5252+ } );
5353+5454+ it( 'registers the blob ref + canonical getBlob URL keyed by the preview URL', async () => {
5555+ const { registry, handler } = setup();
5656+ const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } );
5757+5858+ await handler( { filesList: [ file ], onFileChange: vi.fn() } );
5959+6060+ expect( registry.get( 'blob:preview-1' ) ).toEqual( {
6161+ ref: {
6262+ $type: 'blob',
6363+ ref: { $link: 'bafycid123' },
6464+ mimeType: 'image/png',
6565+ size: 70,
6666+ },
6767+ url: 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafycid123',
6868+ } );
6969+ } );
7070+7171+ it( 'surfaces oversize files via onError and does not upload them', async () => {
7272+ const { uploadBlob, handler } = setup();
7373+ const big = new File( [ 'x' ], 'big.png', { type: 'image/png' } );
7474+ Object.defineProperty( big, 'size', { value: 5_000_000 } );
7575+ const onError = vi.fn();
7676+7777+ await handler( {
7878+ filesList: [ big ],
7979+ onFileChange: vi.fn(),
8080+ onError,
8181+ maxUploadFileSize: 1_000_000,
8282+ } );
8383+8484+ expect( uploadBlob ).not.toHaveBeenCalled();
8585+ expect( onError ).toHaveBeenCalledTimes( 1 );
8686+ expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error );
8787+ } );
8888+} );
8989+9090+describe( 'revokeBlobRegistry', () => {
9191+ it( 'revokes every preview object URL and empties the registry', () => {
9292+ const revoke = vi.fn();
9393+ vi.stubGlobal( 'URL', { ...URL, revokeObjectURL: revoke } );
9494+ const ref = { $type: 'blob' as const, ref: { $link: 'cid' }, mimeType: 'image/png', size: 1 };
9595+ const registry: BlobRegistry = new Map( [
9696+ [ 'blob:preview-1', { ref, url: 'https://pds.example.com/a' } ],
9797+ [ 'blob:preview-2', { ref, url: 'https://pds.example.com/b' } ],
9898+ ] );
9999+100100+ revokeBlobRegistry( registry );
101101+102102+ expect( revoke ).toHaveBeenCalledTimes( 2 );
103103+ expect( revoke ).toHaveBeenCalledWith( 'blob:preview-1' );
104104+ expect( revoke ).toHaveBeenCalledWith( 'blob:preview-2' );
105105+ expect( registry.size ).toBe( 0 );
106106+107107+ vi.unstubAllGlobals();
108108+ } );
109109+} );
+36-13
src/lib/media/mediaUpload.ts
···11import type { Agent } from '@atproto/api';
22-import { buildGetBlobUrl, type BlobRefJson } from './blob';
22+import { buildGetBlobUrl, type BlobUpload } from './blob';
33+44+/** Maps a preview (object) URL → its blob ref + canonical getBlob URL for this session. */
55+export type BlobRegistry = Map< string, BlobUpload >;
3644-/** Maps a returned `getBlob` URL → its stored blob ref (filled during this session). */
55-export type BlobRegistry = Map< string, BlobRefJson >;
77+/**
88+ * Release the preview object URLs held by `registry` and empty it. Each preview URL was
99+ * minted with `URL.createObjectURL`, which pins its `File` in memory until revoked — so
1010+ * an editing session that uploads many images would otherwise leak them all until the
1111+ * page unloads. Call this only when those previews are no longer on screen (the editor is
1212+ * being torn down for a new/other article, or unmounted), never mid-edit.
1313+ */
1414+export function revokeBlobRegistry( registry: BlobRegistry ): void {
1515+ for ( const previewUrl of registry.keys() ) {
1616+ URL.revokeObjectURL( previewUrl );
1717+ }
1818+ registry.clear();
1919+}
620721interface MediaFile {
88- id: string;
922 url: string;
1023 alt: string;
1124}
···20332134/**
2235 * A Gutenberg `mediaUpload` handler backed by atproto blobs (Decision 0006):
2323- * upload each file to the writer's PDS, hand back a `getBlob` URL for preview, and
2424- * record the full blob ref in `registry` so the publish step can persist it.
3636+ * upload each file to the writer's PDS, hand back a local object URL for preview, and
3737+ * record the blob ref + canonical `getBlob` URL in `registry` so publish can persist them.
3838+ *
3939+ * Preview uses a local object URL rather than the live `getBlob` URL because a
4040+ * just-uploaded blob is unreferenced (temporary on the PDS) until a record commits it —
4141+ * `com.atproto.sync.getBlob` fails for it, so an inline `getBlob` preview would 500.
2542 */
2643export type MediaUploadHandler = ( args: MediaUploadArgs ) => Promise< void >;
2744···4865 const res = await agent.uploadBlob( file, { encoding: file.type } );
4966 const { blob } = res.data;
5067 const cid = blob.ref.toString();
5151- const url = buildGetBlobUrl( pdsUrl, did, cid );
5252- registry.set( url, {
5353- $type: 'blob',
5454- ref: { $link: cid },
5555- mimeType: blob.mimeType,
5656- size: blob.size,
6868+ // Preview from a local object URL; persist the portable getBlob URL on publish.
6969+ const previewUrl = URL.createObjectURL( file );
7070+ registry.set( previewUrl, {
7171+ ref: {
7272+ $type: 'blob',
7373+ ref: { $link: cid },
7474+ mimeType: blob.mimeType,
7575+ size: blob.size,
7676+ },
7777+ url: buildGetBlobUrl( pdsUrl, did, cid ),
5778 } );
5858- onFileChange( [ { id: cid, url, alt: '' } ] );
7979+ // No `id`: PDS blobs aren't WP attachments, so the image block must not try
8080+ // to resolve one via /wp/v2/media/<id> (that 404s).
8181+ onFileChange( [ { url: previewUrl, alt: '' } ] );
5982 } catch ( error ) {
6083 onError?.( error instanceof Error ? error : new Error( String( error ) ) );
6184 }
+17
src/lib/media/registerMediaUpload.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { applyFilters } from '@wordpress/hooks';
33+// Side-effect import: registers the editor.MediaUpload override.
44+import './registerMediaUpload';
55+66+describe( 'registerMediaUpload', () => {
77+ it( 'replaces editor.MediaUpload with an inert, non-rendering component', () => {
88+ // A stand-in for whatever component was registered before us (in the app, the
99+ // wp.media-based one from isolated-block-editor).
1010+ const Bundled = () => 'wp.media frame';
1111+ const Filtered = applyFilters( 'editor.MediaUpload', Bundled ) as () => unknown;
1212+1313+ expect( Filtered ).not.toBe( Bundled );
1414+ // Renders nothing → no "Media Library" button, no wp.media access.
1515+ expect( Filtered() ).toBeNull();
1616+ } );
1717+} );
+23
src/lib/media/registerMediaUpload.ts
···11+import { addFilter } from '@wordpress/hooks';
22+33+/**
44+ * Disable Gutenberg's WordPress media library in the SkyPress editor.
55+ *
66+ * `@automattic/isolated-block-editor` bundles `@wordpress/editor`'s media-upload hook,
77+ * which registers the `@wordpress/media-utils` `MediaUpload` component on the
88+ * `editor.MediaUpload` filter. That component opens the legacy Backbone media frame via
99+ * `wp.media(...)` — a global SkyPress never loads — so the Image block's "Media Library"
1010+ * button throws (`can't access property "media", … is undefined`).
1111+ *
1212+ * SkyPress has no media library: images upload straight to the writer's PDS as blobs
1313+ * (Decision 0006) through the "Upload" / drop-zone path, which uses the custom
1414+ * `settings.editor.mediaUpload` handler and is independent of this component. So we
1515+ * override the filter with a no-render component, removing the broken (and inapplicable)
1616+ * "Media Library" button everywhere it appears (placeholder, Replace flow).
1717+ *
1818+ * Importing this module registers the filter as a side effect. It must be imported AFTER
1919+ * `isolated-block-editor` so our filter runs last and wins.
2020+ */
2121+const NoMediaLibrary = (): null => null;
2222+2323+addFilter( 'editor.MediaUpload', 'skypress/disable-media-library', () => NoMediaLibrary );
···11+/**
22+ * Regression guard for the landing-page sky phase carrier (docs/specs/sp8-brand-first-light.md).
33+ *
44+ * `data-phase` lives on exactly one carrier: `<html>`, the only element the pre-paint inline head
55+ * script can reach (it runs in `<head>`, before `.page` exists, so it sets `documentElement`).
66+ *
77+ * The catch: the per-phase rules — `[data-phase='x'] .sky` / `.masthead` / `.hero` — live in
88+ * index.astro's *scoped* `<style>`. Astro appends index.astro's scope id to every part of a
99+ * selector, including the ancestor, yielding `[cid][data-phase='x'] .sky[cid]`. But `<html>` is
1010+ * rendered by Base.astro and never carries index.astro's cid, so a bare (scoped) ancestor matches
1111+ * nothing and the sky renders unstyled. Wrapping the ancestor in `:global([data-phase='x'])` keeps
1212+ * the descendant (`.sky`/`.masthead`/`.hero`) scoped while letting the ancestor match `<html>`.
1313+ *
1414+ * These asserts pin the wiring at the source level — rendering the page through astro/container
1515+ * isn't viable here (the test runner is pinned to jsdom for the WordPress block suites, which
1616+ * breaks esbuild's init invariant).
1717+ */
1818+import { readFileSync } from 'node:fs';
1919+import { fileURLToPath } from 'node:url';
2020+import { describe, expect, it } from 'vitest';
2121+import { phaseForHour } from '../lib/landing/time-of-day';
2222+2323+const read = ( rel: string ) =>
2424+ readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' );
2525+2626+/** Strip the `---` frontmatter and any <style>/<script> blocks, leaving only rendered markup. */
2727+const markupOnly = ( astro: string ) =>
2828+ astro
2929+ .replace( /^---[\s\S]*?\n---/, '' )
3030+ .replace( /<style[\s\S]*?<\/style>/g, '' )
3131+ .replace( /<script[\s\S]*?<\/script>/g, '' );
3232+3333+describe( 'landing page sky phase carrier', () => {
3434+ const index = read( './index.astro' );
3535+ const base = read( '../layouts/Base.astro' );
3636+3737+ it( 'carries the phase on <html> (the no-JS / pre-paint default the head script overwrites)', () => {
3838+ const htmlTag = markupOnly( base ).match( /<html\b[^>]*>/ )?.[ 0 ];
3939+ expect( htmlTag ).toBeDefined();
4040+ expect( htmlTag ).toMatch( /data-phase=/ );
4141+ } );
4242+4343+ it( 'passes the default phase from the landing page into the layout', () => {
4444+ expect( index ).toMatch( /phase=\{\s*DEFAULT_PHASE\s*\}/ );
4545+ } );
4646+4747+ it( 'never puts data-phase on a body element that would shadow <html> in the cascade', () => {
4848+ // Every element in the page markup is a descendant of <html>; none may carry its own
4949+ // data-phase. (The head <script> assigns it via documentElement, never as markup.)
5050+ expect( markupOnly( index ) ).not.toMatch( /data-phase=/ );
5151+ } );
5252+5353+ it( 'updates the phase before first paint via documentElement', () => {
5454+ expect( index ).toMatch( /document\.documentElement\.dataset\.phase\s*=/ );
5555+ } );
5656+5757+ it( 'wraps every per-phase ancestor selector in :global() so it can match <html>', () => {
5858+ // The phase carrier is <html> (rendered by Base.astro), which never carries index.astro's
5959+ // scoped cid. A bare `[data-phase=...]` ancestor would be scoped to that cid and match
6060+ // nothing; only `:global([data-phase=...])` reaches <html>. Assert that EVERY data-phase
6161+ // selector is global-wrapped — a single scoped one silently blanks the sky.
6262+ const style = index.match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? '';
6363+ const total = ( style.match( /\[data-phase=/g ) ?? [] ).length;
6464+ const global = ( style.match( /:global\(\s*\[data-phase=/g ) ?? [] ).length;
6565+ expect( total, 'expected per-phase selectors in the landing-page styles' ).toBeGreaterThan( 0 );
6666+ expect( global, 'every [data-phase=...] selector must be :global()-wrapped' ).toBe( total );
6767+ } );
6868+6969+ // The inline head script must run before any module loads (for a no-flash sky), so it
7070+ // can't import phaseForHour — it hand-mirrors the same hour->phase boundaries. This guard
7171+ // keeps that copy honest: extract its `var p = <ternary>` expression straight from source
7272+ // and assert it agrees with phaseForHour for every hour. (The source comment in
7373+ // time-of-day.ts promises exactly this test.)
7474+ it( 'mirrors the phaseForHour boundaries in the inline head script', () => {
7575+ const expr = index.match( /var p\s*=\s*([\s\S]*?);/ )?.[ 1 ];
7676+ expect( expr, 'inline head script should assign `var p = <phase ternary>;`' ).toBeDefined();
7777+ // Lock the expression to a phase ternary over `h` before evaluating it: only the variable
7878+ // `h`, integer literals, comparison/logical/ternary operators, and quoted phase words are
7979+ // allowed. This makes the `new Function` below incapable of running anything else, even if
8080+ // the source were ever changed to something unexpected.
8181+ expect( expr, 'unexpected tokens in inline phase expression' ).toMatch(
8282+ /^[\sa-z\d<>=|?:'"]+$/
8383+ );
8484+ // eslint-disable-next-line no-new-func -- expr is validated above to be a pure phase ternary.
8585+ const scriptPhase = new Function( 'h', `return ( ${ expr } );` ) as ( h: number ) => string;
8686+ for ( let h = 0; h < 24; h++ ) {
8787+ expect( scriptPhase( h ), `hour ${ h }` ).toBe( phaseForHour( h ) );
8888+ }
8989+ } );
9090+} );
+389
src/styles/editor-chrome.css
···11+/**
22+ * Studio (editor) chrome styles.
33+ *
44+ * The Studio is a `client:only` React island, so Astro's component-scoped styles
55+ * never reach its DOM — these rules must be global. Shared by the real editor
66+ * page (`src/pages/editor.astro`).
77+ *
88+ * The signed-in bars (account / articles / mode / publish) and the editor
99+ * surface share one centred content column so they line up with each other.
1010+ */
1111+1212+:root {
1313+ /* Content column shared by the studio bars and the editor surface. */
1414+ --studio-measure: 60rem;
1515+ --studio-gutter: 1.25rem;
1616+}
1717+1818+.studio__loading {
1919+ max-width: var(--studio-measure);
2020+ margin: 0 auto;
2121+ padding: 2rem var(--studio-gutter);
2222+ color: var(--muted);
2323+}
2424+2525+/* Account bar (signed in) */
2626+.studio__account {
2727+ display: flex;
2828+ align-items: center;
2929+ justify-content: space-between;
3030+ gap: 1rem;
3131+ max-width: var(--studio-measure);
3232+ margin: 0 auto;
3333+ padding: 0.5rem var(--studio-gutter);
3434+ background: var(--panel);
3535+ border-radius: var(--radius);
3636+ font-size: 0.9rem;
3737+ flex-wrap: wrap;
3838+}
3939+.studio__identity {
4040+ display: flex;
4141+ align-items: center;
4242+ gap: 0.6rem;
4343+ min-width: 0;
4444+}
4545+.studio__avatar {
4646+ width: 38px;
4747+ height: 38px;
4848+ border-radius: 50%;
4949+ object-fit: cover;
5050+ flex: none;
5151+}
5252+.studio__avatar--fallback {
5353+ display: inline-flex;
5454+ align-items: center;
5555+ justify-content: center;
5656+ background: var(--sun-tint);
5757+ color: var(--sun);
5858+ font-weight: 700;
5959+}
6060+.studio__who {
6161+ display: flex;
6262+ flex-direction: column;
6363+ line-height: 1.15;
6464+ min-width: 0;
6565+}
6666+.studio__name {
6767+ font-weight: 680;
6868+}
6969+.studio__handle {
7070+ color: var(--muted);
7171+ font-size: 0.8rem;
7272+}
7373+.studio__account-actions {
7474+ display: flex;
7575+ align-items: center;
7676+ gap: 0.5rem;
7777+ flex-wrap: wrap;
7878+}
7979+.studio__viewpage {
8080+ color: var(--sun);
8181+ text-decoration: none;
8282+ font-size: 0.85rem;
8383+ padding: 0.3rem 0.5rem;
8484+ border-radius: var(--radius-sm);
8585+}
8686+.studio__viewpage:hover {
8787+ text-decoration: underline;
8888+}
8989+.studio__signout {
9090+ border: 1px solid var(--line-strong);
9191+ background: var(--paper-raised);
9292+ border-radius: var(--radius-sm);
9393+ padding: 0.3rem 0.7rem;
9494+ cursor: pointer;
9595+ font: inherit;
9696+}
9797+9898+/* Login (signed out) */
9999+.studio__login {
100100+ max-width: 30rem;
101101+ margin: 0 auto;
102102+ padding: 4rem 1.5rem;
103103+}
104104+.studio__error,
105105+.login__error {
106106+ color: var(--ember);
107107+ font-size: 0.9rem;
108108+}
109109+.login__title {
110110+ font-size: 1.6rem;
111111+ margin: 0 0 0.5rem;
112112+}
113113+.login__lede {
114114+ color: var(--muted);
115115+ margin: 0 0 1.5rem;
116116+}
117117+.login__label {
118118+ display: block;
119119+ font-size: 0.85rem;
120120+ font-weight: 600;
121121+ margin-bottom: 0.35rem;
122122+}
123123+.login__input {
124124+ width: 100%;
125125+ box-sizing: border-box;
126126+ padding: 0.6rem 0.7rem;
127127+ border: 1px solid var(--line-strong);
128128+ border-radius: 8px;
129129+ font: inherit;
130130+ margin-bottom: 0.85rem;
131131+}
132132+.login__submit {
133133+ width: 100%;
134134+ padding: 0.65rem 1rem;
135135+ border: 0;
136136+ border-radius: 8px;
137137+ background: var(--sun);
138138+ color: #fff;
139139+ font: inherit;
140140+ font-weight: 600;
141141+ cursor: pointer;
142142+}
143143+.login__note {
144144+ color: var(--muted);
145145+ font-size: 0.8rem;
146146+ margin-top: 1rem;
147147+}
148148+149149+/* Publish panel */
150150+.publish {
151151+ display: flex;
152152+ flex-wrap: wrap;
153153+ align-items: center;
154154+ gap: 0.75rem;
155155+ max-width: var(--studio-measure);
156156+ margin: 0 auto;
157157+ padding: 0.75rem var(--studio-gutter);
158158+}
159159+.publish__title {
160160+ flex: 1 1 18rem;
161161+ padding: 0.5rem 0.7rem;
162162+ border: 1px solid var(--line-strong);
163163+ border-radius: 8px;
164164+ font: inherit;
165165+ font-size: 1.05rem;
166166+}
167167+.publish__target {
168168+ display: inline-flex;
169169+ align-items: center;
170170+ gap: 0.4rem;
171171+ font-size: 0.85rem;
172172+ color: var(--muted);
173173+}
174174+.publish__target--fixed strong {
175175+ color: var(--ink);
176176+}
177177+.publish__select {
178178+ padding: 0.45rem 0.6rem;
179179+ border: 1px solid var(--line-strong);
180180+ border-radius: 8px;
181181+ background: var(--paper-raised);
182182+ font: inherit;
183183+ font-size: 0.9rem;
184184+}
185185+.publish__button {
186186+ padding: 0.5rem 1rem;
187187+ border: 0;
188188+ border-radius: 8px;
189189+ background: var(--sun);
190190+ color: #fff;
191191+ font: inherit;
192192+ font-weight: 600;
193193+ cursor: pointer;
194194+}
195195+.publish__button:disabled {
196196+ opacity: 0.5;
197197+ cursor: not-allowed;
198198+}
199199+.publish__cancel {
200200+ padding: 0.5rem 0.9rem;
201201+ border: 1px solid var(--line-strong);
202202+ background: var(--paper-raised);
203203+ border-radius: 8px;
204204+ font: inherit;
205205+ cursor: pointer;
206206+}
207207+.publish__confirm {
208208+ flex: 1 1 100%;
209209+ background: var(--sun-tint);
210210+ border: 1px solid var(--line-strong);
211211+ border-radius: 10px;
212212+ padding: 0.85rem 1rem;
213213+}
214214+.publish__warning {
215215+ margin: 0 0 0.75rem;
216216+}
217217+.publish__actions {
218218+ display: flex;
219219+ gap: 0.75rem;
220220+ flex-wrap: wrap;
221221+}
222222+.publish__result,
223223+.publish__status,
224224+.publish__error {
225225+ flex: 1 1 100%;
226226+ font-size: 0.9rem;
227227+}
228228+.publish__result code {
229229+ word-break: break-all;
230230+ background: var(--panel);
231231+ padding: 0.1rem 0.3rem;
232232+ border-radius: 4px;
233233+}
234234+.publish__error {
235235+ color: var(--ember);
236236+}
237237+238238+/* Your articles + mode bar */
239239+.myarticles {
240240+ max-width: var(--studio-measure);
241241+ margin: 0 auto;
242242+ padding: 1rem var(--studio-gutter);
243243+ border-bottom: 1px solid var(--line);
244244+}
245245+.myarticles__heading {
246246+ font-size: 0.75rem;
247247+ text-transform: uppercase;
248248+ letter-spacing: 0.1em;
249249+ color: var(--muted);
250250+ margin: 0 0 0.5rem;
251251+}
252252+.myarticles__loading {
253253+ max-width: var(--studio-measure);
254254+ margin: 0 auto;
255255+ padding: 1rem var(--studio-gutter);
256256+ color: var(--muted);
257257+ font-size: 0.9rem;
258258+}
259259+.myarticles__list {
260260+ list-style: none;
261261+ margin: 0;
262262+ padding: 0;
263263+}
264264+.myarticles__item {
265265+ display: flex;
266266+ align-items: center;
267267+ justify-content: space-between;
268268+ gap: 1rem;
269269+ padding: 0.4rem 0;
270270+}
271271+.myarticles__edited,
272272+.myarticles__pub {
273273+ color: var(--muted);
274274+ font-style: normal;
275275+ font-size: 0.85rem;
276276+}
277277+.myarticles__pub {
278278+ font-family: var(--font-mono);
279279+ font-size: 0.78rem;
280280+}
281281+.myarticles__actions {
282282+ display: flex;
283283+ gap: 0.5rem;
284284+}
285285+.myarticles__actions button {
286286+ border: 1px solid var(--line-strong);
287287+ background: var(--paper-raised);
288288+ border-radius: 6px;
289289+ padding: 0.25rem 0.6rem;
290290+ font: inherit;
291291+ font-size: 0.85rem;
292292+ cursor: pointer;
293293+}
294294+.studio__mode {
295295+ display: flex;
296296+ align-items: center;
297297+ justify-content: space-between;
298298+ gap: 1rem;
299299+ max-width: var(--studio-measure);
300300+ margin: 0 auto;
301301+ padding: 0.5rem var(--studio-gutter);
302302+ font-size: 0.85rem;
303303+ color: var(--muted);
304304+}
305305+.studio__mode button {
306306+ border: 1px solid var(--line-strong);
307307+ background: var(--paper-raised);
308308+ border-radius: 6px;
309309+ padding: 0.25rem 0.7rem;
310310+ font: inherit;
311311+ cursor: pointer;
312312+}
313313+314314+/* Editor surface. The bundled isolated-block-editor CSS hard-codes a
315315+ full-width white surface that ignores `prefers-color-scheme`. Constrain
316316+ and frame it as a contained writing panel, and drive its colours from the
317317+ design tokens so it follows light/dark like the rest of the app. */
318318+.skypress-editor {
319319+ max-width: var(--studio-measure);
320320+ margin: 1.5rem auto 3rem;
321321+ padding: 0 var(--studio-gutter);
322322+}
323323+.skypress-editor .iso-editor {
324324+ background-color: var(--paper-raised);
325325+ border: 1px solid var(--line-strong);
326326+ border-radius: var(--radius);
327327+ color: var(--ink);
328328+ box-shadow: var(--shadow);
329329+}
330330+.skypress-editor .iso-editor .edit-post-visual-editor {
331331+ background-color: transparent;
332332+ /* Breathing room above the first block so the toolbar isn't flush to the
333333+ top edge of the framed surface. */
334334+ padding-top: 0.5rem;
335335+}
336336+/* Gutenberg sets `background: white` inline on the device-preview wrapper;
337337+ only !important lets the paper surface show through (esp. in dark mode). */
338338+.skypress-editor .iso-editor .edit-post-visual-editor .is-desktop-preview {
339339+ background: transparent !important;
340340+}
341341+342342+/* Editor toolbar. The bundled chrome hard-codes white (#fff) surfaces and
343343+ near-black (#1e1e1e) ink/borders that ignore `prefers-color-scheme`, so the
344344+ toolbar reads as a white slab in dark mode. Re-skin it from the tokens. SVG
345345+ icons use `fill: currentColor`, so setting the button colour tints them too. */
346346+.skypress-editor .iso-editor .components-accessible-toolbar,
347347+.skypress-editor .iso-editor .components-toolbar,
348348+.skypress-editor .iso-editor .components-toolbar-group {
349349+ background-color: var(--paper-raised);
350350+ border-color: var(--line-strong);
351351+ color: var(--ink);
352352+}
353353+.skypress-editor .iso-editor .components-toolbar-group {
354354+ border-right-color: var(--line);
355355+}
356356+.skypress-editor .iso-editor .components-accessible-toolbar .components-button {
357357+ color: var(--ink);
358358+}
359359+/* Both toolbar rows (document tools: insert/undo/redo; and the block tools that
360360+ appear on selection) sat flush against the editor's left edge — and the block
361361+ row carried no inline padding at all, so it didn't line up with the row above.
362362+ Give them a matching gutter. */
363363+.skypress-editor .iso-editor .editor-document-tools,
364364+.skypress-editor .iso-editor .block-editor-block-contextual-toolbar {
365365+ padding-left: 0.75rem;
366366+ padding-right: 0.75rem;
367367+}
368368+369369+.skypress-editor__status {
370370+ margin: 0.75rem 0 0;
371371+ color: var(--muted);
372372+ font-size: 0.85rem;
373373+}
374374+/* Idle (no save feedback yet): collapse the live region out of view without
375375+ leaving an empty gap. We can't use `display: none` here — that removes the
376376+ element from the accessibility tree, and an `aria-live` region must stay
377377+ rendered before its content changes for the first update to be announced.
378378+ Clip it to zero size instead so it remains in the tree but takes no space. */
379379+.skypress-editor__status:empty {
380380+ position: absolute;
381381+ width: 1px;
382382+ height: 1px;
383383+ margin: -1px;
384384+ padding: 0;
385385+ overflow: hidden;
386386+ clip: rect(0, 0, 0, 0);
387387+ white-space: nowrap;
388388+ border: 0;
389389+}