Skip to content

feat(ranpress): dev and preview servers, and a design pass over the docs site - #404

Merged
chaxus merged 14 commits into
mainfrom
feat/ranpress-dev-preview-and-design
Sep 12, 2026
Merged

chaxus merged 14 commits into
mainfrom
feat/ranpress-dev-preview-and-design

Conversation

@chaxus

@chaxus chaxus commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Twelve commits in three groups: the engine gains a real local-server story, the docs site gets a measured design pass, and two silent bugs found along the way are fixed.

The engine

One host model behind dev, preview and verify. The dev server and the verifier each carried their own copy of "how does the production host resolve a URL", and the copies disagreed — the verifier knew that reaching a page through <path>/index.html means a 308, the dev server served it as a plain 200. Local development therefore looked correct for exactly the layout production redirects away from, which is how 904 canonical URLs shipped naming a URL the host bounces.

host.ts is now the single model. Every rule in it was measured against Cloudflare Pages rather than read off its documentation, including one that surprised me: the redirect runs in reverse too — with about.html on disk, /about/ is a 308 back to /about — but only for the .html candidate, so /sitemap.xml/ is a plain 404. 17 tests pin it, path traversal included.

pnpm -F docs dev pointed at a build/dev.ts that did not exist. docs had no working local server at all since the migration. Both sites now have dev and preview; neither reimplements the rebuild queue any more.

The URL/file-layout fix that the 904 canonicals needed: outFileFor follows the canonical rather than a fixed convention, and verify fails the build on any canonical that only resolves through a redirect.

Two silent bugs

A hard-wrapped CJK paragraph gained a space. VitePress overrode softbreak to drop the newline between two CJK characters; the replacement did not. 114 stray spaces in the first 120 Chinese pages alone — 分 就, 会 与 — invisible in the markdown, visible in every rendered paragraph. Zero across all 1,393 pages now. The fix took two attempts and the second is the interesting one: a walkTokens registered through marked.use() only runs inside marked.parse(), and this renderer drives lexer() and parser() separately, so the hook was never called and the fix looked like it did nothing.

Content below the fold was invisible without JavaScript. .reveal { opacity: 0 } is a bet that the IntersectionObserver always runs. Gated on a js class set before first paint. Separately, .search gave a closed <dialog> a flat display: flex, overriding the UA's display: none — 177px of phantom scroll on every page.

The design pass

Every finding below was invisible to the eye and obvious once measured in a browser.

before after
dead column on the landing 280px 16px
boxed surfaces on the landing 14 6
table clipped inside its scroller 26px 0
running text 76 characters 73
code offset from the prose spine 20px 0
VitePress variable references 90 0

The outline column was reserved for every page without a sidebar — the landing has no outline, so it laid out as 1144px 224px. The measure was applied to the column rather than the text in it, so it capped a 762px table inside a 736px cap and clipped its last sentence mid-word. --measure was declared in em, so the new deck rendered 115px wider than the body text below it.

The pillars were three cards for what are cells in a gallery, with a 3D pointer tilt and a hover spotlight on top; they are three columns under a shared rule now, and the tilt handler is deleted rather than left unreferenced. The five shields.io images — the only saturated colour on the page, from two external hosts, one of them reporting a brotli size that was actually the raw one — are one typographic line built from facts the repository can prove.

Code fences lost their fill: it was a second delimiter doing the same job as the hairlines, and dropping it let the horizontal padding go, so code finally starts at the same x as every paragraph and heading.

Written down

DESIGN.md §11 gains the rules these came from, plus the three measurements that find this class of defect — because none of them would have been caught by looking at a screenshot. verify:design now runs over both consumer sites (docs 203 → 199 violations, site 95, all ratcheted).

packages/docs/CLAUDE.md is rewritten. It still described VitePress 2.x, a .vitepress/ directory that is gone, and a section on debugging hydration in a site that no longer hydrates — the first thing an agent reads before touching the package. Source comments got the same pass: nine past-tense references to VitePress explain rules that still bind and stay; seven were present tense and wrong.

pnpm -F ranui design:bundle builds a Claude Design bundle from real rendered components — shadow trees serialized to Declarative Shadow DOM with their adopted styles inlined, so a card renders with JavaScript disabled and cannot claim an appearance the component does not have.

Verification

tsc, test (3,111 passing), verify:design across three packages, and both site builds with their post-build verifiers — 1,393 docs pages and 8 site pages, every canonical a direct hit.

🤖 Generated with Claude Code

https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d

chaxus and others added 12 commits September 12, 2026 22:01
The dev server and the verifier each carried their own copy of "how does
the production host resolve a URL", and the copies disagreed. The verifier
knew that reaching a page through `<path>/index.html` means the host 308s
to the trailing-slash form; the dev server served that same case as a plain
200. Local development therefore looked correct for exactly the layout
production redirects away from, which is how 904 canonical URLs shipped
naming a URL the host bounces.

