Skip to content
Documentation
Browse the library

All project documents

Use Find to search this page, or Print to save it as PDF.

Contents

LOGO.md · Simplified edition

PageHare logo — the pagehole system (binding)

Status: v1.1 — refined marks and four approved motion states. These brand rules apply to agents and people. If a request conflicts with a rule, stop and explain the conflict before making the change. Informative history lives in docs/design/brand-history.md (read-only).

The idea

One shape: a hole (an ellipse) with a hare's ears rising from it. The hare lives in the page. Everything in the system is a state of that one hole — emerging, spiraling, missed, or diving. The hole ellipse is the same shape in every mark; only what comes out of it changes.

Where the assets live

WhatWhere
Canonical currentColor SVG sourcespackages/app/src/lib/brand/*.svg
Mark component (inlines the sources)packages/app/src/lib/brand/BrandMark.svelte
Header lockup (responsive)packages/app/src/lib/brand/BrandLockup.svelte
Mode-1 tenant attributionpackages/app/src/lib/brand/PoweredBy.svelte
Motion family (CSS-only SVG)packages/app/src/lib/brand/BrandMotion.svelte
Accessible loading wrapperpackages/app/src/lib/components/PageholeLoader.svelte
Loader consumer rulespackages/app/src/lib/components/useDelayedLoading.svelte.ts
Favicons / PWA / apple-touchpackages/app/static/ (wired in app.html via %ph.icons%)
Empty-state artpackages/app/src/lib/brand/empty-state.svg (mark E)
Non-served exports (avatars, banners, motion)brand/exports/
Email lockupspackages/app/src/lib/email/assets/
Retired previous brandbrand/retired/

Usage, asset regeneration, and verification are documented in docs/design/brand-assets.md. The /styleguide brand section renders the actual components, including replayable one-shot motion.

Marks use currentColor. Include their SVG directly in Svelte components at build time. Do not import them as base64 or send SVG strings inside JavaScript. They use the surrounding text color, so they follow the active theme.

Variant table

VariantNameWhat it isUse for
AmasterEars rising from the hole, inside the page frameApp icon ≥ 32px, splash, hero
BbareEars + hole, no frameSmall sizes (< 32px), lockup fallback, powered-by, dense UI
DspiralConcentric ripples around the holeOccasional decoration only; never favicon, app icon, or status
EmissedHole with faint miss-arcs, ear half-sunk404 and empty states — "you fell too far"
—lockupMark + "PageHare" wordmark, one SVGHeader/nav when there is room

There is no static dive mark. The dive (ears sink, ripple, pop back) exists only as motion — BrandMotion, its PageholeLoader wrapper, and the brand/exports/motion/ files. Never render a frozen dive pose.

Handoff rules

  • Lockup ↔ mark B at 120px. The full lockup shows when it has ≥ 120px of inline space; below that it collapses to the bare mark B. Implemented as a container query in BrandLockup.svelte — it reacts to the space it is given, not the viewport.

  • Mark A ↔ mark B at 32px. The framed master mark loses legibility below 32px; hand off to the bare mark B at small sizes (Logo.svelte).

  • Favicons use B; app icons use A. Browser tabs must remain legible at their smallest size, so the scalable favicon and 16px raster use B; 32px and 48px raster favicons use A. The ICO contains those same raster sizes. PWA and apple-touch icons use A with safe padding. Never use the spiral D on either surface.

  • Keep the recognition cues. The refined A uses a lighter page frame and a modestly larger hare-and-hole group; canonical SVG sources own the geometry. Keep the bare B silhouette, serif wordmark, and existing theme palette.

  • Social introductions use the recognizable logo and name. Banners show the PageHare lockup and a short product promise, placed clear of common profile-image crops. E is supporting state art, not the brand introduction.

  • Dive is motion-only. See above.

  • Mode 1 (tenant custom domains): tenant surfaces get mark B "powered by" only. PageHare favicons and OG marks NEVER render on a tenant domain — the hooks.server.ts tenant branch serves the tenant's favicon or none (renderBrandHead in server/theme.ts).

Motion family

BrandMotion animates the existing ears around a fixed hole. Its SVG and CSS ship without a player, animation library, raster frames, or animation JavaScript. The hole's proportions stay canonical: the ears, ripple, and brief accent strokes carry the gesture; the hole itself does not stretch or bounce.

VariantNameUsePlayback and resting pose
diveQuiet diveDefault loading1.8s loop; sinks, makes one ripple, returns to B
earsCurious earsSmaller or quieter loading1.8s loop; ear flicks while the silhouette remains visible
missedWrong burrow404 and empty states2.8s once, then rests as E
successA small hooraySuccessful publishing1.4s once, then rests as B
  • One-shot means once per displayed state. Do not loop 404, empty-state, or success motion. The styleguide has an explicit replay control for review.

  • Success follows the completed publish. Never celebrate autosave, a pending request, or a failed publish. Preserve the ordinary text confirmation.

  • Motion carries no essential information. Use visible state text and appropriate status semantics alongside it. BrandMotion is always decorative; PageholeLoader supplies the accessible loading announcement.

  • Reduced motion is a deliberate resting pose. Disable all animation and show B for loading/success or E for missed. Do not freeze a diving frame or require JavaScript to reach the resting state.

  • Small brand gestures are the scoped exception to DS §7. The approved dive return and success stretch may overshoot modestly. Ordinary UI motion remains restrained; this is not permission to bounce controls or page content.

  • Reader output remains zero presentation JS. A CSS-only missed state may accompany an empty archive or reader error; it does not enable hydration.

Loader UX (consumer rules)

PageholeLoader accepts dive (default) or ears. The loader is a dumb visual; the timing rules live in useDelayedLoading:

  • Don't show for loads shorter than 300ms — a flash of the dive is worse than no loader.

  • Once shown, hold for at least one full cycle (~1.8s) — a half-swallowed hare reads as a glitch.

  • prefers-reduced-motion is honored inside BrandMotion: static B, no dive, ear flick, or ripple.

Themes

The brand ships three themes (repo packages/app/src/lib/styles/): Warren (default — near-white canvas, forest-green accent, baked into tokens.css), Burrow (terracotta) and Twilight (indigo) as [data-theme] files. Themes reassign Tier-1 tokens only; because marks are currentColor, they recolor for free. AA is enforced in both modes by scripts/check-theme-contrast.mjs.

The cold-read ritual (before shipping any new or edited mark)

One person, zero context, three checks: squint (does it still read as a hare in a hole?), upside-down (does the silhouette hold?), and at 24px (is it legible as a favicon?). If any fails, it is not ready.

PAGEHARE_DS.md · Simplified edition

PageHare Design System (PAGEHARE_DS.md)

Status: v0 — foundational, expected to evolve. Rules here are stable; the token values and component inventory grow over time. When they change, update this doc in the same commit.

For agents / instructions: Read this file before creating or modifying any UI, style, token, block, or theme. It is the single source of truth for how to build in this codebase. The token values live in the CSS files it points to; this doc holds the rules. If a request conflicts with an invariant below, stop and surface the conflict rather than diverging.

0. Quick reference (one screen)

ThingWhere / What
Tier 1 theme tokens (--ph-*)packages/app/src/lib/styles/tokens.css — the only place theme values are defined
Tier 2 block tokens (--{block}-*)packages/app/src/lib/styles/blocks.css — defined on the block's selector, derive from Tier 1
Named themespackages/app/src/lib/styles/themes/*.css — [data-theme] blocks reassigning Tier 1 only
Block shapes + token surfacesdocs/PAGEHARE_BLOCKS.md
Living examplethe /styleguide route, rendered by the real block components (see §4)
Layer order@layer reset, tokens, blocks, overrides; — declared once, first
Historical reference inputsdocs/design/reference/ — the original token/example artifacts; read-only history, never edited after Phase 1 lands

The five invariants, compressed: (1) typography is the design — space and tone, not boxes; (2) every design value reads a token — no raw values (lint-enforced); (3) native HTML before any JS component; (4) reader ships ≤ ~20 KB CSS, one font, zero libraries, zero presentation JS — behavioral JS only via per-block budgeted islands, and no other script except the ones listed in §2 "Reader script exceptions"; (5) dark mode and all theming = token reassignment via light-dark() and custom properties — never parallel rulesets, never selector overrides.

1. What this is

PageHare DS is the project's own design system. It has three parts:

  • Tokens — a two-tier CSS custom-property layer (the whole "design system").

  • Primitives — native HTML elements styled on tokens, plus a handful of bits-ui components in the editor only.

  • Patterns — the content blocks (prose, header, items, qa, …), which are the real component library.

The system supports long-form reading with a small download. Each author can have a theme. Make design choices that support these goals.

2. Invariants (do not violate)

  1. Reading is the product. Typography and rhythm are the design. Structure comes from whitespace and faint tone, not cards/borders/shadows.

  2. Tokens are the only design values. No raw hex, rgb, or px design values in component styles — everything reads a token. (Enforced by lint; see §9.)

  3. Native HTML first. Reach for a JS component only when the platform genuinely lacks the primitive. <details>, <dialog>, Popover API, native form controls before anything else.

  4. The reader surface ships near-nothing. Zero component libraries, zero Tailwind, zero presentation JS on the reader. Budget: ≤ ~20 KB CSS, one subsetted variable font, inline SVG icons only. Behavioral JS (a stateful block like a poll) is allowed only via the per-block island mechanism: an interactive block's JS loads only when that block is present on the page, each island carries a size budget, and pages without interactive blocks remain byte-identical to zero-JS output. Any other script on a reader page is forbidden unless it is in the list under "Reader script exceptions" below.

  5. Derived, not duplicated. Dark mode, theme variants, and per-block styling are reassignments of the same tokens — never parallel rulesets. One mechanism: color-scheme + light-dark().

  6. Editor may cost more than the reader. bits-ui (headless, no Tailwind) is allowed in the author/editor/settings surface for the hard primitives only (combobox, select, dropdown menu, tooltip). It never leaks into reader output.

  7. Accessibility is a floor, not a feature. Visible keyboard focus, correct landmarks/semantics, WCAG AA contrast, prefers-reduced-motion respected — on every component, every phase.

Reader script exceptions (the whole list)

The rule is invariant 4: a reader page ships no script. It still holds, and an exception here does not loosen it. This table is every script a reader page may load. A script that is not in it must not ship on a reader route (/a/[handle], /a/[handle]/[slug], and the same pages on an author's subdomain), whatever its size and however it is loaded: inline, <script src>, an ?url import, {@html}, or turning csr back on.

ScriptLoaded byWhat it doesWhy markup cannot do itApproved
components/public/readerShare.jsReaderActions, only when it is given a page to shareShows the hidden Copy link and share-sheet controls; copies the address; opens the device's share sheetThe clipboard and navigator.share are script-only APIsOwner, 2026-10-13 (PH-65, §10)

Not scripts, so not on the list: the JSON-LD block in the page's head (type="application/ld+json" is data and never runs), and a future per-block island, which is governed by invariant 4's island mechanism and its own budget.

Also not on the list: the two frames a reader page carries (components/public/IntentFrame.svelte, owner's decisions, 2026-10-13, PH-74 and PH-79, §10). Subscribe sits in the author card (/embed/subscribe); the reader's account, their avatar, name and sign-out, sits at the end of ReaderBar, the bar across the top of an article and a publication page (/embed/account). Each is an <iframe> onto the reader origin's /embed/, a separate document on another origin with its own script. The reader page's own document still loads no script for them, is not hydrated, and is byte-identical for every reader; each frame's address is a function of the record. A third frame, or a frame onto anything but the reader origin's /embed/, is a new owner decision, exactly as a new script would be.

Every entry must keep to all of these:

  • The page works without it. What the script adds ships hidden and the script shows it; where it does not run, nothing looks broken or inert.

  • Plain JavaScript in one file, with no imports: no framework, no library, no app module. It is loaded as its own asset (?url&no-inline), and the route stays csr = false, never hydrated. An ?url asset is served exactly as written, not minified, so the file carries no comments beyond one pointer line; its documentation lives in the component that loads it.

  • It knows nothing about the reader: no cookie, no storage, no identifier, no network request. It acts only on the reader's click.

  • It renders nothing that markup could render, and it never changes the article's content.

Adding an entry is the owner's decision, never an agent's. A request that needs script on a reader page is a conflict with invariant 4: stop and surface it, with the script-free alternative, before writing anything. If the owner approves, add the row here, log the decision in §10, and count the file against the budgets in size-budgets.json. Growing an existing entry beyond what its row says is a new decision, not maintenance.

3. Architecture

Two levels of CSS tokens:

  • Tier 1 — theme tokens (--ph-*) at :root. Color, type scale, spacing, radius, shadow, motion, stacking (--ph-z-*). This is what a theme is. Named themes and per-tenant themes reassign only Tier 1.

  • Tier 2 — block/component tokens (--{block}-*) defined on the block's own selector (e.g. .ph-qa { --qa-rule: var(--ph-border-subtle); }) — never at :root. Tokens ship only with the block that uses them; components read their Tier 2 tokens directly, with no fallback value. Per-block overrides reassign Tier 2 on the block wrapper (normal custom-property cascade wins).

Cascade order is fixed with @layer:

@layer reset, tokens, blocks, overrides;

Reset first, then Tier 1 tokens, then block (Tier 2) definitions and global block styles, then narrow global overrides. This keeps specificity flat — no !important, no selector wars.

Critical Svelte caveat — all theming is token reassignment, never selector overrides. Svelte emits component-scoped styles unlayered, and unlayered CSS beats every @layer — so no layer, including overrides, can out-cascade a scoped component style at the selector level. This is fine by design: custom properties cascade into scoped styles regardless of layers, so themes, tenant config, and per-block overrides all work by reassigning tokens. The overrides layer exists only for global (non-component) CSS. Never write a selector-level override targeting a component's internals and expect it to win — it won't, reliably.

Responsive policy: blocks adapt via container queries on the block wrapper, not viewport media queries. Blocks render at non-viewport widths (editor preview, Mode-2 headless embeds), so viewport-keyed styles are wrong there by construction. The only viewport-level media queries allowed are user-preference ones (prefers-reduced-motion, prefers-contrast).

Stacking: any z-index must use a --ph-z-* token. Raw z-index values are a lint failure.

A component never: hardcodes a value, reads a Tier 1 token directly for a themeable property (it reads its Tier 2 alias), ships its own reset, or uses a viewport media query for layout.

4. Documentation set (what an LLM needs to build correctly)

Keep this design-system document set. Do not add duplicate sources. Each file answers a different question:

FileQuestion it answersKind
PAGEHARE_DS.md (this file)How do I build here? What are the rules?Rules — prose
packages/app/src/lib/styles/tokens.cssWhat are the exact Tier 1 names & values?Values — code (source of truth)
packages/app/src/lib/styles/blocks.cssWhat Tier 2 surface does each block expose?Values — code
packages/app/src/lib/styles/themes/*.cssWhat named themes exist and what do they override?Values — code
docs/PAGEHARE_BLOCKS.mdWhat blocks exist, their shape (Zod) and token surface?Spec — prose + schema
/styleguide routeWhat does correct output actually look like?Living example — runnable
§10 of this file (Decision log)Why is it this way? What was rejected?History — prose

Living-example succession (done, v0.2): the static docs/design/ds-example.html was the v0 target; it has been deleted and succeeded by the /styleguide route, rendered by the actual Svelte block components — the same "preview = real renderer" principle that governs the editor preview. A hand-written HTML twin of the renderer will drift; the styleguide is output of the real system, not a parallel artifact. The original artifact survives only as read-only history in docs/design/reference/.

Why this split matters for an LLM: rules live in Markdown (stable, explains intent), values live in CSS (single source, no drift, self-documenting via comments), shape lives with the block spec, and a runnable example gives the model something to pattern-match instead of inventing. Values are never duplicated into the Markdown — the CSS files are canonical, and this doc points at them. If you find a value written in two places, that is a bug.

Rule for agents: before writing a component, read (a) the relevant block entry in docs/PAGEHARE_BLOCKS.md, (b) its Tier 2 tokens in blocks.css, and (c) the matching rendering on /styleguide. Reproduce, don't reinvent.

5. Naming conventions

  • Tier 1: --ph-{category}-{role} → --ph-text-body, --ph-accent-text, --ph-space-4, --ph-border-subtle, --ph-z-modal.

  • Tier 2: --{block}-{role} → --qa-rule, --items-bg, --header-size. Defined on the block's selector in blocks.css, deriving from Tier 1.

  • Themes: [data-theme="{name}"] reassigning Tier 1 only.

  • Components read their Tier 2 token directly — var(--qa-rule), no fallback value. The block's selector definition is the default; a fallback would be a second, drift-prone place the default lives. If a token is undefined, that's a bug the styleguide makes visible, not something to paper over.

Consistency of these names is what lets an agent (or the theming UI) target the system programmatically. Treat the naming scheme as an API.

6. Theming model

  • Default theme ships in tokens.css (serif reading type, cool Radix-Slate canvas, muted-green accent).

  • Named themes are Tier-1-only override files under themes/.

  • Per-tenant theme = a config record in the PageHare DB (presentation state, never written to the PDS). Loaded in hooks.server.ts and emitted as a :root style block at SSR time — so the reader ships zero theme JS and no FOUC.

  • Per-block overrides reassign Tier 2 tokens on a block wrapper; exposed in the theming settings page under an advanced section.

  • light-dark() shapes the config schema — this is a real constraint. Because every color token binds its light and dark values in one declaration, tenant theme configs must store paired values per color token (light + dark, or a documented derivation of dark from light). The settings page captures both; the SSR injection emits light-dark(l, d) pairs. This is a schema decision, not an implementation detail — the theme-config table must reflect it from v1, with a schemaVersion field (see §8 deprecation policy).

  • Fallback policy for reach: browsers without light-dark() support must receive the light values — declare the plain light value immediately before the light-dark() declaration where fallback is needed; supporting browsers override it naturally. Apply the same pattern-or-fallback discipline to Popover API, @starting-style, View Transitions, and anchor positioning, documenting the fallback next to each usage.

  • Contrast is enforced at the edge, on both modes, at both tiers: when a tenant changes any color — the top-level accent or a per-block Tier 2 color override — the settings page checks AA against the relevant background for the light and dark values separately and warns before saving. The system guarantees AA for the default theme; it cannot guarantee it for arbitrary tenant colors, so the check lives where the color is chosen.

7. Motion

CSS only. Transitions + @starting-style for entry/exit, View Transitions for navigation as progressive enhancement. Durations/easings are tokens (--ph-duration, --ph-ease). A global prefers-reduced-motion: reduce block zeroes durations. Nothing is animated in JS. Ordinary UI motion stays "barely there" — fades and a few px of travel, never bounce.

Scoped brand exception: the four approved BrandMotion states in LOGO.md may use the small ear movements, dive return, and success stretch of the brand gesture, including modest overshoot. The canonical hole stays fixed. Only loading variants loop; missed/404 and publish-success states play once and settle. Reduced motion renders the static B or E mark with no animated transition. This exception applies to the brand mark only, never controls, reader content, or general page motion. Component motion tokens live with the brand token surface; /styleguide renders the shipping components.

8. Scaling the system (how to extend — read before adding anything)

Follow these steps when extending the system. Use an existing component or concept when it can meet the need.

Add a Tier 1 token — only when a value is genuinely theme-level and reused. Add to tokens.css under the right category, follow naming, provide a light-dark() pair if it's a color. Never add a one-off Tier 1 token for a single component (that's a Tier 2 token).

Add a Tier 2 token / extend a block's surface — add to blocks.css, derive from a Tier 1 token, document the new token in that block's docs/PAGEHARE_BLOCKS.md entry. Make /styleguide render it. Note that blocks.css is lint-exempt, so it holds the token surface only: a block's visual rules belong in its component's scoped styles, where lint:tokens can see them.

Add a new block — justify why an existing block can't absorb it (collapse first). Then: define its Zod shape and token surface in docs/PAGEHARE_BLOCKS.md, add its Tier 2 block in blocks.css, build the Svelte component reading only those tokens, add it to /styleguide. Reader blocks are native HTML + scoped CSS, no libraries.

Add a named theme — new file in themes/, reassign Tier 1 only, verify AA for text/accent pairs, add to the theme preset list surfaced in settings.

Add an editor primitive — first try native HTML. If genuinely insufficient (combobox, complex listbox), use bits-ui headless, style with scoped CSS on tokens, keep it out of any reader path.

Anti-patterns that will be rejected: a new component that duplicates a block's job; a Tier 1 token used to style one component; a value hardcoded "just this once"; a parallel dark-mode ruleset; a selector-level override of component internals; raw z-index; a viewport media query for block layout; any Tailwind or shadcn dependency; a library on the reader surface.

Change & deprecation policy (tokens are an API — stored tenant themes depend on the names):

  • Tokens are add-and-deprecate, never rename-in-place or remove abruptly. Tenant theme configs in the DB reference token names; a silent rename breaks every stored theme.

  • To rename: add the new token, alias the old to it (--ph-old: var(--ph-new);) marked /* @deprecated vN: use --ph-new */, keep the alias for at least one version, and record it in the decision log.

  • Theme configs carry a schemaVersion; when tokens change shape, write a migration for stored configs — never leave configs referencing dead names.

  • Changing a token's value (not name) in the default theme is allowed but is a visual change to every tenant on defaults — note it in the decision log and re-verify AA.

