All project documents
Use Find to search this page, or Print to save it as PDF.
Contents
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
| What | Where |
|---|---|
Canonical currentColor SVG sources | packages/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 attribution | packages/app/src/lib/brand/PoweredBy.svelte |
| Motion family (CSS-only SVG) | packages/app/src/lib/brand/BrandMotion.svelte |
| Accessible loading wrapper | packages/app/src/lib/components/PageholeLoader.svelte |
| Loader consumer rules | packages/app/src/lib/components/useDelayedLoading.svelte.ts |
| Favicons / PWA / apple-touch | packages/app/static/ (wired in app.html via %ph.icons%) |
| Empty-state art | packages/app/src/lib/brand/empty-state.svg (mark E) |
| Non-served exports (avatars, banners, motion) | brand/exports/ |
| Email lockups | packages/app/src/lib/email/assets/ |
| Retired previous brand | brand/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
| Variant | Name | What it is | Use for |
|---|---|---|---|
| A | master | Ears rising from the hole, inside the page frame | App icon ≥ 32px, splash, hero |
| B | bare | Ears + hole, no frame | Small sizes (< 32px), lockup fallback, powered-by, dense UI |
| D | spiral | Concentric ripples around the hole | Occasional decoration only; never favicon, app icon, or status |
| E | missed | Hole with faint miss-arcs, ear half-sunk | 404 and empty states — "you fell too far" |
| — | lockup | Mark + "PageHare" wordmark, one SVG | Header/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.tstenant branch serves the tenant's favicon or none (renderBrandHeadinserver/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.
| Variant | Name | Use | Playback and resting pose |
|---|---|---|---|
dive | Quiet dive | Default loading | 1.8s loop; sinks, makes one ripple, returns to B |
ears | Curious ears | Smaller or quieter loading | 1.8s loop; ear flicks while the silhouette remains visible |
missed | Wrong burrow | 404 and empty states | 2.8s once, then rests as E |
success | A small hooray | Successful publishing | 1.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.
BrandMotionis always decorative;PageholeLoadersupplies 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-motionis honored insideBrandMotion: 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 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)
| Thing | Where / 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 themes | packages/app/src/lib/styles/themes/*.css — [data-theme] blocks reassigning Tier 1 only |
| Block shapes + token surfaces | docs/PAGEHARE_BLOCKS.md |
| Living example | the /styleguide route, rendered by the real block components (see §4) |
| Layer order | @layer reset, tokens, blocks, overrides; — declared once, first |
| Historical reference inputs | docs/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-uicomponents 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)
-
Reading is the product. Typography and rhythm are the design. Structure comes from whitespace and faint tone, not cards/borders/shadows.
-
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.)
-
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. -
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.
-
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(). -
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. -
Accessibility is a floor, not a feature. Visible keyboard focus, correct landmarks/semantics, WCAG AA contrast,
prefers-reduced-motionrespected — 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.
| Script | Loaded by | What it does | Why markup cannot do it | Approved |
|---|---|---|---|---|
components/public/readerShare.js | ReaderActions, only when it is given a page to share | Shows the hidden Copy link and share-sheet controls; copies the address; opens the device's share sheet | The clipboard and navigator.share are script-only APIs | Owner, 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
hiddenand 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 stayscsr = false, never hydrated. An?urlasset 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:
| File | Question it answers | Kind |
|---|---|---|
PAGEHARE_DS.md (this file) | How do I build here? What are the rules? | Rules — prose |
packages/app/src/lib/styles/tokens.css | What are the exact Tier 1 names & values? | Values — code (source of truth) |
packages/app/src/lib/styles/blocks.css | What Tier 2 surface does each block expose? | Values — code |
packages/app/src/lib/styles/themes/*.css | What named themes exist and what do they override? | Values — code |
docs/PAGEHARE_BLOCKS.md | What blocks exist, their shape (Zod) and token surface? | Spec — prose + schema |
/styleguide route | What 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 inblocks.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.tsand emitted as a:rootstyle 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 emitslight-dark(l, d)pairs. This is a schema decision, not an implementation detail — the theme-config table must reflect it from v1, with aschemaVersionfield (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 thelight-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:tokenspasses -
Tier 2 surface (if any) documented in
docs/PAGEHARE_BLOCKS.md -
Rendered in the styleguide (
/styleguideroute) -
AA contrast verified in both light and dark
-
Keyboard-operable with visible focus; correct semantics/landmarks
-
prefers-reduced-motionhonored -
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-valuerequiringvar()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 byrem/em/keywords. Allowed exceptions:0,1pxhairlines,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.cssmust 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-uidirectly, 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
:rootto block selectors; fallbacks removed. Dual defaults (:rootdefinition +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, withschemaVersion; AA checked per mode. (v0.1) -
Static example declared transitional. Succeeded by a
/styleguideroute 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 viarem/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;
/styleguideis 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.cssholds the token surface only; block visuals live in scoped component styles.blocks.cssis exempt fromlint: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
variantsprop. The blocks' oldvariantsmap (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 isprose'slead, 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-noneand--ph-backdrop. Both are theme-level and reused (single-line controls; every modal scrim), and both sit onlint: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.
itemsgoes two-up at a48remcontainer width, chosen wider than--ph-measureso 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:@containerconditions cannot readvar(). (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-bgand on its own wash, in light and dark, byscripts/check-theme-contrast.mjs. (v0.3) -
bits-uinot 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: relativecannot place it.Menu/Popovermeasure the trigger once per open, inbeforetoggle. 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.tsreplacecarbon-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 nowdata-*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)
-
/styleguideimports the real primitives. Its gallery was hand-written HTML while onlyButtonexisted 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/uideleted, 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:tokensis 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.cssnames 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 speaksbg/text-color/accent, so a parallel--ph-canvas/--ph-inkvocabulary would have split the API. Warren is the default (baked intotokens.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/-washre-derived. Per §8, a default-theme value change is allowed but re-verified for AA:scripts/check-theme-contrast.mjspasses in both modes (Warren on-accent 4.93:1). Marks arecurrentColorand 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
#C05B2Egives white-on-accent ≈ 4.38:1 (below AA); the solid-fill--ph-accentis derived to#b4551f(4.93:1) while--ph-accent-textstays in the kit's terracotta family. Accessibility is a floor (Invariant 7); the brand marks usecurrentColor, 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 inhooks.server.tsand emitted through a%ph.htmlattrs%placeholder asdata-theme+ inlinecolor-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 inlinecolor-schemeon<html>beats a future tenant:rootpin by specificity — explicit user choice wins;autoemits nothing and defers to tenant/OS. Controls live on/settingsonly (per §8's preset list); the cookie is the single source of truth — appearance never enters theeditorSettingslocalStorage 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
bylineblock 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]stayscsr = false— verified by thereader-nojsPlaywright 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 asbskyPostRefand 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 fromdocs/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
tagicon 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-blockis 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-uistill 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.sveltealready spreads...rest, sorole="combobox"+aria-expanded/-controls/-autocomplete/-activedescendantattach 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) -
HandleInputis auth-local, notds/Combobox. §8's "one consumer is not a primitive": it lives incomponents/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 becauseMenu/Popoverhang off a bottom-pinned toolbar and live in the top layer; this list is anchored to an input insideAuthCard's panel, which has no scroll container and no overflow clipping, soposition: absolutein aposition: relativewrapper 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 reusedPublishedPageCardfor/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.svelteis now the only page card, andPageCardGrid.sveltethe only grid.PageCardis deliberately store-free and atproto-free — that is what lets the same component serve thecsr = falsearchive and the authenticated dashboard; callers pass strings and compose their own CTAs through thestatus/actionssnippets. (v0.6) -
ds/is sorted by dependency direction, not abstraction level.PageCard/PageCardGridland inds/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 barecomponents/root put it in a directory with no stated contract at all. The admission test fords/is therefore: token-only, no libraries, no app-layer imports, props in — and, per the v0.5HandleInputdecision, more than one consumer.PageCardhas three. Nothing imports theds/barrel (every call site imports by path) and/p/[slug]imports onlyds/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: Nlhwith a matchingline-clamp),PageCardGridsetsgrid-auto-rows: 1fr, and the footer is pushed down withmargin-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-sizeis not token-gated, so thelhliterals passlint:tokens— andlhis 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-subtleon a--ph-bgpage, which made it recede exactly where a listing needs its items to read as discrete things. It is now--ph-bgand 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-smto--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-mutedgoes 4.53:1 → 4.91:1 light and 8.45:1 → 9.06:1 dark, because--ph-bgis further from the text than--ph-bg-subtlewas at both ends). The card also stops using--ph-text-faint, which is 3.16:1 on--ph-bgin 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 atmax-inline-size: 72remincluding--ph-space-5padding underbox-sizing: border-box— a 69rem content box that could never satisfy a 72rem query. Three columns had never rendered. Owning the steps inPageCardGridfixes 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
::aftercovers the card; the share button, overflow menu and byline link re-layer above it withvar(--ph-z-raised)(§3 — never a rawz-index). The clamp lives on an inner.title-clamprather than the<h3>becauseoverflow: hiddenon 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.
PublicHeaderhid 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. DuplicatingAppShell'sdata-open+ backdrop + Escape machinery intocomponents/public/would put an interactive overlay on the landing surface to save a scroll.PublicFooternow 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 onesiteNav.tslist — 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.sveltemarks the current row with--ph-accent-washand--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 takecolor: 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:hoverbecause the two selectors have equal specificity, so source order is what makes current beat hover. (v0.6) -
isCurrentPagespecial-cases/. Every path starts with/, so the prefix test that marks/aboutwould 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:/phadmain { 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 amin-block-size: 100dvh(with avhfallback) flex column wheremaintakesflex: 1 0 autoand the footer takesmargin-block-start: auto— the same techniquePageCarduses for its own footer, and the reason it is a page-level wrapper rather thanposition: fixedis that a fixed credit would sit over the prose on a long read. It is deliberately notPublicFooter: 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 carriesBrandMarkvariantbat 18px — per the LOGO.md handoff rule, the bare mark below 32px. That mark is compile-time-constant inline SVG oncurrentColor, 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
isCurrentPagewith the public nav.app-sidebar.svelteheld a hardcoded two-item array; adding the publication and account destinations made the ordering carry meaning it could not express. The links now come fromcomponents/app/appNav.ts— the counterpart tocomponents/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 needsauthorPath's did-while-the-handle-resolves fallback. Group headings are--ph-text-xsmuted 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 existingisCurrentPagefromsiteNav.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 setreload, which emitsdata-sveltekit-reload—/a/[handle]iscsr = falseand 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 borrowinginsert. (v0.6) -
Correction to the
ReaderFootercolumn:margin-inline: autoopts a flex item out of cross-axis stretch./a/[handle]centred its measure by puttingmax-inline-size+margin-inline: autoonmainitself, which is a flex item of the new.archivecolumn. Auto margins on the cross axis suppressalign-items: stretch, somainfell back to fit-content — a ~308px column on a 1084px viewport, withmax-inline-sizenever binding./p/[slug]escaped it by centring an inner<article>(a normal block) and leavingmainunstyled apart fromflex: 1 0 auto, which is the pattern to copy. The fix isinline-size: 100%onmain, 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 explicitinline-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 incsr = falseand required amending thereader-nojsPlaywright 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 itsalttext 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)_ -
PageCardwas not touched; the count arrives through the existingstatussnippet. That preserves the v0.6 store-free/atproto-free property that lets one component serve both thecsr = falsearchive and the authenticated dashboard. It also had to be plain text, not a link:.foot-startsits 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 alreadymargin-block-start: auto, so row baselines still align. (v0.7) -
StatTile,BarListandViewsChartlive incomponents/app/stats/, notds/. They are token-only and props-only, but they have exactly one consumer, and the v0.5HandleInputdecision is explicit that one consumer is not a primitive. They stay out of/styleguideuntil a second surface needs them.BarListandViewsChartdeliberately use no chart library —chartis stillreservedin 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'sstyle:directive: it comes from the counts, not from the design, which is whylint:tokensis right not to gate it. The bars arearia-hiddenwith 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-faintis 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-bgin 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-faintis 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]topagehare.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, keepscsr = falseandreader-nojsintact, 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 athttps://<name>.pagehare.com/{title-slug}-{id}(the author'ssite.standard.publicationurl+ the record'spath), 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, thereader-nojsgate, 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 passappOriginand the author's base in page data, andReaderFootertakes ahomeprop 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.mdanddocs/design/brand-assets.md. (v0.10) -
Four brand gestures share one CSS-only component.
BrandMotionowns the quiet dive and curious-ears loading loops, the missed/404 one-shot, and the publish-success one-shot.PageholeLoaderretains loading semantics and consumer timing throughuseDelayedLoading; 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./styleguidedemonstrates 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.svelteis 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 ofds/and/styleguideuntil a second surface needs it. Publication name and description use the existingInputon one line each — no textarea primitive was added for a single field. -
v0.11 —
ReaderActionsis 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 — carryingrel="nofollow". The links come fromreaderIntentLinks($lib/intents.ts), a function of the record's document URI, itssiteand the deployment's reader origin, so two renders of one record are identical (ENGINE*INVARIANTS #16) and the route stayscsr = false; no count, no per-reader state, noreturnparameter (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-mdpill 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 asIcon.svelte, and are not inds/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 inpublic/, notds/: one consumer plus the styleguide. The intent page itself reusesAuthCard,HandleInput,Field,AlertandButton— 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 oncsr = falsepages); a shared*.pagehare.comcookie (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).readwas chosen overreaderfor length and is an infrastructure label, never an author's name. No visual change:ReaderActionsand the intent page are as in v0.11. (v0.12) -
v0.13 — The reader hub (
/reader) is an app surface built from existing parts;Tabsgains a second consumer and stays inapp/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 isPageCardin a one-columnPageCardGrid(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 incomponents/app/reader/ReaderList.svelte, and the tabs are the SettingsTabswith the selected tab in the URL hash. v0.10 saidTabsmoves tods/when a second surface needs it; that surface now exists, and the move (with its/styleguideentry, §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 stillcsr = 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.svelteholds one checkbox ("Post to Bluesky") and, when it is ticked, one native<select>("Who can reply");ReplyControlsDialog.sveltechanges that later and hides individual replies. Both followConfirmDialog's recipe (showModal(), the platform's focus trap and Escape,--ph-backdrop, the@starting-stylefade is left out because neither needs it) and are built fromCheckbox,Select,Field,Input,AlertandButton: no new primitive, no new token, no new icon (the menu items reuseshare). They are not inds/: each imports the atproto client or app state, which is whatapp/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 frombskyPostRef, stillcsr = false. Correction found on the way: the editor's overflow menu was built by a baremenuItems()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 abskyPostRefand 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, andreader-nojscovers 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>inReaderActions, same grid and stroke; the route's payload went down (129.65 → 129.62 kB of 130) because the old.discussblock and its styles are gone. On the intent page,components/app/intent/ReplyControl.sveltereusesReaderList(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-4per level, capped at three, with the level passed as a custom property the wayBarListpasses 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 newds/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 tods/with a/styleguideentry when a second one appears. No new token. The character count turns--ph-danger-textpast 300 and carriesaria-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/readerits own left-aligned sign-in under the hub header, which read as a different product from/auth/signinand the intent pages. Signed out, the hub now rendersAuthCard(centered logo above a centered panel,homeabsolute 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. InHandleInputthe 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 staysaria-hiddenand is not part of the value. (v0.16) -
v0.17 — Project documentation is generated static HTML. The document library reuses
tokens.csswith local--docs-*aliases inscripts/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_EDITIONinscripts/docs/catalog.mjs: generated, append-only, or already plain) have no copy, and their page reads the original. The deployed site publishes onlyPUBLIC_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).
ArticleLayoutkeeps 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 = falsestill 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 ontodaa8d8cpreserves 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 remainscsr = false; total-client and all other route budgets retainnext's values. -
Article layout rebased onto
nextat9dc32e7(2026-10-12).nextdropped the Bluesky "discuss" link for the Reply action inReaderActions(F6), so the article layout rendersReaderActionsalone after the content and no discuss link. The article route measures 132.39 of 134 kB gzip, stillcsr = false. With the reader hub now onnext, the total is 742.94 kB, sototal_client_jsgoes 740 → 745 kB (PH-64, owner to accept); this supersedes "total-client retainsnext'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 (showAuthoroff, or no profile), so a page never loses it. Recommend and bookmark are also offered beside the byline, at its far end, as thecompactform ofReaderActions(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 byaria-labelandtitle; 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",relwithnoopener 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 twoReaderActionsnavs with different labels and one link in the card, all still plain links fromreaderIntentLinks, 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, notPageCard: the route has about 1 kB of budget left.ArticleLinkgains optionaldateanddescription;showRelatedkeeps 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_js743.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 andnavigator.sharedo 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 stillcsr = false: nothing is hydrated and no framework code is downloaded. The script only shows controls the markup shipshidden, so a page where it does not run is the script-free menu (Bluesky, email, and the address setuser-select: allto 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 arole="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).
PublicationLayoutis that frame. It reads the article's Tier 2 tokens:.ph-publication-layoutjoins.ph-article-layoutin oneblocks.cssrule, 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 primaryButtonand now opens a new tab like every other intent link (2026-10-12); Share is the compactReaderActionspair beside it, given the publication's address. That loadsreaderShare.json/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 stayscsr = 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'snamewhen 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_jsgoes 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 = falsepage, and the reader's session belongs toread.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.IntentFramereplaces the Subscribe button in the author card ofArticleLayoutandPublicationLayoutwith 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/embedanswers withContent-Security-Policy: frame-ancestorsnaming 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: transparentso the page shows through. Consent: reader sign-in asks forreaderSignInScope(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 acsr = falsepage 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/subscribein the author card as before, without its "Signed in as" line, and/embed/accountat the end of the bar: the reader's avatar and name (atarget="_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 sameframe-ancestors; a page with no publication has neither frame. Signing out in one frame is heard by the other through thestorageevent. Consequences of a fixed row: no account menu (it would be cut at the frame's edge), so sign-out is a button; the compactGraphControlkeeps 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:ReaderFootergains 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.
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 devOpen 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
-
Sign in and choose New page.
-
Enter a title and body. Use headings, lists, links, images, or tables.
-
Wait for Saved locally. This saves a draft in this browser.
-
Open Preview to check the layout.
-
Choose Publish to make the page public.
-
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
| Path | Purpose |
|---|---|
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.md | Instructions for coding agents |
PAGEHARE_DS.md | Required 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 generatorSee 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.
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.
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.
Engine versions: segmenter seg-1, ruleset rules-1, hash fnv1a64-1.
| Order | Rule | Block | When it matches |
|---|---|---|---|
| 1 | header-heading | header | A 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. |
| 2 | qa-alternating | qa | Two 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. |
| 3 | list-items | items | An 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. |
| 4 | prose-fallback | prose | Anything else renders as prose, verbatim. |
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).
| Fence tag | Payload | Block | Purpose |
|---|---|---|---|
chart | csv | reserved | Reserved for the future chart block: a fenced code block tagged chart carrying CSV data. Falls through to prose until the chart block ships. |
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) }.eyebrowandctasare 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.
| Block | Selector | Tokens |
|---|---|---|
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: valuesegment 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-leadexception — 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
```chartfence.
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
metakey 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
levelpreset 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:
-
The author card: the page's
h1, bio, Subscribe, Share, RSS and the Bluesky link. -
Topics.
-
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.
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
| Folder | Read 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.md | Release 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 bynpm run docs:blocksand drift-checked in CI (scripts/generate-blocks-doc.tswrites this exact path). -
ENGINE_INVARIANTS.md— engine invariants; referenced by path fromclaude.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.mdtables,CHANGELOG.mdreconciled section). -
Changelog: run
npm run changelogto 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.
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.
| Surface | Asset choice | Output |
|---|---|---|
| Small browser tab | Bare B | static/favicon.svg, favicon-16.png |
| Larger raster favicon | Refined framed A | static/favicon-32.png, favicon-48.png |
| Legacy browser favicon | Matching 16/32/48px entries | static/favicon.ico |
| Installed app | Framed A on the existing green | static/app-icon.svg, pwa-192.png, pwa-512.png |
| Maskable installed app | Framed A inside the mask-safe area | static/pwa-maskable-512.png |
| Apple home screen | Framed A | static/apple-touch-icon.png |
| Social avatar | Bare B | brand/exports/avatar-*.png |
| Social banner | PageHare lockup and product promise, clear of avatar crops | brand/exports/banner-*.png |
| Link preview | Recognizable PageHare lockup | static/og-image.png |
| Email lockup | Refined lockup at existing email sizes | packages/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:motionFor 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:motionscripts/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
-
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. -
Enable reduced motion: every example should immediately show the correct resting mark with no moving ears, ripple, or accent strokes.
-
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.mdbefore shipping an edited mark. -
Run
npm run brand:assets:check,npm run check, andnpm 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.
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 copyThis 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
-
Change facts and requirements in the original document first.
-
Update its simplified copy. For a new document, create the matching copy.
-
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:blocksnever makes a pair stale. -
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.
-
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:htmlThe 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:watchLeave 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
-
Compare the original and simplified text. Check each requirement and exception.
-
Verify commands, names, links, and current behavior against the source.
-
For a paired document, run
npm run docs:review -- <original-path>. Then runnpm run docs:check,npm run docs:test, andnpm run docs:html. -
Open the changed page and check its headings, tables, code, and links.
-
Record deferred work in TODO.