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.