Definition of done — every DS contribution (new block, token, theme, or primitive) must satisfy all of these before merge:

  • All design values read tokens; lint:tokens passes

  • Tier 2 surface (if any) documented in docs/PAGEHARE_BLOCKS.md

  • Rendered in the styleguide (/styleguide route)

  • AA contrast verified in both light and dark

  • Keyboard-operable with visible focus; correct semantics/landmarks

  • prefers-reduced-motion honored

  • Reader payload budget still met (if the reader surface is touched)

  • Any deprecation recorded per the policy above

9. Enforcement

  • Token enforcement (property-scoped, not value-regex): use Stylelint with stylelint-declaration-strict-value requiring var() for: color, background/background-color, border*-color, outline-color, fill, stroke, font-family, font-size, line-height, box-shadow, z-index, and spacing properties (margin*, padding*, gap). This enforces "reads a token" per property — a value-regex banning hex/px is trivially bypassed by rem/em/keywords. Allowed exceptions: 0, 1px hairlines, 100%, transparent, currentColor, inherit/keywords, and the token-definition files (tokens.css, blocks.css, themes/). CI fails on violations, printing file:line.

  • Layer order: app.css must declare @layer reset, tokens, blocks, overrides; once, first.

  • Reader payload budget: a size check on the built reader CSS (≤ ~20 KB) and a guard that no library CSS is imported into reader routes.

  • Reader script: reader routes set csr = false, and the only scripts a reader page may load are the ones in §2's "Reader script exceptions". There is no automated check for that list yet; review holds it.

  • Browser support policy: features must be Baseline or progressively enhanced with a defined fallback — currently relevant: light-dark(), Popover API, @starting-style, View Transitions, CSS anchor positioning. Document the fallback next to the usage.

  • A11y checks: keyboard focus visible, landmarks present, AA contrast, reduced-motion honored — part of each phase's verification.