`host.ts` is now the single model, and `serve.ts` builds both servers on it:

  createPreviewServer  serve an existing dist/, write nothing
  createDevServer      the same server plus the build/watch loop, which the
                       consuming sites used to reimplement

Every rule in `host.ts` was measured against Cloudflare Pages rather than
read off its documentation, including one that surprised me: the redirect
runs in reverse too — with `about.html` on disk, `/about/` is a 308 back to
`/about` — but only for the `.html` candidate, so `/sitemap.xml/` is a plain
404. `test/host.test.ts` pins all of it, path traversal included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
`pnpm -F docs dev` pointed at a `build/dev.ts` that did not exist — docs had
no working local server at all since the migration. It has one now, watching
the eight prose trees plus styles and client code.

Both sites also gain `preview`: serve the built dist/ and build nothing. It
is what to reach for before a deploy, because a page that only works because
the dev server just rebuilt it has nowhere to hide.

Neither site reimplements the rebuild queue any more; `createDevServer` owns
it. Watch targets name the source trees explicitly rather than the package
root — `dist/` lives under the root and the build writes there, so a
recursive watch would retrigger itself on its own output forever.

docs/CLAUDE.md gets the commands, and a banner saying the rest of it still
describes the VitePress/Vue stack that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
Canonical strings matched VitePress exactly, but the file layout did not.
Both generators wrote every page to `<path>/index.html`, and Cloudflare Pages
serves that at the trailing-slash URL only — `/about` is a 308 to `/about/`.
So the canonical, the sitemap entry and the hreflang alternate all named a
URL the host immediately redirects away from, on 904 docs pages.

The shape of the file decides the shape of the URL served without a
redirect, so `outFileFor` now follows the canonical rather than a fixed
convention: a page whose URL has no trailing slash is written to
`<path>.html`, a genuine section index keeps `<path>/index.html`.

`verify` fails the build on any canonical that only resolves through a
redirect, so this cannot drift back silently. It passes on both sites:
1,393 docs pages and 8 site pages, every canonical a direct hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
… down composition

The design checker only ever ran over ranui's own `.less`. The two sites it
dresses were unchecked, which is where the drift actually was. It now takes
`--roots`, `--tokens`, `--baseline` and `--ignore`, reads `.css` as well as
`.less`, and both sites run it: 203 and 95 existing violations recorded as a
ratcheted baseline, so neither can get worse while they get better.

`--ignore` exists for vendored CSS. `components/math/temml.css` is the math
renderer's own typographic metrics — its `0.5ex` and `0.05em` are not ours to
change, and baselining them would book someone else's code as our debt when
the whole point of a baseline is to reach zero.

DESIGN.md gains §11, on composition rather than components. Every rule in it
came from a concrete failure on the live site: emphasis budget (a page of
cards is a page with no emphasis), spacing as meaning, alignment spines, and
the selection/link semantic lies — a sidebar item filled like a button, a
heading coloured like a link. The existing rules describe how one component
should look; nothing described how a page of them should read, which is why
the checker could pass on a page that was hard to look at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
…ewer boxes

The site defined its own hex values next to ranui's, so a themed component
and the prose around it could disagree about what "background" means. The
palette is now `var(--ran-color-*)` throughout, with the literals kept only
as fallbacks.

Card density was the other half. Sixteen boxed surfaces on the component page
and thirty-seven on the icon page meant nothing was emphasised, because
everything was: code blocks keep top and bottom hairlines instead of a full
border, a demo and the fence documenting it join into one unit rather than
two stacked cards, tables lost their outer box, and the pager is plain links.
Component page 16 → 5, icon page 37 → 5.

Sidebar selection was also a semantic lie — a filled wash reads as a button,
something you press, when it marks where you already are. It is an inset rule
on the leading edge now.

The homepage hero was measured, not eyeballed: the headline clamp ran to 72px
against a 56–128px section pad, which pushed the first real content below the
fold on a laptop. 52px and a 32–64px pad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
…ot boxes

Four defects, none of which were visible by looking and all of which were
obvious once measured in a real browser.

The outline column was reserved for every page without a sidebar. The
landing page has no outline, so it laid out as `1144px 224px` and carried
280px of dead space down its whole right edge — the reason the site read as
off-centre. The track is now conditional on `.toc` existing.

The measure was applied to the column rather than to the text in it, so it
capped the things that are not text. The Properties table wanted 762px
inside a 736px cap, became a scroll container, and lost 26px off its last
column: a sentence ending mid-word. Prose keeps the measure (44rem, ~73
characters); tables, fences and demo stages get the full column.

