From 900959c874129e4516ee0169cabf360330e851d3 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:01:21 +0800 Subject: [PATCH 01/14] feat(ranpress): one host model behind dev, preview and verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/ranpress/README.md | 75 +++++++++++++- packages/ranpress/package.json | 3 +- packages/ranpress/src/dev.ts | 91 ----------------- packages/ranpress/src/host.ts | 120 ++++++++++++++++++++++ packages/ranpress/src/index.ts | 7 +- packages/ranpress/src/serve.ts | 153 ++++++++++++++++++++++++++++ packages/ranpress/src/verify.ts | 24 ++--- packages/ranpress/test/host.test.ts | 110 ++++++++++++++++++++ 8 files changed, 474 insertions(+), 109 deletions(-) delete mode 100644 packages/ranpress/src/dev.ts create mode 100644 packages/ranpress/src/host.ts create mode 100644 packages/ranpress/src/serve.ts create mode 100644 packages/ranpress/test/host.test.ts diff --git a/packages/ranpress/README.md b/packages/ranpress/README.md index 6396a56cd..81d043200 100644 --- a/packages/ranpress/README.md +++ b/packages/ranpress/README.md @@ -17,7 +17,8 @@ src/ ├── feeds.ts # sitemap, RSS, robots.txt ├── search.ts # build-time index; the tokenizer handles CJK ├── verify.ts # post-build checks that fail the build -└── dev.ts # serves the way the production host serves +├── host.ts # one model of how the production host resolves a URL +└── serve.ts # the dev and preview servers, both built on that model ``` ## What a site supplies @@ -35,6 +36,66 @@ const markdown = createMarkdown({ }); ``` +## Serving locally + +Two servers, both answering requests exactly the way Cloudflare Pages does. + +```ts +import { createDevServer, createPreviewServer } from 'ranpress'; + +// Build, watch, serve. Returns once the first build has finished. +await createDevServer({ + distDir: DIST_DIR, + port: 4173, + label: 'site', + note: '(drafts included)', + rebuild: (reason, full) => build({ skipAssets: !full }), + watch: [ + { dir: CONTENT_DIR, match: (file) => file.endsWith('.md') }, + { dir: join(ROOT, 'styles'), full: true }, + ], +}); + +// Serve an existing dist/ and never write to it. +createPreviewServer({ distDir: DIST_DIR, port: 4174 }); +``` + +`rebuild` is the site's own build function; ranpress supplies only the loop around it. A +`watch` target marked `full: true` means a change there needs the whole pipeline (styles +and client code have to go back through vite, which is the slow half); the default is the +cheap content-only path. A build that throws is logged and the last good output keeps +being served, so a typo in one markdown file does not take the server down. + +Watch the source trees, never the package root — `dist/` lives under it and the build +writes there, so a recursive watch would retrigger itself on its own output forever. + +## Why not `serve` or `http-server` + +Because a generic static server resolves files the way Node would, and the production host +does not. `host.ts` is the difference, and its rules were **measured against Cloudflare +Pages**, not read off its documentation: + +| Request | On disk | Response | +| ---------------- | ------------------- | ----------------------- | +| `/about` | `about.html` | `200` | +| `/about` | `about/index.html` | `308` → `/about/` | +| `/about/` | `about/index.html` | `200` | +| `/about/` | `about.html` | `308` → `/about` | +| `/sitemap.xml/` | `sitemap.xml` | `404` — no reverse hop | + +The redirect rows are the reason this file exists. `dev.ts` and `verify.ts` used to carry +a copy of this logic each, and the copies disagreed: the verifier knew that reaching a +page through `/index.html` means a redirect, the dev server served that same case as +a plain `200`. So local development looked correct for exactly the layout production +redirects away from, and **904 canonical URLs shipped naming a URL the host bounces**. One +model, three callers — the dev server, the preview server, and the verifier — and +`followHost` is what lets the verifier say "this URL resolves, and it is still the wrong +URL to publish". + +That is also why a site's `outFileFor` has to agree with its canonical: a page whose +canonical is `/about` must be written to `about.html`, and a section index whose canonical +is `/blog/` must be written to `blog/index.html`. + ## Things that are load-bearing **One page's content per output file.** ranui's own `generateStaticPages()` renders a @@ -62,6 +123,16 @@ a single pass. ## Commands ```sh -pnpm -F ranpress test # 54 tests over the pure functions +pnpm -F ranpress test # the pure functions, plus the host model pnpm -F ranpress tsc ``` + +In a site that uses it: + +```sh +pnpm -F dev # build, watch, serve on :4173 +pnpm -F preview # serve the built dist/ on :4174, exactly as the host will +``` + +`preview` builds nothing — run the build first. It is what you use to check a real +deploy's bytes, including its redirects and its 404 status. diff --git a/packages/ranpress/package.json b/packages/ranpress/package.json index ee1fd1737..e9d98b6f4 100644 --- a/packages/ranpress/package.json +++ b/packages/ranpress/package.json @@ -10,7 +10,8 @@ "./frontmatter": "./src/frontmatter.ts", "./driver": "./src/driver.ts", "./verify": "./src/verify.ts", - "./dev": "./src/dev.ts", + "./serve": "./src/serve.ts", + "./host": "./src/host.ts", "./feeds": "./src/feeds.ts", "./search": "./src/search.ts" }, diff --git a/packages/ranpress/src/dev.ts b/packages/ranpress/src/dev.ts deleted file mode 100644 index f5a3050a7..000000000 --- a/packages/ranpress/src/dev.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * A development server that resolves paths the way the production host does. - * - * That is the whole point of it being here rather than reaching for `serve`. Cloudflare - * Pages maps `/about` to `about/index.html`; a dev server that instead serves files the - * way Node would lets `/about.html` work locally and 404 in production, which is exactly - * the class of difference nobody finds until after a deploy. - */ -import { createServer } from 'node:http'; -import type { Server } from 'node:http'; -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { extname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'; - -const MIME: Record = { - '.html': 'text/html; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.webmanifest': 'application/manifest+json; charset=utf-8', - '.xml': 'application/xml; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.webp': 'image/webp', - '.woff2': 'font/woff2', - '.ico': 'image/x-icon', -}; - -export interface DevServerOptions { - distDir: string; - port: number; - /** Served when nothing matches. Gets a 404 status, like the host would give it. */ - notFoundFile?: string; -} - -/** - * The host's resolution order: exact file, then `/index.html`. - * - * Every returned path is proved to sit inside the output directory first. That is not - * paranoia about a local server: `join()` resolves `..`, so a request for - * `/../../../../.ssh/id_rsa` reads straight out of the home directory, and any page open - * in the browser can make that request while this is running. - */ -const resolveRequest = (distDir: string, urlPath: string): string | null => { - let clean: string; - try { - clean = decodeURIComponent(urlPath.split('?')[0]); - } catch { - // `%ZZ` and friends throw. Unhandled, that takes down the request handler rather - // than returning a 404 for what is simply a malformed URL. - return null; - } - // A NUL truncates the path at the syscall boundary, so `/x\0.png` would reach `/x`. - if (clean.includes('\0')) return null; - - const stripped = clean.replace(/^\//, ''); - const candidates = clean === '/' ? ['index.html'] : [stripped, join(stripped, 'index.html')]; - for (const candidate of candidates) { - const full = resolvePath(distDir, candidate); - // `relative` is the containment test: anything outside starts with `..`, and an - // absolute result means the candidate was itself absolute. - const inside = relative(distDir, full); - if (inside.startsWith('..') || isAbsolute(inside)) continue; - if (existsSync(full) && statSync(full).isFile()) return full; - } - return null; -}; - -export const createDevServer = ({ distDir, port, notFoundFile = '404.html' }: DevServerOptions): Server => - createServer((req, res) => { - const file = resolveRequest(distDir, req.url ?? '/'); - if (file) { - res.writeHead(200, { - 'content-type': MIME[extname(file)] ?? 'application/octet-stream', - 'cache-control': 'no-store', - }); - res.end(readFileSync(file)); - return; - } - // Serve the site's own 404 with a 404 status, which is what the host does — a plain - // text body here would hide a broken 404 page until production. - const custom = join(distDir, notFoundFile); - if (existsSync(custom)) { - res.writeHead(404, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); - res.end(readFileSync(custom)); - return; - } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); - res.end('404'); - }).listen(port); diff --git a/packages/ranpress/src/host.ts b/packages/ranpress/src/host.ts new file mode 100644 index 000000000..245c9a64b --- /dev/null +++ b/packages/ranpress/src/host.ts @@ -0,0 +1,120 @@ +/** + * One model of how the production host turns a URL into a response. + * + * Everything that has to answer "what does a reader actually get for this URL" goes + * through here: the dev server, the preview server, and the post-build verifier. They + * used to carry a copy each, and the copies disagreed. The verifier knew that a request + * for `/about` reaching `about/index.html` means a **redirect**; the dev server served + * that same case as a plain 200. Local development therefore looked correct for exactly + * the layout that production redirects away from, and 904 canonical URLs shipped naming + * a URL the host bounces. One model, three callers, no second opinion. + * + * The order below is Cloudflare Pages' order, and the third step is the subtle one: a + * directory's `index.html` is reachable at the extensionless path only *via* a 308 to the + * trailing-slash form. `resolveHost` reports that as a redirect rather than hiding it. + */ +import { existsSync, statSync } from 'node:fs'; +import { extname, isAbsolute, relative, resolve as resolvePath } from 'node:path'; + +export const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.woff2': 'font/woff2', + '.ico': 'image/x-icon', +}; + +export const mimeFor = (file: string): string => MIME[extname(file)] ?? 'application/octet-stream'; + +export type HostResolution = + | { kind: 'file'; file: string } + | { kind: 'redirect'; to: string } + | { kind: 'notfound' }; + +/** + * Resolve inside `distDir` or not at all. + * + * This is not paranoia about a local server: `resolve()` collapses `..`, so a request for + * `/../../../../.ssh/id_rsa` would otherwise read straight out of the home directory, and + * any page open in the browser can issue that request while the dev server is running. + * `relative` is the containment test — anything outside starts with `..`, and an absolute + * result means the candidate was itself absolute. + */ +const within = (distDir: string, candidate: string): string | null => { + const full = resolvePath(distDir, candidate); + const inside = relative(distDir, full); + return inside.startsWith('..') || isAbsolute(inside) ? null : full; +}; + +const isFile = (full: string | null): full is string => full !== null && existsSync(full) && statSync(full).isFile(); + +/** Returns null for a URL the host could never resolve, rather than throwing. */ +export const decodePath = (urlPath: string): string | null => { + let clean: string; + try { + clean = decodeURIComponent(urlPath.split('?')[0].split('#')[0]); + } catch { + // `%ZZ` and friends throw. Unhandled, that takes down the request handler rather + // than returning a 404 for what is simply a malformed URL. + return null; + } + // A NUL truncates the path at the syscall boundary, so `/x\0.png` would reach `/x`. + return clean.includes('\0') ? null : clean; +}; + +export const resolveHost = (distDir: string, urlPath: string): HostResolution => { + const clean = decodePath(urlPath); + if (clean === null) return { kind: 'notfound' }; + + if (clean === '/') { + const full = within(distDir, 'index.html'); + return isFile(full) ? { kind: 'file', file: full } : { kind: 'notfound' }; + } + + const bare = clean.replace(/^\//, ''); + + if (clean.endsWith('/')) { + const full = within(distDir, `${bare}index.html`); + if (isFile(full)) return { kind: 'file', file: full }; + // The redirect also runs in reverse: with `about.html` on disk, `/about/` is a 308 + // back to `/about`. Measured, not assumed — and it is specifically the `.html` + // candidate that triggers it. An exact file does not: `/sitemap.xml/` is a plain 404 + // even though `/sitemap.xml` serves. + const noSlash = clean.slice(0, -1); + if (isFile(within(distDir, `${noSlash.replace(/^\//, '')}.html`))) return { kind: 'redirect', to: noSlash }; + return { kind: 'notfound' }; + } + + for (const candidate of [bare, `${bare}.html`]) { + const full = within(distDir, candidate); + if (isFile(full)) return { kind: 'file', file: full }; + } + + // Reached only by a redirect to the trailing-slash form — that is what the host does. + if (isFile(within(distDir, `${bare}/index.html`))) return { kind: 'redirect', to: `${clean}/` }; + + return { kind: 'notfound' }; +}; + +/** + * Follow the host's own redirect once, and report whether one happened. + * + * This is what a link checker wants: "does this URL reach a page, and does it get there + * directly?" A canonical or a sitemap entry that only resolves through the redirect is + * still a working link and still wrong to publish. + */ +export const followHost = (distDir: string, urlPath: string): { file: string; redirects: boolean } | null => { + const first = resolveHost(distDir, urlPath); + if (first.kind === 'file') return { file: first.file, redirects: false }; + if (first.kind === 'notfound') return null; + const next = resolveHost(distDir, first.to); + return next.kind === 'file' ? { file: next.file, redirects: true } : null; +}; diff --git a/packages/ranpress/src/index.ts b/packages/ranpress/src/index.ts index bca9b805c..0be47843e 100644 --- a/packages/ranpress/src/index.ts +++ b/packages/ranpress/src/index.ts @@ -22,8 +22,11 @@ export type { Assets, PrepareOptions } from './driver.ts'; export { verifyDist, verifyOrExit } from './verify.ts'; export type { VerifyOptions, Failure } from './verify.ts'; -export { createDevServer } from './dev.ts'; -export type { DevServerOptions } from './dev.ts'; +export { createDevServer, createPreviewServer } from './serve.ts'; +export type { DevServerOptions, ServeOptions, WatchTarget } from './serve.ts'; + +export { resolveHost, followHost, mimeFor, MIME } from './host.ts'; +export type { HostResolution } from './host.ts'; export { renderSitemap, renderFeed, renderRobotsTxt } from './feeds.ts'; export type { FeedItem, FeedOptions, SitemapEntry } from './feeds.ts'; diff --git a/packages/ranpress/src/serve.ts b/packages/ranpress/src/serve.ts new file mode 100644 index 000000000..4366ab3db --- /dev/null +++ b/packages/ranpress/src/serve.ts @@ -0,0 +1,153 @@ +/** + * The two local servers: `preview` and `dev`. + * + * Both answer requests exactly the way the production host does, through the single model + * in `host.ts` — that fidelity is the whole reason these exist instead of reaching for + * `serve` or `http-server`. A generic static server resolves files the way Node would, + * which quietly makes `/about.html` work locally and 404 in production, and hides the + * 308 that a directory-shaped layout really produces. + * + * The difference between them is only what happens to the files: + * + * - **preview** serves an existing `dist/` and never writes to it. It is what you run to + * check a real build — the same bytes Cloudflare will serve, including its redirects + * and its 404 status. + * - **dev** is preview plus a rebuild loop: watch the sources, re-run the build, keep + * serving. A failed build logs and leaves the last good output in place, so a typo in + * one markdown file does not take the server down with it. + */ +import { createServer } from 'node:http'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { existsSync, readFileSync, watch } from 'node:fs'; +import { join } from 'node:path'; +import { MIME, mimeFor, resolveHost } from './host.ts'; + +export interface ServeOptions { + distDir: string; + port: number; + /** Served when nothing matches. Gets a 404 status, like the host would give it. */ + notFoundFile?: string; +} + +const respond = + (distDir: string, notFoundFile: string) => + (req: IncomingMessage, res: ServerResponse): void => { + const url = req.url ?? '/'; + const result = resolveHost(distDir, url); + + if (result.kind === 'redirect') { + // Carry the query string across, as the host does; dropping it would make a + // redirected URL behave differently from the one it redirects to. + const search = url.includes('?') ? `?${url.slice(url.indexOf('?') + 1)}` : ''; + res.writeHead(308, { location: `${result.to}${search}` }); + res.end(); + return; + } + + if (result.kind === 'file') { + res.writeHead(200, { 'content-type': mimeFor(result.file), 'cache-control': 'no-store' }); + res.end(readFileSync(result.file)); + return; + } + + // The site's own 404 page, with a 404 status — which is what the host does. A plain + // text body here would hide a broken 404 page until production. + const custom = join(distDir, notFoundFile); + if (existsSync(custom)) { + res.writeHead(404, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); + res.end(readFileSync(custom)); + return; + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('404'); + }; + +/** + * Serve an existing `dist/` the way the host will. Nothing is built and nothing is + * watched — run it after `build` to inspect the real output. + */ +export const createPreviewServer = ({ distDir, port, notFoundFile = '404.html' }: ServeOptions): Server => { + if (!existsSync(distDir)) { + throw new Error(`preview: ${distDir} does not exist — run the build first.`); + } + return createServer(respond(distDir, notFoundFile)).listen(port); +}; + +export interface WatchTarget { + dir: string; + /** + * Whether a change here needs the full pipeline. Content usually only needs pages + * re-rendered; styles and client code have to go back through the bundler, which is + * the slow half, so the two are declared separately rather than rebuilt alike. + */ + full?: boolean; + /** Ignore changes to files this rejects. Default: react to every file. */ + match?: (file: string) => boolean; +} + +export interface DevServerOptions extends ServeOptions { + /** Runs once at startup and again on every accepted change. */ + rebuild: (reason: string, full: boolean) => Promise; + watch?: WatchTarget[]; + /** Prefixes the ready line, e.g. `site http://localhost:4173`. */ + label?: string; + /** Appended to the ready line, e.g. `(drafts included)`. */ + note?: string; +} + +/** + * Build, watch, serve. + * + * The queue is the part worth keeping in one place: a save during a build must not start + * a second one, or two builds write the same files at once and whichever finishes last + * wins — including the one that started from the older sources. So a change arriving + * mid-build sets a flag, and exactly one follow-up runs when the current build settles. + */ +export const createDevServer = async ({ + distDir, + port, + notFoundFile = '404.html', + rebuild, + watch: targets = [], + label = 'dev', + note = '', +}: DevServerOptions): Promise => { + let building: Promise | null = null; + let pending: { reason: string; full: boolean } | null = null; + + const run = async (reason: string, full: boolean): Promise => { + if (building) { + // Keep the more expensive of the coalesced requests: a pending style change must + // not be downgraded to a content-only rebuild by a later markdown save. + pending = { reason, full: full || (pending?.full ?? false) }; + return; + } + const started = Date.now(); + building = rebuild(reason, full) + .then(() => console.log(` rebuilt (${reason}) in ${Date.now() - started}ms`)) + // A content error must not kill the server — fix the file and it recovers. + .catch((error: unknown) => console.error(` build failed: ${(error as Error).message}`)) + .finally(() => { + building = null; + const next = pending; + pending = null; + if (next) void run(next.reason, next.full); + }); + await building; + }; + + await run('startup', true); + + for (const { dir, full = false, match } of targets) { + if (!existsSync(dir)) continue; + watch(dir, { recursive: true }, (_event, file) => { + if (!file) return; + if (match && !match(file)) return; + void run(file, full); + }); + } + + const server = createServer(respond(distDir, notFoundFile)).listen(port); + console.log(`\n ${label} http://localhost:${port}${note ? ` ${note}` : ''}\n`); + return server; +}; diff --git a/packages/ranpress/src/verify.ts b/packages/ranpress/src/verify.ts index 9c1d4b7d8..51319ce88 100644 --- a/packages/ranpress/src/verify.ts +++ b/packages/ranpress/src/verify.ts @@ -11,6 +11,7 @@ */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; +import { followHost } from './host.ts'; export interface VerifyOptions { distDir: string; @@ -38,17 +39,8 @@ const walkHtml = (dir: string): string[] => { return out; }; -/** `/about` → `dist/about/index.html`; `/` → `dist/index.html`. Also accepts a real file. */ -const resolveUrlPathIn = (DIST_DIR: string, path: string): string | null => { - const clean = path.split('#')[0].split('?')[0]; - const candidates = - clean === '/' ? ['index.html'] : [join(clean.replace(/^\/|\/$/g, ''), 'index.html'), clean.replace(/^\//, '')]; - for (const candidate of candidates) { - const full = join(DIST_DIR, candidate); - if (existsSync(full) && statSync(full).isFile()) return full; - } - return null; -}; +const resolveUrlPathIn = (DIST_DIR: string, path: string): string | null => + followHost(DIST_DIR, path)?.file ?? null; const attrValues = (html: string, pattern: RegExp): string[] => { const out: string[] = []; @@ -103,8 +95,14 @@ export const verifyDist = ({ distDir, origin, notFoundFiles = ['404.html'] }: Ve if (canonical.endsWith('.html')) fail(rel, `canonical points at a .html URL: ${canonical}`); // The canonical must name this very file, or the page is telling search engines // to index a different one — the single most expensive thing to get wrong here. - const target = resolveUrlPathIn(DIST_DIR, canonical.slice(ORIGIN.length) || '/'); - if (target !== file) fail(rel, `canonical ${canonical} does not resolve back to this page`); + const served = followHost(DIST_DIR, canonical.slice(ORIGIN.length) || '/'); + if (served?.file !== file) { + fail(rel, `canonical ${canonical} does not resolve back to this page`); + } else if (served.redirects) { + // The page loads, one hop late, under a URL it does not claim. This is how 904 + // canonicals came to name a redirect without anything looking wrong. + fail(rel, `canonical ${canonical} is served only via a redirect to its trailing-slash form`); + } } // ── internal links ───────────────────────────────────────────────────── diff --git a/packages/ranpress/test/host.test.ts b/packages/ranpress/test/host.test.ts new file mode 100644 index 000000000..294a22224 --- /dev/null +++ b/packages/ranpress/test/host.test.ts @@ -0,0 +1,110 @@ +/** + * The host model is the one piece three callers depend on — the dev server, the preview + * server and the verifier — and getting it wrong is not a local inconvenience: an earlier + * disagreement between two copies of it shipped 904 canonical URLs pointing at a URL the + * host redirects away from. + * + * Every expectation here was measured against Cloudflare Pages (`curl -o /dev/null -w + * '%{http_code} %{redirect_url}'` against ran.chaxus.com), not inferred from its docs. + */ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { followHost, resolveHost } from '../src/host.ts'; + +let dist: string; + +beforeAll(() => { + dist = mkdtempSync(join(tmpdir(), 'ranpress-host-')); + const put = (rel: string): void => { + const full = join(dist, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, rel); + }; + put('index.html'); + put('about.html'); // a page: served directly at /about + put('blog/index.html'); // a section index: served at /blog/ + put('sitemap.xml'); // an exact file, no .html sibling + put('404.html'); +}); + +afterAll(() => rmSync(dist, { recursive: true, force: true })); + +describe('resolveHost', () => { + it('serves the root', () => { + expect(resolveHost(dist, '/')).toMatchObject({ kind: 'file' }); + }); + + it('serves a page at its extensionless URL, directly', () => { + // The whole point of writing `about.html` rather than `about/index.html`. + expect(resolveHost(dist, '/about')).toMatchObject({ kind: 'file' }); + }); + + it('serves a section index at its trailing-slash URL', () => { + expect(resolveHost(dist, '/blog/')).toMatchObject({ kind: 'file' }); + }); + + it('redirects the extensionless URL of a directory to its trailing-slash form', () => { + expect(resolveHost(dist, '/blog')).toEqual({ kind: 'redirect', to: '/blog/' }); + }); + + it('redirects in reverse when the page is a leaf .html', () => { + expect(resolveHost(dist, '/about/')).toEqual({ kind: 'redirect', to: '/about' }); + }); + + it('does not reverse-redirect an exact file', () => { + // Measured: /sitemap.xml serves 200, /sitemap.xml/ is a plain 404. + expect(resolveHost(dist, '/sitemap.xml')).toMatchObject({ kind: 'file' }); + expect(resolveHost(dist, '/sitemap.xml/')).toEqual({ kind: 'notfound' }); + }); + + it('serves an explicit .html URL', () => { + expect(resolveHost(dist, '/about.html')).toMatchObject({ kind: 'file' }); + }); + + it('reports an unknown path as not found', () => { + expect(resolveHost(dist, '/nope')).toEqual({ kind: 'notfound' }); + }); + + it('ignores the query string and the fragment', () => { + expect(resolveHost(dist, '/about?x=1')).toMatchObject({ kind: 'file' }); + expect(resolveHost(dist, '/about#frag')).toMatchObject({ kind: 'file' }); + }); + + describe('refuses to read outside the output directory', () => { + // Any page open in the browser can issue these while the dev server is running. + for (const attack of [ + '/../../../../etc/passwd', + '/..%2f..%2f..%2fetc%2fpasswd', + '/%2e%2e/%2e%2e/etc/passwd', + ]) { + it(attack, () => expect(resolveHost(dist, attack)).toEqual({ kind: 'notfound' })); + } + + it('a malformed escape returns not found rather than throwing', () => { + expect(() => resolveHost(dist, '/%ZZ')).not.toThrow(); + expect(resolveHost(dist, '/%ZZ')).toEqual({ kind: 'notfound' }); + }); + + it('a NUL byte cannot truncate the path at the syscall boundary', () => { + expect(resolveHost(dist, '/about%00.png')).toEqual({ kind: 'notfound' }); + }); + }); +}); + +describe('followHost', () => { + it('reports a direct hit as not redirecting', () => { + expect(followHost(dist, '/about')?.redirects).toBe(false); + }); + + it('follows the redirect and says that it did', () => { + // This is the distinction a canonical or a sitemap entry lives or dies on: the URL + // resolves to a real page, and is still the wrong URL to publish. + expect(followHost(dist, '/blog')).toMatchObject({ redirects: true }); + }); + + it('returns null when nothing resolves', () => { + expect(followHost(dist, '/nope')).toBeNull(); + }); +}); From 67d449cc4b2de8bf8aa666297336a5e39cf5b531 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:01:33 +0800 Subject: [PATCH 02/14] feat(docs,site): local dev and preview servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/CLAUDE.md | 25 +++++++++++++++ packages/docs/build/dev.ts | 30 ++++++++++++++++++ packages/docs/build/preview.ts | 10 ++++++ packages/docs/package.json | 1 + packages/site/README.md | 15 ++++++--- packages/site/build/dev.ts | 58 +++++++--------------------------- packages/site/build/preview.ts | 10 ++++++ packages/site/package.json | 1 + 8 files changed, 99 insertions(+), 51 deletions(-) create mode 100644 packages/docs/build/dev.ts create mode 100644 packages/docs/build/preview.ts create mode 100644 packages/site/build/preview.ts diff --git a/packages/docs/CLAUDE.md b/packages/docs/CLAUDE.md index 3fc09b23f..83c1bb9ce 100644 --- a/packages/docs/CLAUDE.md +++ b/packages/docs/CLAUDE.md @@ -10,6 +10,31 @@ Most of what is non-obvious here is not VitePress — it is the SEO/GEO machiner generated pages, and the Service Worker. Read the relevant section before changing any of it; each carries a failure mode that is silent. +> **Stale below this line.** This file still describes the VitePress/Vue stack. The site +> now runs on **ranpress** (`packages/ranpress`) with its policy in `build/`; there is no +> `.vitepress/`, no Vue and no hydration. The locale registry moved to +> `build/langs/locales.ts`. Treat any VitePress-specific instruction here as historical +> until this file is rewritten. + +--- + +## Commands + +```sh +pnpm -F docs dev # http://localhost:4173 — build, watch the eight prose trees, serve +pnpm -F docs build # generate into dist/, then verify +pnpm -F docs preview # http://localhost:4174 — serve the built dist/, builds nothing +``` + +`dev` and `preview` resolve URLs the way Cloudflare Pages does, **including its redirects**: +`/src/ranui/button` is a 308 to `/src/ranui/button/` when the page is a directory index, and +a direct 200 when it is a leaf. That fidelity is the point — a generic static server hides +exactly the mismatch that once shipped 904 canonicals naming a URL the host bounces. See +`packages/ranpress/README.md` for the resolution table. + +Reach for `preview` before a deploy: it serves the real built bytes and nothing else, so a +page that only works because the dev server just rebuilt it has nowhere to hide. + --- ## Layout diff --git a/packages/docs/build/dev.ts b/packages/docs/build/dev.ts new file mode 100644 index 000000000..d824e45d8 --- /dev/null +++ b/packages/docs/build/dev.ts @@ -0,0 +1,30 @@ +/** + * Development server for the documentation site: rebuild on change, serve through the + * engine's host-accurate server. + * + * Only the eight prose trees and the two asset directories are watched, never the package + * root. `dist/` lives under the root and the build writes into it, so a recursive watch + * there would retrigger itself on its own output and rebuild forever. + * + * A markdown change re-renders pages only. Styles and client code go back through vite, + * which is the slow half, so those are declared as full rebuilds. + */ +import { join } from 'node:path'; +import { createDevServer } from 'ranpress'; +import { LOCALES } from './config.ts'; +import { build, DIST_DIR, ROOT } from './build.ts'; + +const isMarkdown = (file: string): boolean => file.endsWith('.md'); + +await createDevServer({ + distDir: DIST_DIR, + port: Number(process.env.PORT ?? 4173), + label: 'docs', + rebuild: (_reason, full) => build({ skipAssets: !full }), + watch: [ + // `src/` for the root locale, `/src/` for every other one. + ...LOCALES.map((locale) => ({ dir: join(ROOT, locale.dir, 'src'), match: isMarkdown })), + { dir: join(ROOT, 'styles'), full: true }, + { dir: join(ROOT, 'client'), full: true }, + ], +}); diff --git a/packages/docs/build/preview.ts b/packages/docs/build/preview.ts new file mode 100644 index 000000000..5f8d44754 --- /dev/null +++ b/packages/docs/build/preview.ts @@ -0,0 +1,10 @@ +/** + * Serve the built `dist/` exactly as Cloudflare Pages will — same bytes, same redirects, + * same 404 status. Nothing is rebuilt: run `pnpm -F docs build` first. + */ +import { createPreviewServer } from 'ranpress'; +import { DIST_DIR } from './build.ts'; + +const port = Number(process.env.PORT ?? 4174); +createPreviewServer({ distDir: DIST_DIR, port }); +console.log(`\n docs preview http://localhost:${port}\n`); diff --git a/packages/docs/package.json b/packages/docs/package.json index e42c40526..4938fec31 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -6,6 +6,7 @@ "main": "index.js", "scripts": { "dev": "tsx build/dev.ts", + "preview": "tsx build/preview.ts", "build": "sh ./bin/build.sh", "verify": "tsx build/verify.ts", "tsc": "tsc --noEmit", diff --git a/packages/site/README.md b/packages/site/README.md index f38787b82..39d36e494 100644 --- a/packages/site/README.md +++ b/packages/site/README.md @@ -2,9 +2,9 @@ Personal homepage and blog, published to **https://chaxus.com**. -Unlike `packages/docs` (VitePress), this site is built by **its own static site generator** -in `build/`, on top of ranui. It exists to prove that generator at a size where a mistake -is cheap, before anyone proposes pointing it at the 1,393-page documentation site. +This site is built by **ranpress**, the generator in `packages/ranpress`, with its policy +in `build/`. It was the proving ground for that generator at a size where a mistake is +cheap; `packages/docs` — 1,393 pages, eight languages — now runs on the same engine. ``` packages/site/ @@ -17,7 +17,8 @@ packages/site/ │ ├── seo.ts # per-page head, sitemap, RSS, llms.txt, robots.txt │ ├── build.ts # the driver │ ├── verify.ts # post-build checks — fails the deploy, not the reader -│ └── dev.ts # rebuild-on-change, served the way the host serves +│ ├── dev.ts # rebuild-on-change, served the way the host serves +│ └── preview.ts # serve the built dist/ exactly as the host will ├── content/ # index.md, about.md, 404.md, blog/*.md ├── client/ # the one client bundle ├── styles/ # site.css — the whole stylesheet, no preprocessor @@ -31,10 +32,16 @@ packages/site/ ```sh pnpm -F site dev # http://localhost:4173, drafts included, rebuild on change pnpm -F site build # generate into dist/ and verify +pnpm -F site preview # http://localhost:4174, serve the built dist/ — builds nothing pnpm -F site verify # re-run the checks against an existing dist/ pnpm -F site og # regenerate og.png and the PNG icons (needs Chromium) ``` +`dev` and `preview` both resolve URLs the way Cloudflare Pages does, including its +redirects — see `packages/ranpress/README.md` for the table. `preview` is the one to reach +for before a deploy: it serves the real built bytes and nothing else, so a page that only +works because the dev server just rebuilt it has nowhere to hide. + `og` is deliberately not part of `build`: the card and the icons change when the wordmark or the tagline changes, which is close to never, and making every deploy depend on a browser binary is a poor trade. Their outputs are committed — run it after editing diff --git a/packages/site/build/dev.ts b/packages/site/build/dev.ts index 6dcd1326c..78deb8e93 100644 --- a/packages/site/build/dev.ts +++ b/packages/site/build/dev.ts @@ -2,55 +2,19 @@ * Development server for this site: rebuild on change, serve through the engine's * host-accurate server. Drafts are included — that is the point of a draft. */ -import { existsSync, watch } from 'node:fs'; import { join } from 'node:path'; import { createDevServer } from 'ranpress'; import { build, CONTENT_DIR, DIST_DIR, ROOT } from './build.ts'; -const PORT = Number(process.env.PORT ?? 4173); - -let building: Promise | null = null; -let pending = false; - -const rebuild = async (reason: string, withAssets: boolean): Promise => { - if (building) { - pending = true; - return; - } - const started = Date.now(); - building = build({ includeDrafts: true, skipAssets: !withAssets }) - .then((result) => { - console.log(` rebuilt (${reason}): ${result.pages.length} pages in ${Date.now() - started}ms`); - }) - .catch((error: unknown) => { - // A content error must not kill the server — fix the file and it recovers. - console.error(` build failed: ${(error as Error).message}`); - }) - .finally(() => { - building = null; - if (pending) { - pending = false; - void rebuild('queued change', withAssets); - } - }); - await building; -}; - -await rebuild('startup', true); - -// Content changes only need pages re-rendered. Styles and client code have to go through -// vite, which is the slow half, so the two are watched separately. -watch(CONTENT_DIR, { recursive: true }, (_event, file) => { - if (file?.endsWith('.md')) void rebuild(file, false); +await createDevServer({ + distDir: DIST_DIR, + port: Number(process.env.PORT ?? 4173), + label: 'site', + note: '(drafts included)', + rebuild: (_reason, full) => build({ includeDrafts: true, skipAssets: !full }), + watch: [ + { dir: CONTENT_DIR, match: (file) => file.endsWith('.md') }, + { dir: join(ROOT, 'styles'), full: true }, + { dir: join(ROOT, 'client'), full: true }, + ], }); -for (const dir of ['styles', 'client']) { - const full = join(ROOT, dir); - if (existsSync(full)) { - watch(full, { recursive: true }, (_event, file) => { - if (file) void rebuild(file, true); - }); - } -} - -createDevServer({ distDir: DIST_DIR, port: PORT }); -console.log(`\n site http://localhost:${PORT} (drafts included)\n`); diff --git a/packages/site/build/preview.ts b/packages/site/build/preview.ts new file mode 100644 index 000000000..3711163a2 --- /dev/null +++ b/packages/site/build/preview.ts @@ -0,0 +1,10 @@ +/** + * Serve the built `dist/` exactly as Cloudflare Pages will — same bytes, same redirects, + * same 404 status. Nothing is rebuilt: run `pnpm -F site build` first. + */ +import { createPreviewServer } from 'ranpress'; +import { DIST_DIR } from './build.ts'; + +const port = Number(process.env.PORT ?? 4174); +createPreviewServer({ distDir: DIST_DIR, port }); +console.log(`\n site preview http://localhost:${port}\n`); diff --git a/packages/site/package.json b/packages/site/package.json index 365ee1b9e..db334a547 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -6,6 +6,7 @@ "private": true, "scripts": { "dev": "tsx build/dev.ts", + "preview": "tsx build/preview.ts", "build": "sh ./bin/build.sh", "verify": "tsx build/verify.ts", "tsc": "tsc --noEmit", From 1a019c87466ef35f150052b88713d355dccf7a4b Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:02:16 +0800 Subject: [PATCH 03/14] fix(docs,site): serve every page at the URL its canonical names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical strings matched VitePress exactly, but the file layout did not. Both generators wrote every page to `/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 `.html`, a genuine section index keeps `/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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/build/content.ts | 23 ++++++++++++++++++++--- packages/site/build/content.ts | 15 ++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/docs/build/content.ts b/packages/docs/build/content.ts index a55aecb02..480191e77 100644 --- a/packages/docs/build/content.ts +++ b/packages/docs/build/content.ts @@ -59,11 +59,28 @@ const urlForBase = (baseRel: string, locale: LocaleDef): string => { return `${prefix}/${stem}`; }; -/** Always `/index.html`, so URLs need no extension and no redirect sits between. */ +/** + * Where a page is written, which decides the URL the host actually serves. + * + * The trailing slash already carries the distinction: `foo/index.md` produced `/foo/` + * and `foo.md` produced `/foo`. The file layout has to follow it — `foo.html` for the + * second, not `foo/index.html`. + * + * Getting this wrong does not break a page. Cloudflare Pages serves `foo/index.html` at + * `/foo/` and **308s `/foo` to it**, so every canonical and sitemap URL without a + * trailing slash — 904 of them — quietly named a redirect instead of a page. That is the + * same failure packages/docs hit once before in mirror image, and it is invisible from a + * browser: the page loads, just one hop late and under a different URL than the one it + * claims to be canonical for. + * + * Comparing canonicals between the two engines does not catch it either. The strings are + * identical; it is the file layout underneath them that changed. + */ const outFileFor = (url: string, kind: DocKind): string => { if (kind === 'notfound') return '404.html'; - const clean = url.replace(/^\/|\/$/g, ''); - return clean ? `${clean}/index.html` : 'index.html'; + if (url === '/') return 'index.html'; + const clean = url.replace(/^\//, ''); + return clean.endsWith('/') ? `${clean}index.html` : `${clean}.html`; }; /** diff --git a/packages/site/build/content.ts b/packages/site/build/content.ts index 96f65f5c1..3358b04b4 100644 --- a/packages/site/build/content.ts +++ b/packages/site/build/content.ts @@ -28,7 +28,7 @@ export interface Page { file: string; /** Site-absolute URL, extensionless. `/` for the home page. */ url: string; - /** Path within dist. Always `/index.html` so URLs need no extension. */ + /** Path within dist, laid out so the host serves `url` directly — see `outFileFor`. */ outFile: string; title: string; description: string; @@ -72,7 +72,15 @@ const urlFor = (rel: string): string => { }; /** - * `/` → `index.html`; `/blog/` → `blog/index.html`; `/about` → `about/index.html`. + * `/` → `index.html`; `/blog/` → `blog/index.html`; `/about` → `about.html`. + * + * The shape of the file decides the shape of the URL the host serves without redirecting, + * so this has to agree with the canonical. Cloudflare Pages resolves a request by trying + * the exact file, then `.html`, then `/index.html` — and that last case is a + * **308 to the trailing-slash form**, not a direct serve. So a page whose canonical is + * `/about` must be written to `about.html`; writing it to `about/index.html` publishes a + * canonical that names a URL the host immediately redirects away from. A trailing slash in + * `url` (the blog index) is a genuine directory and still maps to `index.html`. * * The 404 page is the exception: Cloudflare Pages serves `/404.html` from the root for * any unmatched path, so it must land there literally rather than at `404/index.html`. @@ -80,7 +88,8 @@ const urlFor = (rel: string): string => { const outFileFor = (url: string, kind: PageKind): string => { if (kind === 'notfound') return '404.html'; if (url === '/') return 'index.html'; - return `${url.replace(/^\/|\/$/g, '')}/index.html`; + const clean = url.replace(/^\//, ''); + return clean.endsWith('/') ? `${clean}index.html` : `${clean}.html`; }; const kindFor = (rel: string): PageKind => { From 72d7eabb5ff58cef06dae5026f7fda0f77aba340 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:02:29 +0800 Subject: [PATCH 04/14] feat(ranui): make the design rules checkable outside ranui, and write down composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/design-baseline.json | 26 ++++ packages/docs/package.json | 3 +- packages/manifest.json | 36 ++++-- packages/ranui/bin/verify-design-rules.ts | 71 +++++++++-- packages/ranui/docs/DESIGN.md | 143 +++++++++++++++++++++- packages/ranui/package.json | 2 +- packages/site/design-baseline.json | 18 +++ packages/site/package.json | 3 +- 8 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 packages/docs/design-baseline.json create mode 100644 packages/site/design-baseline.json diff --git a/packages/docs/design-baseline.json b/packages/docs/design-baseline.json new file mode 100644 index 000000000..318bfaef3 --- /dev/null +++ b/packages/docs/design-baseline.json @@ -0,0 +1,26 @@ +{ + "$comment": "Known design-rule violations, recorded per file by bin/verify-design-rules.ts. This is a ratchet, not an allowlist: a count that rises fails as a new violation, and a count that falls fails until it is lowered here, so a fix cannot regress later. Do not edit by hand — run `pnpm -F ranui verify:design --update-baseline`. The rules themselves are stated in docs/DESIGN.md and CLAUDE.md.", + "rules": { + "dark-unsafe-fallback": { + "styles/demos.css": 2, + "styles/docs.css": 40, + "styles/home.css": 9 + }, + "bare-colour": { + "styles/demos.css": 10, + "styles/docs.css": 4, + "styles/home.css": 5 + }, + "spacing-scale": { + "styles/demos.css": 23, + "styles/docs.css": 51, + "styles/home.css": 59 + }, + "sizing-scale": {}, + "mouse-only-drag": {}, + "hidden-inert": {}, + "undefined-token-fallback": {}, + "shadow-mount-outside-constructor": {}, + "built-then-queried": {} + } +} diff --git a/packages/docs/package.json b/packages/docs/package.json index 4938fec31..5940e5aa6 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -11,7 +11,8 @@ "verify": "tsx build/verify.ts", "tsc": "tsc --noEmit", "check:langs": "node bin/check-langs.ts", - "test": "vitest run" + "test": "vitest run", + "verify:design": "tsx ../ranui/bin/verify-design-rules.ts --roots styles --tokens ../ranui/theme/tokens.less --baseline design-baseline.json" }, "repository": { "type": "git", diff --git a/packages/manifest.json b/packages/manifest.json index 339ca5ba1..ae7a7c0c5 100644 --- a/packages/manifest.json +++ b/packages/manifest.json @@ -3,31 +3,53 @@ "packages": { "ranuts": { "status": "product", - "checks": ["tsc", "test"] + "checks": [ + "tsc", + "test" + ] }, "ranui": { "status": "product", - "checks": ["tsc", "test"] + "checks": [ + "tsc", + "test", + "verify:design" + ] }, "docs": { "status": "support", - "checks": ["tsc", "test"] + "checks": [ + "tsc", + "test", + "verify:design" + ] }, "ranpress": { "status": "support", - "checks": ["tsc", "test"] + "checks": [ + "tsc", + "test" + ] }, "site": { "status": "support", - "checks": ["tsc"] + "checks": [ + "tsc", + "verify:design" + ] }, "im": { "status": "experimental", - "checks": ["tsc", "test"] + "checks": [ + "tsc", + "test" + ] }, "visual": { "status": "experimental", - "checks": ["tsc"] + "checks": [ + "tsc" + ] }, "ranite": { "status": "experimental", diff --git a/packages/ranui/bin/verify-design-rules.ts b/packages/ranui/bin/verify-design-rules.ts index e65321ae6..cbbfb4ae0 100644 --- a/packages/ranui/bin/verify-design-rules.ts +++ b/packages/ranui/bin/verify-design-rules.ts @@ -19,10 +19,44 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; +/** + * The checker is package-agnostic on purpose. + * + * It began as ranui's own and scanned `components/` only, which meant the two sites + * built on ranui's tokens — thousands of lines of stylesheet between them — were + * checked by nothing. A design system whose rules stop at the library boundary is a + * design system the product does not actually follow. + * + * A consumer runs the same binary against its own files: + * + * tsx ../ranui/bin/verify-design-rules.ts --roots styles --tokens ../ranui/theme/tokens.less + */ +const flag = (name: string, fallback: string): string => { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? fallback : (process.argv[i + 1] ?? fallback); +}; + const ROOT = path.resolve(process.cwd()); -const BASELINE_FILE = path.join(ROOT, 'docs', 'design-rule-baseline.json'); +const BASELINE_FILE = path.resolve(ROOT, flag('baseline', path.join('docs', 'design-rule-baseline.json'))); +const DEFAULT_ROOTS = flag('roots', 'components') + .split(',') + .map((r) => r.trim()) + .filter(Boolean); const UPDATE = process.argv.includes('--update-baseline'); +/** + * Files this package did not author. + * + * Vendored third-party CSS carries the other project's metrics — temml's `0.5ex` and + * `0.05em` are the math renderer's typography, not our spacing scale, and we are never + * going to change them. Recording them in the baseline would be worse than ignoring + * them: a baseline entry means "debt we intend to clear", and this is not ours to clear. + */ +const IGNORED = flag('ignore', '') + .split(',') + .map((g) => g.trim()) + .filter(Boolean); + interface Violation { file: string; line: number; @@ -66,6 +100,15 @@ const RUNTIME_CHILDREN = /\/\/\s*runtime children:\s*\S/; const DEFERRED_MOUNT = /\/\/\s*deferred mount:\s*\S/; /** Colour literal in any CSS notation. */ +/** + * Stylesheet extensions these rules read. + * + * `.css` is here because the sites built on this design system are written in plain CSS. + * Keying the rules to `.less` alone is what let a consumer invent its own spacing values + * and raw colours while the library they came from was checked on every commit. + */ +const STYLESHEETS = ['.less', '.css']; + const COLOUR = /#[0-9a-fA-F]{3,8}\b|\brgba?\([^()]*\)|\bhsla?\([^()]*\)/g; /** `var(--token, fallback)`; the fallback may nest one level of parentheses. */ const VAR_WITH_FALLBACK = /var\(\s*(--[a-zA-Z0-9-]+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g; @@ -95,7 +138,7 @@ function lines(source: string): string[] { } /** Global design tokens, as declared by the theme. */ -const THEME_TOKENS = path.join(ROOT, 'theme', 'tokens.less'); +const THEME_TOKENS = path.resolve(ROOT, flag('tokens', path.join('theme', 'tokens.less'))); /** * Every `--ran-*` custom property the theme defines. @@ -121,7 +164,7 @@ const RULES: Rule[] = [ id: 'dark-unsafe-fallback', summary: "a component token's colour fallback is a light-only literal", fix: 'Point the fallback at a token that flips with the theme — `var(--ran-color-text, var(--ran-gray-1000))` — rather than a fixed colour that stays light in dark mode.', - extensions: ['.less'], + extensions: STYLESHEETS, scan(source, file) { const out: Violation[] = []; lines(source).forEach((line, i) => { @@ -140,7 +183,7 @@ const RULES: Rule[] = [ id: 'bare-colour', summary: 'a raw colour literal is used outside a token fallback', fix: 'Use a semantic token (`--ran-color-*`) so the value follows the theme. A genuinely decorative colour still belongs in a component token with its own fallback.', - extensions: ['.less'], + extensions: STYLESHEETS, scan(source, file) { const out: Violation[] = []; lines(source).forEach((line, i) => { @@ -163,7 +206,7 @@ const RULES: Rule[] = [ id: 'spacing-scale', summary: 'spacing uses a literal length instead of the `--ran-space-*` scale', fix: 'Use `var(--ran-space-N)`. The scale is 4px-based with nine steps; a one-off inset that is genuinely not shared belongs in a component token with its own fallback.', - extensions: ['.less'], + extensions: STYLESHEETS, scan(source, file) { const property = /^\s*(padding|margin|gap|row-gap|column-gap)(-(top|right|bottom|left))?\s*:\s*([^;]+);/; const exempt = /^(0|auto|inherit|unset|initial|revert)$/; @@ -183,7 +226,7 @@ const RULES: Rule[] = [ id: 'sizing-scale', summary: 'an intrinsic dimension is drawn from the spacing scale', fix: 'Intrinsic dimensions use `--ran-size-*`. The two scales have different ranges and progressions, so consumers need to retune one without perturbing the other.', - extensions: ['.less'], + extensions: STYLESHEETS, scan(source, file) { const property = /^\s*(width|height|min-width|min-height|max-width|max-height|font-size|line-height|border-radius)\s*:\s*([^;]+);/; @@ -215,7 +258,7 @@ const RULES: Rule[] = [ id: 'hidden-inert', summary: 'a `:host` display rule makes the standard `hidden` attribute do nothing', fix: "Add `:host([hidden]) { display: none; }`. The UA stylesheet's `[hidden] { display: none }` is a user-agent rule, and any author `display` on `:host` outranks it — so `element.hidden = true` silently leaves the element on screen.", - extensions: ['.less'], + extensions: STYLESHEETS, scan(source, file) { const stripped = stripComments(source); // Only a `:host` rule with no condition; `:host([open])` and friends are states the @@ -335,7 +378,7 @@ const RULES: Rule[] = [ * @param extensions Extensions to include, with the leading dot. * @returns Paths relative to the ranui package root, POSIX separators, sorted. */ -async function collect(extensions: string[], roots: string[] = ['components']): Promise { +async function collect(extensions: string[], roots: string[] = DEFAULT_ROOTS): Promise { const out: string[] = []; const walk = async (dir: string): Promise => { for (const entry of await fs.readdir(dir, { withFileTypes: true })) { @@ -344,11 +387,19 @@ async function collect(extensions: string[], roots: string[] = ['components']): const full = path.join(dir, entry.name); if (entry.isDirectory()) await walk(full); else if (extensions.includes(path.extname(entry.name)) && !entry.name.endsWith('.test.ts')) { - out.push(path.relative(ROOT, full).split(path.sep).join('/')); + const rel = path.relative(ROOT, full).split(path.sep).join('/'); + if (IGNORED.some((frag) => rel.includes(frag))) continue; + out.push(rel); } } }; - for (const root of roots) await walk(path.join(ROOT, root)); + for (const root of roots) { + const dir = path.join(ROOT, root); + // A rule may name a directory a given package does not have. That is not an error; + // it simply has nothing to scan there. + if (!(await fs.stat(dir).catch(() => null))) continue; + await walk(dir); + } return out.sort(); } diff --git a/packages/ranui/docs/DESIGN.md b/packages/ranui/docs/DESIGN.md index e4282fa46..3b878f0a9 100644 --- a/packages/ranui/docs/DESIGN.md +++ b/packages/ranui/docs/DESIGN.md @@ -24,8 +24,19 @@ Conflict resolution order: **user goals → verified evidence → this file → ## What is machine-checked -Nine of the rules below are enforced by `pnpm -F ranui verify:design`, which CI runs on -every pull request. Everything else in this file is still binding — it is simply not +Nine of the rules below are enforced by `verify:design`, which CI runs on every pull +request — **for this library and for every surface built on it**. `packages/docs` and +`packages/site` run the same checker over their own stylesheets: + +```sh +tsx ../ranui/bin/verify-design-rules.ts --roots styles --tokens ../ranui/theme/tokens.less --baseline design-baseline.json +``` + +A design system whose rules stop at the library boundary is a design system the product +does not actually follow. Pointing the checker at the sites found 298 violations that had +never been looked at — invented spacing values and raw colours in stylesheets written +against this very system. Vendored third-party CSS is excluded with `--ignore`: its +metrics are not our debt to clear. Everything else in this file is still binding — it is simply not mechanically decidable, so it relies on review and on rendering the result. | Rule | Enforces | Section | @@ -424,6 +435,134 @@ pass that brought existing components in line with it (0.5.0-alpha.0). --- +## 11. Composition — the page, not the component + +Sections 1–10 govern a component. This one governs what happens when many correct +components are assembled into a surface, which is where this system's most expensive +mistakes have actually occurred. Every rule below is followed by the failure it prevents, +and every failure listed is one that shipped. + +### Structure before containers + +**Reach for spacing, alignment, weight and a hairline before a box.** A container is a +claim that its contents are a separate object. Most regions need a background, a hairline +and intentional spacing — nothing more. + +> A documentation page alternated a bordered demo, a bordered code block, a bordered demo +> down its whole length: 16 boxed surfaces on a component page and 37 on the icon page. +> Nothing was wrong with any single box. The page was exhausting to read because every +> element claimed to be a separate object. + +**A box is for a genuinely separate object** — a live demo stage, an overlay, a panel that +floats. Not for a code block, a table, a list row, a previous/next link, or a cell in a +gallery. + +**Never nest a box in a box, and never double the padding.** A card inside a padded panel +does not add another inset. + +> Three containers each contributed their own top padding — the page column, the landing +> wrapper, and the hero — and the first line of content sat 280px below the header. Each +> value was defensible on its own. + +### Emphasis is a budget + +**Colour, weight, badges, fills and elevation are scarce.** If everything is emphasised, +nothing reads as important. Establish priority with structure and proximity first, and +spend emphasis on the one thing that deserves it. + +**Elevation explains stacking, not importance.** Shadows belong to surfaces that genuinely +float above content — popovers, menus, dialogs, notifications. Do not shadow every card. + +### Spacing carries meaning + +The scale in §2 is not only a rhythm; each step states a relationship. Choose the step by +what the gap _means_: + +| Relationship | Step | Example | +| ------------------------------ | --------------- | --------------------------------- | +| Parts of one control | `--ran-space-1` | an icon and its label | +| Closely related controls | `--ran-space-2` | a button row, dialog actions | +| One content group | `--ran-space-3` | a form row, a list item's lines | +| Separate groups in one section | `--ran-space-4` | panel padding, form groups | +| Separate sections | `--ran-space-5` | major blocks on a page | +| Major region boundary | `--ran-space-6` | empty-state breathing room, bands | + +**Inside before outside:** a component's own padding is decided before the gap between +components. **Smaller gap means tighter relationship** — that is the only thing vertical +rhythm communicates, so do not undo it with a decorative divider. + +### Alignment is structure, not polish + +**Establish alignment spines and hold them.** Sibling regions share a content inset; +repeated rows share column geometry; a nested level returns exactly to its parent's spine +when it ends. A missing optional icon or badge must not move the labels beside it. + +**When two edges or gaps are meant to be equal, a one-pixel difference is a defect, not an +optical approximation.** + +### Selection, links and other semantic lies + +**Selection is persistent state and must not look like hover.** Hover is transient; if the +current item is marked with the same fill hover uses, the interface has two names for one +appearance. + +> The current sidebar page was marked with `--ran-color-bg-hover`. It read as "the pointer +> is here", not "you are here". + +**`--ran-color-primary` is commitment; `--ran-color-link` is a link.** Primary is +near-black in this system, so prose links coloured with it are indistinguishable from the +text around them. + +**A link leaves; a button acts.** Anything that changes application state is a button, +whatever it looks like. Do not style a command as a link to make it quiet, and do not let +a button-shaped link keep an underline. + +> Every call-to-action on the home page shipped underlined, because the stylesheet assumed +> a base rule it had never written. + +### One product, one token set + +**A surface built on this system reads `--ran-*`. It does not define a parallel palette.** +Two token sets in one product are two sources of truth that drift silently — the site keeps +its blue while the components on the same page move to another. + +A consumer may alias for readability (`--fg: var(--ran-color-text, …)`) provided every +value resolves from a `--ran-*` token and the fallback is what the surface shows before +this stylesheet loads. It may not invent a second palette. + +### Words are part of the interface + +**Let context carry context.** Do not repeat what the surrounding surface already +establishes — a sidebar destination is `Users`, not `User Management`; a dialog titled +`Delete "Roadmap"?` does not ask the question again in its body. + +**Name the result, not the gesture.** `Save`, `Move`, `Delete` — not `Click to save` or +`Confirm deletion`. `Cancel` is always the action that leaves without committing. + +**Sentence case for English UI.** Reserve ALL CAPS for very short eyebrows, statuses and +acronyms; never on a button, a heading or a sentence. Labels, buttons and short states take +no final period; complete explanatory sentences do. Use a single `…` character, and append +it to any control that opens a dialog or needs more input before it can complete. + +### Review checklist for a composed surface + +Ask in order. A "no" is a finding, not a preference: + +1. Can a new reader recognise the purpose and the primary action without guessing? +2. Does every control's label, state and result describe one consistent outcome? +3. Is emphasis scarce — does the core thing get the weight while colour, badges and + primary buttons stay rare? +4. Could this do less: any entry point, option or state removable without weakening the task? +5. Is the structure exact — shared spines, equal gaps equal to the pixel, no doubled padding? +6. Does it hold in every state: empty, loading, failure, longest translation, narrow width, + dark theme, reduced motion? +7. Has it been looked at in a real browser, at more than one width, in both themes? + +**A screenshot of the happy path is not proof.** Keyboard behaviour, focus, dynamic +content, themes, resizing and failure states are part of the design. + +--- + ## Verification checklist (before shipping UI) - [ ] Primary task and primary action are unmistakable. diff --git a/packages/ranui/package.json b/packages/ranui/package.json index 90d9e9569..9fbc7654d 100644 --- a/packages/ranui/package.json +++ b/packages/ranui/package.json @@ -184,7 +184,7 @@ "doc:api:check": "tsx ./bin/generate-component-api.ts --check", "doc:changelog": "tsx ./bin/generate-changelog-docs.ts", "doc:changelog:check": "tsx ./bin/generate-changelog-docs.ts --check", - "verify:design": "tsx ./bin/verify-design-rules.ts" + "verify:design": "tsx ./bin/verify-design-rules.ts --ignore components/math/temml.css" }, "files": [ "dist", diff --git a/packages/site/design-baseline.json b/packages/site/design-baseline.json new file mode 100644 index 000000000..17b8ef2a4 --- /dev/null +++ b/packages/site/design-baseline.json @@ -0,0 +1,18 @@ +{ + "$comment": "Known design-rule violations, recorded per file by bin/verify-design-rules.ts. This is a ratchet, not an allowlist: a count that rises fails as a new violation, and a count that falls fails until it is lowered here, so a fix cannot regress later. Do not edit by hand — run `pnpm -F ranui verify:design --update-baseline`. The rules themselves are stated in docs/DESIGN.md and CLAUDE.md.", + "rules": { + "dark-unsafe-fallback": {}, + "bare-colour": { + "styles/site.css": 48 + }, + "spacing-scale": { + "styles/site.css": 47 + }, + "sizing-scale": {}, + "mouse-only-drag": {}, + "hidden-inert": {}, + "undefined-token-fallback": {}, + "shadow-mount-outside-constructor": {}, + "built-then-queried": {} + } +} diff --git a/packages/site/package.json b/packages/site/package.json index db334a547..928c397a3 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -10,7 +10,8 @@ "build": "sh ./bin/build.sh", "verify": "tsx build/verify.ts", "tsc": "tsc --noEmit", - "og": "tsx build/og.ts" + "og": "tsx build/og.ts", + "verify:design": "tsx ../ranui/bin/verify-design-rules.ts --roots styles --tokens ../ranui/theme/tokens.less --baseline design-baseline.json" }, "repository": { "type": "git", From bbdf3912fda4414c3277967e0b2a33e107137cb0 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:02:40 +0800 Subject: [PATCH 05/14] refactor(docs): derive the palette from ranui's tokens, and use far fewer boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/styles/demos.css | 21 ++-- packages/docs/styles/docs.css | 213 ++++++++++++++++++++++----------- packages/docs/styles/home.css | 28 +++-- 3 files changed, 168 insertions(+), 94 deletions(-) diff --git a/packages/docs/styles/demos.css b/packages/docs/styles/demos.css index 5e85084c3..4f6cc3e37 100644 --- a/packages/docs/styles/demos.css +++ b/packages/docs/styles/demos.css @@ -206,18 +206,14 @@ flex-direction: column; align-items: center; gap: 12px; - padding: 20px 12px 14px; - border: 1px solid var(--vp-c-divider); - border-radius: 12px; - background: var(--vp-c-bg-soft); - color: var(--vp-c-text-1); + padding: 16px 8px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--fg); font: inherit; cursor: pointer; - transition: - border-color 0.18s ease, - background-color 0.18s ease, - transform 0.18s ease, - box-shadow 0.18s ease; + transition: background-color 0.15s ease; } .icon-cell:hover { @@ -294,10 +290,7 @@ flex-direction: column; align-items: center; gap: 10px; - padding: 18px 12px; - border: 1px solid var(--rule); - border-radius: 12px; - background: var(--bg-soft); + padding: 16px 8px; } .loading-cell__name { font-family: var(--font-mono); diff --git a/packages/docs/styles/docs.css b/packages/docs/styles/docs.css index ac01631ba..0ee78d56f 100644 --- a/packages/docs/styles/docs.css +++ b/packages/docs/styles/docs.css @@ -10,77 +10,101 @@ * is the default. Tokens are declared once on bare `:root` and only redefined after. */ +/* + * The palette is ranui's, not a second one. + * + * These names stay because ~1,000 selectors below use them, but every value now resolves + * from `--ran-*`, which ranui's own stylesheet defines and which `setTheme()` already + * flips between light and dark. A product with two token systems has two sources of + * truth that drift silently — the docs site would keep its own blue while the components + * on the same page moved to another. + * + * The fallbacks are what the site used before ranui's stylesheet loads, and what it + * falls back to if that request fails. They are not a second palette; they are the same + * values written once. + */ :root { - --bg: #ffffff; - --bg-soft: #f6f7f9; - --bg-code: #f8f9fb; - --fg: #1a1d21; - --fg-muted: #596270; - --fg-faint: #8a929e; - --rule: #e3e6ea; - --rule-strong: #c9ced6; - --accent: #0b5fe0; - --accent-hover: #0847ad; - --accent-wash: #eaf1fe; - - --callout-note: #4b5563; - --callout-tip: #0a7d4f; - --callout-warning: #9a6206; - --callout-danger: #b23a2f; - - /* No web font: the site ships eight languages including CJK and Persian, and a face - that covers them is megabytes. System stacks render immediately in every one. */ + --bg: var(--ran-color-bg, #ffffff); + --bg-soft: var(--ran-color-bg-subtle, #f6f7f9); + --bg-code: var(--ran-color-bg-muted, #f8f9fb); + --fg: var(--ran-color-text, #1a1d21); + --fg-muted: var(--ran-color-text-secondary, #596270); + --fg-faint: var(--ran-color-text-disabled, #8a929e); + --rule: var(--ran-color-border-secondary, #e3e6ea); + --rule-strong: var(--ran-color-border, #c9ced6); + --accent: var(--ran-color-primary, #0b5fe0); + --accent-hover: var(--ran-color-primary-hover, #0847ad); + --accent-wash: var(--ran-color-bg-hover, #eaf1fe); + /* + * ranui reserves a blue for links and the focus ring; `--ran-color-primary` is + * near-black and means commitment, not prose. Using primary for links made every link + * in the body the same colour as the text around it. + */ + --link: var(--ran-color-link, #0b5fe0); + + --callout-note: var(--ran-color-text-secondary, #4b5563); + --callout-tip: var(--ran-color-success, #0a7d4f); + --callout-warning: var(--ran-color-warning, #9a6206); + --callout-danger: var(--ran-color-danger, #b23a2f); + + /* ranui ships Geist and its own mono; the CJK and Arabic fallbacks are the docs + site's own concern — it publishes in eight languages, the component library does + not. No web font is loaded for them: a face covering CJK is megabytes. */ --font-sans: - system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans Arabic', - sans-serif; - --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + var(--ran-font-family, system-ui), -apple-system, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', + 'Noto Sans Arabic', sans-serif; + --font-mono: var(--ran-font-mono, ui-monospace), SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + /* + * Spacing comes from ranui's scale — 4/8/12/16/24/32/40/64/96 — so the page's rhythm + * and its components' internal rhythm are the same rhythm. Layout dimensions that are + * not spacing (a sidebar width, a header height) stay literal. + */ --header-h: 60px; --sidebar-w: 272px; --toc-w: 224px; --measure: 46em; - --pad: 24px; + --pad: var(--ran-space-4, 16px); color-scheme: light; } +/* + * Dark is ranui's too. `setTheme()` stamps `data-ran-theme` on , ranui's dark + * token block answers it, and everything above follows — so only `color-scheme` is + * restated here, for the form controls and scrollbars the browser paints itself. + */ @media (prefers-color-scheme: dark) { :root:not([data-ran-theme='light']) { - --bg: #0b0d10; - --bg-soft: #14171c; - --bg-code: #121519; - --fg: #e3e7ec; - --fg-muted: #a0abb9; - --fg-faint: #6d7986; - --rule: #232830; - --rule-strong: #353c47; - --accent: #6ba5ff; - --accent-hover: #96bfff; - --accent-wash: #12203a; - --callout-note: #9aa5b4; - --callout-tip: #46c08b; - --callout-warning: #d9a038; - --callout-danger: #e4776a; + --bg: var(--ran-color-bg, #0b0d10); + --bg-soft: var(--ran-color-bg-subtle, #14171c); + --bg-code: var(--ran-color-bg-muted, #121519); + --fg: var(--ran-color-text, #e3e7ec); + --fg-muted: var(--ran-color-text-secondary, #a0abb9); + --fg-faint: var(--ran-color-text-disabled, #6d7986); + --rule: var(--ran-color-border-secondary, #232830); + --rule-strong: var(--ran-color-border, #353c47); + --accent: var(--ran-color-primary, #6ba5ff); + --accent-hover: var(--ran-color-primary-hover, #96bfff); + --accent-wash: var(--ran-color-bg-hover, #12203a); + --link: var(--ran-color-link, #6ba5ff); color-scheme: dark; } } :root[data-ran-theme='dark'] { - --bg: #0b0d10; - --bg-soft: #14171c; - --bg-code: #121519; - --fg: #e3e7ec; - --fg-muted: #a0abb9; - --fg-faint: #6d7986; - --rule: #232830; - --rule-strong: #353c47; - --accent: #6ba5ff; - --accent-hover: #96bfff; - --accent-wash: #12203a; - --callout-note: #9aa5b4; - --callout-tip: #46c08b; - --callout-warning: #d9a038; - --callout-danger: #e4776a; + --bg: var(--ran-color-bg, #0b0d10); + --bg-soft: var(--ran-color-bg-subtle, #14171c); + --bg-code: var(--ran-color-bg-muted, #121519); + --fg: var(--ran-color-text, #e3e7ec); + --fg-muted: var(--ran-color-text-secondary, #a0abb9); + --fg-faint: var(--ran-color-text-disabled, #6d7986); + --rule: var(--ran-color-border-secondary, #232830); + --rule-strong: var(--ran-color-border, #353c47); + --accent: var(--ran-color-primary, #6ba5ff); + --accent-hover: var(--ran-color-primary-hover, #96bfff); + --accent-wash: var(--ran-color-bg-hover, #12203a); + --link: var(--ran-color-link, #6ba5ff); color-scheme: dark; } @@ -211,8 +235,9 @@ a { background: var(--bg-soft); } .nav__link[aria-current='page'] { - color: var(--accent); - background: var(--accent-wash); + color: var(--fg); + font-weight: 600; + background: transparent; } /* Language menu — a
, so it opens with no script and closes on Escape. */ @@ -446,7 +471,17 @@ a { .doc { min-width: 0; - padding: 36px 0 96px; + padding: 32px 0 96px; +} + +/* + * The landing page is full-bleed and brings its own vertical rhythm, so the doc column + * must not add a second helping. Stacked, the header, this padding and the landing's own + * left 280px of empty space above the first line of content. + */ +.doc:has(> .landing) { + padding-top: 0; + padding-bottom: 0; } /* ── Sidebar ─────────────────────────────────────────────────────────────── */ @@ -526,10 +561,17 @@ body:has(.drawer__toggle:checked) .drawer__scrim { color: var(--fg); background: var(--bg-soft); } +/* + * Selection is persistent state and must read differently from hover, which is transient. + * A fill alone is what hover already uses, so the current page is marked with a rule on + * the leading edge and a weight change instead. + */ .sb__link[aria-current='page'] { - color: var(--accent); - background: var(--accent-wash); + color: var(--fg); font-weight: 600; + box-shadow: inset 2px 0 0 var(--ran-color-primary, var(--fg)); + border-radius: 0; + background: transparent; } .sb__summary { list-style: none; @@ -675,13 +717,13 @@ body:has(.drawer__toggle:checked) .drawer__scrim { } .prose a { - color: var(--accent); + color: var(--link); text-decoration: underline; text-underline-offset: 3px; - text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); + text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } .prose a:hover { - color: var(--accent-hover); + color: var(--link); text-decoration-color: currentColor; } @@ -719,12 +761,18 @@ body:has(.drawer__toggle:checked) .drawer__scrim { /* ── Code ────────────────────────────────────────────────────────────────── */ +/* + * A code block is a quiet surface, not a card: a fill and a hairline, no border box and + * no radius. Both design references land on the same rule — establish structure with + * spacing and hairlines before reaching for a container, and do not give every block its + * own outline. A reference page alternating bordered demo, bordered code, bordered demo + * down its whole length is what makes a documentation page tiring to read. + */ .prose figure.code { position: relative; background: var(--bg-code); - border: 1px solid var(--rule); - border-radius: 8px; - overflow: hidden; + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); } .prose figure.code::after { content: attr(data-lang); @@ -813,8 +861,9 @@ body:has(.drawer__toggle:checked) .drawer__scrim { .table-wrap { overflow-x: auto; - border: 1px solid var(--rule); - border-radius: 8px; + /* The header fill and the row rules already separate it; an outer box is a third + boundary doing the same job. */ + border-top: 1px solid var(--rule); } /* The table sizes to its own content and the wrapper scrolls, rather than the table being forced to 100% and compressing columns until identifiers break mid-word — @@ -840,7 +889,8 @@ body:has(.drawer__toggle:checked) .drawer__scrim { .prose th { font-weight: 600; color: var(--fg-muted); - background: var(--bg-soft); + background: transparent; + border-bottom-color: var(--rule-strong); } .prose tr:last-child td { border-bottom: none; @@ -879,6 +929,25 @@ body:has(.drawer__toggle:checked) .drawer__scrim { from here, and because nothing upgrades it the demo markup is in the server-rendered HTML. `isolation` contains a demo that embeds something with its own high z-index — an always-open dropdown shown as content — so it cannot paint over page chrome. */ +/* + * A demo and the fence under it are one thing — the example and its source. They were + * two separate boxes with a gap, which doubled the count and said they were unrelated. + * Joined into a single framed unit with a hairline between them: half the boxes, and the + * relationship is now what the layout expresses. + */ +ran-demo:has(+ figure.code) { + border-bottom: none; + border-radius: 8px 8px 0 0; + margin-bottom: 0; +} +ran-demo + figure.code { + margin-top: 0; + border: 1px solid var(--rule); + border-top: 1px solid var(--rule); + border-radius: 0 0 8px 8px; + background: var(--bg-code); +} + ran-demo { display: flex; flex-wrap: wrap; @@ -929,15 +998,13 @@ ran-demo p { .pager__link { display: flex; flex-direction: column; - gap: 3px; - padding: 14px 18px; - border: 1px solid var(--rule); - border-radius: 8px; + gap: 4px; + padding: 8px 0; text-decoration: none; color: var(--fg); } -.pager__link:hover { - border-color: var(--accent); +.pager__link:hover .pager__text { + text-decoration: underline; } .pager__link--next { text-align: end; diff --git a/packages/docs/styles/home.css b/packages/docs/styles/home.css index f7effbffe..9962265b0 100644 --- a/packages/docs/styles/home.css +++ b/packages/docs/styles/home.css @@ -60,7 +60,12 @@ position: relative; width: 100%; overflow: hidden; - padding: clamp(56px, 9vw, 128px) 24px 112px; + /* + * Spacing values come from the design system's scale (4/8/12/16/24/32/40/64/96) — + * `clamp` between two of them rather than inventing 56/112/128, which were written for + * VitePress's full-bleed `layout: page` where nothing above contributed padding. + */ + padding: clamp(32px, 5vw, 64px) 24px 96px; isolation: isolate; } .cine > * { @@ -111,9 +116,13 @@ .hero { display: grid; grid-template-columns: 1.1fr 0.9fr; - gap: clamp(36px, 5vw, 72px); - align-items: center; - padding-top: clamp(20px, 4vw, 56px); + gap: clamp(32px, 4vw, 64px); + /* + * Top-aligned, not centred. Centring measured the live panel against a column five + * headline lines tall and dropped it 170px below the text it belongs beside, which + * reads as two unrelated blocks rather than one hero. + */ + align-items: start; } .eyebrow { display: inline-flex; @@ -138,8 +147,13 @@ .headline { margin: 24px 0 0; font-family: var(--vp-font-family-base); - font-size: clamp(40px, 6vw, 72px); - line-height: 1.02; + /* + * Sized against the column it sits in, not the viewport. At 72px this headline wrapped + * to five lines in a 554px column and pushed everything else below the fold; the ceiling + * is the largest size that keeps it to three. + */ + font-size: clamp(32px, 4.2vw, 52px); + line-height: 1.08; font-weight: 720; letter-spacing: -0.035em; text-wrap: balance; @@ -172,7 +186,7 @@ } } .subtitle { - margin: 22px 0 0; + margin: 24px 0 0; max-width: 540px; font-size: 16px; line-height: 1.72; From 730e610aa2a412e35da37cc9cec75ef7337b6b56 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:23:05 +0800 Subject: [PATCH 06/14] refactor(docs): build the page hierarchy out of type and structure, not boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/design-baseline.json | 2 +- packages/docs/styles/docs.css | 58 ++++++++++++++++++++--- packages/ranui/docs/DESIGN.md | 76 ++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/packages/docs/design-baseline.json b/packages/docs/design-baseline.json index 318bfaef3..d0546421c 100644 --- a/packages/docs/design-baseline.json +++ b/packages/docs/design-baseline.json @@ -13,7 +13,7 @@ }, "spacing-scale": { "styles/demos.css": 23, - "styles/docs.css": 51, + "styles/docs.css": 50, "styles/home.css": 59 }, "sizing-scale": {}, diff --git a/packages/docs/styles/docs.css b/packages/docs/styles/docs.css index 0ee78d56f..68fe2a6c1 100644 --- a/packages/docs/styles/docs.css +++ b/packages/docs/styles/docs.css @@ -63,7 +63,10 @@ --header-h: 60px; --sidebar-w: 272px; --toc-w: 224px; - --measure: 46em; + /* rem, not em: `em` resolves against the element's own font-size, so the 18.5px deck + would get a *wider* line than 16px body text — the larger the type, the longer the + line, which is backwards. */ + --measure: 44rem; --pad: var(--ran-space-4, 16px); color-scheme: light; @@ -648,11 +651,30 @@ body:has(.drawer__toggle:checked) .drawer__scrim { /* ── Prose ───────────────────────────────────────────────────────────────── */ +/* + * The measure governs running text, not the column. + * + * Capping `.prose` itself capped everything inside it, including the things that are not + * text and gain nothing from a comfortable line length. The Properties table wanted 762px + * inside a 736px cap, so it quietly became a scroll container and lost 26px off its last + * column — a clipped sentence that looked like a rendering bug rather than a width. + * Prose keeps the measure; tables, fences and demo stages get the full column. + */ .prose { - max-width: var(--measure); + /* Named because it is referenced again: a child that has to cancel this gap must use + the same value, and two hand-copied numbers drift. */ + --prose-gap: 1.1em; display: flex; flex-direction: column; - gap: 1.1em; + gap: var(--prose-gap); +} +.prose > p, +.prose > ul, +.prose > ol, +.prose > blockquote, +.prose > aside, +.prose > dl { + max-width: var(--measure); } .prose > :first-child { margin-top: 0; @@ -667,6 +689,19 @@ body:has(.drawer__toggle:checked) .drawer__scrim { margin: 0; } +/* + * The sentence under the title is the page's deck, and it was set at body size in body + * colour — so the title stood alone and the first thing a reader met was an undifferentiated + * wall. Giving it one step of size and one step down in colour builds the hierarchy out of + * type rather than out of another box, which is what §11 asks for. + */ +.prose > h1 + p { + font-size: 18.5px; + line-height: 1.65; + color: var(--fg-muted); + text-wrap: pretty; +} + .prose h1 { margin: 0 0 0.2em; font-size: clamp(29px, 4vw, 38px); @@ -938,7 +973,12 @@ body:has(.drawer__toggle:checked) .drawer__scrim { ran-demo:has(+ figure.code) { border-bottom: none; border-radius: 8px 8px 0 0; - margin-bottom: 0; + /* + * `.prose` spaces its children with `gap`, which no margin on the child can cancel — + * so the radii claimed these two were one object while an 18px channel ran between + * them saying the opposite. Pull the fence back up by exactly the gap. + */ + margin-bottom: calc(-1 * var(--prose-gap)); } ran-demo + figure.code { margin-top: 0; @@ -1049,10 +1089,16 @@ ran-demo p { } @media (min-width: 1240px) { - body.has-sidebar .layout { + /* + * The outline column is reserved only when there is an outline to put in it. `.toc` is + * rendered per page — the landing has none — and a column declared unconditionally is + * still a column: the home page laid out as `1144px 224px` and left 280px of dead space + * down its whole right edge, which read as the entire site being off-centre. + */ + body.has-sidebar .layout:has(.toc) { grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w); } - .layout:not(:has(.sidebar)) { + .layout:not(:has(.sidebar)):has(.toc) { grid-template-columns: minmax(0, 1fr) var(--toc-w); } .toc { diff --git a/packages/ranui/docs/DESIGN.md b/packages/ranui/docs/DESIGN.md index 3b878f0a9..5a0e4de3a 100644 --- a/packages/ranui/docs/DESIGN.md +++ b/packages/ranui/docs/DESIGN.md @@ -491,6 +491,51 @@ what the gap _means_: components. **Smaller gap means tighter relationship** — that is the only thing vertical rhythm communicates, so do not undo it with a decorative divider. +### The measure governs text, not the column + +**Running text sits at 60–75 characters per line; everything that is not running text does +not.** A table, a code fence, a demo stage or an image has nothing to gain from a +comfortable line length and everything to lose from being narrowed to one. Cap the text +elements, not the column that holds them. + +> A documentation column was capped at the measure, so the cap applied to everything +> inside it. The Properties table wanted 762px inside a 736px cap, silently became a +> scroll container, and lost 26px off its last column — a sentence ending mid-word, which +> reads as a rendering bug rather than as a width. + +**Declare the measure in `rem`, never `em`.** `em` resolves against the element's own +font-size, so the larger the type, the longer the line — exactly backwards. + +> A deck set one step up from body size inherited a `46em` measure and rendered at 851px +> against body text's 736px: the biggest type on the page got the longest line to read. + +**Build hierarchy out of type before reaching for a box.** One step of size and one step +down in colour turns a title and its first sentence into a title and a deck, with no new +container and no new border. + +### If the layout says two things are one object, the spacing must agree + +**A shared border, a shared radius and a gap between them are a contradiction.** Decide +whether the elements are one object or two, and make every property say the same thing. + +> A demo and the fence documenting it were given joined radii (`8px 8px 0 0` above, +> `0 0 8px 8px` below) and a hairline between — and then an 18px channel ran between them +> anyway, because the parent laid its children out with `gap`, which no margin on a child +> can cancel. The shape claimed one object, the spacing claimed two. + +**When a value must equal another value, name it.** A gap that a child has to subtract is +not a literal in two places; it is one custom property referenced twice. + +### Reserve a column only when its content exists + +**A column declared unconditionally is still a column, even on the pages that have nothing +to put in it.** Condition the track on the element actually being there (`:has()`), not on +a page type you assume implies it. + +> 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 +> entire right edge — which read as the whole site being off-centre. + ### Alignment is structure, not polish **Establish alignment spines and hold them.** Sibling regions share a content inset; @@ -554,13 +599,38 @@ Ask in order. A "no" is a finding, not a preference: primary buttons stay rare? 4. Could this do less: any entry point, option or state removable without weakening the task? 5. Is the structure exact — shared spines, equal gaps equal to the pixel, no doubled padding? -6. Does it hold in every state: empty, loading, failure, longest translation, narrow width, - dark theme, reduced motion? -7. Has it been looked at in a real browser, at more than one width, in both themes? +6. Is running text between 60 and 75 characters per line, and is everything that is **not** + running text free of that cap? +7. Does every element that looks like part of one object agree — border, radius **and** + spacing — and does every reserved column actually have content? +8. Does it hold in every state: empty, loading, failure, longest translation, narrow width, + dark theme, reduced motion, **RTL**? +9. Has it been looked at in a real browser, at more than one width, in both themes? **A screenshot of the happy path is not proof.** Keyboard behaviour, focus, dynamic content, themes, resizing and failure states are part of the design. +**Measure it; do not look at it.** Every finding in this section was invisible to the eye +and obvious to `getBoundingClientRect()` — a 26px clip inside a scroll container, an 18px +channel between two elements drawn as one, 280px of dead column, a deck rendering 115px +wider than the body text it sits above. Read the numbers out of a real browser: + +```js +// line length in characters, the number this section is actually about +const cs = getComputedStyle(el); +const probe = Object.assign(document.createElement('span'), { textContent: '0123456789' }); +probe.style.cssText = `font:${cs.font};visibility:hidden;position:absolute;white-space:pre`; +document.body.append(probe); +const ch = el.getBoundingClientRect().width / (probe.getBoundingClientRect().width / 10); +probe.remove(); + +// content clipped inside a scroll container — silent by construction +wrap.scrollWidth - wrap.clientWidth; + +// the page is wider than the window (an RTL off-screen offset, a stray fixed element) +document.documentElement.scrollWidth > innerWidth; +``` + --- ## Verification checklist (before shipping UI) From 40a6ae2f442139059c8695a4fa66f9d2efc0a7db Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 22:47:13 +0800 Subject: [PATCH 07/14] feat(ranui): build a Claude Design bundle from real rendered components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/ranui/.gitignore | 1 + packages/ranui/bin/design-bundle.ts | 390 ++++++++++++++++++++++++++++ packages/ranui/docs/DESIGN.md | 26 ++ packages/ranui/package.json | 1 + 4 files changed, 418 insertions(+) create mode 100644 packages/ranui/bin/design-bundle.ts diff --git a/packages/ranui/.gitignore b/packages/ranui/.gitignore index 75e854d8d..f09dc2510 100644 --- a/packages/ranui/.gitignore +++ b/packages/ranui/.gitignore @@ -2,3 +2,4 @@ node_modules/ /test-results/ /playwright-report/ /playwright/.cache/ +design-bundle/ diff --git a/packages/ranui/bin/design-bundle.ts b/packages/ranui/bin/design-bundle.ts new file mode 100644 index 000000000..6924a6bc9 --- /dev/null +++ b/packages/ranui/bin/design-bundle.ts @@ -0,0 +1,390 @@ +/** + * Build a self-contained design-system bundle for Claude Design (claude.ai/design). + * + * Every card is **real rendered output**, never a hand-written approximation of one. The + * docs site is opened in Chromium, each component's shadow tree is serialized into a + * Declarative Shadow DOM template, and the stylesheets that tree adopted are inlined + * beside it. The result renders with JavaScript disabled and without ranui on the page — + * which is the property that makes it safe to hand to another tool: a card cannot claim + * an appearance the component does not actually have. + * + * Usage — the docs preview server supplies the rendered components: + * + * pnpm -F docs build && pnpm -F docs preview # terminal 1 + * pnpm -F ranui design:bundle # terminal 2 + * + * Then upload with the DesignSync tool (`finalize_plan` → `write_files`). + */ +import { chromium } from '@playwright/test'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const flag = (name: string, fallback: string): string => { + const i = process.argv.indexOf(`--${name}`); + return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +}; + +const ORIGIN = flag('origin', 'http://localhost:4174'); +const OUT = resolve(flag('out', join(ROOT, 'design-bundle'))); + +/** + * Pages whose docs carry live `` examples. A page without one yields no card — + * the script says so rather than emitting an empty shell, because an empty card in a + * design system reads as "this component looks like nothing". + */ +const COMPONENTS = `attachments button card checkbox colorpicker conversation disclosure-row dropdown form +glass icon image input link loading markdown math mermaid message modal player popover preview progress +radar reasoning route router scratch section select skeleton state-dot tab theme-switch token-meter +tool-card voice-button` + .split(/\s+/) + .filter(Boolean); + +declare global { + interface Window { + __dsSerialize: (root: Element) => string; + __dsTokens: () => string; + } +} + +const CHROME = ` +:root{color-scheme:light dark} +*{box-sizing:border-box} +body{margin:0;padding:40px;font-family:var(--ran-font-family,system-ui,sans-serif); + background:var(--ran-color-bg,#fff);color:var(--ran-color-text,#111);line-height:1.6} +.ds-head{margin:0 0 4px;font-size:30px;font-weight:700;letter-spacing:-.022em} +.ds-desc{margin:0 0 36px;font-size:17px;color:var(--ran-color-text-secondary,#596270);max-width:44rem} +.ds-sec{margin:0 0 32px} +.ds-sec>h2{margin:0 0 12px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase; + font-family:var(--ran-font-mono,ui-monospace,monospace);color:var(--ran-color-text-secondary,#596270)} +.ds-stage{display:flex;flex-wrap:wrap;gap:18px;align-items:center;padding:26px 24px; + border:1px solid var(--ran-color-border,#e5e7eb);border-radius:12px; + background:var(--ran-color-bg-subtle,#fafafa)} +.ds-stage--col{flex-direction:column;align-items:stretch} +`; + +mkdirSync(join(OUT, 'components'), { recursive: true }); +mkdirSync(join(OUT, 'foundations'), { recursive: true }); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1100, height: 800 } }); + +/* + * Browser-side code is passed as a **string**, not as a function. + * + * A function argument is serialized after the TypeScript transform has run, and that + * transform wraps named functions in esbuild's `__name()` helper — which does not exist + * in the page. The failure is `ReferenceError: __name is not defined`, thrown from inside + * the injected script where it is thoroughly unobvious. A string is passed through + * untouched. + * + * ranui attaches its shadow roots `mode: 'closed'` by design, so nothing outside can read + * them. Forcing them open *before* any page script runs is the only way to serialize what + * they render. + */ +await ctx.addInitScript({ + content: ` +(() => { + const orig = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (init) { return orig.call(this, { ...init, mode: 'open' }); }; + + window.__dsSerialize = (root) => { + const cssOf = (sr) => sr.adoptedStyleSheets + .map((s) => { try { return [...s.cssRules].map((r) => r.cssText).join('\\n'); } catch { return ''; } }) + .join('\\n'); + const walk = (node) => { + if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.nodeValue ?? ''); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const clone = node.cloneNode(false); + const sr = node.shadowRoot; + if (sr) { + const tpl = document.createElement('template'); + tpl.setAttribute('shadowrootmode', 'open'); + const css = cssOf(sr); + if (css) { const st = document.createElement('style'); st.textContent = css; tpl.content.append(st); } + for (const c of sr.childNodes) { const s = walk(c); if (s) tpl.content.append(s); } + clone.append(tpl); + } + for (const c of node.childNodes) { const s = walk(c); if (s) clone.append(s); } + return clone; + }; + const box = document.createElement('div'); + const out = walk(root); + if (out) box.append(out); + return box.innerHTML; + }; + + /* + * Token values, resolved — not the site's rules copied. + * + * Concatenating the site's own \`:root\` rules reproduces its cascade *order* too, and a + * later light \`:root\` then overrode the earlier \`@media (prefers-color-scheme: dark)\` + * block: the stages rendered white on a black page. Reading each token's computed value + * once per theme sidesteps the cascade entirely and emits exactly the three blocks the + * theming contract specifies. + */ + window.__dsTokens = () => { + const names = new Set(); + for (const sheet of document.styleSheets) { + let rules; + try { rules = [...sheet.cssRules]; } catch { continue; } + const scan = (list) => { + for (const r of list) { + /* + * Read the declarations, then recurse — and never skip past the read. A + * CSSStyleRule in current Chrome also carries a cssRules list (empty, for CSS + * nesting), so treating "has cssRules" as "is a container" skips every style + * rule's own declarations and captures nothing at all. + */ + if (r.style) { + for (const prop of r.style) { + if (prop.startsWith('--ran-')) names.add(prop); + for (const m of r.style.getPropertyValue(prop).matchAll(/var\\((--ran-[a-z0-9-]+)/gi)) names.add(m[1]); + } + } + if (r.cssRules && r.cssRules.length) scan([...r.cssRules]); + } + }; + scan(rules); + } + const read = () => { + const cs = getComputedStyle(document.documentElement); + const out = {}; + for (const n of names) { const v = cs.getPropertyValue(n).trim(); if (v) out[n] = v; } + return out; + }; + const root = document.documentElement; + const prev = root.getAttribute('data-ran-theme'); + root.setAttribute('data-ran-theme', 'light'); + const light = read(); + root.setAttribute('data-ran-theme', 'dark'); + const dark = read(); + if (prev === null) root.removeAttribute('data-ran-theme'); else root.setAttribute('data-ran-theme', prev); + + const decl = (o, pad) => Object.entries(o).map(([k, v]) => pad + k + ': ' + v + ';').join('\\n'); + const changed = Object.fromEntries(Object.entries(dark).filter(([k, v]) => light[k] !== v)); + return [ + ':root {\\n' + decl(light, ' ') + '\\n}', + '@media (prefers-color-scheme: dark) {\\n :root:not([data-ran-theme="light"]) {\\n' + decl(changed, ' ') + '\\n }\\n}', + ':root[data-ran-theme="dark"] {\\n' + decl(changed, ' ') + '\\n}', + ].join('\\n'); + }; +})(); +`, +}); + +const page = await ctx.newPage(); +let tokens = ''; +const made: string[] = []; +const empty: string[] = []; + +for (const name of COMPONENTS) { + const res = await page.goto(`${ORIGIN}/src/ranui/${name}/`, { waitUntil: 'networkidle' }); + if (!res?.ok()) { + console.log(` skip ${name}: HTTP ${res?.status()}`); + continue; + } + await page.waitForTimeout(1800); + if (!tokens) tokens = await page.evaluate(() => window.__dsTokens()); + + const data = await page.evaluate(() => { + const title = document.querySelector('.prose h1')?.textContent?.trim() ?? ''; + const desc = document.querySelector('.prose h1 + p')?.textContent?.trim() ?? ''; + const out: Array<{ label: string; column: boolean; html: string }> = []; + for (const demo of document.querySelectorAll('ran-demo')) { + let label = ''; + let n = demo.previousElementSibling; + while (n) { + if (/^H[2-4]$/.test(n.tagName)) { + // The docs name an example "Button Types `type`" — the trailing code span is the + // attribute, not part of the title, and reads as a stutter once uppercased. + const h = n.cloneNode(true) as Element; + h.querySelectorAll('code, .header-anchor').forEach((c) => c.remove()); + label = (h.textContent ?? '').replace(/#$/, '').trim(); + break; + } + n = n.previousElementSibling; + } + const parts: string[] = []; + for (const child of demo.children) parts.push(window.__dsSerialize(child)); + if (parts.length) out.push({ label, column: demo.hasAttribute('column'), html: parts.join('\n') }); + } + return { title, desc, demos: out }; + }); + + if (!data.demos.length) { + empty.push(name); + continue; + } + + const body = data.demos + .map( + (d) => + `
${d.label ? `

${d.label}

` : ''}\n
${d.html}
`, + ) + .join('\n'); + + writeFileSync( + join(OUT, 'components', `${name}.html`), + ` +${data.title || name} + + +

${data.title || name}

+${data.desc ? `

${data.desc}

` : ''} +${body} +`, + ); + made.push(name); + console.log(` ${name}: ${data.demos.length} example(s)`); +} + +await browser.close(); + +if (!tokens) throw new Error(`design-bundle: no page loaded from ${ORIGIN} — is \`pnpm -F docs preview\` running?`); +writeFileSync(join(OUT, '_tokens.css'), tokens); + +// ── Foundations, generated from the tokens the site actually resolves ──────── +const lightValues: Record = {}; +for (const line of tokens.slice(tokens.indexOf(':root {') + 7, tokens.indexOf('\n}')).split('\n')) { + const m = line.match(/^\s*(--ran-[a-z0-9-]+):\s*(.+);$/i); + if (m) lightValues[m[1]] = m[2].trim(); +} +const names = Object.keys(lightValues); +const family = (re: RegExp): string[] => + names + .filter((n) => re.test(n)) + .sort((a, b) => { + const na = Number(a.match(/(\d+)$/)?.[1] ?? 0); + const nb = Number(b.match(/(\d+)$/)?.[1] ?? 0); + return na - nb || a.localeCompare(b); + }); + +const FOUNDATION_CSS = ` +.ds-grid{display:grid;gap:12px} +.ds-swatch{display:flex;align-items:center;gap:14px;padding:10px 12px; + border:1px solid var(--ran-color-border,#e5e7eb);border-radius:10px} +.ds-chip{width:44px;height:44px;border-radius:8px;flex:none; + border:1px solid var(--ran-color-border-subtle,rgba(0,0,0,.08))} +.ds-name{font-family:var(--ran-font-mono,monospace);font-size:12.5px} +.ds-val{margin-left:auto;font-family:var(--ran-font-mono,monospace);font-size:12px; + color:var(--ran-color-text-secondary,#666)} +.ds-scale{display:flex;gap:4px} +.ds-step{flex:1;min-width:0} +/* The label sits under the swatch, not on it: a number tinted to read on step 100 is + invisible on step 700, and a ramp has no single colour that works on both. */ +.ds-step>i{display:block;height:56px;border-radius:8px; + border:1px solid var(--ran-color-border-subtle,rgba(0,0,0,.07))} +.ds-step>small{display:block;padding-top:5px;text-align:center; + font-family:var(--ran-font-mono,monospace);font-size:10.5px; + color:var(--ran-color-text-secondary,#666)} +`; + +const foundation = (file: string, title: string, desc: string, body: string): void => + writeFileSync( + join(OUT, 'foundations', file), + ` +${title} + + +

${title}

+

${desc}

+${body} +`, + ); + +const swatches = (list: string[]): string => + `
${list + .map( + (n) => + `
${n}${lightValues[n]}
`, + ) + .join('')}
`; + +const ramp = (prefix: string): string => { + const steps = family(new RegExp(`^--ran-${prefix}-\\d+$`)); + if (!steps.length) return ''; + return `

${prefix}

${steps + .map( + (n) => + `${n.match(/(\d+)$/)?.[1]}`, + ) + .join('')}
`; +}; + +foundation( + 'colors.html', + 'Colour', + 'Every hue is a 10-step scale where each step has one fixed job, so interaction states are decided up front. The primary action is monochrome; blue is reserved for links and the focus ring.', + ['gray', 'gray-alpha', 'blue', 'red', 'green', 'amber'].map(ramp).join('\n') + + `

Semantic — surfaces

${swatches(family(/^--ran-color-(bg|background)/))}
+

Semantic — text

${swatches(family(/^--ran-color-text/))}
+

Semantic — border

${swatches(family(/^--ran-color-border/))}
+

Semantic — action

${swatches(family(/^--ran-color-(primary|link|success|warning|danger|error)/))}
`, +); + +foundation( + 'typography.html', + 'Typography', + 'Decide by role — heading, label, copy, button, mono. The role fixes font, size, weight and line-height; never pick a raw px per instance.', + ([ + ['heading', 'Titles'], + ['label', 'Single-line, scannable'], + ['copy', 'Multi-line body'], + ] as const) + .map( + ([role, use]) => `

${role} — ${use}

+${family(new RegExp(`^--ran-text-${role}-\\d$`)) + .map( + (n) => `

+ The quick brown fox 中文排版样例 ${n} · ${lightValues[n]}

`, + ) + .join('')}
`, + ) + .join('\n') + + `

mono

+ const ran = 'Geist Mono' — 0123456789 --ran-font-mono

`, +); + +foundation( + 'spacing.html', + 'Spacing', + 'A limited, rhythmic scale. Spacing carries meaning: related things sit closer than unrelated ones, and a value off the scale is a decision nobody made on purpose.', + `
${family(/^--ran-space-\d+$/) + .map( + (n) => + `
${n}${lightValues[n]}
`, + ) + .join('')}
`, +); + +foundation( + 'radius-elevation.html', + 'Radius & elevation', + 'Radius and shadow are structural, not decorative: a raised surface is a claim that something floats above the page.', + `

Radius

${family(/^--ran-(radius|border-radius)/) + .map( + (n) => + `
${n}${lightValues[n]}
`, + ) + .join('')}
+

Elevation

${family(/^--ran-(shadow|elevation)/) + .map( + (n) => + `
${n}${(lightValues[n] ?? '').slice(0, 42)}
`, + ) + .join('')}
`, +); + +foundation( + 'motion.html', + 'Motion', + 'Prefer none. Motion is for interaction — hover, focus, press — never for flipping light and dark: CSS cannot tell why a property changed, so any palette property in a transition also fades on a theme switch.', + `

Duration

${swatches(family(/^--ran-motion-duration/))}
+

Easing

${swatches(family(/^--ran-motion-ease/))}
`, +); + +console.log(`\n${made.length} component card(s) + 5 foundation card(s) → ${OUT}`); +if (empty.length) console.log(`no live demo on the docs page (no card): ${empty.join(', ')}`); diff --git a/packages/ranui/docs/DESIGN.md b/packages/ranui/docs/DESIGN.md index 5a0e4de3a..7f81ad215 100644 --- a/packages/ranui/docs/DESIGN.md +++ b/packages/ranui/docs/DESIGN.md @@ -59,6 +59,32 @@ target for every entry is zero. --- +## Looking at the whole system at once + +`pnpm -F ranui design:bundle` builds a browsable design-system bundle — one card per +component, plus colour, typography, spacing, radius/elevation and motion — for upload to +[Claude Design](https://claude.ai/design) with the `DesignSync` tool. + +```sh +pnpm -F docs build && pnpm -F docs preview # terminal 1 — supplies the rendered components +pnpm -F ranui design:bundle # terminal 2 — writes packages/ranui/design-bundle/ +``` + +**Every card is real rendered output, never a drawing of one.** The docs site is opened in +Chromium, each component's shadow tree is serialized into a Declarative Shadow DOM +template, and the stylesheets that tree adopted are inlined beside it — so a card renders +with JavaScript disabled and without ranui on the page, and cannot claim an appearance the +component does not have. Token values are read back per theme with `getComputedStyle` +rather than copied out of the stylesheets, so a swatch cannot show a value the system no +longer resolves to. + +A component whose docs page has no live `` gets no card, and the script names +it. Seven currently qualify — `attachments`, `conversation`, `preview`, `reasoning`, +`router`, `tool-card`, `voice-button` — which is worth fixing in the docs, not in the +bundler: a component nobody can see rendered is a component nobody can review. + +--- + ## 1. Color — a state ladder, not a palette Each hue is a **10-step scale** (`100`–`1000`). Every step has **one fixed job**, so interaction states are decided up front: diff --git a/packages/ranui/package.json b/packages/ranui/package.json index 9fbc7654d..3c4517c43 100644 --- a/packages/ranui/package.json +++ b/packages/ranui/package.json @@ -165,6 +165,7 @@ "build:bundle": "vite build -c ./build/config.bundle.ts", "build:iife": "tsx ./bin/build-iife.ts", "prepublish": "npm run build", + "design:bundle": "tsx ./bin/design-bundle.ts", "test": "sh ./bin/test.sh", "test:unit": "vitest run", "test:unit:watch": "vitest", From 39b4f061cfa79da5d50241ec33c167cba3d58d8d Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 23:00:32 +0800 Subject: [PATCH 08/14] fix(docs): two things that only broke when script did not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/build/page.ts | 10 ++++++++++ packages/docs/styles/docs.css | 10 ++++++++++ packages/docs/styles/home.css | 13 +++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/docs/build/page.ts b/packages/docs/build/page.ts index ef917e47c..5525c3b7f 100644 --- a/packages/docs/build/page.ts +++ b/packages/docs/build/page.ts @@ -215,7 +215,17 @@ ${assets.js.map((src) => ``).join('\ * documentation site that is a white flash on every single navigation. Writes nothing * for "system", leaving `prefers-color-scheme` in charge. */ +/* + * Runs before first paint: restores the stored theme, and marks that script is running + * at all. + * + * The `js` class is what lets CSS hide something it expects script to bring back. A + * scroll-reveal that sets `opacity: 0` unconditionally is a bet that the observer will + * always run; when it does not, the content below the fold is simply gone, and a reader + * with JavaScript disabled has no way to know there was anything there. + */ const THEME_BOOTSTRAP = `(function(){try{ +document.documentElement.classList.add('js'); var t=localStorage.getItem('ran-theme'); if(t==='dark'||t==='light'){var e=document.documentElement;e.setAttribute('data-ran-theme',t);e.setAttribute('theme',t);} }catch(e){}})();`; diff --git a/packages/docs/styles/docs.css b/packages/docs/styles/docs.css index 68fe2a6c1..c2cb8f25b 100644 --- a/packages/docs/styles/docs.css +++ b/packages/docs/styles/docs.css @@ -353,6 +353,14 @@ a { background: var(--bg); } +/* + * `display` belongs on `[open]`, not on the dialog itself. + * + * A `` is `display: none` until opened — a flat `display: flex` here overrides + * that, so the closed search panel stayed in the layout at the end of the document. It + * added 177px of scroll to every page and appeared, floating and unreachable, below the + * footer of any full-page capture. + */ .search { width: min(640px, calc(100vw - 32px)); max-height: min(70vh, 560px); @@ -364,6 +372,8 @@ a { color: var(--fg); box-shadow: 0 24px 64px -24px rgba(0, 0, 0, 0.45); overflow: hidden; +} +.search[open] { display: flex; flex-direction: column; } diff --git a/packages/docs/styles/home.css b/packages/docs/styles/home.css index 9962265b0..e19aace0c 100644 --- a/packages/docs/styles/home.css +++ b/packages/docs/styles/home.css @@ -400,14 +400,23 @@ } /* ---------- scroll reveal ---------- */ -.reveal { +/* + * Hidden only where script is running to reveal it again. + * + * `.reveal { opacity: 0 }` on its own is a bet that the IntersectionObserver always runs. + * When it does not — JavaScript disabled, a bundle that 404s, an error earlier in the + * file — every section below the fold stays invisible permanently. Measured: with JS off, + * the closing strip was `opacity: 0` and unreachable. The `js` class is set before first + * paint by the theme bootstrap, so there is no flash of the un-hidden state either. + */ +:root.js .reveal { opacity: 0; transform: translateY(30px); transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); } -.reveal.in { +:root.js .reveal.in { opacity: 1; transform: none; } From 93134f46f9b108ca1919cadf37c9a5d4b51c2fc6 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 23:00:33 +0800 Subject: [PATCH 09/14] refactor(docs): fewer boxes on the landing, and drop the last VitePress names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/build/components.ts | 3 +- packages/docs/client/home.ts | 17 --- packages/docs/design-baseline.json | 4 +- packages/docs/styles/demos.css | 66 ++++----- packages/docs/styles/home.css | 222 +++++++++++++---------------- 5 files changed, 135 insertions(+), 177 deletions(-) diff --git a/packages/docs/build/components.ts b/packages/docs/build/components.ts index 0a637a649..a18986c70 100644 --- a/packages/docs/build/components.ts +++ b/packages/docs/build/components.ts @@ -148,8 +148,7 @@ export const renderHome = (locale: LocaleDef): string => { t.pillars .map( (p, i) => - `` + - `` + + `` + `${icon(p.kind)}` + `

${esc(p.title)}

${esc(p.desc)}

` + `${esc(p.more)} ${ARROW_SM}
`, diff --git a/packages/docs/client/home.ts b/packages/docs/client/home.ts index 0fb4864c8..df8321fc3 100644 --- a/packages/docs/client/home.ts +++ b/packages/docs/client/home.ts @@ -36,23 +36,6 @@ const mountHome = (): void => { wireCopy(button, button.dataset.copy ?? ''); } - // A subtle 3D tilt that follows the pointer across a card. - for (const card of root.querySelectorAll('[data-tilt]')) { - card.addEventListener('pointermove', (event) => { - const r = card.getBoundingClientRect(); - const mx = event.clientX - r.left; - const my = event.clientY - r.top; - card.style.setProperty('--mx', `${mx}px`); - card.style.setProperty('--my', `${my}px`); - card.style.setProperty('--rx', `${((my / r.height) * 2 - 1) * -3}deg`); - card.style.setProperty('--ry', `${((mx / r.width) * 2 - 1) * 3}deg`); - }); - card.addEventListener('pointerleave', () => { - card.style.setProperty('--rx', '0deg'); - card.style.setProperty('--ry', '0deg'); - }); - } - const reveals = [...root.querySelectorAll('[data-reveal]')]; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce) { diff --git a/packages/docs/design-baseline.json b/packages/docs/design-baseline.json index d0546421c..a1192748f 100644 --- a/packages/docs/design-baseline.json +++ b/packages/docs/design-baseline.json @@ -9,12 +9,12 @@ "bare-colour": { "styles/demos.css": 10, "styles/docs.css": 4, - "styles/home.css": 5 + "styles/home.css": 4 }, "spacing-scale": { "styles/demos.css": 23, "styles/docs.css": 50, - "styles/home.css": 59 + "styles/home.css": 57 }, "sizing-scale": {}, "mouse-only-drag": {}, diff --git a/packages/docs/styles/demos.css b/packages/docs/styles/demos.css index 4f6cc3e37..a51630dad 100644 --- a/packages/docs/styles/demos.css +++ b/packages/docs/styles/demos.css @@ -21,7 +21,7 @@ position: relative; min-height: 320px; border-radius: 14px; - border: 1px solid var(--vp-c-divider); + border: 1px solid var(--rule); overflow: hidden; touch-action: none; } @@ -98,9 +98,9 @@ flex-direction: column; gap: 12px; padding: 16px; - border: 1px solid var(--vp-c-divider); + border: 1px solid var(--rule); border-radius: 12px; - background: var(--vp-c-bg-soft); + background: var(--bg-soft); } .gp-row { display: grid; @@ -109,18 +109,18 @@ gap: 10px; } .gp-label { - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 12px; - color: var(--vp-c-text-2); + color: var(--fg-muted); } .gp-row input[type='range'] { width: 100%; - accent-color: var(--vp-c-brand); + accent-color: var(--accent); } .gp-val { - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 12px; - color: var(--vp-c-text-1); + color: var(--fg); text-align: right; font-variant-numeric: tabular-nums; } @@ -130,51 +130,51 @@ align-items: center; grid-template-columns: none; padding-top: 4px; - border-top: 1px solid var(--vp-c-divider); + border-top: 1px solid var(--rule); } .gp-check { display: inline-flex; align-items: center; gap: 6px; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 12.5px; - color: var(--vp-c-text-2); + color: var(--fg-muted); cursor: pointer; } .gp-check input { - accent-color: var(--vp-c-brand); + accent-color: var(--accent); } .gp-reset { margin-left: auto; font-size: 12px; padding: 4px 12px; border-radius: 7px; - border: 1px solid var(--vp-c-divider); - background: var(--vp-c-bg); - color: var(--vp-c-text-2); + border: 1px solid var(--rule); + background: var(--bg); + color: var(--fg-muted); cursor: pointer; } .gp-reset:hover { - border-color: var(--vp-c-text-3); - color: var(--vp-c-text-1); + border-color: var(--fg-faint); + color: var(--fg); } /* ---- code ---- */ .gp-code { position: relative; - border: 1px solid var(--vp-c-divider); + border: 1px solid var(--rule); border-radius: 12px; overflow: hidden; - background: var(--vp-c-bg); + background: var(--bg); } .gp-code pre { margin: 0; padding: 16px 18px; overflow-x: auto; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; - color: var(--vp-c-text-1); + color: var(--fg); } .gp-copy { position: absolute; @@ -183,9 +183,9 @@ font-size: 11.5px; padding: 4px 12px; border-radius: 7px; - border: 1px solid var(--vp-c-divider); - background: var(--vp-c-bg-soft); - color: var(--vp-c-text-2); + border: 1px solid var(--rule); + background: var(--bg-soft); + color: var(--fg-muted); cursor: pointer; } .gp-copy.done { @@ -217,8 +217,8 @@ } .icon-cell:hover { - border-color: var(--vp-c-brand-1); - background: var(--vp-c-bg); + border-color: var(--accent); + background: var(--bg); transform: translateY(-2px); box-shadow: 0 8px 22px -12px rgba(0, 0, 0, 0.35); } @@ -228,12 +228,12 @@ } .icon-cell:focus-visible { - outline: 2px solid var(--vp-c-brand-1); + outline: 2px solid var(--accent); outline-offset: 2px; } .icon-cell.is-copied { - border-color: var(--vp-c-brand-1); + border-color: var(--accent); } .icon-cell__glyph { @@ -241,25 +241,25 @@ align-items: center; justify-content: center; height: 34px; - color: var(--vp-c-text-1); + color: var(--fg); } .icon-cell__name { max-width: 100%; - font-family: var(--vp-font-family-mono, ui-monospace, monospace); + font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1.2; - color: var(--vp-c-text-2); + color: var(--fg-muted); text-align: center; word-break: break-word; } .icon-cell:hover .icon-cell__name { - color: var(--vp-c-text-1); + color: var(--fg); } .icon-cell.is-copied .icon-cell__name { - color: var(--vp-c-brand-1); + color: var(--accent); } @media (prefers-reduced-motion: reduce) { diff --git a/packages/docs/styles/home.css b/packages/docs/styles/home.css index e19aace0c..d9ccbd6a9 100644 --- a/packages/docs/styles/home.css +++ b/packages/docs/styles/home.css @@ -1,10 +1,11 @@ /* - * Compatibility layer for the two stylesheets lifted out of the Vue components. + * Landing and gallery surfaces. * - * They were written against VitePress's theme variables — `--vp-c-text-1`, `--ink`, - * `--hairline` and friends — and there is no VitePress here. Mapping the names onto this - * site's own tokens is a dozen lines; renaming them through ~1,000 lines of selectors - * would be a large diff with nothing to show for it and every opportunity to miss one. + * These carry three local names — `--ink`, `--surface`, `--hairline` — on top of the + * site's palette, which in turn derives from ranui's tokens. There used to be a second + * layer here aliasing VitePress's `--vp-c-*` names onto the same values; every rule now + * names the site variable directly, so there is one indirection instead of two and no + * trace of a theme this site no longer uses. * * The failure mode when a name is missing is worth recording: `--ink` feeds the * headline's `background-clip: text` gradient, so an undefined value made the gradient @@ -13,17 +14,6 @@ .cine, .gp, .icon-gallery { - --vp-c-text-1: var(--fg); - --vp-c-text-2: var(--fg-muted); - --vp-c-text-3: var(--fg-faint); - --vp-c-bg: var(--bg); - --vp-c-bg-alt: var(--bg-soft); - --vp-c-bg-soft: var(--bg-soft); - --vp-c-divider: var(--rule); - --vp-c-brand: var(--accent); - --vp-c-brand-1: var(--accent); - --vp-font-family-base: var(--font-sans); - --vp-font-family-mono: var(--font-mono); --ink: var(--fg); --surface: var(--bg-soft); --hairline: var(--rule); @@ -53,10 +43,10 @@ mode is just the token scale flipping (bright ink text on near-black). */ .cine { --container: 1120px; - --hairline: var(--vp-c-divider); - --ink: var(--vp-c-text-1); + --hairline: var(--rule); + --ink: var(--fg); /* card surface, lifted one step off the page so dark mode reads clearly */ - --surface: var(--vp-c-bg-soft); + --surface: var(--bg-soft); position: relative; width: 100%; overflow: hidden; @@ -105,7 +95,7 @@ .grid { position: absolute; inset: -10%; - background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px); + background-image: radial-gradient(var(--rule) 1px, transparent 1px); background-size: 28px 28px; opacity: 0.5; mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 20%, transparent 72%); @@ -131,7 +121,7 @@ font-size: 13px; font-weight: 500; letter-spacing: 0.02em; - color: var(--vp-c-text-2); + color: var(--fg-muted); padding: 6px 14px; border: 1px solid var(--hairline); border-radius: 999px; @@ -146,7 +136,7 @@ } .headline { margin: 24px 0 0; - font-family: var(--vp-font-family-base); + font-family: var(--font-sans); /* * Sized against the column it sits in, not the viewport. At 72px this headline wrapped * to five lines in a 554px column and pushed everything else below the fold; the ceiling @@ -190,7 +180,7 @@ max-width: 540px; font-size: 16px; line-height: 1.72; - color: var(--vp-c-text-2); + color: var(--fg-muted); } /* hero copy blocks: fade-up with staggered delay */ @@ -218,13 +208,13 @@ border-radius: 10px; border: 1px solid var(--hairline); background: var(--surface); - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 13.5px; - color: var(--vp-c-text-1); + color: var(--fg); } .cmd code::before { content: '$ '; - color: var(--vp-c-text-3); + color: var(--fg-faint); } .copy { flex: 0 0 auto; @@ -234,15 +224,15 @@ height: 30px; border-radius: 7px; border: 1px solid var(--hairline); - background: var(--vp-c-bg); - color: var(--vp-c-text-2); + background: var(--bg); + color: var(--fg-muted); cursor: pointer; /* palette props change instantly on hover — never transition color across a theme flip, or light/dark switching fades element-by-element */ } .copy:hover { - color: var(--vp-c-text-1); - border-color: var(--vp-c-text-3); + color: var(--fg); + border-color: var(--fg-faint); } .copy.done { color: var(--ran-color-success, #28a948); @@ -286,12 +276,12 @@ transform: translateX(3px); } .btn-ghost { - color: var(--vp-c-text-1); + color: var(--fg); border: 1px solid var(--hairline); background: var(--surface); } .btn-ghost:hover { - border-color: var(--vp-c-text-3); + border-color: var(--fg-faint); transform: translateY(-1px); } @@ -310,14 +300,13 @@ align-items: center; gap: 8px; padding: 12px 20px; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--vp-c-text-2); - border-bottom: 1px solid var(--hairline); - background: var(--vp-c-bg-alt); + color: var(--fg-muted); + border-bottom: 1px solid var(--rule); } .live-dot { width: 8px; @@ -356,7 +345,7 @@ .live-progress { width: 100%; --ran-progress-track-height: 6px; - --ran-progress-fill-background: linear-gradient(90deg, var(--vp-c-text-3), var(--ink)); + --ran-progress-fill-background: linear-gradient(90deg, var(--fg-faint), var(--ink)); } .live-loading { --loading-circle-line-border-width: 22px; @@ -375,7 +364,7 @@ .live-skeleton span { height: 34px; border-radius: 8px; - background: linear-gradient(90deg, var(--vp-c-bg-alt), var(--vp-c-divider), var(--vp-c-bg-alt)); + background: linear-gradient(90deg, var(--bg-soft), var(--rule), var(--bg-soft)); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } @@ -396,7 +385,7 @@ .live-note { padding: 12px 20px 16px; font-size: 12.5px; - color: var(--vp-c-text-3); + color: var(--fg-faint); } /* ---------- scroll reveal ---------- */ @@ -438,18 +427,18 @@ border-bottom: 1px solid var(--hairline); } .stat { - background: var(--vp-c-bg); + background: var(--bg); text-align: center; padding: 30px 12px; } .stat-num { display: block; - font-family: var(--vp-font-family-base); + font-family: var(--font-sans); font-size: clamp(30px, 4.4vw, 42px); font-weight: 680; letter-spacing: -0.02em; line-height: 1; - color: var(--vp-c-text-1); + color: var(--fg); font-variant-numeric: tabular-nums; } .stat-label { @@ -457,7 +446,7 @@ margin-top: 8px; font-size: 13px; font-weight: 500; - color: var(--vp-c-text-3); + color: var(--fg-faint); } /* ---------- pillars: subtle tilt ---------- */ @@ -466,18 +455,24 @@ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; - perspective: 1000px; } +/* + * Three ways in, not three cards. + * + * These are cells in a gallery — §11's example of what a box is *not* for. Bordered and + * filled, they read as three separate objects competing with the hero directly above, + * and the 3D tilt and hover spotlight spent the page's whole emphasis budget on + * navigation furniture. A shared top rule, the column spine, and a link that responds on + * hover say the same thing with nothing drawn around it. + */ .pillar { position: relative; display: flex; flex-direction: column; - padding: 30px; - border-radius: 14px; - border: 1px solid var(--hairline); - background: var(--surface); + padding: 30px 30px 30px 0; + border-top: 1px solid var(--rule); overflow: hidden; - transform: perspective(1000px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); + /* motion props only — border-color changes instantly on hover; box-shadow uses theme-stable rgba so neither drags on a theme flip */ transition: @@ -486,40 +481,17 @@ opacity 0.7s var(--ran-motion-ease-smooth, ease); transform-style: preserve-3d; } -.pillar .spotlight { - position: absolute; - inset: 0; - pointer-events: none; - opacity: 0; - transition: opacity var(--ran-motion-duration-base, 0.25s) var(--ran-motion-ease-smooth, ease); - background: radial-gradient( - 240px circle at var(--mx, 50%) var(--my, 0), - color-mix(in srgb, var(--ink) 8%, transparent), - transparent 60% - ); -} -.pillar:hover { - border-color: var(--vp-c-text-3); - box-shadow: 0 18px 40px -22px rgba(0, 0, 0, 0.32); -} -.pillar:hover .spotlight { - opacity: 1; -} .pillar > * { position: relative; - transform: translateZ(18px); } .pillar-icon { - width: 44px; - height: 44px; + width: 26px; + height: 26px; display: grid; place-items: center; - border-radius: 11px; - color: var(--vp-c-text-1); - border: 1px solid var(--hairline); - background: var(--vp-c-bg); + color: var(--fg-muted); } -.pillar-icon :deep(svg) { +.pillar-icon svg { width: 22px; height: 22px; } @@ -528,14 +500,14 @@ font-size: 21px; font-weight: 640; letter-spacing: -0.01em; - color: var(--vp-c-text-1); + color: var(--fg); border: 0; } .pillar p { margin: 10px 0 0; font-size: 14px; line-height: 1.65; - color: var(--vp-c-text-2); + color: var(--fg-muted); flex: 1; } .pillar-more { @@ -545,7 +517,7 @@ gap: 5px; font-size: 13px; font-weight: 600; - color: var(--vp-c-text-1); + color: var(--fg); } .pillar-more svg { transition: transform var(--ran-motion-duration-base, 0.2s) var(--ran-motion-ease-spring, ease); @@ -565,21 +537,21 @@ } .kicker { display: block; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 12px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--vp-c-text-3); + color: var(--fg-faint); } .sec-head h2 { margin: 12px 0 0; - font-family: var(--vp-font-family-base); + font-family: var(--font-sans); font-size: clamp(26px, 3.6vw, 38px); font-weight: 680; letter-spacing: -0.025em; line-height: 1.16; - color: var(--vp-c-text-1); + color: var(--fg); border: 0; padding: 0; } @@ -588,22 +560,25 @@ max-width: 560px; font-size: 15px; line-height: 1.65; - color: var(--vp-c-text-2); + color: var(--fg-muted); } /* ---------- capabilities bento ---------- */ .bento { display: grid; grid-template-columns: 1fr 1fr; - gap: 1px; - background: var(--hairline); - border: 1px solid var(--hairline); - border-radius: 14px; - overflow: hidden; + /* The internal rule is the only boundary these need — the section heading above and + the row hairlines inside already say where this starts and how it is divided. An + outer frame was a third statement of the same thing. */ + gap: 0; + border-top: 1px solid var(--rule); } .caps-col { - padding: 28px 30px; - background: var(--vp-c-bg); + padding: 28px 30px 28px 0; +} +.caps-col + .caps-col { + padding-inline: 30px 0; + border-inline-start: 1px solid var(--rule); } .caps-col-head { display: flex; @@ -614,17 +589,17 @@ border-bottom: 1px solid var(--hairline); } .caps-lib { - font-family: var(--vp-font-family-base); + font-family: var(--font-sans); font-size: 18px; font-weight: 660; letter-spacing: -0.01em; - color: var(--vp-c-text-1); + color: var(--fg); } .caps-lib-tag { - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; - color: var(--vp-c-text-3); + color: var(--fg-faint); text-transform: uppercase; letter-spacing: 0.08em; } @@ -646,7 +621,7 @@ margin-top: 2px; width: 20px; height: 20px; - color: var(--vp-c-text-3); + color: var(--fg-faint); } .caps-ico :deep(svg) { width: 20px; @@ -661,75 +636,76 @@ .caps-name { font-size: 14px; font-weight: 600; - color: var(--vp-c-text-1); + color: var(--fg); } .caps-name code { margin-left: 4px; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; - color: var(--vp-c-text-3); - background: var(--vp-c-bg-alt); + color: var(--fg-faint); + background: var(--bg-soft); padding: 1px 6px; border-radius: 5px; } .caps-desc { font-size: 13px; line-height: 1.55; - color: var(--vp-c-text-2); + color: var(--fg-muted); } /* ---------- get started ---------- */ .panel { display: grid; grid-template-columns: 1fr 1fr; - gap: 1px; - background: var(--hairline); - border: 1px solid var(--hairline); - border-radius: 14px; - overflow: hidden; + /* The internal rule is the only boundary these need — the section heading above and + the row hairlines inside already say where this starts and how it is divided. An + outer frame was a third statement of the same thing. */ + gap: 0; + border-top: 1px solid var(--rule); } .code-cell { display: flex; flex-direction: column; - background: var(--vp-c-bg); +} +.code-cell + .code-cell { + border-inline-start: 1px solid var(--rule); } .code-head { - padding: 12px 20px; - font-family: var(--vp-font-family-mono); + padding: 12px 20px 12px 0; + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--vp-c-text-2); - border-bottom: 1px solid var(--hairline); - background: var(--vp-c-bg-alt); + color: var(--fg-muted); + border-bottom: 1px solid var(--rule); } .snippet { flex: 1; margin: 0; padding: 20px 22px; - font-family: var(--vp-font-family-mono); + font-family: var(--font-mono); font-size: 13px; line-height: 1.75; overflow-x: auto; - color: var(--vp-c-text-1); + color: var(--fg); } .snippet .c-com { - color: var(--vp-c-text-3); + color: var(--fg-faint); } .snippet .c-kw { - color: var(--vp-c-text-1); + color: var(--fg); font-weight: 600; } .snippet .c-str { - color: var(--vp-c-text-2); + color: var(--fg-muted); } .snippet .c-tag { - color: var(--vp-c-text-1); + color: var(--fg); } .snippet .c-attr { - color: var(--vp-c-text-3); + color: var(--fg-faint); } /* ---------- feature strip ---------- */ @@ -743,7 +719,7 @@ border-bottom: 1px solid var(--hairline); } .feature { - background: var(--vp-c-bg); + background: var(--bg); padding: 24px 24px; } .feature-head { @@ -755,7 +731,7 @@ flex: 0 0 auto; width: 18px; height: 18px; - color: var(--vp-c-text-3); + color: var(--fg-faint); } .feature-icon :deep(svg) { width: 18px; @@ -765,13 +741,13 @@ margin: 0; font-size: 14px; font-weight: 600; - color: var(--vp-c-text-1); + color: var(--fg); } .feature p { margin: 8px 0 0; font-size: 13px; line-height: 1.55; - color: var(--vp-c-text-2); + color: var(--fg-muted); } /* ---------- responsive ---------- */ From 38e8ad07b76d32050e83678b6c32ac022e0b5603 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 23:16:21 +0800 Subject: [PATCH 10/14] refactor(docs): put code on the page's spine, and retire the badge images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/build/components.ts | 2 + packages/docs/build/package-facts.ts | 87 ++++++++++++++++++++++++++++ packages/docs/cn/src/ranui/index.md | 11 +--- packages/docs/de/src/ranui/index.md | 11 +--- packages/docs/es/src/ranui/index.md | 11 +--- packages/docs/fa/src/ranui/index.md | 11 +--- packages/docs/ja/src/ranui/index.md | 11 +--- packages/docs/ko/src/ranui/index.md | 11 +--- packages/docs/pt/src/ranui/index.md | 11 +--- packages/docs/src/ranui/index.md | 11 +--- packages/docs/styles/docs.css | 60 ++++++++++++++++++- 11 files changed, 170 insertions(+), 67 deletions(-) create mode 100644 packages/docs/build/package-facts.ts diff --git a/packages/docs/build/components.ts b/packages/docs/build/components.ts index a18986c70..b36ebfac0 100644 --- a/packages/docs/build/components.ts +++ b/packages/docs/build/components.ts @@ -16,6 +16,7 @@ * the old way while this renders them the new way, from one source. */ import { homeCopy } from './langs/home-copy.ts'; +import { renderPackageFacts } from './package-facts.ts'; import { demoCopy } from './langs/demo-copy.ts'; import { localeHref } from './langs/locales.ts'; import type { LocaleDef } from './config.ts'; @@ -25,6 +26,7 @@ import { resolveLinkFrom } from './links.ts'; /** The component hooks the markdown renderer is configured with. */ export const componentRenderers = { HomeCinematic: () => renderHome(currentLocale()), + PackageFacts: (attrs: string) => renderPackageFacts(attrs), GlassPlayground: () => renderGlassPlayground(currentLocale()), IconGallery: () => renderIconGallery(currentLocale()), Loading: () => renderLoadingGallery(), diff --git a/packages/docs/build/package-facts.ts b/packages/docs/build/package-facts.ts new file mode 100644 index 000000000..8cd997813 --- /dev/null +++ b/packages/docs/build/package-facts.ts @@ -0,0 +1,87 @@ +/** + * Package facts, read from the workspace at build time. + * + * These replaced a row of five shields.io images. The images 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 at least one of them was wrong: the badge labelled + * `brotli` reported 3.8 KB, which is the *raw* size of `dist/index.js`. + * + * Only facts that can be proved from the repository are stated. The CI-status and + * download-count badges needed a network call and are gone; a size figure is gone too, + * 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. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface PackageFacts { + name: string; + version: string; + license: string; + formats: string[]; + npm: string; + source: string; +} + +// Resolved here rather than imported from `build.ts`: that module imports the renderers, +// which import this one, and a top-level `join(ROOT, '..')` then runs before `ROOT` is +// initialised — `ReferenceError: Cannot access 'ROOT' before initialization`. +const PACKAGES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const cache = new Map(); + +export const readPackageFacts = (name: string): PackageFacts => { + const cached = cache.get(name); + if (cached) return cached; + + const dir = join(PACKAGES_DIR, name); + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { + version?: string; + license?: string; + exports?: Record; + }; + + // Formats are claimed only where the repository proves them: the exports map for + // esm/cjs, and a built directory for iife. + const root = (pkg.exports?.['.'] ?? {}) as Record; + const formats: string[] = []; + if (root.import) formats.push('esm'); + if (root.require) formats.push('cjs'); + if (existsSync(join(dir, 'dist', 'iife'))) formats.push('iife'); + + const facts: PackageFacts = { + name, + version: pkg.version ?? '', + license: pkg.license ?? '', + formats, + npm: `https://www.npmjs.com/package/${name}`, + source: `https://github.com/chaxus/ran/tree/main/packages/${name}`, + }; + cache.set(name, facts); + return facts; +}; + +const ATTR = /package="([a-z0-9-]+)"/i; + +/** + * One typographic line, not a grid of labelled cells. + * + * Every item is a value that reads the same in all eight languages — a version, a licence + * identifier, format names, a repository path — so this needs no translated chrome and + * `check:langs` has nothing new to enforce. + */ +export const renderPackageFacts = (attrs: string): string => { + const name = ATTR.exec(attrs)?.[1]; + if (!name) throw new Error(' needs a package="" attribute'); + const f = readPackageFacts(name); + + const items = [ + `v${f.version}`, + f.license && `${f.license}`, + f.formats.length && `${f.formats.join(' · ')}`, + `packages/${f.name}`, + ].filter(Boolean); + + return `

${items.join('')}

`; +}; diff --git a/packages/docs/cn/src/ranui/index.md b/packages/docs/cn/src/ranui/index.md index b54c1aa86..7a8d3738c 100644 --- a/packages/docs/cn/src/ranui/index.md +++ b/packages/docs/cn/src/ranui/index.md @@ -8,14 +8,9 @@ description: 'ranui 是基于原生自定义元素()的 Web Components Svelte、Solid、Astro 乃至一个纯 HTML 文件里,用法完全一样:不需要适配层,也不用操心框架版本。 TypeScript 类型、基于设计令牌的明暗主题、Shadow DOM 封装和服务端渲染都是内置的。 -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**:`ranui` · - **源码**:`packages/ranui` + + + - ranui 仍处于 **alpha** 阶段,版本之间可能有破坏性变更。请锁定具体版本号,升级前先读 [更新日志](/cn/src/ranui/changelog)。 diff --git a/packages/docs/de/src/ranui/index.md b/packages/docs/de/src/ranui/index.md index 6041e5a21..4157d9ae3 100644 --- a/packages/docs/de/src/ranui/index.md +++ b/packages/docs/de/src/ranui/index.md @@ -10,14 +10,9 @@ Es gibt keinen Adapter und keine Framework-Version, die zusammenpassen müsste. helles und dunkles Theme über Design-Tokens, Kapselung per Shadow DOM und Server-Rendering sind enthalten. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **Quelltext**: `packages/ranui` + + + - ranui ist **Alpha**: Versionen bringen Breaking Changes mit. Pinne eine exakte Version und lies vor dem Upgrade das [Änderungsprotokoll](/de/src/ranui/changelog). diff --git a/packages/docs/es/src/ranui/index.md b/packages/docs/es/src/ranui/index.md index bc3098cd5..a6173077c 100644 --- a/packages/docs/es/src/ranui/index.md +++ b/packages/docs/es/src/ranui/index.md @@ -9,14 +9,9 @@ Una biblioteca de UI construida sobre **custom elements nativos**. Cada componen No hay adaptador ni versión de framework que hacer coincidir. Incluye tipos TypeScript, tema claro y oscuro mediante design tokens, encapsulación con Shadow DOM y renderizado en servidor. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **código**: `packages/ranui` + + + - ranui está en **alfa**: las versiones traen cambios incompatibles. Fija una versión exacta y lee el [registro de cambios](/es/src/ranui/changelog) antes de actualizar. diff --git a/packages/docs/fa/src/ranui/index.md b/packages/docs/fa/src/ranui/index.md index 3a2b80c5a..cdcff975d 100644 --- a/packages/docs/fa/src/ranui/index.md +++ b/packages/docs/fa/src/ranui/index.md @@ -9,14 +9,9 @@ description: 'ranui کتابخانهٔ رابط کاربری Web Components اس آداپتوری در کار است و نه نسخه‌ای از فریم‌ورک که باید با آن جور دربیاید. تایپ‌های TypeScript، پوستهٔ روشن و تیره بر پایهٔ design token، کپسوله‌سازی با Shadow DOM و رندر سمت سرور از همان ابتدا هستند. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **کد منبع**: `packages/ranui` + + + - ranui در مرحلهٔ **alpha** است: نسخه‌ها تغییرات ناسازگار دارند. نسخهٔ دقیق را قفل کنید و پیش از ارتقا [فهرست تغییرات](/fa/src/ranui/changelog) را بخوانید. diff --git a/packages/docs/ja/src/ranui/index.md b/packages/docs/ja/src/ranui/index.md index 0fc3db7a9..e881294b3 100644 --- a/packages/docs/ja/src/ranui/index.md +++ b/packages/docs/ja/src/ranui/index.md @@ -10,14 +10,9 @@ React でも Vue でも Svelte でも Solid でも Astro でも、素の HTML デザイントークンによるライト/ダークテーマ、Shadow DOM によるカプセル化、サーバーレンダリングを 最初から備えています。 -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **ソース**: `packages/ranui` + + + - ranui は **alpha** です。バージョンには破壊的変更が入ります。バージョンを正確に固定し、 アップグレード前に[更新履歴](/ja/src/ranui/changelog)を読んでください。 diff --git a/packages/docs/ko/src/ranui/index.md b/packages/docs/ko/src/ranui/index.md index 13db3230c..304ee175a 100644 --- a/packages/docs/ko/src/ranui/index.md +++ b/packages/docs/ko/src/ranui/index.md @@ -9,14 +9,9 @@ React, Vue, Svelte, Solid, Astro, 혹은 순수 HTML 파일에서 똑같이 동 맞춰야 할 프레임워크 버전도 없습니다. TypeScript 타입, 디자인 토큰 기반 라이트/다크 테마, Shadow DOM 캡슐화, 서버 렌더링이 기본으로 들어 있습니다. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **소스**: `packages/ranui` + + + - ranui 는 **alpha**입니다. 버전마다 호환성을 깨는 변경이 들어갑니다. 정확한 버전을 고정하고, 업그레이드 전에 [변경 이력](/ko/src/ranui/changelog)을 읽으세요. diff --git a/packages/docs/pt/src/ranui/index.md b/packages/docs/pt/src/ranui/index.md index 63fe81be8..d0f6616a6 100644 --- a/packages/docs/pt/src/ranui/index.md +++ b/packages/docs/pt/src/ranui/index.md @@ -9,14 +9,9 @@ Uma biblioteca de UI construída sobre **custom elements nativos**. Cada compone puro. Não há adaptador nem versão de framework para casar. Tipos TypeScript, tema claro e escuro por design tokens, encapsulamento com Shadow DOM e renderização no servidor já vêm incluídos. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **código**: `packages/ranui` + + + - O ranui está em **alfa**: as versões trazem mudanças incompatíveis. Fixe uma versão exata e leia o [registro de alterações](/pt/src/ranui/changelog) antes de atualizar. diff --git a/packages/docs/src/ranui/index.md b/packages/docs/src/ranui/index.md index 4deabc6d6..3e6314731 100644 --- a/packages/docs/src/ranui/index.md +++ b/packages/docs/src/ranui/index.md @@ -9,14 +9,9 @@ in React, Vue, Svelte, Solid, Astro or a plain HTML file the same way. There is no framework version to match. TypeScript types, light/dark theming through design tokens, Shadow DOM encapsulation and server rendering are included. -Build Status -npm-v -npm-d -brotli -module formats: umd, esm - -- **npm**: `ranui` · - **source**: `packages/ranui` + + + - ranui is **alpha**: versions ship breaking changes. Pin an exact version and read the [changelog](/src/ranui/changelog) before upgrading. diff --git a/packages/docs/styles/docs.css b/packages/docs/styles/docs.css index c2cb8f25b..a251e06ff 100644 --- a/packages/docs/styles/docs.css +++ b/packages/docs/styles/docs.css @@ -470,6 +470,42 @@ a { } } +/* + * Package facts — one line, the site's own voice. + * + * This replaced five shields.io images: the only saturated colour on the page, in a + * visual language belonging to nobody, fetched from two external hosts on every view. + * Mono at label size, separated by the same hairline the stats row uses, so it reads as + * metadata rather than as decoration competing with the title above it. + */ +.prose .pkg-facts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + margin: 0; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--fg-muted); +} +.pkg-facts__item { + padding: var(--ran-space-1, 4px) var(--ran-space-3, 12px); + border-inline-start: 1px solid var(--rule); +} +.pkg-facts__item:first-child { + padding-inline-start: 0; + border-inline-start: none; +} +.prose a.pkg-facts__item { + color: var(--fg); + text-decoration: none; +} +.prose a.pkg-facts__item:hover { + color: var(--link); + text-decoration: underline; + text-underline-offset: 3px; +} + /* ── Layout ──────────────────────────────────────────────────────────────── */ .layout { @@ -813,9 +849,18 @@ body:has(.drawer__toggle:checked) .drawer__scrim { * own outline. A reference page alternating bordered demo, bordered code, bordered demo * down its whole length is what makes a documentation page tiring to read. */ +/* + * Code sits on the page's alignment spine, and the hairlines are the only boundary. + * + * The fill was a second delimiter doing the same job as the rules, which is what made a + * page of examples read as a stack of grey slabs — and it is the most repeated element on + * the site, so it set the tone everywhere. Dropping it also lets the horizontal padding + * go, which is what mattered: code was inset 20px while every paragraph and heading + * started at 328px, so no code block on the site stood on the same spine as the prose + * around it. Mono type and syntax colour distinguish code perfectly well on their own. + */ .prose figure.code { position: relative; - background: var(--bg-code); border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } @@ -823,7 +868,7 @@ body:has(.drawer__toggle:checked) .drawer__scrim { content: attr(data-lang); position: absolute; top: 8px; - inset-inline-end: 12px; + inset-inline-end: 0; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; @@ -833,7 +878,7 @@ body:has(.drawer__toggle:checked) .drawer__scrim { } .prose figure.code pre { margin: 0; - padding: 18px 20px; + padding: 18px 0; overflow-x: auto; font-family: var(--font-mono); font-size: 13.5px; @@ -990,6 +1035,15 @@ ran-demo:has(+ figure.code) { */ margin-bottom: calc(-1 * var(--prose-gap)); } +/* Inside a box, code is inset again — the spine it aligns to is the box's, not the + page's. Only a standalone fence stands on the prose spine. */ +/* `.prose figure.code pre` is (0,2,2); these have to out-specify it or the inset silently + loses and the code sits flush against the box edge. */ +.prose ran-demo + figure.code pre, +.prose .code-group figure.code pre { + padding-inline: 20px; +} + ran-demo + figure.code { margin-top: 0; border: 1px solid var(--rule); From 094da67066ab009395f52577ebff9837263d1b57 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 23:21:47 +0800 Subject: [PATCH 11/14] fix(ranpress): a hard-wrapped CJK paragraph must not gain a space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/ranpress/src/markdown.ts | 35 ++++++++++++++++++ packages/ranpress/test/markdown-cjk.test.ts | 41 +++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/ranpress/test/markdown-cjk.test.ts diff --git a/packages/ranpress/src/markdown.ts b/packages/ranpress/src/markdown.ts index 0e47ad239..b6c71211b 100644 --- a/packages/ranpress/src/markdown.ts +++ b/packages/ranpress/src/markdown.ts @@ -293,6 +293,25 @@ const plainText = (tokens: Token[] | undefined): string => { */ const CUSTOM_ANCHOR = /\{#([A-Za-z0-9_-]+)\}[ \t]*$/; +/* + * A hard-wrapped CJK paragraph must not gain a space where the line broke. + * + * markdown-it and marked both turn a single newline into a literal `\n`, which the + * browser then collapses to a space — fine between two Latin words, wrong between two + * Chinese characters, where it renders as `分 就`. Source files that wrap CJK prose at a + * sensible column would otherwise be full of stray spaces, and the only workarounds are + * to stop wrapping or to proofread the output. + * + * The break is kept when either side is Latin, a digit, code or punctuation — those + * already read as separate words and the source style spaces them. Lookahead rather than + * a captured second character, so `甲\n乙\n丙` collapses at both breaks. + */ +const CJK = + '\\u2e80-\\u2fdf\\u3000-\\u303f\\u3041-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\ufe30-\\ufe4f\\uff00-\\uffef'; +const CJK_SOFTBREAK = new RegExp(`([${CJK}])\\n(?=[${CJK}])`, 'g'); + +export const collapseCjkSoftbreaks = (text: string): string => text.replace(CJK_SOFTBREAK, '$1'); + export const stripCustomAnchor = (text: string): string => text.replace(CUSTOM_ANCHOR, '').trimEnd(); // ── Custom containers ─────────────────────────────────────────────────────── @@ -553,6 +572,22 @@ export const createMarkdown = ({ components, ); const tokens = marked.lexer(source); + /* + * Walked here, not registered through `marked.use({ walkTokens })`. + * + * That hook only runs inside `marked.parse()`. This renderer drives the lexer and + * the parser separately — it needs the token tree for the outline, the sections and + * the excerpt — so a registered `walkTokens` is silently never called. It was, and + * the CJK fix below appeared to do nothing at all. + * + * Leaf text tokens only: fenced code and inline code are their own token types, so + * a newline inside them is never reached from here. + */ + marked.walkTokens(tokens, (token) => { + if (token.type === 'text' && !token.tokens) { + token.text = collapseCjkSoftbreaks(token.text); + } + }); const html = marked.parser(tokens) as string; let consumed = 0; const nextSlug = (): string => headings[consumed++] ?? ''; diff --git a/packages/ranpress/test/markdown-cjk.test.ts b/packages/ranpress/test/markdown-cjk.test.ts new file mode 100644 index 000000000..6e165cb53 --- /dev/null +++ b/packages/ranpress/test/markdown-cjk.test.ts @@ -0,0 +1,41 @@ +/** + * A hard-wrapped CJK paragraph must not gain a space where the line broke. + * + * This regressed once already: the VitePress build overrode `softbreak` to drop the + * newline between two CJK characters, the replacement did not, and 114 stray spaces + * appeared across the Chinese pages of the first 120 files alone — invisible in the + * markdown, visible in every rendered paragraph as `分 就`. + */ +import { describe, expect, it } from 'vitest'; +import { collapseCjkSoftbreaks } from '../src/markdown.ts'; + +describe('collapseCjkSoftbreaks', () => { + it('drops the break between two Chinese characters', () => { + expect(collapseCjkSoftbreaks('这一部分\n就是这样')).toBe('这一部分就是这样'); + }); + + it('drops it after CJK punctuation too', () => { + expect(collapseCjkSoftbreaks('很好,\n它会工作')).toBe('很好,它会工作'); + }); + + it('collapses every break in a run, not just the first', () => { + expect(collapseCjkSoftbreaks('甲\n乙\n丙')).toBe('甲乙丙'); + }); + + it('keeps the break when either side is Latin', () => { + // The source style already spaces these, so removing it would join two words. + expect(collapseCjkSoftbreaks('框架版本。\nTypeScript 类型')).toBe('框架版本。\nTypeScript 类型'); + expect(collapseCjkSoftbreaks('React、Vue、\nSvelte')).toBe('React、Vue、\nSvelte'); + expect(collapseCjkSoftbreaks('one\ntwo')).toBe('one\ntwo'); + }); + + it('handles Japanese kana and full-width forms', () => { + expect(collapseCjkSoftbreaks('コンポーネント\nです')).toBe('コンポーネントです'); + }); + + it('leaves text without a break alone', () => { + expect(collapseCjkSoftbreaks('一个建立在原生自定义元素之上的组件库')).toBe( + '一个建立在原生自定义元素之上的组件库', + ); + }); +}); From 386a93317eb88a93534befd347fca188ded2fccb Mon Sep 17 00:00:00 2001 From: chaxus Date: Sat, 12 Sep 2026 23:25:56 +0800 Subject: [PATCH 12/14] docs(docs): rewrite CLAUDE.md for the stack that actually exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 `` 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) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/docs/CLAUDE.md | 316 ++++++++++++++----------- packages/docs/bin/check-langs.ts | 10 +- packages/docs/build/build.ts | 11 +- packages/docs/build/components.ts | 5 +- packages/docs/build/langs/demo-copy.ts | 7 +- packages/docs/build/langs/locales.ts | 18 +- packages/docs/build/langs/structure.ts | 2 +- packages/docs/build/langs/types.ts | 4 +- packages/docs/build/nav.ts | 6 +- 9 files changed, 209 insertions(+), 170 deletions(-) diff --git a/packages/docs/CLAUDE.md b/packages/docs/CLAUDE.md index 83c1bb9ce..7cda2c348 100644 --- a/packages/docs/CLAUDE.md +++ b/packages/docs/CLAUDE.md @@ -1,39 +1,40 @@ # packages/docs — the documentation site -VitePress 2.x (alpha) site published to **https://ran.chaxus.com** via Cloudflare Pages. -Eight languages: English under `src/`, every other locale under `/src/` (`cn`, `ja`, `es`, -`pt`, `ko`, `de`, `fa`), mirrored page for page. `.vitepress/langs/locales.ts` is the registry — -adding a language means a row there, not a new hard-coded list somewhere else, and +Published to **https://ran.chaxus.com** via Cloudflare Pages. 1,393 pages, eight languages: +English under `src/`, every other locale under `/src/` (`cn`, `ja`, `es`, `pt`, `ko`, +`de`, `fa`), mirrored page for page. `build/langs/locales.ts` is the registry — adding a +language means a row there, not a new hard-coded list somewhere else, and `pnpm -F docs check:langs` fails on anything a new locale did not get. -Most of what is non-obvious here is not VitePress — it is the SEO/GEO machinery, the -generated pages, and the Service Worker. Read the relevant section before changing any of it; -each carries a failure mode that is silent. +The site is generated by **ranpress** (`packages/ranpress`), which supplies the mechanism — +frontmatter, markdown, discovery, writing, verification, the servers. Everything in `build/` +is this site's *policy*: what a page is, what URL it gets, what it looks like. There is no +VitePress, no Vue and no hydration; if you find a note anywhere claiming otherwise, it is +older than the migration. -> **Stale below this line.** This file still describes the VitePress/Vue stack. The site -> now runs on **ranpress** (`packages/ranpress`) with its policy in `build/`; there is no -> `.vitepress/`, no Vue and no hydration. The locale registry moved to -> `build/langs/locales.ts`. Treat any VitePress-specific instruction here as historical -> until this file is rewritten. +Most of what is non-obvious here is not the generator — it is the SEO/GEO machinery, the +generated pages, and the Service Worker. Read the relevant section before changing any of +it; each carries a failure mode that is silent. --- ## Commands ```sh -pnpm -F docs dev # http://localhost:4173 — build, watch the eight prose trees, serve -pnpm -F docs build # generate into dist/, then verify -pnpm -F docs preview # http://localhost:4174 — serve the built dist/, builds nothing +pnpm -F docs dev # http://localhost:4173 — build, watch the eight prose trees, serve +pnpm -F docs build # generate into dist/, verify, then assemble llms-full.txt and sw.js +pnpm -F docs preview # http://localhost:4174 — serve the built dist/, builds nothing +pnpm -F docs verify # re-run the post-build checks against an existing dist/ +pnpm -F docs check:langs # every locale has every page, and every copy table has every locale +pnpm -F docs verify:design # the ranui design rules, over this site's stylesheets ``` -`dev` and `preview` resolve URLs the way Cloudflare Pages does, **including its redirects**: -`/src/ranui/button` is a 308 to `/src/ranui/button/` when the page is a directory index, and -a direct 200 when it is a leaf. That fidelity is the point — a generic static server hides -exactly the mismatch that once shipped 904 canonicals naming a URL the host bounces. See -`packages/ranpress/README.md` for the resolution table. - -Reach for `preview` before a deploy: it serves the real built bytes and nothing else, so a -page that only works because the dev server just rebuilt it has nowhere to hide. +`dev` and `preview` resolve URLs the way Cloudflare Pages does, **including its redirects**. +That fidelity is the point: a generic static server hides exactly the mismatch that once +shipped 904 canonicals naming a URL the host bounces. The resolution table is in +`packages/ranpress/README.md`. Reach for `preview` before a deploy — it serves the real +built bytes and nothing else, so a page that only works because the dev server just rebuilt +it has nowhere to hide. --- @@ -41,103 +42,135 @@ page that only works because the dev server just rebuilt it has nowhere to hide. ``` packages/docs/ -├── .vitepress/ -│ ├── config.ts # SEO: canonical, hreflang, per-page description, JSON-LD, sitemap -│ ├── common/index.ts # shared constants + the inline