10. Decision log (append-only — the "why")

  • Dropped Carbon + shadcn + Tailwind. Two design vocabularies fighting over :root; app-chrome payload on prose pages. Replaced with owned tokens. (v0)

  • bits-ui directly, no shadcn. shadcn-svelte is styled wrappers over bits-ui + Tailwind; we own styling, so the wrapper is overhead. bits-ui headless, editor-only. (v0)

  • Serif / cool-slate / muted-green default. Serif for calm long-form reading (Literata); cool Radix-Slate canvas keeps the serif from tipping cozy; muted cool-leaning green harmonizes with slate. (v0)

  • Values in CSS, rules in MD. Prevents dual-source drift; the CSS files are canonical. (v0)

  • Rejected: Skeleton / Material / Polaris. They ship their own token systems and app-scale payload — the Carbon problem again. (v0)

  • Tier 2 moved from :root to block selectors; fallbacks removed. Dual defaults (:root definition + var() fallback) were two drift-prone sources; block-scoped definitions also stop pages carrying unused token surfaces. (v0.1)

  • Theming is token-reassignment only — selector overrides declared unreliable. Svelte scoped styles are unlayered and beat all @layers; custom properties are the one mechanism that cascades through. (v0.1)

  • light-dark() pairing acknowledged as a schema constraint. Tenant theme configs store paired light/dark values per color token, with schemaVersion; AA checked per mode. (v0.1)

  • Static example declared transitional. Succeeded by a /styleguide route rendered by real block components (preview = real renderer). (v0.1)

  • Add-and-deprecate token policy + definition-of-done adopted. Token names are an API consumed by stored tenant themes. (v0.1)

  • Lint upgraded to property-scoped stylelint-declaration-strict-value. Value-regex was bypassable via rem/em/keywords. (v0.1)

  • Interactive blocks admitted as budgeted islands. Reader default stays zero-JS; stateful blocks (polls etc.) hydrate per-block, load-on-presence, size-budgeted. Live state is client-fetched, never SSR'd (ENGINE*INVARIANTS #16). *(v0.2)_

  • Static example deleted; /styleguide is the living example. The succession planned in §4 is executed — the styleguide renders the real block components, so it cannot drift from reader output. docs/design/reference/ keeps the original artifact as read-only history. (v0.2)

  • blocks.css holds the token surface only; block visuals live in scoped component styles. blocks.css is exempt from lint:tokens (literal values legitimately live there), so any visual rule written in it would silently escape token enforcement. Splitting them keeps every design value linted. (v0.2)

  • Reader route sets csr = false. "Zero presentation JS on the reader" is now enforced by the framework rather than by discipline: /p/[slug] is server-rendered and never hydrated. Blocks are native HTML, and <details> carries the page's only interactivity. Budgeted islands (§2.4) will opt back in per block when a stateful block ships. (v0.2)

  • Renderer presets replace the Tailwind variants prop. The blocks' old variants map (card/hero/highlighted/bordered) was dead code — nothing ever passed it — and it contradicted §2.1: structure comes from whitespace and faint tone, not boxes. Removed. The preset seam survives as typed renderer props; the only one today is prose's lead, set by the route on the document's opening prose block. Presets are renderer props, never schema fields, so presentation never round-trips through the PDS. (v0.2)

  • Added Tier 1 --ph-leading-none and --ph-backdrop. Both are theme-level and reused (single-line controls; every modal scrim), and both sit on lint:tokens-gated properties (line-height, background) where the DS example had used literals. Additive only — no existing token changed. (v0.2)

  • Block responsiveness realized as container queries. items goes two-up at a 48rem container width, chosen wider than --ph-measure so the reading column never splits and only wide embeds / editor previews do. The threshold is a literal in the block's CSS, not a token: @container conditions cannot read var(). (v0.2)

  • Added Tier 1 status tokens, deliberately theme-invariant. --ph-{success,warning,danger}-* do not follow the accent: a failed save is red in every theme, and a named theme that recolored it would be lying about severity. AA is enforced for each status text on --ph-bg and on its own wash, in light and dark, by scripts/check-theme-contrast.mjs. (v0.3)

  • bits-ui not adopted; the platform covered every case. DS §2.6 permits it for combobox/select/dropdown/tooltip. In practice the editor's selects are short fixed option lists (native <select> wins on keyboard support, mobile pickers, and payload), and menus/popovers are the Popover API. No combobox exists yet. If one appears, bits-ui remains the sanctioned escape hatch — but the dependency is not paid for until then. (v0.3)

  • Popover placement pins to a measured rect, not CSS anchor positioning. Anchor positioning is not Baseline (no Firefox), and a popover lives in the top layer, so an ancestor's position: relative cannot place it. Menu/Popover measure the trigger once per open, in beforetoggle. Documented fallback per DS §6; revisit when anchor positioning lands. (v0.3)

  • Icons are an owned inline-SVG registry, not a vendor set. Icon.svelte + icons.ts replace carbon-icons-svelte: no icon font, no package, no network request. Names are semantic (launch, overflow) rather than pictorial, so a glyph can change without touching call sites. {@html} is confined to that constant registry — never user content. (v0.3)

  • Editor reading preferences became token reassignment. Font size, measure, and line height were Tailwind prose-* classes — inert once Tailwind's entry import was dropped, so the settings had silently stopped doing anything. They are now data-* attributes on the ProseMirror element that reassign --editor-* on the subtree. (v0.3)

  • The editor renders the reader's typography. The writing surface is serif, one measure, prose leading: what you see while writing is what publishes. (v0.3)

  • /styleguide imports the real primitives. Its gallery was hand-written HTML while only Button existed as a component; now every control on it is the shipping component. The living example is output of the system at both the block and the primitive layer. (v0.3)

  • Full revamp executed; Carbon/shadcn/Tailwind removed. packages/ui deleted, 14 packages uninstalled (tailwindcss + plugins, carbon-icons-svelte, bits-ui, tailwind-merge, tailwind-variants, tw-animate-css, clsx, …), the Tailwind Vite plugin and Prettier plugin dropped. lint:tokens is blocking in CI. From here the repo files are canonical; docs/design/reference/ is read-only history. (v0.3)

  • pagehole brand adopted; theme set is Warren / Burrow / Twilight. The brand kit's themes.css names three themes with anchors --ph-canvas / --ph-ink / --ph-accent. Those names were mapped into the existing DS vocabulary (canvas→--ph-bg, ink→--ph-text-color, accent kept) rather than forked — the tenant-theming schema (server/theme.ts) already speaks bg/text-color/accent, so a parallel --ph-canvas/--ph-ink vocabulary would have split the API. Warren is the default (baked into tokens.css :root, replacing the old slate+green default); burrow (terracotta) and twilight (indigo) ship as [data-theme] files but nothing selects them yet. The prior named themes ink and sepia were retired (deleted) — burrow supersedes sepia, twilight supersedes ink; no stored tenant config referenced them (the tenant table is still a stub), so no schema migration was owed. (v0.4)

  • Warren default token values changed (visual change to every tenant on defaults). Canvas #fbfcfd→#fcfcfd, ink #11181c→#1c2024, accent #33805f/#4cc38a → #2f7e5b/#3fa97c, with --ph-accent-hover/-text/-wash re-derived. Per §8, a default-theme value change is allowed but re-verified for AA: scripts/check-theme-contrast.mjs passes in both modes (Warren on-accent 4.93:1). Marks are currentColor and inherit --ph-text-color/accent at the usage site, so they follow the theme with zero baked color. (v0.4)

  • Burrow solid-accent darkened from the kit swatch for AA. The kit's terracotta #C05B2E gives white-on-accent ≈ 4.38:1 (below AA); the solid-fill --ph-accent is derived to #b4551f (4.93:1) while --ph-accent-text stays in the kit's terracotta family. Accessibility is a floor (Invariant 7); the brand marks use currentColor, so the exact fill token can shift without touching the marks. (v0.4)

  • User appearance preference is a cookie stamped onto <html> at SSR. ph-appearance=<mode>.<theme> (strict allowlist: auto|light|dark × warren|burrow|twilight) is read in hooks.server.ts and emitted through a %ph.htmlattrs% placeholder as data-theme + inline color-scheme ($lib/appearance.ts). Attribute injection — not a <style> block — is required because named themes are [data-theme] selectors; it is also the one mechanism that reaches the zero-JS reader with no FOUC. This is the first consumer of the burrow/twilight files. Precedence: the user's inline color-scheme on <html> beats a future tenant :root pin by specificity — explicit user choice wins; auto emits nothing and defers to tenant/OS. Controls live on /settings only (per §8's preset list); the cookie is the single source of truth — appearance never enters the editorSettings localStorage blob. Constraint: any future prerendered route would leak the literal placeholder, since the hook transform runs per-request. (v0.4)

  • Byline and discuss link are page chrome, not blocks. A published page now carries an author byline (avatar, name, date, reading estimate) and, when the author announced it, a link to the Bluesky thread. Neither went through the block engine: blocks render content, and provenance is not content. Adding a byline block would have meant a Zod shape, a rule, fixtures, and a slot in the classification order for something that is never authored and never varies. Both are plain markup in the route beside the existing <footer>, on Tier 1 tokens, and /p/[slug] stays csr = false — verified by the reader-nojs Playwright project. (v0.5)

  • Comments are a link, not a component. PageHare hosts no comment system. On publish the author may mint an app.bsky.feed.post; its strongRef is stored on the record as bskyPostRef and the page renders a static permalink to the thread. The replies are the comments — moderated, spam-filtered and notified by Bluesky. This is what keeps ENGINE*INVARIANTS #16 intact: an SSR'd thread would be reader-generated state baked into a cacheable render, and a client-fetched one would put JS back on the reader. A link is neither. Rejected for launch: SSR'd reply lists, and the lazy island from docs/plans/app-public-split.md. *(v0.5)_

  • Author archive reuses PublishedPageCard; no new list primitive. /a/[actor] is the third surface listing published pages (homepage discover, dashboard, archive). Per §8's "collapse into an existing concept first", it renders the existing card rather than a fourth variant. The actor segment accepts a handle or a DID because the byline degrades to a DID when the AppView and PLC are both unreachable — a link that only worked in the happy path would be worse than no link. (v0.5)

  • Page details lives in one floating editor popover. A document icon at the right above the formatting toolbar opens native-popover sections for tags and a hosted cover image. The trigger and panel follow the visual viewport; the panel scrolls internally. Tags are shown and edited only within Page details, keeping the top bar clear. Tags save on commit; cover URL/description save only on Apply, and removal saves immediately. Failed saves keep their values with Retry, and publishing waits for applied metadata. Covers use HTTPS URLs and appear only in Page details, preview, and the published article; the writing area contains no cover. The popover shows the complete image within a 120px-high thumbnail. Preview and published banners use centered cropping: 16:9 below a 40rem content-container width and 2:1 at or above it, reserving space before loading. No uploads, crop-position controls, or card/social-thumbnail changes. This supersedes the pinned editable tag-row and v0.5 dialog decisions.

  • Added the tag icon to the owned registry. Per the v0.3 icon decision: no vendor set, semantic name, inner markup of a 24×24 stroke viewBox. (v0.5)

  • Toast viewport moved to bottom-center and lifted clear of the editor toolbar. --toast-inset-block is a component-local custom property on .viewport (the .toolbar { --toolbar-safe-pad } pattern), not a Tier 1 token — per §8, a value used by one component is not theme-level. (v0.5)

  • The sign-in handle typeahead is a hand-rolled ARIA combobox; bits-ui still not paid for. The v0.3 decision reserved bits-ui for "when a real combobox appears" — one has, and it was declined anyway. The reasons are specific to this combobox, not general: it has exactly one consumer, one option shape, no multi-select, no tag chips, and no free-form/filtered duality, so the headless machinery would cover about thirty lines of keyboard handling at the cost of a dependency reachable from /auth/signin. Input.svelte already spreads ...rest, so role="combobox" + aria-expanded/-controls/-autocomplete/-activedescendant attach to the shipping primitive with zero DS change. Revisit if a second combobox appears with different requirements — that is when a shared abstraction earns its keep, per §8. (v0.5)

  • HandleInput is auth-local, not ds/Combobox. §8's "one consumer is not a primitive": it lives in components/app/auth/ with no styleguide entry until a second caller exists. It is also the first panel to not use the v0.3 measured-rect technique — that exists because Menu/Popover hang off a bottom-pinned toolbar and live in the top layer; this list is anchored to an input inside AuthCard's panel, which has no scroll container and no overflow clipping, so position: absolute in a position: relative wrapper is both simpler and correct. Measured-rect placement is for top-layer popovers, not for every dropdown. (v0.5)

  • All three page lists collapse into one PageCard + PageCardGrid; supersedes the v0.5 archive decision. That decision reused PublishedPageCard for /a/[actor] but left the dashboard's inline <li> alone, so two copies of the same card drifted: tags capped at 3 vs 2, excerpts at 150 vs 100 chars, hover moving the border on one and the background on the other. $lib/components/PageCard.svelte is now the only page card, and PageCardGrid.svelte the only grid. PageCard is deliberately store-free and atproto-free — that is what lets the same component serve the csr = false archive and the authenticated dashboard; callers pass strings and compose their own CTAs through the status/actions snippets. (v0.6)

  • ds/ is sorted by dependency direction, not abstraction level. PageCard/PageCardGrid land in ds/ even though a primitive "does not know what a published page is" — §8's abstraction test is the wrong one to sort a folder by, because it answers "is this a primitive?" and the question a folder answers is "who may import this?". app/ is defined by reaching for app stores, the atproto client or app types; public/ by being public-route-only. A props-only component rendered by both layers satisfies neither definition, and parking it at the bare components/ root put it in a directory with no stated contract at all. The admission test for ds/ is therefore: token-only, no libraries, no app-layer imports, props in — and, per the v0.5 HandleInput decision, more than one consumer. PageCard has three. Nothing imports the ds/ barrel (every call site imports by path) and /p/[slug] imports only ds/Button.svelte, so folder membership carries no reader-payload cost. (v0.6)

  • Card geometry is fixed, not content-driven. Card height used to track excerpt length, so a grid row was a staircase. The title reserves two lines and the excerpt three (min-block-size: Nlh with a matching line-clamp), PageCardGrid sets grid-auto-rows: 1fr, and the footer is pushed down with margin-block-start: auto. A short page and a long one now produce the same card, and every footer in a row lands on the same baseline. The character cap (CARD_EXCERPT_CHARS) is a transport budget only; the CSS clamp decides where text actually stops, so the same page truncates identically everywhere. min-block-size is not token-gated, so the lh literals pass lint:tokens — and lh is the right unit precisely because it is derived from the element's own type tokens rather than a hardcoded pixel height. (v0.6)

  • Cards are paper on a tinted region — the inverse of the previous tint-on-paper. The card was --ph-bg-subtle on a --ph-bg page, which made it recede exactly where a listing needs its items to read as discrete things. It is now --ph-bg and the listing region behind it is --ph-bg-subtle. Still §2.1-compliant: the structure comes from tone and a hairline, with no shadow at rest. The excerpt also moves up from --ph-text-sm to --ph-text-ui — a card is a reading surface, and 14px prose was below the floor the rest of the system holds. The inversion improves contrast in both modes (--ph-text-muted goes 4.53:1 → 4.91:1 light and 8.45:1 → 9.06:1 dark, because --ph-bg is further from the text than --ph-bg-subtle was at both ends). The card also stops using --ph-text-faint, which is 3.16:1 on --ph-bg in light mode: the tag overflow counter and the "created in another app" excerpt are now --ph-text-muted, with italics carrying the de-emphasis instead of color. AA is a floor (§2.7); a token named "faint" is not a licence to go under it. (v0.6)

  • The 3-up column step was dead code; thresholds moved to 46rem / 68rem. Discover declared @container discover (min-inline-size: 72rem) on a container capped at max-inline-size: 72rem including --ph-space-5 padding under box-sizing: border-box — a 69rem content box that could never satisfy a 72rem query. Three columns had never rendered. Owning the steps in PageCardGrid fixes it once and removes the third copy of the same rules. Cautionary note for future container work: a threshold equal to the container's own max is always unreachable; leave room for the padding. (v0.6)

  • The whole card is the primary CTA, via a stretched link. The title's ::after covers the card; the share button, overflow menu and byline link re-layer above it with var(--ph-z-raised) (§3 — never a raw z-index). The clamp lives on an inner .title-clamp rather than the <h3> because overflow: hidden on an ancestor of the stretched link would clip the link's own hit area away. (v0.6)

  • The footer is the mobile route to the marketing pages; no hamburger in the public chrome. PublicHeader hid Discover / Why PageHare / About below 40rem with nothing behind them, so on a phone the three destinations were unreachable. The fix is not a drawer: the app shell's hamburger exists because a signed-in author navigates constantly between many surfaces, whereas a signed-out visitor has three destinations and is already scrolling a feed. Duplicating AppShell's data-open + backdrop + Escape machinery into components/public/ would put an interactive overlay on the landing surface to save a scroll. PublicFooter now carries the full set and stacks one-per-line at the same 40rem threshold, so the header staying lean is a choice with a destination rather than a dead end. Both components read one siteNav.ts list — the header hiding what the footer must show is exactly the drift a shared source prevents. (v0.6)

  • Active state is aria-current="page" plus colour; the sidebar's wash does not generalize to inline navs. app-sidebar.svelte marks the current row with --ph-accent-wash and --ph-accent-text, which suits a vertical list of full-width rows. Behind an inline header link the same wash reads as a button — a second CTA beside "Get started". The public navs take color: var(--ph-accent-text) alone: still §2.1's "tone, not a bar or a fill", and no font-weight change, which would reflow the nav on every navigation. The rule is declared after :hover because the two selectors have equal specificity, so source order is what makes current beat hover. (v0.6)

  • isCurrentPage special-cases /. Every path starts with /, so the prefix test that marks /about would mark Discover on every page. The root href matches exactly; the rest match exactly or on a /-delimited prefix so future child routes still light their parent. (v0.6)

  • The reader credit is its own footer (ReaderFooter), pinned by a page-level flex column. /p/[slug] and /a/[handle] each hand-rolled <footer><p>Created with <a>PageHare</a></p></footer> with duplicated styles, and neither stuck to the bottom: /p had main { min-block-size: 100vh }, which pushed the credit below the fold on every page, and a one-card archive left it floating mid-screen. Both routes now wrap their content in a min-block-size: 100dvh (with a vh fallback) flex column where main takes flex: 1 0 auto and the footer takes margin-block-start: auto — the same technique PageCard uses for its own footer, and the reason it is a page-level wrapper rather than position: fixed is that a fixed credit would sit over the prose on a long read. It is deliberately not PublicFooter: a published page owes the reader a quiet credit, not a site nav with a Sign in link. The credit reads "Published with PageHare" (it names what happened to the page, where "Created with" claimed authorship the tool does not have) and carries BrandMark variant b at 18px — per the LOGO.md handoff rule, the bare mark below 32px. That mark is compile-time-constant inline SVG on currentColor, so the reader surface stays zero-JS, zero network requests, and theme-following. (v0.6)

  • The signed-in nav is labelled groups from a config module, and shares isCurrentPage with the public nav. app-sidebar.svelte held a hardcoded two-item array; adding the publication and account destinations made the ordering carry meaning it could not express. The links now come from components/app/appNav.ts — the counterpart to components/public/siteNav.ts — grouped as Write / Publication / Account, because the surface splits by what you are acting on, not by importance. It is a function of the signed-in identity rather than a constant, since the publication link is the user's own archive and needs authorPath's did-while-the-handle-resolves fallback. Group headings are --ph-text-xs muted UI type in the sidebar's existing token set — no new tokens, and deliberately lighter than the rows so the eye lands on the links first. Active state drops the sidebar's exact-match test for the existing isCurrentPage from siteNav.ts (the v0.6 decision above), which also fixes a sub-path never lighting its parent row; the accent-wash treatment is unchanged, and per that same decision it stays sidebar-only. Items may set reload, which emits data-sveltekit-reload — /a/[handle] is csr = false and a client-side navigation into it would hydrate the reader surface inside the app shell. Two icons were added to the owned registry per the v0.3 rule (user, settings); Settings had been borrowing insert. (v0.6)

  • Correction to the ReaderFooter column: margin-inline: auto opts a flex item out of cross-axis stretch. /a/[handle] centred its measure by putting max-inline-size + margin-inline: auto on main itself, which is a flex item of the new .archive column. Auto margins on the cross axis suppress align-items: stretch, so main fell back to fit-content — a ~308px column on a 1084px viewport, with max-inline-size never binding. /p/[slug] escaped it by centring an inner <article> (a normal block) and leaving main unstyled apart from flex: 1 0 auto, which is the pattern to copy. The fix is inline-size: 100% on main, restoring the full-width basis the measure caps. The general rule for the page-level flex column: centre a block inside the flex item, or give the item an explicit inline-size; never rely on stretch alongside an auto inline margin. (v0.6)

  • View counts are an author surface, not reader chrome. Per-page analytics shipped in August 2026, and the counts appear only on the dashboard and /stats/[id] — never on /p/[slug]. This is ENGINE*INVARIANTS #16 applied literally: a published page's HTML must be a pure function of (record content, saved decisions, tenant theme), and a count is neither. The v0.5 "comments are a link, not a component" decision rejected the same shape for the same reason. Three alternatives were considered and dropped: a budgeted island (§2.4's sanctioned door) would have been the first crack in csr = false and required amending the reader-nojs Playwright project, for a number no reader asked for; a server-rendered SVG badge keeps the HTML pure by moving the number into an image, but an image cannot read Tier 1 tokens and its alt text cannot carry a live value accessibly; SSR'ing the count breaks #16 outright. Counting itself is a read-path side effect that writes nothing into the response, so the render stays pure and the page stays cacheable — the invariant constrains output, not requests. *(v0.7)_

  • PageCard was not touched; the count arrives through the existing status snippet. That preserves the v0.6 store-free/atproto-free property that lets one component serve both the csr = false archive and the authenticated dashboard. It also had to be plain text, not a link: .foot-start sits under the card's stretched title link (v0.6), so a nested link there is unclickable. The drill-through to /stats/[id] is an overflow-menu item, which works identically in the grid and the table. In the table — where there is no stretched link — the number itself is the link. Card geometry is unaffected: the count joins the footer, which is already margin-block-start: auto, so row baselines still align. (v0.7)

  • StatTile, BarList and ViewsChart live in components/app/stats/, not ds/. They are token-only and props-only, but they have exactly one consumer, and the v0.5 HandleInput decision is explicit that one consumer is not a primitive. They stay out of /styleguide until a second surface needs them. BarList and ViewsChart deliberately use no chart library — chart is still reserved in the fence registry and §8 lists a library on the reader surface as a rejected anti-pattern; a bar is one tinted backdrop sized by a custom property, which keeps the whole thing themeable. The one non-token value in each is a data ratio (--bar-fill, --col-fill) set via Svelte's style: directive: it comes from the counts, not from the design, which is why lint:tokens is right not to gate it. The bars are aria-hidden with the numbers beside them, and the chart carries a visually-hidden table so a screen reader gets 30 labelled values rather than 30 unlabelled columns. (v0.7)

  • Correction, applied while building the stats UI: --ph-text-faint is not a general de-emphasis token. Four new rules reached for it out of habit — the card's view count, the chart axis, the stat-tile hint, the empty-list line. The v0.6 card decision had already established it as 3.16:1 on --ph-bg in light mode, i.e. under the AA floor (§2.7), and every one of those surfaces is --ph-bg. All four moved to --ph-text-muted, with size carrying the de-emphasis instead of colour. The general rule this settles: --ph-text-faint is for decoration, never for information a reader is meant to read — if it has a number or a word in it, it is at least --ph-text-muted. (v0.7)

  • Reader actions are intents on pagehare.com, not components on the reader. Recommend, subscribe, bookmark and reply ship as plain <a> links on /p/[slug] to pagehare.com/intent/<action>?subject=<at-uri>; the action, the sign-in and any reader-specific state live on that app-surface page. This is the v0.5 "comments are a link" and v0.7 "counts are an author surface" decisions generalised: a link built from the record's AT-URIs is a pure function of content (ENGINE*INVARIANTS #16), needs no JS, keeps csr = false and reader-nojs intact, and works unchanged from any tenant domain — so no cross-domain reader session ever exists. Social writes are standard.site records (site.standard.graph.recommend / .subscription, key: tid) written browser → the reader's PDS; bookmarks are the only private reader state and live in the PageHare DB. Rejected: a tenant-host session handoff (login-CSRF and logout fan-out surface, a server-side OAuth client, for state the reader page must not render anyway); a reader-page island showing per-reader state (needs identity on the tenant origin); claps and 1–5 ratings (no standard lexicon, no interop, and ratings in a public repo deter honest scores). A public-count island remains possible later but must supersede v0.7 explicitly. Plan: docs/plans/reader-social.md. *(v0.8)_

  • The reader surface is served on author subdomains; the reader route is /a/[handle]/[slug]. A published page lives at https://<name>.pagehare.com/{title-slug}-{id} (the author's site.standard.publication url + the record's path), or at /a/{did}/{title-slug}-{id} until the author claims a name. The flat /p/[slug] route is gone, so wherever an earlier entry in this log says /p/[slug] it means this route: every rule attached to it (csr = false, zero presentation JS, the reader-nojs gate, counts never in reader HTML, reader actions as plain links) carries over unchanged. One new rule follows from the page being on a different origin from the app: reader chrome links to the app with absolute URLs. The editor, sign-in and API do not exist on a subdomain, so a relative /edit/… or / there is a 404 or the author's own archive; the routes pass appOrigin and the author's base in page data, and ReaderFooter takes a home prop for the credit link. Why subdomains: a DID in the path was unreadable, a handle changes, and a shared flat path cannot be verified per publication by other standard.site apps. Rejected: handle-in-path (rename breaks every link unless redirected through an index), a shortened DID (still opaque). Details: docs/architecture/storage.md, "Author subdomains". (v0.9)

  • Brand recognition is consistent from favicon to social banner. Small favicons use the bare B silhouette, app icons use framed A, and D remains occasional decoration. A's page frame is lighter and its inner hare-and-hole group slightly larger; the serif wordmark, B geometry, and theme palette are retained. Social banners lead with the recognizable PageHare lockup and a product promise instead of the missed-state illustration. Canonical SVGs, inline components, and regenerated exports share the same approved shapes. Rules and reproducible asset workflow: LOGO.md and docs/design/brand-assets.md. (v0.10)

  • Four brand gestures share one CSS-only component. BrandMotion owns the quiet dive and curious-ears loading loops, the missed/404 one-shot, and the publish-success one-shot. PageholeLoader retains loading semantics and consumer timing through useDelayedLoading; completed publishing alone triggers the success gesture. The approved modest ear overshoot is a scoped exception to §7, not a change to ordinary UI motion. The hole remains fixed, one-shots settle into established static marks, and reduced-motion users see B or E immediately. /styleguide demonstrates the actual components and an explicit replay control; reader empty/error states need no presentation JS. (v0.10)

  • v0.10 — Settings is tabbed (Publication · Appearance · Editor), and the tabs are not a DS primitive. The page had grown four unrelated cards on one scroll; the split follows who a setting is for — what readers see of you, how PageHare looks to you, how the editor behaves — so a Theme tab has an obvious home later. components/app/Tabs.svelte is native <button>s with tablist semantics (arrow keys, Home/End, roving tabindex, token-only styles); the selected tab lives in the URL hash so a reload keeps it. It has one consumer, so by the v0.5 rule it stays out of ds/ and /styleguide until a second surface needs it. Publication name and description use the existing Input on one line each — no textarea primitive was added for a single field.

  • v0.11 — ReaderActions is the reader page's one row of actions, and it is links only. The v0.8 decision put recommend, subscribe and bookmark on intent pages; this is the component that links to them (components/public/ReaderActions.svelte), placed after the article and before the Bluesky thread link. Each item is an <a> to <reader origin>/intent/<action>?subject=<at-uri> (see v0.12) — absolute, because the page is on another origin — carrying rel="nofollow". The links come from readerIntentLinks ($lib/intents.ts), a function of the record's document URI, its site and the deployment's reader origin, so two renders of one record are identical (ENGINE*INVARIANTS #16) and the route stays csr = false; no count, no per-reader state, no return parameter (the intent page resolves the article's address itself). It is not a block: like the byline it is page chrome, has no Tier 2 surface, and reads Tier 1 tokens in scoped styles — a bordered --ph-radius-md pill in UI type, deliberately the quietest thing that still reads as pressable, with the shared --ph-focus-ring. The three glyphs are written as literal <svg> in the component, on the same 24×24 grid and 1.5 stroke as Icon.svelte, and are not in ds/icons.ts: that registry is app chrome ("the reader surface uses none of these"), and importing it measured +3 kB gzip on the reader route's attributed payload and pushed it over its budget, for three paths. They move into the registry if an app surface ever needs the same glyphs. Reply is registered but not linked: the page already links to the Bluesky thread (v0.5) and F6 replaces that link. It lives in public/, not ds/: one consumer plus the styleguide. The intent page itself reuses AuthCard, HandleInput, Field, Alert and Button — no new primitive. *(v0.11)_

  • v0.12 — Intents live on a reader origin, read.pagehare.com; supersedes the "on pagehare.com" half of v0.8. v0.8's reasoning stands (links only, no reader session on author domains); what changes is which origin the links point at. One origin for both roles meant one OAuth session for both, and the app could not tell a reader from an author: a reader who signed in to recommend a page was registered as a publisher and shown the dashboard, and an author who did the same replaced the session they publish with. A separate origin is a separate OAuth client by construction, so the reader client declares no publishing permission (its consent screen can never offer one), the two sessions never share storage, and "going to the root asks again" is simply the publisher sign-in. It is also where the reader hub will live. Every publication still links to the one reader origin, so a reader signs in once for all of them. Rejected: sign-in on each author subdomain (one OAuth client and one sign-in per publication, the author's hostname on the consent screen, script and a session on csr = false pages); a shared *.pagehare.com cookie (needs server-held sessions, per-reader state on cached reader pages, and does not reach custom domains); keeping one origin with an "upgrade to publisher" gate (one session still serves both roles, and the consent screen cannot promise reader-only). read was chosen over reader for length and is an infrastructure label, never an author's name. No visual change: ReaderActions and the intent page are as in v0.11. (v0.12)

  • v0.13 — The reader hub (/reader) is an app surface built from existing parts; Tabs gains a second consumer and stays in app/ for now. The hub is three tabs (Feed · Subscriptions · Bookmarks) for a signed-in reader, on the reader origin. It adds no primitive and no token: the feed is PageCard in a one-column PageCardGrid (the same card for a PageHare page and another app's, so nothing foreign is ever rendered as content), the two lists are hairline-separated rows in components/app/reader/ReaderList.svelte, and the tabs are the Settings Tabs with the selected tab in the URL hash. v0.10 said Tabs moves to ds/ when a second surface needs it; that surface now exists, and the move (with its /styleguide entry, §8) is deferred to PH-39 rather than folded into a feature PR. The hub is never wrapped in the app chrome: a reader has no dashboard to navigate to, the same reasoning as intent pages. Nothing here reaches reader output (/a/[handle]/[slug] is unchanged and still csr = false).

  • v0.14 — Publishing can ask first: a publish dialog and a reply-controls dialog, both native <dialog> and both app components. v0.5 made comments a link to a Bluesky post; F5 lets the author make that post while publishing and say who may reply. components/app/publish/PublishDialog.svelte holds one checkbox ("Post to Bluesky") and, when it is ticked, one native <select> ("Who can reply"); ReplyControlsDialog.svelte changes that later and hides individual replies. Both follow ConfirmDialog's recipe (showModal(), the platform's focus trap and Escape, --ph-backdrop, the @starting-style fade is left out because neither needs it) and are built from Checkbox, Select, Field, Input, Alert and Button: no new primitive, no new token, no new icon (the menu items reuse share). They are not in ds/: each imports the atproto client or app state, which is what app/ means (v0.6), and neither renders on a reader page. Plain Publish stays one click: the dialog opens from its own menu item, or from Publish only when the author's setting says to offer the post, because most publishes are republishes and a dialog on each would be friction for a choice made once per page. A page is posted once; after that the menu offers "Reply controls" in place of the post. Hiding a reply takes a pasted bsky.app link rather than a list of replies: the list is F6's thread view on the reader origin, and the author's dashboard has no thread to pick from yet. The dialog says in words that hiding is honoured by Bluesky's apps and does not delete anything. The reader page is unchanged: still one static link from bskyPostRef, still csr = false. Correction found on the way: the editor's overflow menu was built by a bare menuItems() call in a component that is not in runes mode, so it was never rebuilt and still read "Publish" after a publish; it now takes the page and the publishing flag as arguments.

  • v0.15 — The conversation is read and joined on the reply intent; the reader page's Bluesky link becomes the fourth reader action. v0.5 put one static link to the Bluesky thread under the article. F6 replaces it with Reply, a fourth pill in ReaderActions (v0.11), shown only when the record has a bskyPostRef and pointing at <reader origin>/intent/reply?subject=<document AT-URI>. It is still a link and nothing else: built from the record, no fetch, no script, no count, csr = false, and reader-nojs covers it. So v0.5's reasoning holds (comments are a link, not a component) and only the destination changes: the thread is now shown on PageHare's own intent page, where a reader can also answer, with each reply linking to itself on bsky.app. The glyph is a fourth literal <svg> in ReaderActions, same grid and stroke; the route's payload went down (129.65 → 129.62 kB of 130) because the old .discuss block and its styles are gone. On the intent page, components/app/intent/ReplyControl.svelte reuses ReaderList (v0.13) for the thread's states and rows, which is what that component was written to allow; nesting is a left step of --ph-space-4 per level, capped at three, with the level passed as a custom property the way BarList passes a ratio (v0.7): the count is data, the step is a token. The composer is a native <textarea> styled in the control's own scoped CSS, not a new ds/ primitive: it is the first multi-line input in the app and has one consumer (v0.5, v0.10: one consumer is not a primitive). It moves to ds/ with a /styleguide entry when a second one appears. No new token. The character count turns --ph-danger-text past 300 and carries aria-invalid; the refusal for a thread that does not allow the reader is plain text in place of the composer, not a disabled control, so there is nothing to press that cannot work.

  • v0.16 — Signed out, the reader hub is the AuthCard; HandleInput's @ leads the field. v0.13 gave /reader its own left-aligned sign-in under the hub header, which read as a different product from /auth/signin and the intent pages. Signed out, the hub now renders AuthCard (centered logo above a centered panel, home absolute to the app origin, no "Back to home" link, as on the intent pages), so there is one sign-in shell and three callers. The hub layout (small logo, title, session line, tabs) is for a signed-in reader only; while the session is being checked the page shows a centered status line on the same background and neither layout, so neither a signed-in nor a signed-out reader sees the other's chrome flash. In HandleInput the decorative @ moves from the end of the field to the start, where a handle's @ is written; the input's start padding is --ph-space-6. It stays aria-hidden and is not part of the value. (v0.16)

  • v0.17 — Project documentation is generated static HTML. The document library reuses tokens.css with local --docs-* aliases in scripts/docs/style.css. It uses native links and disclosure controls, system font fallbacks, and no browser JavaScript. The library is separate from the app and its reader renderer; it does not add an app component, theme, or global token. (v0.17)

  • v0.18 — Project documentation uses product blocks; supersedes v0.17's separate renderer and font fallbacks. The static document library uses the product parser, rule engine, and Svelte block renderers at build time. It shares the reset, tokens, component CSS, Literata font, and logo. Library navigation has its own small stylesheet. Documentation sections fall back to the product prose block when a rule would flatten heading levels or lose inline content. No presentation JavaScript ships. (v0.18)

  • v0.19 — Original and simplified document editions. Original Markdown remains authoritative at its existing paths. Reading editions live under docs/simplified/ and supply the static HTML library. Shared product rendering stays the same. Publishing checks require a matching copy and recorded hashes for both files; authors compare their meaning before recording those hashes. Generated regions are not hashed: the HTML takes them from the original. Single-edition documents (SINGLE_EDITION in scripts/docs/catalog.mjs: generated, append-only, or already plain) have no copy, and their page reads the original. The deployed site publishes only PUBLIC_DOCS, because the repository is private; local builds publish everything and link each page to its Markdown on GitHub. (v0.19)

