A calm place to write long-form, and publish it to the open social web.
skypress.blog/
Working agreement for SkyPress#
SkyPress is built from a detailed brief (see docs/ and the original build brief). This
file captures the durable, non-obvious constraints an agent needs before touching code.
Read the relevant docs/decisions/* and docs/specs/* before working in an area.
Process#
- Brainstorm → spec → plan → build, test-driven. Write the failing test first.
- Record non-obvious calls in
docs/decisions/NNNN-title.md(context, options, choice, why). These are first-class deliverables. - Vertical slices over horizontal layers. Keep the app runnable at every step.
- Where specs live.
docs/specs/sp0–sp12are the archive of the initial v1 build (all Built/Complete) — read them for the "how" and the cross-referencedDepends onhistory, but don't extend the SP numbering. New, post-v1 work gets a dated design + plan pair underdocs/superpowers/specs/anddocs/superpowers/plans/(e.g.2026-06-09-editor-page-rework-design.md). Durable rationale still graduates to adocs/decisions/NNNN-*.md.
Hard constraints (learned the hard way — see decisions)#
- React 18 only.
@wordpress/block-editor@15.x→@wordpress/elementpeer-depends onreact@^18. Do not introduce React 19. (Decision 0001) — This pin is upstream-driven: Gutenberg tried React 19 and reverted it (WordPress/gutenberg#78940, merged 2026-06-04; plugins bundling a legacyreact/jsx-runtimetripped React 19's element-symbol check, react.dev/errors/525). Upgrade trigger: when Gutenberg re-lands React 19 and@wordpress/element's peer range opens to^19, bumpreact/react-domand drop thereact/react-domkeys fromoverridesinpackage.json. It's a normal version bump, not an architecture change (Decision 0021). - SkyPress depends on
@wordpress/block-editorand friends directly at the current release line; there is no@wordpress/*overridesmap (onlyreact/react-domstay pinned). Depending directly, the tree resolves to a single coherent copy of every store singleton —@wordpress/data,core-data,element,blocks,block-editor— without any overrides. The old duplicate-registry crash (reading 'get' of undefined) came specifically from IBE pinning an old line while transitive caret ranges floated a newer one; that root cause is gone. Upgrading is now a normal@wordpressversion bump, not regenerating an override map. One caveat:core-data/notices/dateinstall as multiple nested copies (no hoisted top-level one) and each registers its store →Store "core" is already registered+ a split registry. The fix is the bundler dedupe backstop — runnpm dedupe, then list them inresolve.dedupein bothastro.config.mjsandvitest.config.ts(deduping before the hoist breaks the build). (Decision 0021 — supersedes the pinning half of Decision 0003.) - Reading pages must never import
@wordpress/*. The editor stack is browser-only (toucheswindow/moment/registries at import) and cannot render server-side. Use the dependency-freesrc/lib/blocks/render.ts.@wordpressbelongs only in the editor island and in tests. (Decision 0003) - Render fidelity is test-locked.
render.tsmust match@wordpress/blocks.serialize()for the curated blocks —render.test.tsenforces it. Adding a block means adding arender.tscase and a fidelity assertion. - Curated block allowlist is the content model. Add blocks deliberately; removing one after content exists is a breaking change. (Decision 0002)
- Untrusted content: stored block trees come from arbitrary PDSes. The reader
sanitises HTML before injecting it (
src/lib/reader/sanitize.ts). Three standing rules for the read path: (a) any server-sidefetchto a host derived from user input (a handle,did:web, a PDSserviceEndpoint) MUST go throughsrc/lib/net/safe-fetch.ts(SSRF guard); (b) never inject PDS-sourced HTML without sanitising — turn document blocks into HTML throughsrc/lib/reader/render-article.ts, which runs blob-resolve → render → sanitise in the one safe order (Decision 0018); (c) read routes resolve author/publication/document throughsrc/lib/reader/read-context.ts— don't re-assemble the handle → DID → PDS → slug-match → site-join chain in page frontmatter. (Decision 0016) - OAuth is a browser public client (
@atproto/oauth-client-browser, Decision 0004). In dev you must serve onhttp://127.0.0.1:<port>, notlocalhost(atproto loopback requirement), and the loopbackclient_idmust be path-less — seesrc/lib/auth/oauth.ts. Auth + editor live in theStudioclient-only (client:only="react") island — the editor is composed directly from@wordpress/block-editor, not by wrappingIsolatedBlockEditor. - Colocated tests under
src/pages/MUST be underscore-prefixed (e.g._index.meta.test.ts). Astro's file router imports every.tsinsrc/pages/during static-path collection; a*.test.tsthere runs its top-levelimport … from 'vitest', which throws outside the vitest runner → the build's prerender server 500s (Vitest failed to access its internal state). A leading_makes Astro ignore the file as a route while vitest'ssrc/**/*.test.tsglob still finds it. @wordpress/*is inlined viassr.noExternalin the vitest config, butmoment/moment-timezonestay external (native CJS). Inlining moment breaks moment-timezone's augmentation of it (moment.tzends up undefined). Separately,@wordpress/block-editorships no types, so its surface is declared insrc/types/wordpress.d.ts.
Product guardrails (from the brief)#
- SkyPress is an editor + OAuth client + public renderer — never a PDS or relay.
- OAuth only (no app passwords). Secrets never in the client.
- Don't surprise users: publishing also creates a Bluesky post — the UI must say so.
- Lexicon discipline: prefer optional fields + open unions; treat shipped constraints as
frozen (additions only, else
-v2). License: GPL-2.0-or-later.
Commands#
npm run dev | build | preview | test | check