`--measure` was declared in `em`, which resolves against the element's own
font-size — so the new deck, one step up in size, rendered 115px *wider*
than the body text below it. It is `rem` now.

A demo and its fence were drawn as one object — joined radii, a hairline
between — with an 18px channel running between them, because `.prose`
spaces its children with `gap`, which no child margin can cancel. The gap is
a named property now and the fence subtracts exactly it.

DESIGN.md §11 gains the four rules these came from, the RTL and line-length
entries in the review checklist, and the three measurements that find this
class of defect — because none of them would have been caught by looking at
a screenshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
`pnpm -F ranui design:bundle` produces one card per component plus five
foundation cards (colour, typography, spacing, radius/elevation, motion) for
upload to claude.ai/design via DesignSync.

The point is that nothing in it is drawn by hand. The docs preview server
supplies the components, Chromium renders them, and each shadow tree is
serialized into a Declarative Shadow DOM template with the stylesheets it
adopted inlined beside it — so a card renders with JavaScript disabled and
without ranui on the page, and cannot show an appearance the component does
not actually have. Token values are read back per theme with
`getComputedStyle` rather than lifted from the stylesheets, so a swatch
cannot display a value the system no longer resolves to.

Three things had to be got right and are commented where they bite:

ranui attaches shadow roots `mode: 'closed'`, so `attachShadow` is patched
to open before any page script runs — otherwise there is nothing to read.

Copying the site's `:root` rules copies its cascade order too, and a later
light `:root` overrode the earlier `prefers-color-scheme: dark` block: the
stages rendered white on a black page. Resolving each token per theme
sidesteps the cascade entirely.

A `CSSStyleRule` in current Chrome carries an empty `cssRules` list for CSS
nesting, so treating "has cssRules" as "is a container" skipped every style
rule's declarations and captured no tokens at all.

Browser-side code is passed as a string, not a function: a function argument
is serialized after the TypeScript transform, which wraps named functions in
esbuild's `__name()` helper and throws `__name is not defined` inside the
injected script.

Seven components produce no card because their docs pages carry no live
demo — attachments, conversation, preview, reasoning, router, tool-card,
voice-button. The script names them rather than emitting empty shells; an
empty card reads as "this component looks like nothing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
A scroll-reveal that sets `opacity: 0` unconditionally is a bet that its
IntersectionObserver will always run. When it does not — JavaScript disabled,
a bundle that 404s, an error earlier in the file — every section below the
fold is invisible permanently, and a reader has no way to know anything was
there. Measured with JS off: the landing's closing strip was `opacity: 0` and
unreachable. The rule is gated on a `js` class now, set before first paint by
the theme bootstrap, so nothing flashes and nothing depends on the observer
to be readable.