Append new decisions here with a version marker. Never rewrite history; supersede it.

  • Article reading layout (2026-10-07). ArticleLayout keeps editor content in a bounded center column, with an author rail and section navigation / related reading on wide containers. Native <details> carries the compact table of contents; container queries move profile and related reading below the article. Only section navigation sticks, inside a track that ends before related reading. Presets and visibility preferences are author-scoped PageHare DB state, never PDS content. Reader SSR remains unhydrated. The publication archive keeps its existing layout.

  • Article layout bundle allowance (2026-10-08). The manifest-attributed article route grows from 128.11 kB gzip on the base revision to approximately 131 kB with the author rail, two native contents presentations, and related reading. Its build-artifact budget moves from 130 to 132 kB; csr = false still means readers download no presentation JavaScript. The styleguide loads the new layout example separately, and layout validation stays server-only. Total-client and other route budgets are unchanged.

  • Article layout on current next (2026-10-08). Rebasing onto daa8d8c preserves the new recommend/subscribe/bookmark links and uses the shared service-DID module. With that reader surface the manifest-attributed article artifact measures 132.36 kB gzip, so its allowance is 134 kB. Published reading remains csr = false; total-client and all other route budgets retain next's values.

  • Article layout rebased onto next at 9dc32e7 (2026-10-12). next dropped the Bluesky "discuss" link for the Reply action in ReaderActions (F6), so the article layout renders ReaderActions alone after the content and no discuss link. The article route measures 132.39 of 134 kB gzip, still csr = false. With the reader hub now on next, the total is 742.94 kB, so total_client_js goes 740 → 745 kB (PH-64, owner to accept); this supersedes "total-client retains next's values" in the entry above.

  • Byline without a face; reader actions split by what they act on (2026-10-12). Owner's call. The byline's avatar is gone: the author card already shows it, and the byline is provenance in text (name · date · reading time). Subscribe moves into the author card as its one primary Button, because it acts on the publication while recommend, bookmark and reply act on the page; the row after the article carries Subscribe only when there is no card (showAuthor off, or no profile), so a page never loses it. Recommend and bookmark are also offered beside the byline, at its far end, as the compact form of ReaderActions (no border, no rule), and stay in the row after the article with Reply. A right-rail placement was rejected: the rail exists only on wide containers, only when the page has sections or related links, and never in the focused layout. Recommend and bookmark are a glyph alone, 22px at 1.75 stroke, named by aria-label and title; Reply and Subscribe keep their word (a speech bubble and a bell read as "comments" and "notifications"). Every intent link, and the Bluesky profile link, opens a new tab (target="_blank", rel with noopener noreferrer), so the reader's place in the article survives the sign-in round trip on the reader origin; links within the author's site (archive, related, sections) do not. Supersedes v0.11's "one row of actions": there are now two ReaderActions navs with different labels and one link in the card, all still plain links from readerIntentLinks, no script, no count, csr = false. No new token or primitive. The article route measures 132.8 of 134 kB gzip.

  • "Keep reading" follows the article; the rail is section navigation only (2026-10-12). Owner's call. With a table of contents, the two reading lists sat at the foot of a 13rem rail, level with the end of the article and easy to miss; without one they jumped to the top of it. They are now one list, "Keep reading", in the article's own column, directly after the action row, on every layout and width; where the author card follows the article it comes after the list, ruled off the same way, so a short article is not followed by the card with the list under it. Beside the article (three columns, wide) the card spans the article and the list, so a card taller than a short article does not push the list down. The action row's rule and the list's rule sit the same distance from the icons. "Related articles" and "More by this author" are merged: both were already the same author's pages, so the list is tag matches first, then the newest, five at most. Each row is the title in the prose face, two clamped lines of the record's stored description, and the first-publish date in a fixed locale (the render is edge-cached); "View all articles →" ends the list and leaves the author card, which is now face, name, bio, Subscribe and the Bluesky link. Rows are plain links, not PageCard: the route has about 1 kB of budget left. ArticleLink gains optional date and description; showRelated keeps its name and now reads "Keep reading" in Settings. Three Tier 2 tokens are added to .ph-article-layout: --article-rule, --article-title-font, --article-title-size. This supersedes the "related reading on wide containers" half of the 2026-10-07 entry. The article route measures 132.88 of 134 kB gzip; total_client_js 743.68 of 745.

  • Share on the article page: a native popover, plus the reader page's one script (2026-10-13). Owner asked for a share button on the article, then for copy link and the device's share sheet. Both rows of ReaderActions (beside the byline, after the article) end with two glyph-only buttons: Copy link and Share. Share is a <button popovertarget> whose menu is Post on Bluesky, Copy link, Email and More options (the device's share sheet). It is not an intent: it writes no record, needs no reader and does not go to the reader origin; every value is a function of the canonical address and the title, so the render stays pure (ENGINE_INVARIANTS #16). This is an exception to §2.4's "zero presentation JS on the reader", by the owner's decision (PH-65): the clipboard and navigator.share do not exist in markup. It is held to one file, components/public/readerShare.js: plain JavaScript with no imports, loaded as its own static module asset (?url&no-inline), 1.4 kB raw, 0.7 kB gzip, knowing nothing about the reader. The route is still csr = false: nothing is hydrated and no framework code is downloaded. The script only shows controls the markup ships hidden, so a page where it does not run is the script-free menu (Bluesky, email, and the address set user-select: all to select by hand) with nothing broken. It is not a precedent for other script on the reader page; a stateful block still goes through the island mechanism. It is the first row of §2's "Reader script exceptions", which is now the whole list of scripts a reader page may load. Copying swaps the link glyph for a tick for two seconds and says "Link copied" in a role="status"; a refused copy opens the menu with the address to select. Placement is CSS anchor positioning (position-area, position-try-fallbacks) inside @supports; the fallback, per §2's policy, is the browser's own popover rule, which centres the menu in the viewport. Glyphs are literal <svg> in the component for the reason v0.11 gives; Bluesky's mark is the one filled shape. Budgets: the article route goes 134 → 136 kB, the total 745 → 748 kB and /intent/[action] 120 → 121 kB (PH-66, owner to accept).

  • Publication page: the article's three columns, around a list (2026-10-13). Owner asked for the publication page to follow the article page, with the author on the right, the page cards in the middle and the author's tags on the left as a filter, and for Subscribe and Share to live there (PH-63). PublicationLayout is that frame. It reads the article's Tier 2 tokens: .ph-publication-layout joins .ph-article-layout in one blocks.css rule, so there is no new token and the two pages share measure, rail and gaps. The author is the right rail here and the left rail on the article, by the owner's placement: on the article the right rail is section navigation; here the left one is navigation (topics), so navigation keeps one side per page and the card takes the other. The card is the page's header (h1), first in source and on a narrow screen, so a reader arriving from a byline sees whose pages these are, and Subscribe, before the list. Subscribe and Share sit together in the card: both act on the publication. Subscribe is the card's one primary Button and now opens a new tab like every other intent link (2026-10-12); Share is the compact ReaderActions pair beside it, given the publication's address. That loads readerShare.js on /a/[handle]: the same file, loaded the same way by the same component on a route §2 already names, doing what its row says, at the owner's request; no new row. Topics are plain links (?tag=), filtered on the server, so the route stays csr = false; the canonical address of a filtered view is the unfiltered page. Both rails stick (they are short; the topics rail scrolls inside itself if it is not). The heading is the publication's name when it has one, with "by {author}" under it when the two differ. Rejected: Subscribe in a top bar across all three columns (a second header above the card that already names the publication), and a client-side filter (script on a reader page). Budgets: the publication route measures 118.72 of 130 kB; total_client_js goes 748 → 750 kB (PH-72, owner to accept).

  • Subscribe inside the page: a frame onto the reader origin, and one consent screen (2026-10-13). Owner asked for subscribing without leaving the article or publication page, for the page to show the reader as signed in and to turn Subscribe into Unsubscribe, and for every permission to be taken at sign-in (PH-74). Per-reader state cannot be in a csr = false page, and the reader's session belongs to read.pagehare.com, another origin. Owner chose, of the options put: (A) a frame, with (C) the intent page kept as the fallback and the place sign-in happens; (B), a second reader script, was approved but not used, because a script on the page cannot read the session either and the frame needed none. IntentFrame replaces the Subscribe button in the author card of ArticleLayout and PublicationLayout with an <iframe> onto /embed/subscribe?subject=<publication at-uri> on the reader origin. Inside it, GraphControl (compact) is the same write path as the intent page: a small button and one line, "Signed in as …". Signed out, with scripts off, or holding no permission, the frame shows the plain link to the intent page that stood there before; it never shows a form and never leaves for a consent screen (a PDS refuses to be framed). What still holds: the reader page ships no new script and is the same HTML for everyone (ENGINE_INVARIANTS #16); nothing is written on load; the record is written browser → PDS from the reader origin; no PageHare server learns who subscribed. Clickjacking: one click in the frame writes a record, so /embed answers with Content-Security-Policy: frame-ancestors naming only the app and the subject's own origins. Size is fixed (--ph-space-7 + --ph-space-4, at most 18rem) because the page cannot measure a frame; the frame's document sets --ph-bg: transparent so the page shows through. Consent: reader sign-in asks for readerSignInScope (identity, recommend-and-subscribe, reply, the reader API) on one screen, on intent pages and the hub alike, reversing R4's one-scope-per-feature rule; a session holding less is still asked on the click. Known limits, recorded in PH-75 to PH-77: the session inside a frame is unverified in Safari and Firefox; an author's custom domain will see the link, not the state; a forced light/dark mismatch between origins gives the frame an opaque backdrop; the frame's own document loads client JS that no reader budget counts.

  • A bar across reader pages, and a second frame for the reader's account (2026-10-13). Owner found "Signed in as …" under Subscribe in the author card poor, and asked for a permanent place for an avatar, a name and sign-out, a way back to the reader and the publication from an article, and a small link for publishers; asked whether that should be a top bar or the hub's sidebar on every publication (PH-79). Chosen: a top bar, ReaderBar, on /a/[handle] and /a/[handle]/[slug]. A sidebar was rejected: the hub's sidebar is the reader's own subscriptions, so on a csr = false page all of it would be a tall frame; the article already has three columns; and on a custom domain, where a frame has no session, a sidebar is an empty column while a bar is still two links. The bar is plain markup except its last item. Left is always one level up: on an article the author's publication (their avatar and name; the publication's own name is not read on that route), on a publication the reader hub. An article also links the hub on the right, above 40rem. Two frames, by the owner's choice. The first build moved the one Subscribe frame into the bar beside the account; the owner then asked for Subscribe and Unsubscribe to stay with the author, where it reads better. So the page now has two: /embed/subscribe in the author card as before, without its "Signed in as" line, and /embed/account at the end of the bar: the reader's avatar and name (a target="_top" link to the hub at that publication's pages; avatar and display name asked of the public AppView from the frame, as the hub does) and a sign-out button. Signed out, the account frame is "Sign in" (the hub's #signin, a new tab), shown only once the session is known to be absent. The account frame is addressed by the page's publication and checked like Subscribe's, so it gets the same frame-ancestors; a page with no publication has neither frame. Signing out in one frame is heard by the other through the storage event. Consequences of a fixed row: no account menu (it would be cut at the frame's edge), so sign-out is a button; the compact GraphControl keeps its label while busy and on an error becomes the link to the intent page, with the message kept for assistive tech; a frame's document hides its overflow, since a pixel too many put a scrollbar in the bar; the account frame is 14rem wide, 5.5rem at or below 40rem, where the name is read out but not drawn. The row after the article still carries Subscribe only when the author card is hidden, and there it is the plain link: it cannot know the reader. Not sticky: hiding a pinned bar on scroll needs script. Footer: ReaderFooter gains two small links, the hub and "Start your own publication" (the app's sign-in). The draft preview's Edit control moved to the foot of the window, since the top is the bar's. What still holds: no new script on a reader page, the same HTML for every reader, nothing written on load, and both frames only on the reader origin's /embed/. Cost: each page view now boots the reader origin's client twice, once per frame (PH-80). Follow-ups PH-80, PH-81.

README.md · Simplified edition

PageHare

PageHare turns your writing into a web page. Write in the editor, check the preview, and publish. Rules choose the layout without changing your words.

Start the app locally

Use Node.js 22 and npm 10 or later. Run these commands from the repository root:

npm install
cp packages/app/.env.example packages/app/.env
npm run dev

Open http://127.0.0.1:5173. Use this exact address for local sign-in. The app uses an AT Protocol account, such as a Bluesky account. Your account provider handles your password.

Write and publish

  1. Sign in and choose New page.

  2. Enter a title and body. Use headings, lists, links, images, or tables.

  3. Wait for Saved locally. This saves a draft in this browser.

  4. Open Preview to check the layout.

  5. Choose Publish to make the page public.

  6. Copy the published link to share it.

Drafts stay on this device. Clearing browser storage can remove them. Changes to a published page stay local until you choose Republish. Shared editing is planned; a published link does not grant edit access.

Published pages use https://<name>.pagehare.com/<title-slug>-<id> after you claim a name. Until then, they use /a/<did>/<title-slug>-<id>.

How the app works

  • SvelteKit and Svelte 5

    run the app and render public pages.

  • Tiptap

    provides the rich-text editor. Markdown is the saved content format.

  • AT Protocol

    provides sign-in and stores published pages on your Personal Data Server (PDS). A DID is your account's stable identifier.

  • PageHare's block engine

    chooses prose, headings, lists, and question-and-answer layouts. The preview and published page use the same rules.

  • PageHare's design system

    controls type, spacing, and color through CSS tokens.

  • SST on AWS

    deploys the app with Lambda, CloudFront, and S3. DynamoDB stores the publisher index, subdomain names, and analytics. It does not store page bodies.

For exact engine rules, read the block specification. For record fields and storage rules, read the storage guide.

Repository layout

PathPurpose
packages/app/Editor, public pages, settings, and server routes
packages/core/Block engine, content conversion, and AT Protocol adapters
lexicons/PageHare record schemas
scripts/Build, validation, and maintenance tools
docs/Guides, plans, specifications, and release history
AGENTS.mdInstructions for coding agents
PAGEHARE_DS.mdRequired design-system rules

Commands

npm run dev             # Run the development app
npm run build           # Build the app for deployment
npm run check           # Check types and Svelte components
npm run lint            # Check formatting and code rules
npm run test            # Run unit and component tests
npm run e2e             # Run browser tests
npm run docs:html       # Build the HTML document library
npm run docs:watch      # Rebuild HTML when documents change
npm run docs:test       # Check the document generator

See the browser-test guide for test setup. Copy environment settings from packages/app/.env.example. A deployed PUBLIC_APP_URL must match the site's HTTPS address for sign-in to work.

Documentation

Start with the documentation index. It links to user guides, product status, technical rules, and operations guides.

Read the writing guide before creating or editing a document. Keep originals at their current paths and reading copies under docs/simplified/<original-path>. HTML uses the reading copies. A single-edition document (generated, append-only, or already plain) has no copy, and HTML uses the original. For a paired document, update both files, compare them, then run npm run docs:review -- <original-path>.

Deployment

Pushes to next deploy the preview. Pushes to main deploy production. The SST deployment guide explains credentials, domains, and failure recovery.

Contributing

Create a branch, make the change, update the relevant documents, and run the checks for your change. Open a pull request for review.

docs/ENGINE_INVARIANTS.md · Simplified edition

Engine Invariants

These rules are required. Each rule states how it is checked. The engine lives in packages/core/src/lib/blocks/. Derived content fields, called projections, live in packages/core/src/lib/content/.

1. Determinism

The same Markdown must produce the same decisions on every run and in every environment. The segmenter and rule checks must not read the clock, use randomness, or depend on locale-specific string behavior. Enforced: double-run equality test over all fixtures (packages/core/tests/engine.spec.ts, "determinism" suite) in CI.

2. Purity and isomorphism

Code under src/lib/blocks/ and src/lib/content/ must not perform input/output (I/O), including file reads, file writes, or network requests. It must not access environment variables. It must not import SvelteKit or browser DOM code. The server and editor preview use the same engine code and input. Enforced: no-restricted-imports / no-restricted-globals in packages/core/eslint.config.js, scoped to those directories; violations fail CI, not code review.

3. The hash spec is versioned

Section hashes are FNV-1a 64-bit over normalized text. The normalization (fnv1a64-1): NFC → CRLF/CR→LF → tab/space runs collapse to one space → per-line trim → outer trim. Saved decisions record the hash version they were pinned under. Changing the normalization or hash function is a migration event (bump HASH_VERSION in versions.ts), not a refactor. Enforced: literal-value tests in packages/core/tests/hash.spec.ts lock the spec.

4. Provenance precedence and drift policy

Merging saved decisions over engine defaults: manual > ai > rule. A saved decision whose section hash no longer exists in the current segmentation is drift: the section falls back to the rule default and the orphaned decision is surfaced to the author — never silently applied elsewhere, never silently deleted. Enforced: mergeDecisions in blocks/decisions.ts; tests in tests/decisions.spec.ts; orphan UI in the preview route.

5. Everything is version-stamped

Every BlockDecision must carry rulesetVersion, segmenterVersion, and hashVersion. Save the versions that produced each decision. These values let us trace changes in rendered output back to a rule change. Enforced: stamped in classify.ts; constants in versions.ts.

6. Rule hygiene

match functions never throw — a throw is a bug treated as no-match (surfaced via the onRuleError callback; the app logs it in dev). No rule reads another rule's output or mutates the section. First-match-wins is the only control flow. Enforced: try/catch wrapper in classify.ts; the winning rule's props are additionally Zod-validated, so an invalid rule output is also a no-match.

7. Schema evolution policy

Changing a block's Zod schema requires an explicit migration path for saved props. A saved decision whose props fail current-schema parsing falls back to the rule default and is flagged (ClassifiedSection.flagged) — render never crashes on stale data. Enforced: safeParse in mergeDecisions; test in tests/decisions.spec.ts.

8. AI contract

AI proposals must target section hashes from the current segmentation; stale-hash proposals are rejected outright. AI output is Zod-parsed; invalid output is discarded, never coerced or partially applied. The block catalog in the AI prompt is generated from the rules' describe strings (generateBlockCatalog) — hand-written catalog text is forbidden. Enforced: acceptAiProposals in blocks/ai.ts; tests in tests/ai.spec.ts.

9. Content is data, never code

The engine must never execute content as code. Parse and validate fenced data, such as chart CSV or JSON. Do not use eval or interpret the data as instructions. The renderer must show raw HTML in Markdown as text. Do not use {@html} in block renderers.

10. Known limitations are documented, not patched

The question-detection convention and its locale limits are explicit scope in PAGEHARE_BLOCKS.md. Do not "fix" them with heuristics that break determinism.

11. Generated doc regions are inviolable

The rules and fence-tag tables in PAGEHARE_BLOCKS.md live between <!-- BEGIN/END GENERATED: … --> markers. Do not edit content inside these markers by hand. Enforced: npm run docs:blocks:check in CI.

12. Fixture coverage is structural

Every rule ships with fixtures; every segmenter exception (hero-lead) and termination case (noise mid-run, trailing unanswered question) has a dedicated boundary fixture. The runner globs packages/core/tests/fixtures/*/ — adding a rule without a fixture means adding a rule the runner can't see exercised, and review rejects it.

13. Record projections are pure derivations

textContent, description, and com.pagehare.document are produced by pure functions over the mdast of content.markdown, written in the same applyWrites commit, and never read back as inputs to a write. Read paths may display them as stored (description for excerpts and SEO, textContent for word counts) — the markdown stays the only source a write derives from. Facet byte offsets index textContent, so the two are built in a single walk and regenerate together or not at all. A drift bug is repaired by a re-derivation pass over affected records, never by hand-editing projections. Enforced: round-trip test (derive → re-derive → byte-identical) in tests/projections.spec.ts; write path in adapters/record.ts + adapters/index.ts.

14. mdast is never persisted

Use mdast only while the engine runs. Do not save mdast JSON in the PDS, in PageHare's database, or in a cache that outlives the request or session. Exchange content as Markdown strings. Rich-text block props therefore contain Markdown slices, and the preview cache stores decisions rather than sections.

15. Presentation never enters the PDS

Saved presentation decisions are keyed (document AT-URI, sectionHash, hashVersion, rulesetVersion) and live in PageHare's DecisionStore. Nothing presentation-related — block choices, variants, layout — is ever written into the site.standard.document record.

16. Render is a pure function of its inputs

A published page's HTML is a pure function of (record content, saved decisions, tenant theme) — nothing else, ever. Dynamic or reader-generated state (poll results, counts, any interaction data) is never server-rendered into the page: interactive blocks SSR only their static shell from the fence payload; live state is fetched client-side from a separate, uncacheable endpoint. This is what makes CID-keyed immutable edge caching (zero invalidation logic) possible — an SSR'd vote count is a bug, not a feature.

This constrains the render output, not the request. A read-path side effect that writes nothing into the response is permitted: view counting (August 2026, docs/plans/simple-analytics.md) increments counters during the same load. It is invisible to the HTML, so the render stays pure and the page stays cacheable. The test is not "does the request do anything?" but "could two requests for the same (content, decisions, theme) produce different bytes?" — if yes, it is a violation. Analytics counts therefore render only on author surfaces (dashboard, /stats/[id]), never on /p/[slug]. Enforced: spec-level for now; becomes a review gate when the first stateful block ships.

17. Stateful fences carry stable instance ids

Section hashes key presentation decisions — an edited section should drop its pins. They must never key accumulated state: fixing a typo in a poll question must not orphan its votes. Any fence tag whose block accumulates external state (poll, form, counter) is marked stateful in the fence registry and MUST carry a stable instance id in its payload (generated at insert time). External state is keyed (document AT-URI, instanceId) — never by section hash. Enforced: registry stateful flag + payload Zod schema requiring id, once such a tag exists.

docs/PAGEHARE_BLOCKS.md · Simplified edition

PageHare Blocks — Canonical Spec

Authors write content. PageHare's rules choose its layout without changing the words. Markdown is the source of truth. It is stored as com.pagehare.content.markdown inside site.standard.document on the author's Personal Data Server (PDS). The same input always produces the same block choices. AI Format is a planned option for sections that the rules leave as prose.

This document defines the block rules. Run npm run docs:blocks to build its generated tables from code. Never edit inside the generated markers. CI fails if the tables differ from the code.

The engine in one paragraph

packages/core/src/lib/blocks/ exports segment, classify, blockSchemas, and rules. Remark parses Markdown into a syntax tree called mdast. segment(ast) splits this tree into sections. classify(sections) checks the rules in order and uses the first match. If no specific rule matches, the section stays as prose.

Rules, AI proposals, and manual choices use these same section boundaries. The Zod blockSchemas object defines the allowed block shapes. It validates rule and AI output and defines the types passed to the renderer.

Rules (generated)

Check rules in array order and use the first match. Do not combine rules or add weights or confidence scores. If a rule needs a setting, use two rules or an AI case.

<!-- BEGIN GENERATED: rules -->

Engine versions: segmenter seg-1, ruleset rules-1, hash fnv1a64-1.

OrderRuleBlockWhen it matches
1header-headingheaderA standalone heading, or the document-leading H1 with a short lead paragraph, becomes a header. A short lead (≤120 chars) is the subtitle; a longer one is the description.
2qa-alternatingqaTwo or more alternating question paragraphs (ending in ?) and answer paragraphs become a Q&A. Each question keeps its ? and each answer may span multiple paragraphs.
3list-itemsitemsAn unordered list of three or more items becomes an items grid. A leading bold phrase is the item label and the rest its explanation; a trailing " — key: value" segment becomes item metadata.
4prose-fallbackproseAnything else renders as prose, verbatim.
<!-- END GENERATED: rules -->

Fence-tag registry (generated)

Exotic blocks that markdown cannot express structurally enter via the fence info string — a deterministic rule that degrades gracefully in any other markdown client. Fence payloads are parsed and Zod-validated only; content is data, never code. Fence tags whose blocks accumulate external state (a future poll, form, counter) are marked stateful in the registry and carry a stable instance id in their payload — external state is keyed (document AT-URI, instanceId), never by section hash, and lives in PageHare's store, never in the markdown (ENGINE_INVARIANTS #16/#17).

<!-- BEGIN GENERATED: fence-tags -->
Fence tagPayloadBlockPurpose
chartcsvreservedReserved for the future chart block: a fenced code block tagged chart carrying CSV data. Falls through to prose until the chart block ships.
<!-- END GENERATED: fence-tags -->

Block shapes

Block shapes are defined only in packages/core/src/lib/blocks/schemas.ts (Zod). Rich-text props are markdown strings — verbatim slices of the canonical markdown; plain-text props are plaintext projections. Summary (see the schemas for authority):

  • prose — { body: markdown }

  • header — { eyebrow?, heading, subtitle?, description?: markdown, ctas?: {label, href}[] (max 2) }. eyebrow and ctas are never rule-filled (markdown headings cannot express them); AI or manual decisions may fill them.

  • items — { heading?, description?: markdown, items: {label, explanation?: markdown, meta?: Record<string,string>}[] (min 3) }

  • qa — { heading?, description?: markdown, items: {prompt, response: markdown, meta?}[] (min 2) }

Shapes scale, variants don't. Classification targets data shapes (bounded, ~20–30 long-term). Presentational variants live in the renderer layer (packages/app/src/lib/components/public/blocks/) as presets and never add classification rules. Presets are props on the renderer, not fields on the schema, so presentation never round-trips through the PDS. Today exactly one exists: prose's lead, set by the route on the document's opening prose block.

Note that a preset may not contradict a DS invariant: card/bordered/shadowed variants of items and qa are rejected, because structure comes from whitespace and faint tone, not boxes (PAGEHARE_DS.md §2.1).

Tier 2 token surface

Each block defines its tokens on its own selector in packages/app/src/lib/styles/blocks.css — never at :root — and its component reads them directly, with no fallback value (PAGEHARE_DS.md §3/§5). Block visuals live in the component's scoped styles, where lint:tokens can see them; blocks.css is lint-exempt and holds the token surface only.

BlockSelectorTokens
prose.ph-prose--prose-font, --prose-size, --prose-leading, --prose-measure, --prose-color, --prose-link, --prose-link-underline, --prose-flow, --prose-lead-size, --prose-lead-leading, --prose-heading-size, --prose-heading-space, --prose-quote-rule, --prose-quote-color, --prose-quote-indent, --prose-code-font, --prose-code-size, --prose-code-bg, --prose-code-pad, --prose-pre-pad
header.ph-header--header-font, --header-ui-font, --header-size, --header-weight, --header-leading, --header-tracking, --header-color, --header-sub-color, --header-space, --header-eyebrow-color, --header-eyebrow-size, --header-eyebrow-tracking, --header-eyebrow-space, --header-sub-size, --header-sub-space, --header-desc-space, --header-cta-space, --header-cta-gap
items.ph-items--items-gap, --items-pad, --items-bg, --items-radius, --items-border, --items-space, --items-heading-size, --items-heading-space, --items-desc-color, --items-desc-space, --items-label-font, --items-label-weight, --items-label-space, --items-body-size, --items-body-color, --items-body-leading, --items-meta-color, --items-meta-size, --items-meta-gap
qa.ph-qa--qa-font, --qa-q-weight, --qa-q-color, --qa-a-color, --qa-a-space, --qa-rule, --qa-indent, --qa-gap, --qa-space, --qa-marker, --qa-marker-size, --qa-heading-size, --qa-heading-space, --qa-desc-color, --qa-desc-space, --qa-meta-color, --qa-meta-size

Rendering contracts. qa is a native <details>/<summary> disclosure — its answers are always in the DOM, so they are indexable and readable with JS disabled; the first item is open by default. items goes two-up via a container query on its wrapper at 48rem, deliberately wider than --ph-measure, so the reading column never splits and only wide embeds and editor previews do. Blocks carry no viewport media queries: they render at non-viewport widths.

Authoring conventions

Items: labels and metadata

  • A leading bold phrase in a list item is its label; the rest (after :, —, –, or -) is the explanation. - **Fast**: ships in seconds

  • A trailing — key: value segment in the explanation becomes item metadata: - **Fast** — ships in 2s — since: 2024 → meta: { since: "2024" }

  • Plain single-paragraph items map with the whole text as the label.

Q&A: question detection

A paragraph is a question when its trimmed plain text ends with ? (or ?), or when it is a single bold phrase ending in ?. Two or more alternating question/answer paragraphs form a Q&A; answers may span multiple paragraphs.

Segmentation behavior worth knowing

  • Headings and horizontal rules (---) open section boundaries; the --- itself belongs to no section and is not rendered.

  • Every heading is its own section, with exactly one exception: a document-leading H1 plus its short (≤200 chars) following paragraph merge into a hero section (the hero-lead exception — the only entry in the segmenter's exception registry).

  • Noise terminates runs strictly. A list dropped between two Q/A pairs splits them into two sections — no tolerance windows. Merging across noise is exclusively AI or manual territory.

  • Tables fall through to prose. A markdown table tells you structure, not intent. Charts enter only via AI Format or the explicit ```chart fence.

Known limitations (explicit scope — do not "fix" with heuristics)

  • Question detection is the trailing-? convention only. No leading-¿ Spanish detection, no locale-aware interrogative analysis, no support for scripts without a question-mark convention. These would break the determinism guarantee; locale-aware classification is AI Format territory.

  • Table cells flatten to inline text when serialized from the editor (GFM pipe tables cannot carry block content).

  • The meta key syntax accepts Latin identifier keys only ([A-Za-z][\w-]*).

Decision provenance

Every classification is { block, props, source: 'rule' | 'ai' | 'manual', ruleId? } plus version stamps (rulesetVersion, segmenterVersion, hashVersion). One decision type, three sources; merge precedence is manual > ai > rule. Saved decisions are keyed by (document AT-URI, sectionHash, hashVersion, rulesetVersion) and live in PageHare's own store — presentation is never written into the PDS record.

See docs/ENGINE_INVARIANTS.md for the full contract.

Article page layout (reader chrome)

ArticleLayout.svelte arranges existing blocks on the article page. It is not a content block, and the engine does not classify it. The styleguide renders the real component. It uses these .ph-article-layout tokens: --article-measure, --article-rail, --article-gap, --article-small-gap, --article-link-pad, --article-top, --article-bottom, --article-pad, --article-ui-font, --article-ui-size, --article-leading, --article-color, --article-muted, --article-link, --article-rule, --article-title-font, and --article-title-size. Their definitions are in styles/blocks.css.

Headings:

  • The opening H1 header stays above the byline and the cover.

  • Other heading blocks keep their heading level from the source. The header renderer's level preset does this. The canonical markdown and the block schemas do not change.

  • Section anchors use occurrence numbers, so readers can go to each of two headings with the same text.

Section navigation:

  • A page with two or more sections gets a list of contents.
  • On a compact layout, this list is a native collapsible element.
  • On a wide layout, it is a navigation rail that stays in view while the reader scrolls (sticky).

The reading list comes after the content, then the profile. This is the order in the DOM and on mobile. On a wide layout, the rail holds only section navigation.

Settings → Article layout has three presets: Three columns, Article + right sidebar, and Focused. It also has toggles for the author, the author link, section navigation, related reading, and reading time.

  • The byline can show without the full profile. It is text only (the avatar is in the profile). It ends with the recommend and bookmark links.
  • The profile holds the Subscribe button. If there is no profile, Subscribe is in the row after the article.
  • An empty rail takes no space.

"Keep reading" is one list after the article, in the article's column:

  • It shows pages that share a tag first, then the author's newest pages.
  • It shows five pages at most, and never the current article.
  • Each row shows the title, two lines of the stored description, and the date of first publication.
  • The list ends with a link to the archive.
  • It uses visible native documents from the author's latest 50 records.
  • If the lookup fails, the page shows no list.

Publication page layout

PublicationLayout.svelte uses the same frame around a list. It is the author's publication page: /a/[handle], or the root of the author's subdomain. It reads the .ph-article-layout tokens through .ph-publication-layout, in one rule in styles/blocks.css. So the two pages always share the same measure, rail and gaps. The styleguide renders it after the article example.

In the source order and on a narrow screen, the page shows:

  1. The author card: the page's h1, bio, Subscribe, Share, RSS and the Bluesky link.

  2. Topics.

  3. The list.

From 64rem, the card is a sticky right rail. From 80rem, Topics is a sticky left rail if the author has tags.

Topics:

  • Topics are the author's 20 most-used tags, most used first. Spellings that differ only in case, accents, spacing or punctuation count as one topic.

  • Each topic is a plain link to the same page with ?tag=. The server filters the list.

  • The canonical address stays the unfiltered page.

  • A tag that no page uses shows an empty list, with status 200.

The list is PageCardGrid with one column. Share is ReaderActions (compact, with no intent links), given the publication's address.

Validation (2026-10-08): targeted checks covered API ownership, storage persistence, article visibility, identity resolution, reading with JavaScript off, resizing from desktop to mobile, all three presets, hidden and empty rails, saved settings, and keyboard tab navigation. Default light and dark body and link contrast was checked; the lowest sampled ratio was 4.91:1. The article layout keeps the reader action links that next added. Settings use the shared service-DID helper from next. PH-63 tracks a separate review of the publication-page UX.

docs/README.md · Simplified edition

PageHare docs

Start here to find a guide, plan, or technical rule. Keep each fact in one document. When a feature ships, keep its plan in plans/ as history. Put its current behavior in the relevant guide or specification.

Read the writing and HTML guide before editing. Run npm run docs:html and open .docs-site/index.html to browse the HTML library. Run npm run docs:watch to rebuild it as documents change.

Layout

FolderRead it for
product/MVP1 scope and launch checklist, roadmap, bugs, and open tasks
plans/Work planned or completed. Keep completed plans as history and state their status. The reader-social plan has a handoff and test checklist.
architecture/Storage and records, social counts and backlinks, bundle-size checks, OAuth scopes (which permission each sign-in and feature asks for), and diagrams. Link to diagrams here instead of copying them.
design/Brand assets and motion. Use /styleguide for current components. Keep reference/ unchanged as history.
operations/Deploy with SST, find AWS resources, and set deploy permissions
guides/Use each app page, run browser tests, test the reader-social features by hand, and write and publish documents. Carbon and Storybook guides are historical.
archive/Old ECS and Fargate deployment guides. Do not use them for current deployment.
CHANGELOG.mdRelease history, updated with npm run changelog

Pinned at docs/ root (path-coupled — do not move)

  • PAGEHARE_BLOCKS.md — block spec; its rules/fence tables are generated by npm run docs:blocks and drift-checked in CI (scripts/generate-blocks-doc.ts writes this exact path).

  • ENGINE_INVARIANTS.md — engine invariants; referenced by path from claude.md, plans, and code comments.

Also path-pinned at the repo root: PAGEHARE_DS.md (design-system rules — read before any UI work) and claude.md (agent instructions; note git tracks it lowercase).

Conventions

  • Put status first. Plans and product docs state their status (not started / in progress / done / superseded) in the first lines.

  • Link to the source. Keep each fact in one doc and link to it elsewhere. Values live in code (CSS tokens, Zod schemas, lexicon JSON); docs hold rules and intent.

  • Generated regions are never hand-edited (PAGEHARE_BLOCKS.md tables, CHANGELOG.md reconciled section).

  • Changelog: run npm run changelog to append commits since the last reconcile point; hand-written release notes go above the generated history.

Original and simplified editions

Documents at the paths above retain their original wording. Most have a reading copy under docs/simplified/<original-path>, and the HTML library uses those copies. Single-edition documents (generated, append-only, or already plain) have no copy; the HTML library uses the original. For example, docs/architecture/storage.md pairs with docs/simplified/docs/architecture/storage.md.

Follow the document workflow to update both editions and record a comparison. Simplified editions are not separate specifications.

docs/design/brand-assets.md · Simplified edition

PageHare brand assets and motion

Status: implemented. Binding brand rules live in LOGO.md; this guide covers use and regeneration. The design system governs ordinary UI motion. The /styleguide brand section is the runnable reference, using the same components as the product.

Sources and outputs

The source currentColor marks live in packages/app/src/lib/brand/. BrandMark.svelte includes those shapes directly. When a source SVG changes, update the matching branch in that component. BrandLockup.svelte switches between the full wordmark and the bare mark. The wordmark is outlined SVG, so its lettering needs no font request.

SurfaceAsset choiceOutput
Small browser tabBare Bstatic/favicon.svg, favicon-16.png
Larger raster faviconRefined framed Astatic/favicon-32.png, favicon-48.png
Legacy browser faviconMatching 16/32/48px entriesstatic/favicon.ico
Installed appFramed A on the existing greenstatic/app-icon.svg, pwa-192.png, pwa-512.png
Maskable installed appFramed A inside the mask-safe areastatic/pwa-maskable-512.png
Apple home screenFramed Astatic/apple-touch-icon.png
Social avatarBare Bbrand/exports/avatar-*.png
Social bannerPageHare lockup and product promise, clear of avatar cropsbrand/exports/banner-*.png
Link previewRecognizable PageHare lockupstatic/og-image.png
Email lockupRefined lockup at existing email sizespackages/app/src/lib/email/assets/

static/ in this table means packages/app/static/. The manifest uses the dedicated app-icon SVG, so the small-tab SVG can stay bare B. Tenant favicon and Open Graph rules remain in server/theme.ts; app-brand assets are not substituted for a tenant's identity.

Use the motion components

BrandMotion.svelte accepts variant (dive, ears, missed, or success) and size (inline pixels). Motion is always decorative and hidden from assistive technology: nearby status text explains the state. Use PageholeLoader for an announced loading status and BrandMark with title for a named static mark.

<script lang="ts">
	import BrandMotion from '$lib/brand/BrandMotion.svelte';
	import PageholeLoader from '$lib/components/PageholeLoader.svelte';
	import { useDelayedLoading } from '$lib/components/useDelayedLoading.svelte.js';

	let { loading }: { loading: boolean } = $props();
	const load = useDelayedLoading(() => loading);
</script>

{#if load.visible}
	<PageholeLoader variant="ears" size={32} label="Loading more pages" />
{/if}

<BrandMotion variant="missed" size={96} />
<h2>No pages yet</h2>

Use PageholeLoader for loading: it owns the status semantics and accessible label, while useDelayedLoading gates visibility. The two loading variants share the same cycle, so switching to curious ears preserves the existing delay and minimum-display rules. Timers control visibility only; CSS animates the SVG.

The product uses:

  • Quiet dive:

    dashboard, editor, and preview splash loading.

  • Curious ears:

    loading more pages in Discover.

  • Wrong burrow:

    404 and first-run empty states in Discover and the dashboard. It plays once and rests as E; filtered empty lists remain ordinary text.

  • A small hooray:

    the confirmation toast after publishing succeeds in the editor or preview. It plays once and rests as B. Other toasts retain their normal status treatment.

Non-404 errors use static B. Reduced-motion users see B immediately for loading or success and E for missed; ripples and accent strokes stay hidden. Static fallbacks are CSS-driven and work without JavaScript. Keep status text and controls usable independently of the illustration.

Regenerate the asset family

Run from the repository root after installing workspace dependencies. Static generation uses the existing Playwright dependency and a Chromium browser; motion exports also require ffmpeg on PATH. Use Playwright's installed Chromium, or set CHROMIUM_PATH to an existing browser executable.

npm run brand:assets
npm run brand:assets:check
npm run brand:motion

For an environment with system Chromium:

CHROMIUM_PATH=/usr/bin/chromium npm run brand:assets
CHROMIUM_PATH=/usr/bin/chromium npm run brand:assets:check
CHROMIUM_PATH=/usr/bin/chromium npm run brand:motion

scripts/generate-brand-assets.mjs derives the static images from the canonical SVGs, the existing palette in tokens.css, and the bundled Literata font for banner copy. The check rerenders the outputs and compares bytes, and also checks that inline BrandMark shapes match their SVG sources.

scripts/generate-brand-motion.mjs compiles the actual BrandMotion component, renders it in Chromium, and exports the animation frames. Outputs in brand/exports/motion/ use these stems:

  • dive-loop

  • ears-loop

  • missed-once

  • success-once

Each exports GIF, WebP, MP4, and WebM, with a contact sheet for frame review. Missed and success GIF/WebP exports play once. The product renders inline SVG and CSS; it does not load these media exports.

Review a change

  1. Open /styleguide, inspect all four states at 24, 48, and 96px, and use the replay control for the two one-shot gestures. Check each theme in light/dark.

  2. Enable reduced motion: every example should immediately show the correct resting mark with no moving ears, ripple, or accent strokes.

  3. Inspect favicons at actual 16, 24, and 32px sizes, app icons with masking, banner crops, and light/dark browser-tab backgrounds. Follow the cold-read ritual in LOGO.md before shipping an edited mark.

  4. Run npm run brand:assets:check, npm run check, and npm run lint:tokens -w app; check loading, 404, and publishing behavior with the corresponding UI tests. Reader changes must preserve the zero-JS gate.

Historical brand inputs in docs/design/brand-history.md, docs/design/reference/, and brand/retired/ remain historical references; regeneration does not rewrite them.

docs/guides/documentation.md · Original edition

Write and publish project documents

Status: active.

Writing rules

Use plain English inspired by ASD-STE100. We do not claim full compliance with that standard or its controlled vocabulary.

  • Start with what the reader needs to know or do.
  • Use common words, active voice, and one main idea per sentence.
  • Aim for sentences of 20 words or fewer. Keep a longer sentence when splitting it would make a technical rule less clear.
  • Use one name for each concept. Define an acronym when readers first need it.
  • Put actions in order. Use numbered steps for procedures.
  • State the condition before the action: “If publishing fails, keep the draft.”
  • Keep exact commands, paths, field names, limits, dates, and error messages.
  • Preserve “must”, “must not”, “may”, and all exceptions. Simpler wording must not weaken a requirement or turn a plan into a shipped feature.
  • Add a diagram when it explains a relationship better than prose. HTML is generated automatically. Videos are optional and are not required for each doc.

For example, replace “The adapter facilitates persistence of canonical content” with “The adapter saves the Markdown content.” Define “canonical” as “source of truth” when that distinction matters.

Where a document belongs

Use the documentation index. Keep one source for each fact and link to it elsewhere. Keep the current status near the top of plans and product documents. Separate current behavior, planned work, and historical decisions.

Do not rewrite code samples, test fixtures, quotations, generated regions, or append-only decision logs as a language cleanup. Historical reference files in docs/design/reference/ stay unchanged. Mark old guides as historical when their instructions no longer apply.

Maintain two editions

The original document stays at its existing path and remains the source of truth. Its reading copy lives at docs/simplified/<original-path>. For example:

docs/architecture/storage.md                  original
docs/simplified/docs/architecture/storage.md  simplified reading copy

This PR restores pre-PR wording in existing originals. New workflow and hosting notes remain as additions. Old agent guidance is retained with a notice that points to the current rules. Git retains the full change history.

Single-edition documents

Some documents have no simplified copy. Their HTML page reads the original. They are listed in SINGLE_EDITION in scripts/docs/catalog.mjs:

  • generated or append-only files that tooling rewrites, such as docs/CHANGELOG.md;

  • files edited in almost every session, such as docs/product/TODO.md;

  • originals that are already plain, or not yet simplified (PH-85).

Edit a single-edition document once, in the original. Do not run docs:review for it. To simplify one later, remove it from SINGLE_EDITION, create its copy, and record the comparison. A new document is paired by default.

Update a paired document

  1. Change facts and requirements in the original document first.

  2. Update its simplified copy. For a new document, create the matching copy.

  3. Compare both files. Preserve every requirement, exception, example, command, and status. A simplified edition is not a summary. If wording is already clear, it can stay the same in both files. Do not edit generated sections in the copy. Hashes ignore them, and the HTML build takes them from the original, so npm run docs:blocks never makes a pair stale.

  4. Keep relative links and image paths relative to the original file location. The HTML builder resolves them there, so the two editions use the same links. Raw copies on GitHub are intended for comparison; use the HTML library to browse.

  5. Record the comparison for the files you checked:

    npm run docs:review -- docs/architecture/storage.md
    npm run docs:check

docs:review records hashes of both files in docs/simplified/manifest.json. It does not rewrite text or prove equivalent meaning. It records that the author compared the pair. CI and HTML builds fail when either file changes without a new comparison, or a copy is missing. Do not refresh hashes just to bypass a failure.

For a deletion, remove the original, its simplified copy, and its manifest entry in the same change. Treat a rename as a deletion plus a new pair. To change only wording for readers, edit the simplified copy and compare it with the original.

The original files are authoritative, including agent instructions. Reading copies are not additional policies. Historical references and test fixtures remain outside the existing HTML catalog. PH-85 tracks the remaining detailed language review; a current hash does not mean every section has already been simplified.

Build the HTML library

npm run docs:html

The build reads the simplified copy of each paired document, and the original of each single-edition document. Each page states its edition and links to its Markdown on GitHub.

Open .docs-site/index.html in a browser. Choose a document or All documents for one HTML page you can search with the browser's Find command or print to PDF. The output uses relative links, so it also works without a web server.

npm run docs:watch

Leave this command running while you edit. It detects additions, edits, and removals, then rebuilds the library after the pair passes its freshness check. Update the copy and record the comparison, then refresh the browser. Build failures appear in the terminal; fix the error before using the output.

Shared product rendering

The build uses PageHare’s Markdown parser, section rules, and Svelte block components. The rule engine chooses header, items, Q&A, or prose for each section. A documentation guard keeps sections in the product’s prose block when a richer block would lose heading levels, links, inline code, or task checkboxes.

The library also uses the product’s reset, theme tokens, block styles, Literata font, and logo. Component CSS comes from the same Svelte source as the app. Only the library navigation and page layout have separate CSS. The output contains static HTML and CSS, with no browser JavaScript.

Use inline Markdown links. Reference-style links and footnotes are not supported by the product renderer yet; the docs build reports an error instead of dropping them. The all-documents page opens Q&A answers for searching and printing. Watch mode also rebuilds when shared block code or styles change. Restart the watcher after changing the generator or its pair-checking scripts.

Automatic updates and hosting

The app's build command does not build the document library, so a document problem never blocks an app deployment. CI checks the pairs and tests the generator. The next deployment publishes a separate static documentation site at docs.next.pagehare.com, built with npm run docs:html:public. Production (main) has no documentation site. Merge a document change into next to deploy it.

Search engines must not index the site. Every response has an X-Robots-Tag: noindex, nofollow, noarchive header, and every page has a noindex meta tag. robots.txt allows crawling, because a crawler must fetch a page to see its noindex.

The repository is private, but the site is public. The public build includes only the documents in PUBLIC_DOCS (scripts/docs/catalog.mjs) and their linked images. It has no GitHub links, and links to unpublished documents keep their text without a link. The build fails if a page contains an AWS ARN, a 12-digit account number, or an access key ID. Before you add a document to PUBLIC_DOCS, check that it has no account details, internal hosts, runbooks, or unreleased plans. Run npm run docs:html:public to preview the public site.

SST manages the certificate, DNS record, S3 bucket, and CloudFront distribution. It uses the existing Route 53 zone and GitHub deployment role. A successful local build does not prove that AWS permissions or the live domain work. After deployment, open the index, a nested guide, its image, and the all-documents page on the domain.

Do not edit .docs-site/ or commit generated HTML. Fix the Markdown or generator, then build again. The build is deterministic and uses no AI service or API key.

Check an edit

  1. Compare the original and simplified text. Check each requirement and exception.

  2. Verify commands, names, links, and current behavior against the source.

  3. For a paired document, run npm run docs:review -- <original-path>. Then run npm run docs:check, npm run docs:test, and npm run docs:html.

  4. Open the changed page and check its headings, tables, code, and links.

  5. Record deferred work in TODO.