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 add/publication-system

# Conflicts:
# src/components/Studio.tsx
# src/pages/editor.astro
# src/pages/index.astro

+787 -371
+29 -6
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. Build a fetchable URL — `getBlob` on the writer's PDS for their DID + the blob CID — 23 - and hand it back to the editor via `onFileChange([{ id: cid, url, alt }])` so the 24 - image previews immediately. 25 - 3. **Retain the full blob ref** in an in-memory registry keyed by that URL. 22 + 2. Hand the editor a **local object URL** (`URL.createObjectURL(file)`) via 23 + `onFileChange([{ url, alt }])` so the image previews immediately. **No `id`** — PDS 24 + blobs aren't WP attachments; an `id` makes the Image block fetch `/wp/v2/media/<id>`, 25 + which 404s. 26 + 3. **Retain the blob ref + the canonical `getBlob` URL** in an in-memory registry keyed 27 + by the object URL. 28 + 29 + ### Preview via object URL, not the live `getBlob` URL (corrected 2026-06-08) 30 + A just-uploaded blob is **unreferenced** (temporary on the PDS) until a record commits a 31 + reference to it; `com.atproto.sync.getBlob` fails for it (observed: **500** on a 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. 38 + 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. 43 + 44 + The "Media Library" button (Gutenberg's `editor.MediaUpload`, which opens the legacy 45 + `wp.media` Backbone frame) is **disabled** via a filter override — SkyPress has no media 46 + library; uploads go straight to the PDS through the Upload/drop-zone path. See 47 + `src/lib/media/registerMediaUpload.ts`. 26 48 27 49 At **publish**, `attachBlobRefs` walks the block tree and, for every image whose `url` is 28 50 one we uploaded, writes the blob ref (`BlobRef.ipld()` → ··· 38 60 resolving the writer's *current* PDS — so the image survives a PDS migration. 39 61 40 62 ### In-editor URL strategy 41 - Live `getBlob` URL (not a SkyPress media proxy) for v1 — simplest, proves the upload. A 42 - caching/resizing proxy through the edge is a later optimisation (brief §5). 63 + Local object URL for preview; the canonical `getBlob` URL (not a SkyPress media proxy) is 64 + persisted at publish. A caching/resizing proxy through the edge is a later optimisation 65 + (brief §5). 43 66 44 67 ### Externally-URL'd images 45 68 Allowed **as-is** for v1 (no `skypressBlob`, just their `url`). Only files added through
+4 -2
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 - `getBlob` URL. 14 + local object URL (a just-uploaded, unreferenced blob can't be served by `getBlob` yet 15 + — see Decision 0006). 15 16 2. On publish, image blocks carry a proper `skypressBlob` ref (`{$type:'blob',…}`) in the 16 17 stored `content`, keeping the blob in-use (not GC'd) and portable. 17 18 3. Pure helpers (`getBlob` URL builder, blob-ref attach transform) unit-tested. ··· 26 27 blob.test.ts Vitest unit tests (written first) 27 28 pds.ts browser: resolvePdsUrl(did) from the DID document 28 29 mediaUpload.ts browser: createMediaUpload({ agent, did, pdsUrl, registry }) 30 + registerMediaUpload.ts browser: disables the wp.media "Media Library" button 29 31 src/lib/auth/AuthProvider.tsx resolves + exposes `pdsUrl` on sign-in 30 32 src/components/Studio.tsx owns the blob registry; wires mediaUpload + publish 31 33 src/components/SkyEditor.tsx accepts `mediaUpload`, sets settings.editor.mediaUpload ··· 50 52 | Check | Result | 51 53 |---|---| 52 54 | 25 unit tests (incl. blob helpers) | pass | 53 - | Editor upload | `mediaUpload` → `uploadBlob` → image previews via the PDS `getBlob` URL | 55 + | Editor upload | `mediaUpload` → `uploadBlob` → image previews via a local object URL | 54 56 | Stored content | image block carries `skypressBlob` = `{$type:'blob', ref:{$link:'bafkrei…'}, mimeType:'image/png', size:70}` | 55 57 | Blob served | `getBlob` returns the bytes (200, 70 bytes) | 56 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) |
+9 -1
src/components/SkyEditor.tsx
··· 1 1 import { useCallback, useState } from 'react'; 2 2 import IsolatedBlockEditor from '@automattic/isolated-block-editor'; 3 + // Side-effect import: must run AFTER isolated-block-editor so our editor.MediaUpload 4 + // filter registers last and replaces the bundled wp.media-based one (see the module). 5 + import '../lib/media/registerMediaUpload'; 3 6 import { createBlock, type BlockInstance } from '@wordpress/blocks'; 4 7 import type { BlockNode } from '../lib/blocks/render'; 5 8 ··· 42 45 * reading pages (Decision 0001). 43 46 */ 44 47 export default function SkyEditor( { onChange, mediaUpload, initialBlocks }: SkyEditorProps ) { 45 - const [ status, setStatus ] = useState< string >( 'Start writing…' ); 48 + // Empty until the first autosave. The in-editor placeholder already prompts 49 + // the writer; this line is reserved for save feedback ("Draft saved · …"). 50 + // Kept in the DOM as an `aria-live` region so updates are announced; the 51 + // `:empty` rule clips it out of view (not `display: none`, which would drop 52 + // it from the accessibility tree and suppress the announcement) while idle. 53 + const [ status, setStatus ] = useState< string >( '' ); 46 54 47 55 // Load existing content (editing) by rebuilding block instances from the stored tree. 48 56 // Core blocks are registered globally by the time onLoad runs.
+8 -1
src/components/Studio.tsx
··· 6 6 import SkyEditor from './SkyEditor'; 7 7 import PublishPanel from './PublishPanel'; 8 8 import MyArticles from './MyArticles'; 9 - import { createMediaUpload, type BlobRegistry } from '../lib/media/mediaUpload'; 9 + import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload'; 10 10 import { displayNameFor, authorPath } from '../lib/auth/profile'; 11 11 import type { MyArticle } from '../lib/publish/publisher'; 12 12 import { listPublications, type Publication } from '../lib/publish/publications'; ··· 40 40 }; 41 41 }, [ agent, did, refreshKey ] ); 42 42 43 + // Release the preview object URLs this session minted when the Studio unmounts. 44 + useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] ); 45 + 43 46 const mediaUpload = useMemo( () => { 44 47 if ( ! agent || ! did || ! pdsUrl ) { 45 48 return undefined; ··· 57 60 const viewerName = displayNameFor( { did, handle, displayName, avatar } ); 58 61 const publicPath = authorPath( handle ); 59 62 63 + // Switching articles re-mounts the editor, so the current previews leave the DOM — 64 + // safe to release the object URLs they held before loading the next article. 60 65 const startEdit = ( article: MyArticle ) => { 66 + revokeBlobRegistry( registry ); 61 67 setEditing( article ); 62 68 setBlocks( article.blocks as unknown as BlockInstance[] ); 63 69 }; 64 70 const startNew = () => { 71 + revokeBlobRegistry( registry ); 65 72 setEditing( null ); 66 73 setBlocks( [] ); 67 74 };
+8 -2
src/layouts/Base.astro
··· 4 4 interface Props { 5 5 title: string; 6 6 description?: string; 7 + /** 8 + * Optional landing-page sky phase. Rendered as `data-phase` on `<html>` so the 9 + * no-JS / pre-paint default has a sky; the inline head script then overwrites it 10 + * from the visitor's clock. `<html>` is the single phase carrier — see index.astro. 11 + */ 12 + phase?: string; 7 13 } 8 - const { title, description } = Astro.props; 14 + const { title, description, phase } = Astro.props; 9 15 --- 10 16 11 17 <!doctype html> 12 - <html lang="en"> 18 + <html lang="en" data-phase={phase}> 13 19 <head> 14 20 <meta charset="utf-8" /> 15 21 <meta name="viewport" content="width=device-width, initial-scale=1" />
+15 -5
src/lib/media/blob.test.ts
··· 30 30 } ); 31 31 32 32 describe( 'attachBlobRefs', () => { 33 - const lookup = ( url: string ) => ( url === 'blob://uploaded' ? REF : undefined ); 33 + // The editor previews via a transient object URL (`blob:…`); publish looks that key 34 + // up and persists the canonical, portable getBlob URL instead. 35 + const CANONICAL = 36 + 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafycid123'; 37 + const lookup = ( url: string ) => 38 + url === 'blob:preview-1' ? { ref: REF, url: CANONICAL } : undefined; 34 39 35 - it( 'attaches skypressBlob to an uploaded image, recursing inner blocks', () => { 40 + it( 'attaches skypressBlob and rewrites the url to the canonical getBlob URL, recursing inner blocks', () => { 36 41 const blocks: BlockNode[] = [ 37 - { name: 'core/image', attributes: { url: 'blob://uploaded', alt: 'x' }, innerBlocks: [] }, 42 + { name: 'core/image', attributes: { url: 'blob:preview-1', alt: 'x' }, innerBlocks: [] }, 38 43 { 39 44 name: 'core/gallery', 40 45 attributes: {}, 41 46 innerBlocks: [ 42 - { name: 'core/image', attributes: { url: 'blob://uploaded' }, innerBlocks: [] }, 47 + { name: 'core/image', attributes: { url: 'blob:preview-1' }, innerBlocks: [] }, 43 48 ], 44 49 }, 45 50 ]; 46 51 const out = attachBlobRefs( blocks, lookup ); 47 52 expect( out[ 0 ].attributes?.skypressBlob ).toEqual( REF ); 53 + expect( out[ 0 ].attributes?.url ).toBe( CANONICAL ); 54 + expect( out[ 0 ].attributes?.alt ).toBe( 'x' ); 48 55 expect( out[ 1 ].innerBlocks?.[ 0 ].attributes?.skypressBlob ).toEqual( REF ); 56 + expect( out[ 1 ].innerBlocks?.[ 0 ].attributes?.url ).toBe( CANONICAL ); 49 57 } ); 50 58 51 59 it( 'leaves external images (no matching upload) untouched', () => { ··· 54 62 ]; 55 63 const out = attachBlobRefs( blocks, lookup ); 56 64 expect( 'skypressBlob' in ( out[ 0 ].attributes ?? {} ) ).toBe( false ); 65 + expect( out[ 0 ].attributes?.url ).toBe( 'https://example.com/cat.jpg' ); 57 66 } ); 58 67 59 68 it( 'does not mutate the input blocks', () => { 60 69 const blocks: BlockNode[] = [ 61 - { name: 'core/image', attributes: { url: 'blob://uploaded' }, innerBlocks: [] }, 70 + { name: 'core/image', attributes: { url: 'blob:preview-1' }, innerBlocks: [] }, 62 71 ]; 63 72 attachBlobRefs( blocks, lookup ); 64 73 expect( 'skypressBlob' in ( blocks[ 0 ].attributes ?? {} ) ).toBe( false ); 74 + expect( blocks[ 0 ].attributes?.url ).toBe( 'blob:preview-1' ); 65 75 } ); 66 76 } ); 67 77
+17 -5
src/lib/media/blob.ts
··· 11 11 size: number; 12 12 } 13 13 14 + /** 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`). 18 + */ 19 + export interface BlobUpload { 20 + ref: BlobRefJson; 21 + url: string; 22 + } 23 + 14 24 /** Block names whose `url` attribute may reference an uploaded blob. */ 15 25 const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 16 26 ··· 49 59 50 60 /** 51 61 * Attach the stored blob ref (`skypressBlob`) to every image block whose `url` was 52 - * uploaded this session (resolved via `lookup`). Returns a new tree; does not mutate 62 + * uploaded this session (resolved via `lookup`), and rewrite that `url` to the canonical, 63 + * portable `getBlob` URL — the editor previewed from a transient object URL which would 64 + * otherwise be persisted as a dead `blob:` string. Returns a new tree; does not mutate 53 65 * the input. Images without a matching upload (e.g. external URLs) are left as-is, so 54 66 * the reader keeps their `url`. 55 67 */ 56 68 export function attachBlobRefs( 57 69 blocks: BlockNode[], 58 - lookup: ( url: string ) => BlobRefJson | undefined 70 + lookup: ( url: string ) => BlobUpload | undefined 59 71 ): BlockNode[] { 60 72 return blocks.map( ( block ) => { 61 73 const url = block.attributes?.url; 62 - const ref = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string' 74 + const upload = IMAGE_BLOCKS.has( block.name ) && typeof url === 'string' 63 75 ? lookup( url ) 64 76 : undefined; 65 77 return { 66 78 name: block.name, 67 - attributes: ref 68 - ? { ...block.attributes, skypressBlob: ref } 79 + attributes: upload 80 + ? { ...block.attributes, skypressBlob: upload.ref, url: upload.url } 69 81 : { ...block.attributes }, 70 82 innerBlocks: attachBlobRefs( block.innerBlocks ?? [], lookup ), 71 83 };
+109
src/lib/media/mediaUpload.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from './mediaUpload'; 4 + 5 + /** A fake `uploadBlob` response shaped like the atproto BlobRef the agent returns. */ 6 + function fakeUploadResult( cid: string, mimeType: string, size: number ) { 7 + return { data: { blob: { ref: { toString: () => cid }, mimeType, size } } }; 8 + } 9 + 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 + function setup() { 22 + const registry: BlobRegistry = new Map(); 23 + const uploadBlob = vi 24 + .fn() 25 + .mockResolvedValue( fakeUploadResult( 'bafycid123', 'image/png', 70 ) ); 26 + const agent = { uploadBlob } as unknown as Agent; 27 + const handler = createMediaUpload( { 28 + agent, 29 + did: 'did:plc:abc', 30 + pdsUrl: 'https://pds.example.com', 31 + registry, 32 + } ); 33 + return { registry, uploadBlob, handler }; 34 + } 35 + 36 + it( 'uploads to the PDS, previews via an object URL, and omits any attachment id', async () => { 37 + const { uploadBlob, handler } = setup(); 38 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 39 + const onFileChange = vi.fn(); 40 + 41 + await handler( { filesList: [ file ], onFileChange } ); 42 + 43 + expect( uploadBlob ).toHaveBeenCalledWith( file, { encoding: 'image/png' } ); 44 + expect( onFileChange ).toHaveBeenCalledTimes( 1 ); 45 + 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' ); 49 + // No `id`: PDS blobs are not WP attachments, so the image block must not try to 50 + // fetch /wp/v2/media/<id> (that 404s). 51 + expect( 'id' in media ).toBe( false ); 52 + } ); 53 + 54 + it( 'registers the blob ref + canonical getBlob URL keyed by the preview URL', async () => { 55 + const { registry, handler } = setup(); 56 + const file = new File( [ 'x' ], 'cat.png', { type: 'image/png' } ); 57 + 58 + await handler( { filesList: [ file ], onFileChange: vi.fn() } ); 59 + 60 + expect( registry.get( 'blob:preview-1' ) ).toEqual( { 61 + ref: { 62 + $type: 'blob', 63 + ref: { $link: 'bafycid123' }, 64 + mimeType: 'image/png', 65 + size: 70, 66 + }, 67 + url: 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafycid123', 68 + } ); 69 + } ); 70 + 71 + it( 'surfaces oversize files via onError and does not upload them', async () => { 72 + const { uploadBlob, handler } = setup(); 73 + const big = new File( [ 'x' ], 'big.png', { type: 'image/png' } ); 74 + Object.defineProperty( big, 'size', { value: 5_000_000 } ); 75 + const onError = vi.fn(); 76 + 77 + await handler( { 78 + filesList: [ big ], 79 + onFileChange: vi.fn(), 80 + onError, 81 + maxUploadFileSize: 1_000_000, 82 + } ); 83 + 84 + expect( uploadBlob ).not.toHaveBeenCalled(); 85 + expect( onError ).toHaveBeenCalledTimes( 1 ); 86 + expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error ); 87 + } ); 88 + } ); 89 + 90 + 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 } ); 94 + const ref = { $type: 'blob' as const, ref: { $link: 'cid' }, mimeType: 'image/png', size: 1 }; 95 + 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' } ], 98 + ] ); 99 + 100 + revokeBlobRegistry( registry ); 101 + 102 + expect( revoke ).toHaveBeenCalledTimes( 2 ); 103 + expect( revoke ).toHaveBeenCalledWith( 'blob:preview-1' ); 104 + expect( revoke ).toHaveBeenCalledWith( 'blob:preview-2' ); 105 + expect( registry.size ).toBe( 0 ); 106 + 107 + vi.unstubAllGlobals(); 108 + } ); 109 + } );
+36 -13
src/lib/media/mediaUpload.ts
··· 1 1 import type { Agent } from '@atproto/api'; 2 - import { buildGetBlobUrl, type BlobRefJson } from './blob'; 2 + import { buildGetBlobUrl, type BlobUpload } from './blob'; 3 + 4 + /** Maps a preview (object) URL → its blob ref + canonical getBlob URL for this session. */ 5 + export type BlobRegistry = Map< string, BlobUpload >; 3 6 4 - /** Maps a returned `getBlob` URL → its stored blob ref (filled during this session). */ 5 - export type BlobRegistry = Map< string, BlobRefJson >; 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. 13 + */ 14 + export function revokeBlobRegistry( registry: BlobRegistry ): void { 15 + for ( const previewUrl of registry.keys() ) { 16 + URL.revokeObjectURL( previewUrl ); 17 + } 18 + registry.clear(); 19 + } 6 20 7 21 interface MediaFile { 8 - id: string; 9 22 url: string; 10 23 alt: string; 11 24 } ··· 20 33 21 34 /** 22 35 * A Gutenberg `mediaUpload` handler backed by atproto blobs (Decision 0006): 23 - * upload each file to the writer's PDS, hand back a `getBlob` URL for preview, and 24 - * record the full blob ref in `registry` so the publish step can persist it. 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. 38 + * 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 — 41 + * `com.atproto.sync.getBlob` fails for it, so an inline `getBlob` preview would 500. 25 42 */ 26 43 export type MediaUploadHandler = ( args: MediaUploadArgs ) => Promise< void >; 27 44 ··· 48 65 const res = await agent.uploadBlob( file, { encoding: file.type } ); 49 66 const { blob } = res.data; 50 67 const cid = blob.ref.toString(); 51 - const url = buildGetBlobUrl( pdsUrl, did, cid ); 52 - registry.set( url, { 53 - $type: 'blob', 54 - ref: { $link: cid }, 55 - mimeType: blob.mimeType, 56 - size: blob.size, 68 + // Preview from a local object URL; persist the portable getBlob URL on publish. 69 + const previewUrl = URL.createObjectURL( file ); 70 + registry.set( previewUrl, { 71 + ref: { 72 + $type: 'blob', 73 + ref: { $link: cid }, 74 + mimeType: blob.mimeType, 75 + size: blob.size, 76 + }, 77 + url: buildGetBlobUrl( pdsUrl, did, cid ), 57 78 } ); 58 - onFileChange( [ { id: cid, url, alt: '' } ] ); 79 + // No `id`: PDS blobs aren't WP attachments, so the image block must not try 80 + // to resolve one via /wp/v2/media/<id> (that 404s). 81 + onFileChange( [ { url: previewUrl, alt: '' } ] ); 59 82 } catch ( error ) { 60 83 onError?.( error instanceof Error ? error : new Error( String( error ) ) ); 61 84 }
+17
src/lib/media/registerMediaUpload.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { applyFilters } from '@wordpress/hooks'; 3 + // Side-effect import: registers the editor.MediaUpload override. 4 + import './registerMediaUpload'; 5 + 6 + describe( 'registerMediaUpload', () => { 7 + it( 'replaces editor.MediaUpload with an inert, non-rendering component', () => { 8 + // A stand-in for whatever component was registered before us (in the app, the 9 + // wp.media-based one from isolated-block-editor). 10 + const Bundled = () => 'wp.media frame'; 11 + const Filtered = applyFilters( 'editor.MediaUpload', Bundled ) as () => unknown; 12 + 13 + expect( Filtered ).not.toBe( Bundled ); 14 + // Renders nothing → no "Media Library" button, no wp.media access. 15 + expect( Filtered() ).toBeNull(); 16 + } ); 17 + } );
+23
src/lib/media/registerMediaUpload.ts
··· 1 + import { addFilter } from '@wordpress/hooks'; 2 + 3 + /** 4 + * Disable Gutenberg's WordPress media library in the SkyPress editor. 5 + * 6 + * `@automattic/isolated-block-editor` bundles `@wordpress/editor`'s media-upload hook, 7 + * which registers the `@wordpress/media-utils` `MediaUpload` component on the 8 + * `editor.MediaUpload` filter. That component opens the legacy Backbone media frame via 9 + * `wp.media(...)` — a global SkyPress never loads — so the Image block's "Media Library" 10 + * button throws (`can't access property "media", … is undefined`). 11 + * 12 + * SkyPress has no media library: images upload straight to the writer's PDS as blobs 13 + * (Decision 0006) through the "Upload" / drop-zone path, which uses the custom 14 + * `settings.editor.mediaUpload` handler and is independent of this component. So we 15 + * override the filter with a no-render component, removing the broken (and inapplicable) 16 + * "Media Library" button everywhere it appears (placeholder, Replace flow). 17 + * 18 + * Importing this module registers the filter as a side effect. It must be imported AFTER 19 + * `isolated-block-editor` so our filter runs last and wins. 20 + */ 21 + const NoMediaLibrary = (): null => null; 22 + 23 + addFilter( 'editor.MediaUpload', 'skypress/disable-media-library', () => NoMediaLibrary );
+5 -319
src/pages/editor.astro
··· 2 2 import Base from '../layouts/Base.astro'; 3 3 import Logo from '../components/Logo.astro'; 4 4 import Studio from '../components/Studio.tsx'; 5 + // The Studio is a `client:only` React island, so Astro's scoped styles never 6 + // reach its DOM — its chrome is styled globally from this shared stylesheet. 7 + import '../styles/editor-chrome.css'; 5 8 --- 6 9 7 10 <Base title="Write — SkyPress"> ··· 23 26 display: flex; 24 27 align-items: center; 25 28 gap: 1rem; 29 + max-width: 60rem; 30 + margin: 0 auto; 26 31 padding: 0.75rem 1.25rem; 27 32 border-bottom: 1px solid var(--line); 28 33 flex-wrap: wrap; ··· 42 47 color: var(--muted); 43 48 } 44 49 </style> 45 - 46 - <!-- The Studio is a `client:only` React island, so Astro's scoped styles never 47 - reach its DOM. The editor-surface rules below must therefore be global. --> 48 - <style is:global> 49 - .studio__loading { 50 - padding: 2rem 1.25rem; 51 - color: var(--muted); 52 - } 53 - 54 - /* Account bar (signed in) */ 55 - .studio__account { 56 - display: flex; 57 - align-items: center; 58 - justify-content: space-between; 59 - gap: 1rem; 60 - padding: 0.5rem 1.25rem; 61 - background: var(--panel); 62 - border-bottom: 1px solid var(--line); 63 - font-size: 0.9rem; 64 - flex-wrap: wrap; 65 - } 66 - .studio__identity { 67 - display: flex; 68 - align-items: center; 69 - gap: 0.6rem; 70 - min-width: 0; 71 - } 72 - .studio__avatar { 73 - width: 38px; 74 - height: 38px; 75 - border-radius: 50%; 76 - object-fit: cover; 77 - flex: none; 78 - } 79 - .studio__avatar--fallback { 80 - display: inline-flex; 81 - align-items: center; 82 - justify-content: center; 83 - background: var(--sun-tint); 84 - color: var(--sun); 85 - font-weight: 700; 86 - } 87 - .studio__who { 88 - display: flex; 89 - flex-direction: column; 90 - line-height: 1.15; 91 - min-width: 0; 92 - } 93 - .studio__name { 94 - font-weight: 680; 95 - } 96 - .studio__handle { 97 - color: var(--muted); 98 - font-size: 0.8rem; 99 - } 100 - .studio__account-actions { 101 - display: flex; 102 - align-items: center; 103 - gap: 0.5rem; 104 - flex-wrap: wrap; 105 - } 106 - .studio__viewpage { 107 - color: var(--sun); 108 - text-decoration: none; 109 - font-size: 0.85rem; 110 - padding: 0.3rem 0.5rem; 111 - border-radius: var(--radius-sm); 112 - } 113 - .studio__viewpage:hover { 114 - text-decoration: underline; 115 - } 116 - .studio__signout { 117 - border: 1px solid var(--line-strong); 118 - background: var(--paper-raised); 119 - border-radius: var(--radius-sm); 120 - padding: 0.3rem 0.7rem; 121 - cursor: pointer; 122 - font: inherit; 123 - } 124 - 125 - /* Login (signed out) */ 126 - .studio__login { 127 - max-width: 30rem; 128 - margin: 0 auto; 129 - padding: 4rem 1.5rem; 130 - } 131 - .studio__error, 132 - .login__error { 133 - color: var(--ember); 134 - font-size: 0.9rem; 135 - } 136 - .login__title { 137 - font-size: 1.6rem; 138 - margin: 0 0 0.5rem; 139 - } 140 - .login__lede { 141 - color: var(--muted); 142 - margin: 0 0 1.5rem; 143 - } 144 - .login__label { 145 - display: block; 146 - font-size: 0.85rem; 147 - font-weight: 600; 148 - margin-bottom: 0.35rem; 149 - } 150 - .login__input { 151 - width: 100%; 152 - box-sizing: border-box; 153 - padding: 0.6rem 0.7rem; 154 - border: 1px solid var(--line-strong); 155 - border-radius: 8px; 156 - font: inherit; 157 - margin-bottom: 0.85rem; 158 - } 159 - .login__submit { 160 - width: 100%; 161 - padding: 0.65rem 1rem; 162 - border: 0; 163 - border-radius: 8px; 164 - background: var(--sun); 165 - color: #fff; 166 - font: inherit; 167 - font-weight: 600; 168 - cursor: pointer; 169 - } 170 - .login__note { 171 - color: var(--muted); 172 - font-size: 0.8rem; 173 - margin-top: 1rem; 174 - } 175 - 176 - /* Publish panel */ 177 - .publish { 178 - display: flex; 179 - flex-wrap: wrap; 180 - align-items: center; 181 - gap: 0.75rem; 182 - padding: 0.75rem 1.25rem; 183 - border-bottom: 1px solid var(--line); 184 - } 185 - .publish__title { 186 - flex: 1 1 18rem; 187 - padding: 0.5rem 0.7rem; 188 - border: 1px solid var(--line-strong); 189 - border-radius: 8px; 190 - font: inherit; 191 - font-size: 1.05rem; 192 - } 193 - .publish__target { 194 - display: inline-flex; 195 - align-items: center; 196 - gap: 0.4rem; 197 - font-size: 0.85rem; 198 - color: var(--muted); 199 - } 200 - .publish__target--fixed strong { 201 - color: var(--ink); 202 - } 203 - .publish__select { 204 - padding: 0.45rem 0.6rem; 205 - border: 1px solid var(--line-strong); 206 - border-radius: 8px; 207 - background: var(--paper-raised); 208 - font: inherit; 209 - font-size: 0.9rem; 210 - } 211 - .publish__button { 212 - padding: 0.5rem 1rem; 213 - border: 0; 214 - border-radius: 8px; 215 - background: var(--sun); 216 - color: #fff; 217 - font: inherit; 218 - font-weight: 600; 219 - cursor: pointer; 220 - } 221 - .publish__button:disabled { 222 - opacity: 0.5; 223 - cursor: not-allowed; 224 - } 225 - .publish__cancel { 226 - padding: 0.5rem 0.9rem; 227 - border: 1px solid var(--line-strong); 228 - background: var(--paper-raised); 229 - border-radius: 8px; 230 - font: inherit; 231 - cursor: pointer; 232 - } 233 - .publish__confirm { 234 - flex: 1 1 100%; 235 - background: var(--sun-tint); 236 - border: 1px solid var(--line-strong); 237 - border-radius: 10px; 238 - padding: 0.85rem 1rem; 239 - } 240 - .publish__warning { 241 - margin: 0 0 0.75rem; 242 - } 243 - .publish__actions { 244 - display: flex; 245 - gap: 0.75rem; 246 - flex-wrap: wrap; 247 - } 248 - .publish__result, 249 - .publish__status, 250 - .publish__error { 251 - flex: 1 1 100%; 252 - font-size: 0.9rem; 253 - } 254 - .publish__result code { 255 - word-break: break-all; 256 - background: var(--panel); 257 - padding: 0.1rem 0.3rem; 258 - border-radius: 4px; 259 - } 260 - .publish__error { 261 - color: var(--ember); 262 - } 263 - 264 - /* Your articles + mode bar */ 265 - .myarticles { 266 - padding: 1rem 1.25rem; 267 - border-bottom: 1px solid var(--line); 268 - } 269 - .myarticles__heading { 270 - font-size: 0.75rem; 271 - text-transform: uppercase; 272 - letter-spacing: 0.1em; 273 - color: var(--muted); 274 - margin: 0 0 0.5rem; 275 - } 276 - .myarticles__loading { 277 - padding: 1rem 1.25rem; 278 - color: var(--muted); 279 - font-size: 0.9rem; 280 - } 281 - .myarticles__list { 282 - list-style: none; 283 - margin: 0; 284 - padding: 0; 285 - } 286 - .myarticles__item { 287 - display: flex; 288 - align-items: center; 289 - justify-content: space-between; 290 - gap: 1rem; 291 - padding: 0.4rem 0; 292 - } 293 - .myarticles__edited, 294 - .myarticles__pub { 295 - color: var(--muted); 296 - font-style: normal; 297 - font-size: 0.85rem; 298 - } 299 - .myarticles__pub { 300 - font-family: var(--font-mono); 301 - font-size: 0.78rem; 302 - } 303 - .myarticles__actions { 304 - display: flex; 305 - gap: 0.5rem; 306 - } 307 - .myarticles__actions button { 308 - border: 1px solid var(--line-strong); 309 - background: var(--paper-raised); 310 - border-radius: 6px; 311 - padding: 0.25rem 0.6rem; 312 - font: inherit; 313 - font-size: 0.85rem; 314 - cursor: pointer; 315 - } 316 - .studio__mode { 317 - display: flex; 318 - align-items: center; 319 - justify-content: space-between; 320 - gap: 1rem; 321 - padding: 0.5rem 1.25rem; 322 - font-size: 0.85rem; 323 - color: var(--muted); 324 - } 325 - .studio__mode button { 326 - border: 1px solid var(--line-strong); 327 - background: var(--paper-raised); 328 - border-radius: 6px; 329 - padding: 0.25rem 0.7rem; 330 - font: inherit; 331 - cursor: pointer; 332 - } 333 - 334 - /* Editor surface. The bundled isolated-block-editor CSS hard-codes a 335 - full-width white surface that ignores `prefers-color-scheme`. Constrain 336 - and frame it as a contained writing panel, and drive its colours from the 337 - design tokens so it follows light/dark like the rest of the app. */ 338 - .skypress-editor { 339 - max-width: 60rem; 340 - margin: 1.5rem auto 3rem; 341 - padding: 0 1.25rem; 342 - } 343 - .skypress-editor .iso-editor { 344 - background-color: var(--paper-raised); 345 - border: 1px solid var(--line-strong); 346 - border-radius: var(--radius); 347 - color: var(--ink); 348 - box-shadow: var(--shadow); 349 - } 350 - .skypress-editor .iso-editor .edit-post-visual-editor { 351 - background-color: transparent; 352 - } 353 - /* Gutenberg sets `background: white` inline on the device-preview wrapper; 354 - only !important lets the paper surface show through (esp. in dark mode). */ 355 - .skypress-editor .iso-editor .edit-post-visual-editor .is-desktop-preview { 356 - background: transparent !important; 357 - } 358 - .skypress-editor__status { 359 - margin: 0.75rem 0 0; 360 - color: var(--muted); 361 - font-size: 0.85rem; 362 - } 363 - </style>
+18 -17
src/pages/index.astro
··· 10 10 <Base 11 11 title="SkyPress — a writing studio for the open social web" 12 12 description="A standalone, long-form writing studio for the AT Protocol. Write in blocks and publish to the open social web, under your own account." 13 + phase={DEFAULT_PHASE} 13 14 > 14 15 <Fragment slot="head"> 15 16 <script is:inline> ··· 29 30 </script> 30 31 </Fragment> 31 32 32 - <div class="page" data-phase={DEFAULT_PHASE}> 33 + <div class="page"> 33 34 <div class="sky" aria-hidden="true"> 34 35 <div class="stars"></div> 35 36 <div class="bloom"></div> ··· 42 43 <div class="masthead__right"> 43 44 <AuthorPill client:only="react" /> 44 45 <a class="btn btn--ghost" href="/dashboard">Dashboard</a> 45 - <a class="btn btn--ghost" href="/editor">Open the studio</a> 46 + <a class="btn btn--ghost" href="/editor">Studio</a> 46 47 </div> 47 48 </header> 48 49 ··· 149 150 } 150 151 151 152 /* Per-phase sky gradient + star visibility + hero text colour */ 152 - [data-phase='night'] .sky { background: linear-gradient(180deg, #080611, #140f28 50%, #231a38); } 153 - [data-phase='night'] .stars { opacity: 1; } 154 - [data-phase='dawn'] .sky { background: linear-gradient(180deg, #231533, #7a2f63 42%, #d7613a 78%, #f4a14a); } 155 - [data-phase='dawn'] .stars { opacity: 0.5; } 156 - [data-phase='morning'] .sky { background: linear-gradient(180deg, #3a2752, #c0567f 34%, #f5934a 70%, #ffd98a); } 157 - [data-phase='midday'] .sky { background: linear-gradient(180deg, #e9b977, #f7dca5 45%, #fdf0d6); } 158 - [data-phase='golden'] .sky { background: linear-gradient(180deg, #4a2150, #b5417a 30%, #ef7d3a 64%, #f9c25a 92%, #ffe6ab); } 159 - [data-phase='dusk'] .sky { background: linear-gradient(180deg, #140f2a, #4a2150 38%, #93324f 70%, #c75a3b); } 160 - [data-phase='dusk'] .stars { opacity: 0.5; } 153 + :global([data-phase='night']) .sky { background: linear-gradient(180deg, #080611, #140f28 50%, #231a38); } 154 + :global([data-phase='night']) .stars { opacity: 1; } 155 + :global([data-phase='dawn']) .sky { background: linear-gradient(180deg, #231533, #7a2f63 42%, #d7613a 78%, #f4a14a); } 156 + :global([data-phase='dawn']) .stars { opacity: 0.5; } 157 + :global([data-phase='morning']) .sky { background: linear-gradient(180deg, #3a2752, #c0567f 34%, #f5934a 70%, #ffd98a); } 158 + :global([data-phase='midday']) .sky { background: linear-gradient(180deg, #e9b977, #f7dca5 45%, #fdf0d6); } 159 + :global([data-phase='golden']) .sky { background: linear-gradient(180deg, #4a2150, #b5417a 30%, #ef7d3a 64%, #f9c25a 92%, #ffe6ab); } 160 + :global([data-phase='dusk']) .sky { background: linear-gradient(180deg, #140f2a, #4a2150 38%, #93324f 70%, #c75a3b); } 161 + :global([data-phase='dusk']) .stars { opacity: 0.5; } 161 162 162 163 /* The whole sky zone (masthead + hero) is coloured by the phase, independent of the 163 164 OS theme: light text on dark skies, ink on the pale midday sky. */ 164 - [data-phase='night'] .masthead, [data-phase='night'] .hero, 165 - [data-phase='dawn'] .masthead, [data-phase='dawn'] .hero, 166 - [data-phase='morning'] .masthead, [data-phase='morning'] .hero, 167 - [data-phase='golden'] .masthead, [data-phase='golden'] .hero, 168 - [data-phase='dusk'] .masthead, [data-phase='dusk'] .hero { 165 + :global([data-phase='night']) .masthead, :global([data-phase='night']) .hero, 166 + :global([data-phase='dawn']) .masthead, :global([data-phase='dawn']) .hero, 167 + :global([data-phase='morning']) .masthead, :global([data-phase='morning']) .hero, 168 + :global([data-phase='golden']) .masthead, :global([data-phase='golden']) .hero, 169 + :global([data-phase='dusk']) .masthead, :global([data-phase='dusk']) .hero { 169 170 --sky-ink: #fbf7ef; 170 171 --sky-soft: rgba(251, 247, 239, 0.94); 171 172 --sky-line: rgba(251, 247, 239, 0.55); 172 173 --sky-chip: rgba(255, 255, 255, 0.12); 173 174 --sky-shadow: 0 1px 14px rgba(20, 10, 4, 0.4); 174 175 } 175 - [data-phase='midday'] .masthead, [data-phase='midday'] .hero { 176 + :global([data-phase='midday']) .masthead, :global([data-phase='midday']) .hero { 176 177 --sky-ink: #241a10; 177 178 --sky-soft: rgba(36, 26, 16, 0.9); 178 179 --sky-line: rgba(36, 26, 16, 0.42);
+90
src/pages/index.phase.test.ts
··· 1 + /** 2 + * Regression guard for the landing-page sky phase carrier (docs/specs/sp8-brand-first-light.md). 3 + * 4 + * `data-phase` lives on exactly one carrier: `<html>`, the only element the pre-paint inline head 5 + * script can reach (it runs in `<head>`, before `.page` exists, so it sets `documentElement`). 6 + * 7 + * The catch: the per-phase rules — `[data-phase='x'] .sky` / `.masthead` / `.hero` — live in 8 + * index.astro's *scoped* `<style>`. Astro appends index.astro's scope id to every part of a 9 + * selector, including the ancestor, yielding `[cid][data-phase='x'] .sky[cid]`. But `<html>` is 10 + * rendered by Base.astro and never carries index.astro's cid, so a bare (scoped) ancestor matches 11 + * nothing and the sky renders unstyled. Wrapping the ancestor in `:global([data-phase='x'])` keeps 12 + * the descendant (`.sky`/`.masthead`/`.hero`) scoped while letting the ancestor match `<html>`. 13 + * 14 + * These asserts pin the wiring at the source level — rendering the page through astro/container 15 + * isn't viable here (the test runner is pinned to jsdom for the WordPress block suites, which 16 + * breaks esbuild's init invariant). 17 + */ 18 + import { readFileSync } from 'node:fs'; 19 + import { fileURLToPath } from 'node:url'; 20 + import { describe, expect, it } from 'vitest'; 21 + import { phaseForHour } from '../lib/landing/time-of-day'; 22 + 23 + const read = ( rel: string ) => 24 + readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' ); 25 + 26 + /** Strip the `---` frontmatter and any <style>/<script> blocks, leaving only rendered markup. */ 27 + const markupOnly = ( astro: string ) => 28 + astro 29 + .replace( /^---[\s\S]*?\n---/, '' ) 30 + .replace( /<style[\s\S]*?<\/style>/g, '' ) 31 + .replace( /<script[\s\S]*?<\/script>/g, '' ); 32 + 33 + describe( 'landing page sky phase carrier', () => { 34 + const index = read( './index.astro' ); 35 + const base = read( '../layouts/Base.astro' ); 36 + 37 + it( 'carries the phase on <html> (the no-JS / pre-paint default the head script overwrites)', () => { 38 + const htmlTag = markupOnly( base ).match( /<html\b[^>]*>/ )?.[ 0 ]; 39 + expect( htmlTag ).toBeDefined(); 40 + expect( htmlTag ).toMatch( /data-phase=/ ); 41 + } ); 42 + 43 + it( 'passes the default phase from the landing page into the layout', () => { 44 + expect( index ).toMatch( /phase=\{\s*DEFAULT_PHASE\s*\}/ ); 45 + } ); 46 + 47 + it( 'never puts data-phase on a body element that would shadow <html> in the cascade', () => { 48 + // Every element in the page markup is a descendant of <html>; none may carry its own 49 + // data-phase. (The head <script> assigns it via documentElement, never as markup.) 50 + expect( markupOnly( index ) ).not.toMatch( /data-phase=/ ); 51 + } ); 52 + 53 + it( 'updates the phase before first paint via documentElement', () => { 54 + expect( index ).toMatch( /document\.documentElement\.dataset\.phase\s*=/ ); 55 + } ); 56 + 57 + it( 'wraps every per-phase ancestor selector in :global() so it can match <html>', () => { 58 + // The phase carrier is <html> (rendered by Base.astro), which never carries index.astro's 59 + // scoped cid. A bare `[data-phase=...]` ancestor would be scoped to that cid and match 60 + // nothing; only `:global([data-phase=...])` reaches <html>. Assert that EVERY data-phase 61 + // selector is global-wrapped — a single scoped one silently blanks the sky. 62 + const style = index.match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? ''; 63 + const total = ( style.match( /\[data-phase=/g ) ?? [] ).length; 64 + const global = ( style.match( /:global\(\s*\[data-phase=/g ) ?? [] ).length; 65 + expect( total, 'expected per-phase selectors in the landing-page styles' ).toBeGreaterThan( 0 ); 66 + expect( global, 'every [data-phase=...] selector must be :global()-wrapped' ).toBe( total ); 67 + } ); 68 + 69 + // The inline head script must run before any module loads (for a no-flash sky), so it 70 + // can't import phaseForHour — it hand-mirrors the same hour->phase boundaries. This guard 71 + // keeps that copy honest: extract its `var p = <ternary>` expression straight from source 72 + // and assert it agrees with phaseForHour for every hour. (The source comment in 73 + // time-of-day.ts promises exactly this test.) 74 + it( 'mirrors the phaseForHour boundaries in the inline head script', () => { 75 + const expr = index.match( /var p\s*=\s*([\s\S]*?);/ )?.[ 1 ]; 76 + expect( expr, 'inline head script should assign `var p = <phase ternary>;`' ).toBeDefined(); 77 + // Lock the expression to a phase ternary over `h` before evaluating it: only the variable 78 + // `h`, integer literals, comparison/logical/ternary operators, and quoted phase words are 79 + // allowed. This makes the `new Function` below incapable of running anything else, even if 80 + // the source were ever changed to something unexpected. 81 + expect( expr, 'unexpected tokens in inline phase expression' ).toMatch( 82 + /^[\sa-z\d<>=|?:'"]+$/ 83 + ); 84 + // eslint-disable-next-line no-new-func -- expr is validated above to be a pure phase ternary. 85 + const scriptPhase = new Function( 'h', `return ( ${ expr } );` ) as ( h: number ) => string; 86 + for ( let h = 0; h < 24; h++ ) { 87 + expect( scriptPhase( h ), `hour ${ h }` ).toBe( phaseForHour( h ) ); 88 + } 89 + } ); 90 + } );
+389
src/styles/editor-chrome.css
··· 1 + /** 2 + * Studio (editor) chrome styles. 3 + * 4 + * The Studio is a `client:only` React island, so Astro's component-scoped styles 5 + * never reach its DOM — these rules must be global. Shared by the real editor 6 + * page (`src/pages/editor.astro`). 7 + * 8 + * The signed-in bars (account / articles / mode / publish) and the editor 9 + * surface share one centred content column so they line up with each other. 10 + */ 11 + 12 + :root { 13 + /* Content column shared by the studio bars and the editor surface. */ 14 + --studio-measure: 60rem; 15 + --studio-gutter: 1.25rem; 16 + } 17 + 18 + .studio__loading { 19 + max-width: var(--studio-measure); 20 + margin: 0 auto; 21 + padding: 2rem var(--studio-gutter); 22 + color: var(--muted); 23 + } 24 + 25 + /* Account bar (signed in) */ 26 + .studio__account { 27 + display: flex; 28 + align-items: center; 29 + justify-content: space-between; 30 + gap: 1rem; 31 + max-width: var(--studio-measure); 32 + margin: 0 auto; 33 + padding: 0.5rem var(--studio-gutter); 34 + background: var(--panel); 35 + border-radius: var(--radius); 36 + font-size: 0.9rem; 37 + flex-wrap: wrap; 38 + } 39 + .studio__identity { 40 + display: flex; 41 + align-items: center; 42 + gap: 0.6rem; 43 + min-width: 0; 44 + } 45 + .studio__avatar { 46 + width: 38px; 47 + height: 38px; 48 + border-radius: 50%; 49 + object-fit: cover; 50 + flex: none; 51 + } 52 + .studio__avatar--fallback { 53 + display: inline-flex; 54 + align-items: center; 55 + justify-content: center; 56 + background: var(--sun-tint); 57 + color: var(--sun); 58 + font-weight: 700; 59 + } 60 + .studio__who { 61 + display: flex; 62 + flex-direction: column; 63 + line-height: 1.15; 64 + min-width: 0; 65 + } 66 + .studio__name { 67 + font-weight: 680; 68 + } 69 + .studio__handle { 70 + color: var(--muted); 71 + font-size: 0.8rem; 72 + } 73 + .studio__account-actions { 74 + display: flex; 75 + align-items: center; 76 + gap: 0.5rem; 77 + flex-wrap: wrap; 78 + } 79 + .studio__viewpage { 80 + color: var(--sun); 81 + text-decoration: none; 82 + font-size: 0.85rem; 83 + padding: 0.3rem 0.5rem; 84 + border-radius: var(--radius-sm); 85 + } 86 + .studio__viewpage:hover { 87 + text-decoration: underline; 88 + } 89 + .studio__signout { 90 + border: 1px solid var(--line-strong); 91 + background: var(--paper-raised); 92 + border-radius: var(--radius-sm); 93 + padding: 0.3rem 0.7rem; 94 + cursor: pointer; 95 + font: inherit; 96 + } 97 + 98 + /* Login (signed out) */ 99 + .studio__login { 100 + max-width: 30rem; 101 + margin: 0 auto; 102 + padding: 4rem 1.5rem; 103 + } 104 + .studio__error, 105 + .login__error { 106 + color: var(--ember); 107 + font-size: 0.9rem; 108 + } 109 + .login__title { 110 + font-size: 1.6rem; 111 + margin: 0 0 0.5rem; 112 + } 113 + .login__lede { 114 + color: var(--muted); 115 + margin: 0 0 1.5rem; 116 + } 117 + .login__label { 118 + display: block; 119 + font-size: 0.85rem; 120 + font-weight: 600; 121 + margin-bottom: 0.35rem; 122 + } 123 + .login__input { 124 + width: 100%; 125 + box-sizing: border-box; 126 + padding: 0.6rem 0.7rem; 127 + border: 1px solid var(--line-strong); 128 + border-radius: 8px; 129 + font: inherit; 130 + margin-bottom: 0.85rem; 131 + } 132 + .login__submit { 133 + width: 100%; 134 + padding: 0.65rem 1rem; 135 + border: 0; 136 + border-radius: 8px; 137 + background: var(--sun); 138 + color: #fff; 139 + font: inherit; 140 + font-weight: 600; 141 + cursor: pointer; 142 + } 143 + .login__note { 144 + color: var(--muted); 145 + font-size: 0.8rem; 146 + margin-top: 1rem; 147 + } 148 + 149 + /* Publish panel */ 150 + .publish { 151 + display: flex; 152 + flex-wrap: wrap; 153 + align-items: center; 154 + gap: 0.75rem; 155 + max-width: var(--studio-measure); 156 + margin: 0 auto; 157 + padding: 0.75rem var(--studio-gutter); 158 + } 159 + .publish__title { 160 + flex: 1 1 18rem; 161 + padding: 0.5rem 0.7rem; 162 + border: 1px solid var(--line-strong); 163 + border-radius: 8px; 164 + font: inherit; 165 + font-size: 1.05rem; 166 + } 167 + .publish__target { 168 + display: inline-flex; 169 + align-items: center; 170 + gap: 0.4rem; 171 + font-size: 0.85rem; 172 + color: var(--muted); 173 + } 174 + .publish__target--fixed strong { 175 + color: var(--ink); 176 + } 177 + .publish__select { 178 + padding: 0.45rem 0.6rem; 179 + border: 1px solid var(--line-strong); 180 + border-radius: 8px; 181 + background: var(--paper-raised); 182 + font: inherit; 183 + font-size: 0.9rem; 184 + } 185 + .publish__button { 186 + padding: 0.5rem 1rem; 187 + border: 0; 188 + border-radius: 8px; 189 + background: var(--sun); 190 + color: #fff; 191 + font: inherit; 192 + font-weight: 600; 193 + cursor: pointer; 194 + } 195 + .publish__button:disabled { 196 + opacity: 0.5; 197 + cursor: not-allowed; 198 + } 199 + .publish__cancel { 200 + padding: 0.5rem 0.9rem; 201 + border: 1px solid var(--line-strong); 202 + background: var(--paper-raised); 203 + border-radius: 8px; 204 + font: inherit; 205 + cursor: pointer; 206 + } 207 + .publish__confirm { 208 + flex: 1 1 100%; 209 + background: var(--sun-tint); 210 + border: 1px solid var(--line-strong); 211 + border-radius: 10px; 212 + padding: 0.85rem 1rem; 213 + } 214 + .publish__warning { 215 + margin: 0 0 0.75rem; 216 + } 217 + .publish__actions { 218 + display: flex; 219 + gap: 0.75rem; 220 + flex-wrap: wrap; 221 + } 222 + .publish__result, 223 + .publish__status, 224 + .publish__error { 225 + flex: 1 1 100%; 226 + font-size: 0.9rem; 227 + } 228 + .publish__result code { 229 + word-break: break-all; 230 + background: var(--panel); 231 + padding: 0.1rem 0.3rem; 232 + border-radius: 4px; 233 + } 234 + .publish__error { 235 + color: var(--ember); 236 + } 237 + 238 + /* Your articles + mode bar */ 239 + .myarticles { 240 + max-width: var(--studio-measure); 241 + margin: 0 auto; 242 + padding: 1rem var(--studio-gutter); 243 + border-bottom: 1px solid var(--line); 244 + } 245 + .myarticles__heading { 246 + font-size: 0.75rem; 247 + text-transform: uppercase; 248 + letter-spacing: 0.1em; 249 + color: var(--muted); 250 + margin: 0 0 0.5rem; 251 + } 252 + .myarticles__loading { 253 + max-width: var(--studio-measure); 254 + margin: 0 auto; 255 + padding: 1rem var(--studio-gutter); 256 + color: var(--muted); 257 + font-size: 0.9rem; 258 + } 259 + .myarticles__list { 260 + list-style: none; 261 + margin: 0; 262 + padding: 0; 263 + } 264 + .myarticles__item { 265 + display: flex; 266 + align-items: center; 267 + justify-content: space-between; 268 + gap: 1rem; 269 + padding: 0.4rem 0; 270 + } 271 + .myarticles__edited, 272 + .myarticles__pub { 273 + color: var(--muted); 274 + font-style: normal; 275 + font-size: 0.85rem; 276 + } 277 + .myarticles__pub { 278 + font-family: var(--font-mono); 279 + font-size: 0.78rem; 280 + } 281 + .myarticles__actions { 282 + display: flex; 283 + gap: 0.5rem; 284 + } 285 + .myarticles__actions button { 286 + border: 1px solid var(--line-strong); 287 + background: var(--paper-raised); 288 + border-radius: 6px; 289 + padding: 0.25rem 0.6rem; 290 + font: inherit; 291 + font-size: 0.85rem; 292 + cursor: pointer; 293 + } 294 + .studio__mode { 295 + display: flex; 296 + align-items: center; 297 + justify-content: space-between; 298 + gap: 1rem; 299 + max-width: var(--studio-measure); 300 + margin: 0 auto; 301 + padding: 0.5rem var(--studio-gutter); 302 + font-size: 0.85rem; 303 + color: var(--muted); 304 + } 305 + .studio__mode button { 306 + border: 1px solid var(--line-strong); 307 + background: var(--paper-raised); 308 + border-radius: 6px; 309 + padding: 0.25rem 0.7rem; 310 + font: inherit; 311 + cursor: pointer; 312 + } 313 + 314 + /* Editor surface. The bundled isolated-block-editor CSS hard-codes a 315 + full-width white surface that ignores `prefers-color-scheme`. Constrain 316 + and frame it as a contained writing panel, and drive its colours from the 317 + design tokens so it follows light/dark like the rest of the app. */ 318 + .skypress-editor { 319 + max-width: var(--studio-measure); 320 + margin: 1.5rem auto 3rem; 321 + padding: 0 var(--studio-gutter); 322 + } 323 + .skypress-editor .iso-editor { 324 + background-color: var(--paper-raised); 325 + border: 1px solid var(--line-strong); 326 + border-radius: var(--radius); 327 + color: var(--ink); 328 + box-shadow: var(--shadow); 329 + } 330 + .skypress-editor .iso-editor .edit-post-visual-editor { 331 + background-color: transparent; 332 + /* Breathing room above the first block so the toolbar isn't flush to the 333 + top edge of the framed surface. */ 334 + padding-top: 0.5rem; 335 + } 336 + /* Gutenberg sets `background: white` inline on the device-preview wrapper; 337 + only !important lets the paper surface show through (esp. in dark mode). */ 338 + .skypress-editor .iso-editor .edit-post-visual-editor .is-desktop-preview { 339 + background: transparent !important; 340 + } 341 + 342 + /* Editor toolbar. The bundled chrome hard-codes white (#fff) surfaces and 343 + near-black (#1e1e1e) ink/borders that ignore `prefers-color-scheme`, so the 344 + toolbar reads as a white slab in dark mode. Re-skin it from the tokens. SVG 345 + icons use `fill: currentColor`, so setting the button colour tints them too. */ 346 + .skypress-editor .iso-editor .components-accessible-toolbar, 347 + .skypress-editor .iso-editor .components-toolbar, 348 + .skypress-editor .iso-editor .components-toolbar-group { 349 + background-color: var(--paper-raised); 350 + border-color: var(--line-strong); 351 + color: var(--ink); 352 + } 353 + .skypress-editor .iso-editor .components-toolbar-group { 354 + border-right-color: var(--line); 355 + } 356 + .skypress-editor .iso-editor .components-accessible-toolbar .components-button { 357 + color: var(--ink); 358 + } 359 + /* Both toolbar rows (document tools: insert/undo/redo; and the block tools that 360 + appear on selection) sat flush against the editor's left edge — and the block 361 + row carried no inline padding at all, so it didn't line up with the row above. 362 + Give them a matching gutter. */ 363 + .skypress-editor .iso-editor .editor-document-tools, 364 + .skypress-editor .iso-editor .block-editor-block-contextual-toolbar { 365 + padding-left: 0.75rem; 366 + padding-right: 0.75rem; 367 + } 368 + 369 + .skypress-editor__status { 370 + margin: 0.75rem 0 0; 371 + color: var(--muted); 372 + font-size: 0.85rem; 373 + } 374 + /* Idle (no save feedback yet): collapse the live region out of view without 375 + leaving an empty gap. We can't use `display: none` here — that removes the 376 + element from the accessibility tree, and an `aria-live` region must stay 377 + rendered before its content changes for the first update to be announced. 378 + Clip it to zero size instead so it remains in the tree but takes no space. */ 379 + .skypress-editor__status:empty { 380 + position: absolute; 381 + width: 1px; 382 + height: 1px; 383 + margin: -1px; 384 + padding: 0; 385 + overflow: hidden; 386 + clip: rect(0, 0, 0, 0); 387 + white-space: nowrap; 388 + border: 0; 389 + }
+10
src/types/wordpress.d.ts
··· 28 28 declare module '@wordpress/block-library' { 29 29 export function registerCoreBlocks(): void; 30 30 } 31 + 32 + declare module '@wordpress/hooks' { 33 + export function addFilter( 34 + hookName: string, 35 + namespace: string, 36 + callback: ( ...args: unknown[] ) => unknown, 37 + priority?: number 38 + ): void; 39 + export function applyFilters( hookName: string, value: unknown, ...args: unknown[] ): unknown; 40 + }