`.search` gave the search `<dialog>` a flat `display: flex`, which overrides
the UA's `display: none` for a closed dialog. The closed panel therefore sat
in the layout at the end of every page: 177px of phantom scroll, and a search
box floating below the footer of any full-page capture. `display` belongs on
`[open]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
…ss names

The landing carried 14 boxed surfaces. Three were the pillars — cells in a
gallery, which §11 names as the case a box is *not* for — bordered, filled,
and spending the page's whole emphasis budget on navigation furniture, with a
3D pointer tilt and a hover spotlight on top. They are three columns under a
shared rule now. The capabilities and install sections each had an outer frame
around content whose rows already carry hairlines; one internal spine says the
same thing. 14 → 6, and each survivor is a button, a copy affordance, or the
live demo stage §11 allows.

The tilt handler and its `<span class="spotlight">` are deleted rather than
left unreferenced — a pointermove listener on every card, computing angles
nothing reads, is not free.

Separately, the stylesheets still spoke VitePress: 90 references to
`--vp-c-text-1`, `--vp-c-divider` and friends, resolved through an alias block
onto the site's own names. Every rule names the site variable directly now, so
there is one indirection instead of two and no trace of a theme this site does
not use — §11's "one product, one token set" applied to the last place it was
not true.

design-baseline: 202 → 199.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
…ages

Every paragraph and heading starts at 328px; code started at 348. No code
block on the site stood on the same spine as the prose around it, because the
fence's fill needed horizontal padding to breathe. The fill was a second
delimiter doing the same job as the hairlines already above and below it —
and it is the most repeated element on the site, so a page of examples read as
a stack of grey slabs. Dropping it lets the padding go: code now starts at
328, exactly where the prose does. A fence inside a box (a demo's source, a
code group) keeps its inset, because there the spine is the box's.

The five shields.io images are gone too. They were the only saturated colour
on the page, in a visual language belonging to nobody, fetched from two
external hosts on every page view — and the one labelled `brotli` reported
3.8 KB, which is the *raw* size of `dist/index.js`. In their place, one
typographic line built from facts the repository can prove: version and
licence from package.json, formats from the exports map plus a built `iife`
directory, and the source link. Values only, no labels, so all eight locales
render it unchanged and `check:langs` has nothing new to enforce.

Two badges are simply gone rather than reimplemented. CI status and download
count each need a network call at build time. A size figure is gone because no
single file is an honest answer for a library whose components load as
separate chunks: `dist/index.js` is a re-export shim and `dist/button.js` is
0.1 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
VitePress overrode markdown-it's `softbreak` to emit nothing when both sides
of a line break are CJK. The replacement renderer did not, so every Chinese
and Japanese paragraph wrapped at a sensible column picked up a stray space
where the source happened to break — `分 就`, `会 与`. Invisible in the
markdown, visible in every rendered paragraph. 114 of them in the first 120
Chinese pages alone; zero across all 1,393 pages now.

The break is kept when either side is Latin, a digit or punctuation: the
source style already spaces those, and removing it would join two words.

The fix took two attempts and the second is the interesting one. Registering
`walkTokens` through `marked.use()` does nothing here: that hook runs inside
`marked.parse()`, and this renderer drives `lexer()` and `parser()` separately
because it needs the token tree for the outline, the sections and the excerpt.
The transform is an explicit `marked.walkTokens(tokens, …)` between the two,
with a comment saying why — a registered hook that is never called looks
exactly like a fix that does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
The file still described VitePress 2.x, a `.vitepress/` directory that is
gone, Vue components, a theme config, and a whole section on debugging
hydration mismatches in a site that no longer hydrates anything. It is the
first thing an agent reads before touching this package, so every one of
those was an instruction to do the wrong thing.

Rewritten against the current build: ranpress as the engine with `build/` as
this site's policy, the commands including the new `dev` and `preview`, the
URL-versus-file-layout rule that `verify` now enforces, the build-time
`<PascalCase />` elements, where the sidebar and the per-locale copy tables
really live, what the client bundle deliberately does not do, and the two §11
rules the stylesheets are built around.

What survived is what is still true and still expensive to rediscover: the
POSIX-sh constraints in `bin/build.sh`, the four Service Worker rules, the
`<ran-demo>` cross-axis trap, the overlay z-index escalation and its `closing`
tail, and the reasons there is no i18n runtime and no custom PWA prompt.

Source comments got the same pass. Nine references to VitePress are past-tense
rationale for a rule that still binds — link resolution mirrors its slugs
because 17,216 anchors depend on it — and those stay. Seven were present tense
and wrong, claiming VitePress renders these pages, matches these sidebars, or
reports these errors today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Deploying ran with  Cloudflare Pages  Cloudflare Pages

Latest commit: 40b9395
Status: ✅  Deploy successful!
Preview URL: https://2db1e4f1.ran-4ty.pages.dev
Branch Preview URL: https://feat-ranpress-dev-preview-an.ran-4ty.pages.dev

View logs

Comment thread packages/ranpress/src/serve.ts Fixed
Format-only. `lint:prettier` runs `--check` in CI and these 16 files were
written by hand or by a script that does not know the repo's config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
@chaxus chaxus changed the title ranpress dev/preview servers, and a design pass over the docs site feat(ranpress): dev and preview servers, and a design pass over the docs site Sep 12, 2026
CodeQL flagged `Location` being assembled from `req.url` in the preview/dev
server (Server-side URL redirect, medium).

It was not exploitable, and I checked before changing anything: `resolveHost`
refuses to resolve outside `distDir`, so a redirect is only ever produced for
a path that reached a real file inside the output. Measured against the
running server — `//evil.com`, `//evil.com/`, `/%2f%2fevil.com` and
`/.//evil.com` are all 404, no `Location` at all.

Fixed anyway, because the reason it was safe lived two modules away from the
header that depended on it. A redirect header is exactly where a later change
to the resolver becomes an open redirect while nothing nearby looks different.
The target is now checked where it is used — root-relative, and not the
protocol-relative `//` that would send a reader to another origin — and the
query string goes through `encodeURI`, which leaves `&`, `=` and `?` alone so
the query keeps its meaning while control characters become escapes instead
of a second header.

`test/serve.test.ts` runs a real server and pins both halves: the redirects
that must happen, including the query carried across, and the four shapes that
must not become one. The 404 responder moved into its own function rather than
being duplicated at the new early return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
*/
const raw = url.includes('?') ? url.slice(url.indexOf('?') + 1) : '';
const search = raw ? `?${encodeURI(raw)}` : '';
res.writeHead(308, { location: `${target}${search}` });
@chaxus
chaxus merged commit 4a2a7cf into main Sep 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants