diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000000..ef9039ea38 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "ran", + "interface": { + "displayName": "ran" + }, + "plugins": [ + { + "name": "ran", + "source": { + "source": "local", + "path": "./plugins/ran" + }, + "policy": { + "installation": "AVAILABLE" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 51e761afe3..e57a6afdb9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,23 +4,15 @@ "name": "chaxus", "url": "https://github.com/chaxus/ran" }, - "description": "Skills for the ran monorepo libraries — ranui (web components) and ranuts (utilities) — so AI can read and use them without spelunking the source. Install whichever you use.", + "description": "Product design methods and version-aware ranui and ranuts skills in one plugin.", "metadata": { "pluginRoot": "./plugins" }, "plugins": [ { - "name": "ranui", - "source": "./plugins/ranui", - "description": "Build, review and polish ranui UI: task-to-rule lookup, public contracts and browser acceptance.", - "author": { - "name": "chaxus" - } - }, - { - "name": "ranuts", - "source": "./plugins/ranuts", - "description": "ranuts utility library: import map (utils/node/visual/vnode), ranuts/utils inventory, conventions.", + "name": "ran", + "source": "./plugins/ran", + "description": "Product design methods and version-aware ranui and ranuts skills in one plugin.", "author": { "name": "chaxus" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ec38a59d8..1ea52a2fd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: paths: - "packages/**" - "bin/**" + - "tsconfig.tooling.json" + - "plugins/**" + - "design/**" + - ".agents/plugins/**" + - ".claude-plugin/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -17,6 +22,11 @@ on: paths: - "packages/**" - "bin/**" + - "tsconfig.tooling.json" + - "plugins/**" + - "design/**" + - ".agents/plugins/**" + - ".claude-plugin/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -61,6 +71,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Type-check repository tooling + run: pnpm typecheck:tooling + - name: Verify repository scripts run: pnpm run verify:scripts @@ -146,7 +159,7 @@ jobs: pnpm release:prepare pnpm verify:release - # `pnpm run test` reaches ranui through `run-checks.mjs`, which runs the package's + # `pnpm run test` reaches ranui through `run-checks.ts`, which runs the package's # own `test` script. That script runs `vitest run`; the coverage gate used to be a # separate step that ran `vitest run --coverage` afterwards, executing the same # suite a second time to produce one report. Asking for coverage up front makes the diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8c29d1d61b..83e541729f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,6 +6,9 @@ on: paths: - "packages/docs/**" - "packages/ranui/**" + - "plugins/ran/**" + - "design/**" + - ".agents/plugins/**" - "packages/ranuts/**" - "packages/ranview/**" - "packages/im/**" @@ -18,6 +21,9 @@ on: paths: - "packages/docs/**" - "packages/ranui/**" + - "plugins/ran/**" + - "design/**" + - ".agents/plugins/**" - "packages/ranuts/**" - "packages/ranview/**" - "packages/im/**" @@ -73,6 +79,10 @@ jobs: - name: Build ranuts run: pnpm --filter ranuts run build + - name: Verify agent-evaluation browser oracles (no model calls) + if: matrix.browser == 'chromium' + run: pnpm verify:agent-evals + - name: Build ranui run: pnpm --filter ranui run build @@ -120,7 +130,7 @@ jobs: # The launcher installs/builds the three required workspaces inside the same # digest-pinned renderer used for local baseline updates. Missing PNGs fail. - name: Compare reviewed screenshot baselines - run: node packages/ranui/bin/visual.mjs check + run: node packages/ranui/bin/visual.ts check - name: Upload visual differences and environment if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe98cc1011..8803e4beea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,8 @@ jobs: pnpm verify:links pnpm -F docs check:langs pnpm verify:design - node bin/run-checks.mjs tsc + pnpm typecheck:tooling + node bin/run-checks.ts tsc - name: Test libraries and applications run: pnpm test @@ -119,7 +120,7 @@ jobs: path: .release/ - name: Publish verified tarballs using npm trusted publishing - run: node bin/release.mjs publish --tag "$RELEASE_TAG" --confirm + run: node bin/release.ts publish --tag "$RELEASE_TAG" --confirm - name: Preserve partial or complete publication results if: always() diff --git a/.prettierignore b/.prettierignore index 9ee76bf63d..0c68930e08 100644 --- a/.prettierignore +++ b/.prettierignore @@ -43,3 +43,13 @@ packages/ranui/docs/style-tokens-public.md # The next-generation docs build's output, written alongside VitePress's during the # migration so the two can be compared. Generated, never edited. packages/docs/dist + +# Agent reference bytes are generated from formatted canonical design sources and hash-bound. +plugins/ran/skills/design/references/ + +# Captured evidence JSON is an immutable measurement record. Some manifests hash +# neighboring JSON byte-for-byte; formatting those records would invalidate provenance. +packages/*/test/evidence/**/*.json + +# Preserve raw Playwright failure snapshots, including the original whitespace. +packages/*/test/evidence/**/error-context.md diff --git a/AGENTS.md b/AGENTS.md index c6298ec04e..b1f2dfd24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,16 @@ Start with the owner of the change; do not load every reference or introduce a second standard. +For product positioning and website ownership, start with [PRODUCT.md](PRODUCT.md). +`packages/docs` owns ran.chaxus.com; `packages/ranpress` owns its build mechanism. + | Work | Read first | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Visual hierarchy, composition, tokens and craft | [ranui design standard](packages/ranui/docs/DESIGN.md) | -| Input, focus, drafts, feedback and recovery | [interaction standard](packages/ranui/docs/INTERACTION.md) | -| Optional batch/queue, settings, account/data or revision workflow | [workflow patterns](packages/ranui/docs/WORKFLOW_PATTERNS.md), only when relevant | -| Unsure which reference owns a decision | Short [interface index](packages/ranui/docs/INTERFACE_GUIDE.md) | +| Visual hierarchy, composition, tokens and craft | [portable design methods](design/DESIGN.md), then [ranui implementation adapter](packages/ranui/docs/adapters/DESIGN.md) | +| Browser proof, evidence freshness and acceptance status | [acceptance contract](design/ACCEPTANCE.md) | +| Input, focus, drafts, feedback and recovery | [interaction standard](design/INTERACTION.md) | +| Optional batch/queue, settings, account/data or revision workflow | [workflow patterns](design/WORKFLOW_PATTERNS.md), only when relevant | +| Unsure which reference owns a decision | Short [interface index](design/INTERFACE_GUIDE.md) | | Component state, events, lifecycle or SSR | [engineering standard](packages/ranui/docs/CODING.md), then [implementation reference](packages/ranui/CLAUDE.md) | | Exact attributes, events, slots, parts or theme variables | Generated [component API](packages/ranui/docs/COMPONENTS.md) and [public tokens](packages/ranui/docs/style-tokens-public.md); verify against source | | Public documentation or localized prose | [documentation site reference](packages/docs/CLAUDE.md) | @@ -17,6 +21,11 @@ Start with the owner of the change; do not load every reference or introduce a s do not create a parallel palette or copy one application's dimensions into library defaults. The index routes decisions; workflow recipes describe host responsibilities, not extra ranui APIs. +Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining. +Keep copy that adds a decision, constraint or consequence; preserve accessible names, field labels, +units, errors and risk disclosures. [DESIGN's wording rules](design/DESIGN.md#words-are-part-of-the-interface) +own this principle; do not remove useful guidance merely to make a screenshot quieter. + For visual changes, use the [UI task lookup](packages/ranui/docs/INTERFACE_GUIDE.md#ui-task-lookup) to find exact owner sections and an evidence brief. Read only applicable owner sections; reuse existing controls and local token mappings. Stop when @@ -35,3 +44,8 @@ The local scope below does not weaken the complete repository CI gates: Public ranui guidance has English and Chinese entry points under `packages/docs/src/ranui/` and `packages/docs/cn/src/ranui/`. Link to canonical owners instead of duplicating long rules. + +Shared methods are edited in `design/`; ranui-specific guidance in `packages/ranui/docs/adapters/`. +Run `pnpm sync:design` and `pnpm sync:agent-plugin` after changing them. Generated npm guides +and bundled plugin references are checked distributions, not additional rule owners. +The unified `plugins/ran` entry has three skills; read only the one relevant to the task. diff --git a/PRODUCT.md b/PRODUCT.md index 42684b9348..98598b6c25 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,33 +1,31 @@ -# Product +# ran -## Register +ran provides design methods, UI components and TypeScript utilities for developers and agents building web products. -brand +## Three pillars -## Users +| Pillar | Responsibility | Delivery | +| ---------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| Ran Design | Product composition, coherent visual language, interaction, motion and evidence-based acceptance | Portable skills, agent plugin and public guidance | +| ranui | Accessible custom elements, themes and explicit component behavior | Independently installable npm package | +| ranuts | Typed utilities with explicit runtime boundaries | Independently installable npm package | -Frontend engineers, UI library maintainers, and technical learners evaluating Web Components for real applications. They arrive with a developer mindset: they want to see whether ranui is native, themeable, inspectable, and usable across frameworks before reading full API documentation. +Ran Design must also work in products that do not use ranui. The ranui adapter connects general methods to the installed library's actual tokens and APIs. ranuts remains useful without a UI dependency. -## Product Purpose +`@alixex/ranview` remains an independently published DOM/reactivity foundation. ranpress is private build infrastructure for the website. These implementation roles do not require additional top-level product pillars. -ranui is an experimental Web Components UI library. The demo should introduce the library, prove that the components are real custom elements, and make theme packs, import paths, styling hooks, and representative components easy to inspect in one page. +## Website -## Brand Personality +**https://ran.chaxus.com** is the product website, documentation and live-example destination. Its existing application owner is `packages/docs`; its build mechanism is `packages/ranpress`. Keep one navigation, search and locale system for the three pillars. The personal `chaxus.com` application is retired from this repository; domain and hosting administration are separate. -Precise, experimental, and tool-minded. The interface should feel like a component workbench for engineers, not a generic SaaS landing page or a decorative gallery. +The website should help a visitor understand the product, try a real interaction, choose a library or agent workflow, and reach accurate instructions. Use composed examples with realistic content and complete states. Let the same example connect design rationale, implementation and verification. -## Anti-references +## Ownership and quality -Avoid purple-gradient SaaS templates, oversized hero metrics, vague marketing claims, stock illustrations, and demo pages that look like raw component test fixtures. Avoid hiding the actual components behind screenshots or decorative mockups. +General methodology belongs to a neutral `design/` owner as it is extracted. Package source owns APIs and tokens. Skills route to the applicable references; public pages explain and demonstrate them. Generated distributions may include copies, but those copies must identify their source and cannot become separately edited standards. -## Design Principles +A library consumer should not need the plugin, and a plugin user should not need to install ranui to use general design guidance. Package and plugin releases are independent; library-specific guidance must identify its applicable version. -- Show real components first. -- Treat theme switching as the signature interaction. -- Keep developer next steps visible: install, import, inspect, style. -- Make the page dense enough for scanning without feeling like an API dump. -- Preserve ranui's experimental honesty while making the library feel intentional. +A polished result combines coherent proportions and typography with complete behavior, readable content and purposeful motion. Claims about visuals require current rendered evidence; claims about transitions require continuous recordings. Automated checks, agent review and human acceptance are distinct. -## Accessibility & Inclusion - -Target WCAG AA contrast for page chrome and documentation content. Respect reduced motion, keep touch targets at least 44px on coarse pointers, avoid hover-only functionality, and ensure anchors land below sticky navigation. +The accepted direction and staged implementation are recorded in the [architecture plan](docs/superpowers/plans/2026-10-23-ran-product-architecture.md). The [ranui demo brief](packages/ranui/demo/PRODUCT.md) applies only to its component workbench. diff --git a/README.md b/README.md index fe461278dd..341d5050c2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

ran

-

UI components and TypeScript tools for web applications.

+

Design methods, UI components and TypeScript tools for web products.

Documentation · CI

English · 中文 · 日本語 · Español · Português · 한국어 · Deutsch · فارسی

@@ -49,28 +49,47 @@ Components use custom elements and standard browser events, so no React or Vue r ## Use with an agent -The [ranui skill](./plugins/ranui/skills/ranui/SKILL.md) guides UI implementation, visual -review and browser acceptance. It reads the host's installed ranui references on demand; -it does not install the library or add tools, hooks or external services. +The unified **ran** plugin contains [design](./plugins/ran/skills/design/SKILL.md), +[ranui](./plugins/ran/skills/ranui/SKILL.md) and [ranuts](./plugins/ran/skills/ranuts/SKILL.md) +skills. Design guidance works with the host's existing stack, including projects without ranui. +Library skills read the host's installed package version and public contracts. The plugin bundles +its design references; it needs no MCP server, account or hook. -**Claude Code plugin** — add this repository's marketplace, then install ranui: +For this checkout, register the local marketplace with `codex plugin marketplace add .` +or `claude plugin marketplace add .`, then install `ran@ran`. The remote instructions below +apply once the unified plugin is available on the repository's default branch. + +**Claude Code plugin** — add this repository's marketplace, then install ran: ```sh claude plugin marketplace add chaxus/ran -claude plugin install ranui@ran +claude plugin install ran@ran ``` -Invoke `/ranui:ranui` in a new session. The same marketplace also offers `ranuts` for utility work. +In a new session, use `/ran:design`, `/ran:ranui` or `/ran:ranuts` for the relevant task. + +**Codex plugin** — register the repository marketplace and install the same skill: + +```sh +codex plugin marketplace add chaxus/ran +codex plugin add ran@ran +``` -**Codex skill** — after cloning this repository, copy `plugins/ranui/skills/ranui/` into -your host project's `.agents/skills/ranui/`. Start a new task and invoke `$ranui`. -This uses the same skill body; the `.claude-plugin` manifest is the Claude installation route. -See [Codex skill discovery](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills). +Start a new task and invoke `$design`, `$ranui` or `$ranuts`. For a local checkout, use `codex plugin marketplace add .` +from the repository root. The portable [plugin manifest](./plugins/ran/plugin.json) and +[Codex marketplace](./.agents/plugins/marketplace.json) follow the +[official packaging format](https://developers.openai.com/plugins/build/plugins). +Both installation routes load the same three skill bodies and bundled design references. Example task: “Use ranui to polish this settings pane: keep related text together, align control labels, and verify actual screenshots in light/dark at narrow/wide widths.” -The [UI task lookup](./packages/ranui/docs/INTERFACE_GUIDE.md#ui-task-lookup) maps these -requests to canonical rules and an evidence brief. Source contributors start with [AGENTS.md](./AGENTS.md). +The [UI task lookup](./packages/ranui/docs/INTERFACE_GUIDE.md#ui-task-lookup) routes to one owner: +[design](./packages/ranui/docs/DESIGN.md), [interaction](./packages/ranui/docs/INTERACTION.md), +[public API](./packages/ranui/docs/COMPONENTS.md), or [evidence and decisions](./packages/ranui/docs/ACCEPTANCE.md). +The plugin starts with a short task contract, preserves the host's product decisions and requires +actual rendered evidence for UI claims. Automated checks, agent inspection and human acceptance +remain distinct. [Six opt-in live scenarios](./plugins/ran/evals/README.md) test the guidance's +behavior separately from its token/API checks. Source contributors start with [AGENTS.md](./AGENTS.md). ## Work on the repository diff --git a/README.zh-CN.md b/README.zh-CN.md index 09c06f1fe7..c950aa71ab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,27 +49,42 @@ document.body.append(button); ## 与 agent 一起使用 -[ranui skill](./plugins/ranui/skills/ranui/SKILL.md) 用于界面实现、视觉审查和浏览器验收。 -它按需读取宿主项目已安装的 ranui 规范,不会安装组件库,也不包含工具、hooks 或外部服务。 +统一的 **ran 插件**包含 [design](./plugins/ran/skills/design/SKILL.md)、 +[ranui](./plugins/ran/skills/ranui/SKILL.md) 和 [ranuts](./plugins/ran/skills/ranuts/SKILL.md) +三个 skill。设计方法适用于现有技术栈,无需安装 ranui;库相关指引读取宿主实际安装版本的公开契约。 +插件自带设计参考文件,无需 MCP 服务、账号或 hook。 + +测试当前分支时,在仓库根目录运行 `codex plugin marketplace add .` 或 +`claude plugin marketplace add .`,再安装 `ran@ran`。下面的远程安装方式在统一插件进入默认分支后适用。 **Claude Code 插件**:添加本仓库的 marketplace,再安装 ranui: ```sh claude plugin marketplace add chaxus/ran -claude plugin install ranui@ran +claude plugin install ran@ran ``` -新会话中使用 `/ranui:ranui`。同一 marketplace 也提供用于工具函数任务的 `ranuts`。 +新会话中按任务使用 `/ran:design`、`/ran:ranui` 或 `/ran:ranuts`。 + +**Codex 插件**:注册仓库的 marketplace,安装同一份 skill: -**Codex skill**:克隆本仓库后,将 `plugins/ranui/skills/ranui/` 复制到宿主项目的 -`.agents/skills/ranui/`,新建任务后使用 `$ranui`。两者使用同一份 skill 正文; -`.claude-plugin` 清单用于 Claude 的插件安装。 -参见 [Codex skill 发现规则](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills)。 +```sh +codex plugin marketplace add chaxus/ran +codex plugin add ran@ran +``` -使用示例:“按 ranui 规范优化这个设置侧栏,收拢相关文案、对齐控件文字, -并用明暗主题、窄宽屏的实际截图验收。” -[UI 任务索引](./packages/ranui/docs/INTERFACE_GUIDE.md#ui-task-lookup) 将这些问题直接对应到 -权威规则和验收简表。修改 ran 源码时先读 [AGENTS.md](./AGENTS.md)。 +新任务中使用 `$design`、`$ranui` 或 `$ranuts`。本地检出仓库时,在仓库根目录执行 `codex plugin marketplace add .`。 +[插件清单](./plugins/ran/plugin.json)和 [Codex marketplace](./.agents/plugins/marketplace.json) +遵循[官方打包格式](https://developers.openai.com/plugins/build/plugins)。 +两种安装入口使用同一份 skill 正文,不需要 MCP 服务、额外账号或 hook。 + +使用示例:“用 ranui 优化这个设置侧栏,保留任务和交互,用相关主题、宽度的实际截图验证。” +[UI 任务索引](./packages/ranui/docs/INTERFACE_GUIDE.md#ui-task-lookup)按任务进入唯一规则来源: +[设计](./packages/ranui/docs/DESIGN.md)、[交互](./packages/ranui/docs/INTERACTION.md)、 +[公开 API](./packages/ranui/docs/COMPONENTS.md)和[证据与验收](./packages/ranui/docs/ACCEPTANCE.md)。 +插件先明确简短的任务契约,再保留宿主产品已有决策,并用实际渲染证据支撑界面结论。 +自动检查、agent 视觉审查和用户验收分别记录;[六个可选 agent 场景](./plugins/ran/evals/README.md) +验证指引的实际执行效果,不用 token/API 检查冒充行为验证。源码贡献者先读 [AGENTS.md](./AGENTS.md)。 ## 参与开发 diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000000..94e999a9ff --- /dev/null +++ b/bin/README.md @@ -0,0 +1,20 @@ +# Repository tooling + +Repository-owned scripts are TypeScript ES modules executed directly by the supported +Node.js 24+ runtime. Use explicit `.ts` relative imports and erasable TypeScript syntax; +no loader, emitted copy, path alias or second script runner is required. + +- `pnpm typecheck:tooling` checks strict NodeNext types without emitting files. +- `pnpm verify:scripts` runs isolated cleanup, declarations, licensing, size and release tests. +- `pnpm verify:agent-evals` checks the evaluation harness, including browser negative controls. + +Node strips types when running these scripts; it does **not** check them. Keep the separate +type-check gate in CI. Narrow JSON and caught errors at the boundary before acting on paths, +package metadata or release receipts. `tooling-json.ts` owns the shared small validators. + +Published library output remains JavaScript ESM with declarations. Temporary `.mjs` consumer +fixtures intentionally verify those JavaScript exports without a TypeScript runtime. Vendored +third-party skills and recorded historical evidence retain their original source files. + +Browser evaluation files run serially to avoid competing Vite/browser cold starts. Each file +retains its asynchronous interaction cases, original timeouts, negative controls and zero retries. diff --git a/bin/build-site.sh b/bin/build-site.sh deleted file mode 100755 index bc8c94ba11..0000000000 --- a/bin/build-site.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Build chaxus.com (packages/site). POSIX sh — see packages/docs/bin/build.sh for why. -# -# This is the Cloudflare Pages build command for the personal site, and it mirrors -# bin/build.sh (which builds the documentation site). The site imports ranui's built -# output, so ranui — and ranuts underneath it — have to exist first. -set -eu -npx pnpm -F ranuts build -pnpm -F ranui build -pnpm -F site build diff --git a/bin/build.sh b/bin/build.sh index 8c67e5aa65..d37ffb6c33 100644 --- a/bin/build.sh +++ b/bin/build.sh @@ -4,8 +4,3 @@ pnpm -F ranuts build pnpm -F @alixex/ranview build pnpm -F ranui build pnpm -F docs build -# The site is a deployable artifact too. Its build ends in verify.ts, which fails on a -# dead link, a canonical naming another page, or a page missing from the sitemap — all -# things Cloudflare Pages would otherwise accept and publish. Building it here means CI -# finds that before the deploy does. -pnpm -F site build diff --git a/bin/clean.sh b/bin/clean.sh index c5a6fab2e9..5f17f3607d 100644 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -1,4 +1,4 @@ #!/bin/sh set -eu -exec node "$(dirname "$0")/clean.mjs" +exec node "$(dirname "$0")/clean.ts" diff --git a/bin/clean.mjs b/bin/clean.ts similarity index 88% rename from bin/clean.mjs rename to bin/clean.ts index 50048985d1..65900dcf33 100644 --- a/bin/clean.mjs +++ b/bin/clean.ts @@ -1,10 +1,11 @@ +import { object, hasErrorCode } from './tooling-json.ts'; import { lstatSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; // Resolve from this script so running the shell wrapper elsewhere is safe. const root = fileURLToPath(new URL('../', import.meta.url)); -const manifest = JSON.parse(readFileSync(join(root, 'packages/manifest.json'), 'utf8')); +const manifest = object(JSON.parse(readFileSync(join(root, 'packages/manifest.json'), 'utf8'))); if (!manifest.packages || typeof manifest.packages !== 'object' || Array.isArray(manifest.packages)) { throw new Error('Invalid packages manifest: expected a packages object'); } @@ -32,7 +33,7 @@ for (const owner of owners) { break; } } catch (error) { - if (error.code !== 'ENOENT') throw error; + if (!hasErrorCode(error, 'ENOENT')) throw error; safe = false; break; } diff --git a/bin/component-bundle-size.mjs b/bin/component-bundle-size.ts similarity index 68% rename from bin/component-bundle-size.mjs rename to bin/component-bundle-size.ts index a516be7f13..9dd34c6b5f 100644 --- a/bin/component-bundle-size.mjs +++ b/bin/component-bundle-size.ts @@ -1,3 +1,23 @@ +import type { build as viteBuild } from 'vite'; +export interface ConsumerChunk { + type: 'chunk'; + fileName: string; + code: string; + imports: string[]; + isEntry?: boolean; + name?: string; + dynamicImports?: string[]; +} +export interface ConsumerAsset { + type: 'asset'; + fileName: string; + source: string | Uint8Array; +} +export interface BundleSize { + bytes: number; + gzipBytes: number; + chunks?: number; +} /** Measure browser consumers of packed packages with Vite's normal tree shaking. */ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; @@ -13,12 +33,12 @@ export const componentBundleBudgets = { player: { bytes: 300_000, gzipBytes: 80_000 }, }; -export function initialJavaScriptSize(output, entryName = 'consumer') { +export function initialJavaScriptSize(output: readonly (ConsumerChunk | ConsumerAsset)[], entryName = 'consumer') { const chunks = new Map(output.filter((item) => item.type === 'chunk').map((chunk) => [chunk.fileName, chunk])); const entry = [...chunks.values()].find((chunk) => chunk.isEntry && chunk.name === entryName); assert.ok(entry, `Missing consumer entry: ${entryName}`); - const visited = new Set(); - function visit(fileName) { + const visited = new Set(); + function visit(fileName: string): void { if (visited.has(fileName)) return; const chunk = chunks.get(fileName); assert.ok(chunk, `Unbundled initial JavaScript dependency: ${fileName}`); @@ -26,7 +46,11 @@ export function initialJavaScriptSize(output, entryName = 'consumer') { for (const dependency of chunk.imports) visit(dependency); } visit(entry.fileName); - const initial = [...visited].map((name) => chunks.get(name)); + const initial = [...visited].map((name) => { + const chunk = chunks.get(name); + assert.ok(chunk); + return chunk; + }); const bytes = initial.reduce((sum, chunk) => sum + Buffer.byteLength(chunk.code), 0); assert.ok(bytes > 0, 'Consumer registration was removed from the JavaScript bundle'); return { @@ -36,20 +60,24 @@ export function initialJavaScriptSize(output, entryName = 'consumer') { }; } -export function assertComponentBundleBudget(entry, size, budgets = componentBundleBudgets) { +export function assertComponentBundleBudget( + entry: string, + size: Partial, + budgets: Record = componentBundleBudgets, +) { const budget = budgets[entry]; assert.ok(budget, `Missing component budget: ${entry}`); - for (const metric of ['bytes', 'gzipBytes']) { + for (const metric of ['bytes', 'gzipBytes'] as const) { assert.ok( - size[metric] <= budget[metric], + typeof size[metric] === 'number' && size[metric] <= budget[metric], `${entry} initial JavaScript ${metric}: ${size[metric]} exceeds budget ${budget[metric]}`, ); } } -export async function verifyComponentBundles(fixture, repository) { +export async function verifyComponentBundles(fixture: string, repository: string) { const require = createRequire(join(repository, 'packages/ranui/package.json')); - const { build } = await import(pathToFileURL(require.resolve('vite')).href); + const { build }: { build: typeof viteBuild } = await import(pathToFileURL(require.resolve('vite')).href); const report = []; for (const entry of Object.keys(componentBundleBudgets)) { const result = await build({ @@ -75,6 +103,7 @@ export async function verifyComponentBundles(fixture, repository) { rollupOptions: { input: { consumer: 'virtual:consumer' } }, }, }); + assert.ok(!Array.isArray(result) && 'output' in result, 'Expected one in-memory consumer bundle'); const size = initialJavaScriptSize(result.output); console.log( `Packed ranui/${entry}: ${size.bytes} initial JS bytes, ${size.gzipBytes} gzip bytes (${size.chunks} chunks)`, diff --git a/bin/lint.sh b/bin/lint.sh index f39e52ee5a..a6cfb3fe53 100644 --- a/bin/lint.sh +++ b/bin/lint.sh @@ -5,9 +5,11 @@ bin=./node_modules/.bin $bin/oxlint --config .oxlintrc.json . $bin/prettier --check --cache . +pnpm typecheck:tooling + # The manifest decides which packages are checked; keep it honest before acting on it. -node bin/verify-packages.mjs -node bin/verify-md-links.mjs +node bin/verify-packages.ts +node bin/verify-md-links.ts # build ranuts and ranui first so their type declarations are available for dependent packages pnpm -F ranuts build @@ -16,4 +18,4 @@ pnpm -F ranui build # Every package declaring the `tsc` check in packages/manifest.json. Reading the list from # there rather than repeating it here is the point: a package that falls out of a # hand-maintained list is invisible, because nothing runs it any more. -node bin/run-checks.mjs tsc +node bin/run-checks.ts tsc diff --git a/bin/pack-result.ts b/bin/pack-result.ts new file mode 100644 index 0000000000..05b3f772c2 --- /dev/null +++ b/bin/pack-result.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { object, text } from './tooling-json.ts'; + +/** npm 12 keys pack results by package name; npm 10/11 return an array. */ +export function packedFilename(value: unknown): string { + const results = Array.isArray(value) ? value : Object.values(object(value, 'npm pack result')); + assert.equal(results.length, 1, 'npm pack must return one archive'); + return text(object(results[0], 'packed archive').filename, 'packed filename'); +} diff --git a/bin/package-size.mjs b/bin/package-size.ts similarity index 79% rename from bin/package-size.mjs rename to bin/package-size.ts index e5d62fc802..8a49f5b0cc 100644 --- a/bin/package-size.mjs +++ b/bin/package-size.ts @@ -1,9 +1,10 @@ +import type { ReleaseReceipt } from './release-types.ts'; /** Logical file bytes, not filesystem allocation or pnpm store disk usage. */ import assert from 'node:assert/strict'; import { lstat, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; -export async function directorySize(directory) { +export async function directorySize(directory: string): Promise<{ bytes: number; files: number }> { const result = { bytes: 0, files: 0 }; for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); @@ -21,7 +22,11 @@ export async function directorySize(directory) { return result; } -export async function measureInstallation(fixture, archives, receipt) { +export async function measureInstallation( + fixture: string, + archives: string, + receipt: Pick, +) { const modules = join(fixture, 'node_modules'); const packages = []; for (const pkg of receipt.packages) { @@ -50,7 +55,13 @@ export async function measureInstallation(fixture, archives, receipt) { }; } -export function assertSizeBudgets(report, budgets) { +export function assertSizeBudgets( + report: { + installation: { bytes: number }; + packages: { name: string; archiveBytes: number; unpackedBytes: number }[]; + }, + budgets: { installationBytes: number; packages: Record }, +) { assert.ok( report.installation.bytes <= budgets.installationBytes, `Production installation exceeds budget: ${report.installation.bytes} > ${budgets.installationBytes}`, @@ -62,7 +73,7 @@ export function assertSizeBudgets(report, budgets) { names.add(pkg.name); const budget = budgets.packages[pkg.name]; assert.ok(budget, `Missing package budget: ${pkg.name}`); - for (const key of ['archiveBytes', 'unpackedBytes']) + for (const key of ['archiveBytes', 'unpackedBytes'] as const) assert.ok( pkg[key] > 0 && pkg[key] <= budget[key], `${pkg.name}: ${key} exceeds budget: ${pkg[key]} > ${budget[key]}`, diff --git a/bin/packages.mjs b/bin/packages.ts similarity index 75% rename from bin/packages.mjs rename to bin/packages.ts index 73723d43b3..3ed35322d7 100644 --- a/bin/packages.mjs +++ b/bin/packages.ts @@ -1,3 +1,4 @@ +import { object, packageManifest, strings, text } from './tooling-json.ts'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; @@ -17,9 +18,23 @@ export const NEEDS_REASON = ['stub', 'non-js']; * * @returns Declared packages, in the order checks should run. */ -export function readManifest() { - const parsed = JSON.parse(readFileSync(MANIFEST, 'utf8')); - return Object.entries(parsed.packages).map(([name, entry]) => ({ name, ...entry })); +export interface PackageEntry { + name: string; + status: string; + checks?: string[]; + reason?: string; +} +export function readManifest(): PackageEntry[] { + const parsed = object(JSON.parse(readFileSync(MANIFEST, 'utf8'))); + return Object.entries(object(parsed.packages, 'packages')).map(([name, value]) => { + const entry = object(value, `package ${name}`); + return { + name, + status: text(entry.status, 'status'), + ...(entry.checks === undefined ? {} : { checks: strings(entry.checks, 'checks') }), + ...(entry.reason === undefined ? {} : { reason: text(entry.reason, 'reason') }), + }; + }); } /** @@ -61,9 +76,9 @@ export function readDirectories() { * @param name Directory name under `packages/`. * @returns Its package.json, or null. */ -export function readPackageJson(name) { +export function readPackageJson(name: string) { const path = join(ROOT, 'packages', name, 'package.json'); - return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null; + return existsSync(path) ? packageManifest(JSON.parse(readFileSync(path, 'utf8'))) : null; } /** @@ -75,7 +90,7 @@ export function readPackageJson(name) { * @param name Directory name under `packages/`. * @returns True when the directory holds more than a couple of TypeScript files. */ -export function hasTypeScriptSource(name) { +export function hasTypeScriptSource(name: string) { const skip = new Set([ 'node_modules', 'dist', @@ -86,7 +101,7 @@ export function hasTypeScriptSource(name) { '.vitepress', ]); let count = 0; - const walk = (dir) => { + const walk = (dir: string): void => { if (count > 2) return; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (skip.has(entry.name)) continue; diff --git a/bin/prepare-declarations.mjs b/bin/prepare-declarations.ts similarity index 83% rename from bin/prepare-declarations.mjs rename to bin/prepare-declarations.ts index 593782dd06..353ba0e377 100644 --- a/bin/prepare-declarations.mjs +++ b/bin/prepare-declarations.ts @@ -1,3 +1,4 @@ +import type { createScanner as CreateScanner, SyntaxKind as SyntaxKinds } from 'typescript/unstable/ast'; /** Normalize emitted declarations for NodeNext ESM consumers. */ import { readFile, readdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; @@ -6,7 +7,7 @@ import { pathToFileURL } from 'node:url'; const assets = /\.(?:css|less|scss|sass|styl|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|wasm|json)(?:[?#].*)?$/i; -async function declarations(directory) { +async function declarations(directory: string): Promise { const files = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); @@ -16,11 +17,25 @@ async function declarations(directory) { return files; } -function moduleReferences(source, { createScanner, SyntaxKind }) { +interface Reference { + start: number; + end: number; + value: string; + path?: boolean; +} +interface Edit { + start: number; + end: number; + text: string; +} +function moduleReferences( + source: string, + { createScanner, SyntaxKind }: { createScanner: typeof CreateScanner; SyntaxKind: typeof SyntaxKinds }, +): Reference[] { const scanner = createScanner(false, undefined, source); const references = []; - let previous; - let beforePrevious; + let previous: { kind: number; text: string; value: string } | undefined; + let beforePrevious: typeof previous; for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) { const start = scanner.getTokenStart(); const end = scanner.getTokenEnd(); @@ -54,24 +69,24 @@ function moduleReferences(source, { createScanner, SyntaxKind }) { return references; } -function relativeSpecifier(from, target) { +function relativeSpecifier(from: string, target: string) { const path = relative(dirname(from), target).split(sep).join('/'); return path.startsWith('.') ? path : `./${path}`; } -function applyEdits(source, edits) { +function applyEdits(source: string, edits: readonly Edit[]) { for (const edit of [...edits].sort((a, b) => b.start - a.start)) source = source.slice(0, edit.start) + edit.text + source.slice(edit.end); return source; } /** aliasRoot is an absolute path (or a path relative to cwd), defaulting to dist. */ -export async function prepareDeclarations({ dist, aliasRoot = dist }) { +export async function prepareDeclarations({ dist, aliasRoot = dist }: { dist: string; aliasRoot?: string }) { dist = resolve(dist); aliasRoot = resolve(aliasRoot); // TypeScript belongs to the package invoking its build, not the repository root. const require = createRequire(join(process.cwd(), 'package.json')); - const ast = require('typescript/unstable/ast'); + const ast: typeof import('typescript/unstable/ast') = require('typescript/unstable/ast'); const files = await declarations(dist); const available = new Set(files); const outputs = []; diff --git a/bin/release-types.ts b/bin/release-types.ts new file mode 100644 index 0000000000..917a5ffdca --- /dev/null +++ b/bin/release-types.ts @@ -0,0 +1,37 @@ +import { object, text } from './tooling-json.ts'; +export interface ReleaseIdentity { + name: string; + version: string; + integrity?: string; +} +export interface ReleaseArchive extends ReleaseIdentity { + archive: string; + integrity: string; +} +export interface ReleaseReceipt { + tag: string; + packages: ReleaseArchive[]; + commit?: string; + preparedFromCleanCheckout?: boolean; +} +export function releaseReceipt(value: unknown): ReleaseReceipt { + const data = object(value, 'release receipt'); + if (!Array.isArray(data.packages)) throw new TypeError('Release receipt packages must be an array'); + const packages = data.packages.map((value): ReleaseArchive => { + const pkg = object(value, 'release archive'); + return { + name: text(pkg.name, 'archive name'), + version: text(pkg.version, 'archive version'), + archive: text(pkg.archive, 'archive path'), + integrity: text(pkg.integrity, 'archive integrity'), + }; + }); + const receipt: ReleaseReceipt = { tag: text(data.tag, 'release tag'), packages }; + if (data.commit !== undefined) receipt.commit = text(data.commit, 'release commit'); + if (data.preparedFromCleanCheckout !== undefined) { + if (typeof data.preparedFromCleanCheckout !== 'boolean') + throw new TypeError('Clean checkout receipt must be boolean'); + receipt.preparedFromCleanCheckout = data.preparedFromCleanCheckout; + } + return receipt; +} diff --git a/bin/release.mjs b/bin/release.ts similarity index 73% rename from bin/release.mjs rename to bin/release.ts index 02d98bdd0c..38b97dd8e6 100644 --- a/bin/release.mjs +++ b/bin/release.ts @@ -1,3 +1,7 @@ +import { object, packageManifest, hasErrorCode } from './tooling-json.ts'; +import type { PackageManifest } from './tooling-json.ts'; +import { releaseReceipt } from './release-types.ts'; +import type { ReleaseIdentity, ReleaseArchive } from './release-types.ts'; /** Prepare, verify and publish the three library tarballs, in dependency order. */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; @@ -14,15 +18,19 @@ export const RELEASE_PACKAGES = [ { directory: 'ranui', name: 'ranui' }, ]; const registry = 'https://registry.npmjs.org/'; -const json = async (path) => JSON.parse(await readFile(path, 'utf8')); -const digest = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`; -const archiveName = (directory, version) => `${directory}-${version}.tgz`; -const command = (name, args, cwd = ROOT) => execFileSync(name, args, { cwd, stdio: 'inherit' }); -const git = (...args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim(); +const json = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); +const digest = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`; +const archiveName = (directory: string, version: string) => `${directory}-${version}.tgz`; +const command = (name: string, args: string[], cwd = ROOT) => execFileSync(name, args, { cwd, stdio: 'inherit' }); +const git = (...args: string[]) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim(); -export function validatePlan(manifests, { tag, nodeVersion = process.versions.node } = {}) { +export function validatePlan( + values: readonly unknown[], + { tag, nodeVersion = process.versions.node }: { tag?: string; nodeVersion?: string } = {}, +) { assert.ok(Number(nodeVersion.split('.')[0]) >= 24, 'Release tooling requires Node 24 or newer'); - assert.equal(manifests.length, 3, 'Release exactly the three libraries'); + assert.equal(values.length, 3, 'Release exactly the three libraries'); + const manifests = values.map(packageManifest); const seen = new Set(); for (const [index, manifest] of manifests.entries()) { assert.equal(manifest.name, RELEASE_PACKAGES[index].name, 'Unexpected release package or dependency order'); @@ -47,23 +55,25 @@ export function validatePlan(manifests, { tag, nodeVersion = process.versions.no } export async function currentTag(root = ROOT) { - const pkg = await json(join(root, 'packages/ranui/package.json')); + const pkg = packageManifest(await json(join(root, 'packages/ranui/package.json'))); return /-(alpha|beta|rc)\.\d+$/.exec(pkg.version)?.[1] ?? 'latest'; } -export async function readPlan(root = ROOT, tag) { - const workspace = [await json(join(root, 'package.json'))]; +export async function readPlan(root = ROOT, tag?: string) { + const workspace = [packageManifest(await json(join(root, 'package.json')))]; for (const entry of await readdir(join(root, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === 'node_modules') continue; try { - workspace.push(await json(join(root, 'packages', entry.name, 'package.json'))); + workspace.push(packageManifest(await json(join(root, 'packages', entry.name, 'package.json')))); } catch (error) { - if (error.code !== 'ENOENT') throw error; + if (!hasErrorCode(error, 'ENOENT')) throw error; } } for (const pkg of workspace) assert.equal(pkg.engines?.node, '>=24.0.0', `${pkg.name}: engines must be >=24.0.0`); const manifests = await Promise.all( - RELEASE_PACKAGES.map((pkg) => json(join(root, 'packages', pkg.directory, 'package.json'))), + RELEASE_PACKAGES.map(async (pkg) => + packageManifest(await json(join(root, 'packages', pkg.directory, 'package.json'))), + ), ); for (const pkg of manifests) { assert.equal(pkg.scripts?.prepack, 'pnpm run build', `${pkg.name}: prepack must build the package`); @@ -73,7 +83,13 @@ export async function readPlan(root = ROOT, tag) { } /** A network/auth failure is never evidence that a version is available. */ -export async function checkRegistry(plan, { request = fetch, resume = false } = {}) { +export async function checkRegistry( + plan: readonly ReleaseIdentity[], + { + request = fetch, + resume = false, + }: { request?: (url: string, init?: RequestInit) => Promise; resume?: boolean } = {}, +) { const existing = new Set(); for (const pkg of plan) { const response = await request(`${registry}${encodeURIComponent(pkg.name)}/${pkg.version}`, { @@ -82,9 +98,10 @@ export async function checkRegistry(plan, { request = fetch, resume = false } = if (response.status === 404) continue; assert.ok(response.ok, `Registry check failed for ${pkg.name}: HTTP ${response.status}`); assert.ok(resume, `${pkg.name}@${pkg.version} is already published; choose a new version`); - const published = await response.json(); + const published = object(await response.json(), 'registry metadata'); + const dist = published.dist === undefined ? {} : object(published.dist, 'registry dist'); assert.ok( - pkg.integrity && published.dist?.integrity === pkg.integrity, + pkg.integrity && dist.integrity === pkg.integrity, `${pkg.name}: published integrity differs from the verified archive`, ); existing.add(pkg.name); @@ -92,9 +109,14 @@ export async function checkRegistry(plan, { request = fetch, resume = false } = return existing; } -export async function verifyArtifacts(directory, plan, tag, { requireVerification = false } = {}) { +export async function verifyArtifacts( + directory: string, + plan: readonly ReleaseIdentity[], + tag: string, + { requireVerification = false } = {}, +) { assert.ok(!(await lstat(directory)).isSymbolicLink(), 'Artifact directory must not be a symlink'); - const receipt = await json(join(directory, 'release.json')); + const receipt = releaseReceipt(await json(join(directory, 'release.json'))); assert.equal(receipt.tag, tag, 'Artifact tag differs from the release plan'); assert.equal(receipt.packages?.length, 3, 'Artifact receipt must contain three libraries'); for (const [index, pkg] of receipt.packages.entries()) { @@ -109,21 +131,28 @@ export async function verifyArtifacts(directory, plan, tag, { requireVerificatio assert.equal(digest(await readFile(file)), pkg.integrity, `${pkg.name}: artifact integrity mismatch`); } if (requireVerification) { - const verified = await json(join(directory, 'verified.json')).catch(() => { - throw new Error('Run verify:release before publishing these archives'); - }); + const verified = object( + await json(join(directory, 'verified.json')).catch(() => { + throw new Error('Run verify:release before publishing these archives'); + }), + ); assert.deepEqual(verified.artifacts, receipt, 'Verification belongs to different release artifacts'); } return receipt; } -export function verifyPackedManifest(packed, source, plan) { +export function verifyPackedManifest( + packed: PackageManifest, + source: PackageManifest, + plan: readonly ReleaseIdentity[], +) { assert.equal(packed.name, source.name); assert.equal(packed.version, source.version); - assert.equal(packed.engines.node, '>=24.0.0'); + assert.equal(packed.engines?.node, '>=24.0.0'); assert.equal(packed.type, 'module'); for (const dependency of plan) { - if (source.dependencies?.[dependency.name]) assert.equal(packed.dependencies[dependency.name], dependency.version); + if (source.dependencies?.[dependency.name]) + assert.equal(packed.dependencies?.[dependency.name], dependency.version); } assert.ok( !JSON.stringify(packed.dependencies ?? {}).includes('workspace:'), @@ -131,7 +160,7 @@ export function verifyPackedManifest(packed, source, plan) { ); } -export async function prepare(tag) { +export async function prepare(tag?: string) { const commit = git('rev-parse', 'HEAD'); const cleanBefore = git('status', '--porcelain') === ''; tag ??= await currentTag(); @@ -147,11 +176,13 @@ export async function prepare(tag) { try { assert.ok((await lstat(path)).isFile(), 'Archive output must be a regular file'); } catch (error) { - if (error.code !== 'ENOENT') throw error; + if (!hasErrorCode(error, 'ENOENT')) throw error; } // prepack builds each package exactly once, after its local dependencies. command('pnpm', ['pack', '--out', path], join(ROOT, 'packages', source.directory)); - const packed = JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { encoding: 'utf8' })); + const packed = packageManifest( + JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { encoding: 'utf8' })), + ); verifyPackedManifest(packed, pkg, plan); const entries = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).split('\n'); for (const entry of ['package/dist/index.js', 'package/dist/index.d.ts']) @@ -166,7 +197,23 @@ export async function prepare(tag) { console.log(`Prepared ${packages.length} verified archives in .release/ for tag ${tag}`); } -export function assertPublication({ confirm, preparedClean, commit, head, dirty, branch, ciRef }) { +export function assertPublication({ + confirm, + preparedClean, + commit, + head, + dirty, + branch, + ciRef, +}: { + confirm: boolean; + preparedClean?: boolean; + commit?: string; + head: string; + dirty: string; + branch: string; + ciRef?: string; +}) { assert.ok(confirm, 'Actual npm publication requires --confirm'); assert.equal(preparedClean, true, 'Publication requires archives prepared from a clean checkout'); assert.equal(commit, head, 'Archives belong to a different source commit'); @@ -174,7 +221,11 @@ export function assertPublication({ confirm, preparedClean, commit, head, dirty, assert.ok(branch === 'main' || ciRef === 'refs/heads/main', 'Publish only from main'); } -export async function publish({ tag, dryRun = true, confirm = false } = {}) { +export async function publish({ + tag, + dryRun = true, + confirm = false, +}: { tag?: string; dryRun?: boolean; confirm?: boolean } = {}) { tag ??= await currentTag(); const plan = await readPlan(ROOT, tag); const directory = join(ROOT, '.release'); @@ -191,7 +242,7 @@ export async function publish({ tag, dryRun = true, confirm = false } = {}) { }); } const existing = await checkRegistry(receipt.packages, { resume: !dryRun }); - const completed = []; + const completed: (ReleaseArchive & { status: string })[] = []; try { for (const pkg of receipt.packages) { if (existing.has(pkg.name)) { @@ -246,7 +297,7 @@ async function main() { if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch((error) => { - console.error(error.message); + console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); } diff --git a/bin/run-checks.mjs b/bin/run-checks.ts similarity index 91% rename from bin/run-checks.mjs rename to bin/run-checks.ts index 72ce7ba62d..404017d589 100644 --- a/bin/run-checks.mjs +++ b/bin/run-checks.ts @@ -5,14 +5,14 @@ * inside a shell script drifts from the manifest silently, and the drift is invisible * precisely because nothing runs the package that fell out. * - * Usage: node bin/run-checks.mjs + * Usage: node bin/run-checks.ts */ import { spawnSync } from 'node:child_process'; -import { readManifest } from './packages.mjs'; +import { readManifest } from './packages.ts'; const check = process.argv[2]; if (check === undefined) { - console.error('usage: node bin/run-checks.mjs '); + console.error('usage: node bin/run-checks.ts '); process.exit(2); } diff --git a/bin/sync-agent-plugin.ts b/bin/sync-agent-plugin.ts new file mode 100644 index 0000000000..b1eebe38c8 --- /dev/null +++ b/bin/sync-agent-plugin.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +const bundlePath = 'plugins/ran/skills/design/references'; +export function markdownFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }) + .flatMap((entry) => { + if (entry.isSymbolicLink()) throw new Error(`Reference symlink is not portable: ${entry.name}`); + return entry.isDirectory() + ? markdownFiles(path.join(root, entry.name)).map((file) => `${entry.name}/${file}`) + : entry.name.endsWith('.md') + ? [entry.name] + : []; + }) + .sort(); +} +export function verifyLocalLinks(root: string, files: string[]): void { + for (const file of files) { + const source = readFileSync(path.join(root, file), 'utf8'); + for (const match of source.matchAll(/\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g)) { + const link = match[1]!.replace(/^<|>$/g, ''); + if (/^(?:[a-z][a-z\d+.-]*:|#)/i.test(link)) continue; + const target = decodeURIComponent(link.split(/[?#]/)[0]!); + const absolute = path.resolve(root, path.dirname(file), target); + if (path.relative(root, absolute).startsWith('..') || !existsSync(absolute)) + throw new Error(`Unresolved portable reference: ${file} -> ${link}`); + } + } +} +export function syncAgentPlugin(root: string, check = false): void { + const sourceRoot = path.join(root, 'design'); + const names = markdownFiles(sourceRoot); + if (!names.includes('README.md')) throw new Error('design/README.md must be the portable entry'); + verifyLocalLinks(sourceRoot, names); + const expected = new Map(); + const sources = names.map((name) => { + const content = readFileSync(path.join(sourceRoot, name), 'utf8'); + const generated = `\n\n${content}`; + expected.set(name, generated); + return { source: `design/${name}`, sha256: hash(content), output: name, outputSha256: hash(generated) }; + }); + expected.set( + 'manifest.json', + JSON.stringify({ schema: 1, generator: 'bin/sync-agent-plugin.ts', sources }, null, 2) + '\n', + ); + const destination = path.join(root, bundlePath); + if (check) { + if (!existsSync(destination)) throw new Error('Missing agent reference bundle; run sync:agent-plugin'); + const actual = [...markdownFiles(destination), 'manifest.json'].sort(); + if (JSON.stringify(actual) !== JSON.stringify([...expected.keys()].sort())) + throw new Error('Stale agent reference file set; run sync:agent-plugin'); + for (const [name, content] of expected) + if (!existsSync(path.join(destination, name)) || readFileSync(path.join(destination, name), 'utf8') !== content) + throw new Error(`Stale agent reference: ${name}; run sync:agent-plugin`); + verifyLocalLinks(destination, names); + return; + } + rmSync(destination, { recursive: true, force: true }); + for (const [name, content] of expected) { + mkdirSync(path.dirname(path.join(destination, name)), { recursive: true }); + writeFileSync(path.join(destination, name), content); + } +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.slice(2).some((arg) => arg !== '--check')) + throw new Error('Usage: node bin/sync-agent-plugin.ts [--check]'); + syncAgentPlugin(fileURLToPath(new URL('../', import.meta.url)), process.argv.includes('--check')); +} diff --git a/bin/sync-design-docs.ts b/bin/sync-design-docs.ts new file mode 100644 index 0000000000..cbd08f448f --- /dev/null +++ b/bin/sync-design-docs.ts @@ -0,0 +1,58 @@ +import { format } from 'prettier'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const guideNames = [ + 'DESIGN', + 'INTERACTION', + 'ACCEPTANCE', + 'WORKFLOW_PATTERNS', + 'REFERENCE_RESEARCH', + 'INTERFACE_GUIDE', +] as const; +const repository = fileURLToPath(new URL('../', import.meta.url)); + +/** Compose portable methods with package-owned implementation guidance. */ +export async function renderDesignDocs(root: string): Promise> { + const outputs = new Map(); + for (const name of guideNames) { + const source = `design/${name}.md`; + let content = await readFile(resolve(root, source), 'utf8'); + // Installed npm guides resolve public engineering contracts locally, without a checkout. + content = content + .replaceAll('https://github.com/chaxus/ran/blob/main/packages/ranui/docs/', './') + .replaceAll('https://github.com/chaxus/ran/blob/main/packages/ranui/CLAUDE.md', '../CLAUDE.md'); + const sources = [source]; + if (name === 'DESIGN' || name === 'INTERACTION') { + const adapter = `packages/ranui/docs/adapters/${name}.md`; + sources.push(adapter); + content += `\n---\n\n${(await readFile(resolve(root, adapter), 'utf8')).replaceAll('](../', '](./').replaceAll('](./../CLAUDE.md)', '](../CLAUDE.md)')}`; + } + const banner = `\n\n`; + outputs.set( + `packages/ranui/docs/${name}.md`, + await format(banner + content.trimEnd() + '\n', { parser: 'markdown', printWidth: 120 }), + ); + } + return outputs; +} + +export async function syncDesignDocs(root: string, check: boolean): Promise { + const stale: string[] = []; + for (const [path, content] of await renderDesignDocs(root)) { + if (check) { + const current = await readFile(resolve(root, path), 'utf8').catch(() => null); + if (current !== content) stale.push(path); + } else await writeFile(resolve(root, path), content); + } + if (stale.length) + throw new Error(`Generated design guides are stale: ${stale.join(', ')}. Run node bin/sync-design-docs.ts.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.some((arg) => arg !== '--check')) throw new Error('Usage: node bin/sync-design-docs.ts [--check]'); + await syncDesignDocs(repository, args.includes('--check')); + console.log(`Design guides ${args.includes('--check') ? 'verified' : 'generated'}.`); +} diff --git a/bin/test.sh b/bin/test.sh index 41f5af9f56..823f690d1d 100644 --- a/bin/test.sh +++ b/bin/test.sh @@ -5,4 +5,4 @@ set -e pnpm -F ranuts build # Every package declaring the `test` check in packages/manifest.json. -node bin/run-checks.mjs test +node bin/run-checks.ts test diff --git a/bin/test/bundled-notices.test.mjs b/bin/test/bundled-notices.test.ts similarity index 91% rename from bin/test/bundled-notices.test.mjs rename to bin/test/bundled-notices.test.ts index 2844abfb41..198261696b 100644 --- a/bin/test/bundled-notices.test.mjs +++ b/bin/test/bundled-notices.test.ts @@ -1,3 +1,4 @@ +import type { TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -6,16 +7,16 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../packages/ranuts/bin/copy-bundled-notices.mjs'); +const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../packages/ranuts/bin/copy-bundled-notices.ts'); const license = 'GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999\nFixture license bytes must be preserved.\n'; const primary = '/* Copyright Netscape Communications Corporation. GNU Lesser General Public License. */'; const secondary = '/* Copyright Another Contributor. GNU Lesser General Public License. */'; -function fixture(t) { +function fixture(t: TestContext) { const root = mkdtempSync(join(tmpdir(), 'ran-bundled-notices-')); t.after(() => rmSync(root, { recursive: true, force: true })); - const put = (path, text) => { + const put = (path: string, text: string) => { const file = join(root, path); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); @@ -36,7 +37,7 @@ function fixture(t) { return { root, put, - read: (path) => readFileSync(join(root, path), 'utf8'), + read: (path: string) => readFileSync(join(root, path), 'utf8'), run: () => spawnSync(process.execPath, [script, root], { encoding: 'utf8' }), }; } @@ -62,7 +63,7 @@ test('IIFE distribution retains exact upstream license, every source header and assert.ok(bundle.endsWith(`var ${name} = {};\n`)); } assert.equal(run().status, 0); - assert.equal(read('dist/iife/index.iife.js').match(/jschardet@3\.1\.4/g).length, 1); + assert.equal(read('dist/iife/index.iife.js').match(/jschardet@3\.1\.4/g)?.length, 1); }); test('missing upstream copyright material fails the build instead of emitting incomplete notices', (t) => { diff --git a/bin/test/changelog-channel.test.mjs b/bin/test/changelog-channel.test.ts similarity index 100% rename from bin/test/changelog-channel.test.mjs rename to bin/test/changelog-channel.test.ts diff --git a/bin/test/clean.test.mjs b/bin/test/clean.test.ts similarity index 93% rename from bin/test/clean.test.mjs rename to bin/test/clean.test.ts index d17d379dbe..13cf79ec97 100644 --- a/bin/test/clean.test.mjs +++ b/bin/test/clean.test.ts @@ -1,3 +1,5 @@ +import type { TestContext } from 'node:test'; +import type { SpawnSyncReturns } from 'node:child_process'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { @@ -18,11 +20,11 @@ import test from 'node:test'; const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -function fixture(t) { +function fixture(t: TestContext) { const temporary = mkdtempSync(join(tmpdir(), 'ran-clean-')); t.after(() => rmSync(temporary, { recursive: true, force: true })); const root = join(temporary, 'repo'); - const put = (path, content = 'keep me') => { + const put = (path: string, content: string | Uint8Array = 'keep me') => { const target = join(root, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); @@ -30,7 +32,7 @@ function fixture(t) { }; put('package.json', readFileSync(join(repository, 'package.json'))); put('packages/manifest.json', JSON.stringify({ packages: { ranui: {}, docs: {}, rust: {} } })); - for (const file of ['clean.sh', 'clean.mjs']) { + for (const file of ['clean.sh', 'clean.ts', 'tooling-json.ts']) { if (existsSync(join(repository, 'bin', file))) copyFileSync(join(repository, 'bin', file), put(`bin/${file}`)); } // The old cleaner must never reach package-manager caches during the red run. @@ -53,7 +55,7 @@ function fixture(t) { return { temporary, root, put, run }; } -function succeeded(result) { +function succeeded(result: SpawnSyncReturns) { assert.equal(result.status, 0, result.stderr || result.stdout); } diff --git a/bin/test/component-bundle-size.test.mjs b/bin/test/component-bundle-size.test.ts similarity index 84% rename from bin/test/component-bundle-size.test.mjs rename to bin/test/component-bundle-size.test.ts index d443ff59be..228d2f6530 100644 --- a/bin/test/component-bundle-size.test.mjs +++ b/bin/test/component-bundle-size.test.ts @@ -1,10 +1,16 @@ +import type { ConsumerChunk, ConsumerAsset } from '../component-bundle-size.ts'; import assert from 'node:assert/strict'; import test from 'node:test'; import { gzipSync } from 'node:zlib'; -import { initialJavaScriptSize, assertComponentBundleBudget } from '../component-bundle-size.mjs'; +import { initialJavaScriptSize, assertComponentBundleBudget } from '../component-bundle-size.ts'; test('initial load counts shared static chunks once, including cycles, excluding lazy chunks and assets', () => { - const chunk = (fileName, code, imports = [], extra = {}) => ({ type: 'chunk', fileName, code, imports, ...extra }); + const chunk = ( + fileName: string, + code: string, + imports: string[] = [], + extra: Partial = {}, + ): ConsumerChunk => ({ type: 'chunk', fileName, code, imports, ...extra }); const entry = chunk('entry.js', '你好', ['shared.js', 'other.js'], { name: 'consumer', isEntry: true, @@ -12,7 +18,7 @@ test('initial load counts shared static chunks once, including cycles, excluding }); const shared = chunk('shared.js', 'shared', ['entry.js']); const other = chunk('other.js', 'other', ['shared.js']); - const output = [ + const output: (ConsumerChunk | ConsumerAsset)[] = [ entry, shared, other, diff --git a/bin/test/declarations.test.mjs b/bin/test/declarations.test.ts similarity index 90% rename from bin/test/declarations.test.mjs rename to bin/test/declarations.test.ts index 13668e9a2c..d4e5077e61 100644 --- a/bin/test/declarations.test.mjs +++ b/bin/test/declarations.test.ts @@ -1,3 +1,5 @@ +import type { TestContext } from 'node:test'; +import type { SpawnSyncReturns } from 'node:child_process'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; @@ -8,14 +10,14 @@ import { fileURLToPath } from 'node:url'; import test from 'node:test'; const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const script = join(repository, 'bin/prepare-declarations.mjs'); +const script = join(repository, 'bin/prepare-declarations.ts'); const require = createRequire(join(repository, 'packages/ranuts/package.json')); const typescript = dirname(require.resolve('typescript/package.json')); -function fixture(t) { +function fixture(t: TestContext) { const root = mkdtempSync(join(tmpdir(), 'ran-declarations-')); t.after(() => rmSync(root, { recursive: true, force: true })); - const put = (path, content) => { + const put = (path: string, content: string) => { const target = join(root, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); @@ -25,11 +27,11 @@ function fixture(t) { symlinkSync(typescript, join(root, 'node_modules/typescript'), 'junction'); const run = (aliasRoot = '.') => spawnSync(process.execPath, [script, 'dist', aliasRoot], { cwd: root, encoding: 'utf8' }); - const read = (path) => readFileSync(join(root, path), 'utf8'); + const read = (path: string) => readFileSync(join(root, path), 'utf8'); return { root, put, read, run }; } -function succeeded(result) { +function succeeded(result: SpawnSyncReturns) { assert.equal(result.status, 0, result.stderr || result.stdout); } @@ -116,7 +118,7 @@ test('the async API is idempotent without generating a second declaration graph' put('dist/nested/index.d.ts', 'export interface Nested { id: string }\n'); succeeded(run()); const esm = read('dist/index.d.ts'); - const source = `import { prepareDeclarations } from ${JSON.stringify(new URL('../prepare-declarations.mjs', import.meta.url).href)}; await prepareDeclarations({ dist: ${JSON.stringify(join(root, 'dist'))} });`; + const source = `import { prepareDeclarations } from ${JSON.stringify(new URL('../prepare-declarations.ts', import.meta.url).href)}; await prepareDeclarations({ dist: ${JSON.stringify(join(root, 'dist'))} });`; succeeded(spawnSync(process.execPath, ['--input-type=module', '-e', source], { cwd: root, encoding: 'utf8' })); assert.equal(read('dist/index.d.ts'), esm); assert.equal(existsSync(join(root, 'dist/cjs-types')), false); diff --git a/bin/test/pack-result.test.ts b/bin/test/pack-result.test.ts new file mode 100644 index 0000000000..975c9e70ee --- /dev/null +++ b/bin/test/pack-result.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { packedFilename } from '../pack-result.ts'; + +const archive = { name: 'ranuts', filename: 'ranuts-0.5.1.tgz' }; +test('reads npm 10/11 single-archive output', () => { + assert.equal(packedFilename([archive]), archive.filename); +}); +test('reads npm 12 package-keyed single-archive output', () => { + assert.equal(packedFilename({ ranuts: archive }), archive.filename); +}); +for (const [name, value] of [ + ['empty array', []], + ['empty object', {}], + ['multiple array archives', [archive, archive]], + ['multiple keyed archives', { ranuts: archive, ranui: { filename: 'ranui-0.5.1.tgz' } }], + ['error response', { error: { code: 'EPACK' } }], + ['invalid filename', [{ filename: 123 }]], + ['null output', null], +] as const) { + test(`rejects ${name} instead of extracting an arbitrary archive`, () => { + assert.throws(() => packedFilename(value)); + }); +} diff --git a/bin/test/package-size.test.mjs b/bin/test/package-size.test.ts similarity index 99% rename from bin/test/package-size.test.mjs rename to bin/test/package-size.test.ts index 9b4ef05092..e66ba87755 100644 --- a/bin/test/package-size.test.mjs +++ b/bin/test/package-size.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { directorySize, assertSizeBudgets } from '../package-size.mjs'; +import { directorySize, assertSizeBudgets } from '../package-size.ts'; test('size measurement counts nested files but never follows dependency links', async (t) => { const root = await mkdtemp(join(tmpdir(), 'ran-size-test-')); diff --git a/bin/test/ranui-font-notices.test.mjs b/bin/test/ranui-font-notices.test.ts similarity index 84% rename from bin/test/ranui-font-notices.test.mjs rename to bin/test/ranui-font-notices.test.ts index 3360ecbde1..72bd532622 100644 --- a/bin/test/ranui-font-notices.test.mjs +++ b/bin/test/ranui-font-notices.test.ts @@ -1,3 +1,4 @@ +import type { TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -6,7 +7,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../packages/ranui/bin/copy-bundled-notices.mjs'); +const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../packages/ranui/bin/copy-bundled-notices.ts'); const documents = [ 'NOTICE.md', 'GUST-FONT-LICENSE.txt', @@ -17,10 +18,10 @@ const documents = [ 'README-Latin-Modern-Math.txt', ]; -function fixture(t) { +function fixture(t: TestContext) { const root = mkdtempSync(join(tmpdir(), 'ran-font-notices-')); t.after(() => rmSync(root, { recursive: true, force: true })); - const put = (path, text) => { + const put = (path: string, text: string) => { const target = join(root, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, text); @@ -30,11 +31,11 @@ function fixture(t) { for (const name of documents) put(`assets/fonts/licenses/${name}`, `Original ${name}\r\nCopyright fixture\n`); put('dist/THIRD_PARTY_LICENSES.md', '# Dependency licenses\n'); put('dist/IIFE-LICENSES.md', '# IIFE licenses\n'); - put('bin/copy-bundled-notices.mjs', ''); - copyFileSync(script, join(root, 'bin/copy-bundled-notices.mjs')); + put('bin/copy-bundled-notices.ts', ''); + copyFileSync(script, join(root, 'bin/copy-bundled-notices.ts')); return { root, - run: () => spawnSync(process.execPath, [join(root, 'bin/copy-bundled-notices.mjs')], { encoding: 'utf8' }), + run: () => spawnSync(process.execPath, [join(root, 'bin/copy-bundled-notices.ts')], { encoding: 'utf8' }), }; } @@ -51,7 +52,7 @@ test('math font distribution preserves every upstream document byte for byte', ( for (const name of ['THIRD_PARTY_LICENSES.md', 'IIFE-LICENSES.md']) { const text = readFileSync(join(root, 'dist', name), 'utf8'); assert.ok(text.includes('./third-party/math-fonts/NOTICE.md')); - assert.equal(text.match(/## Bundled mathematical fonts/g).length, 1); + assert.equal(text.match(/## Bundled mathematical fonts/g)?.length, 1); } }); diff --git a/bin/test/release.test.mjs b/bin/test/release.test.ts similarity index 96% rename from bin/test/release.test.mjs rename to bin/test/release.test.ts index f291f8f3b2..e5d6cdb837 100644 --- a/bin/test/release.test.mjs +++ b/bin/test/release.test.ts @@ -10,10 +10,15 @@ import { verifyArtifacts, verifyPackedManifest, RELEASE_PACKAGES, -} from '../release.mjs'; +} from '../release.ts'; -const manifests = () => [ - { name: 'ranuts', version: '0.5.0-rc.0', engines: { node: '>=24.0.0' } }, +const manifests = (): { + name: string; + version: string; + engines: { node: string }; + dependencies: Record; +}[] => [ + { name: 'ranuts', version: '0.5.0-rc.0', engines: { node: '>=24.0.0' }, dependencies: {} }, { name: '@alixex/ranview', version: '0.2.0-rc.0', @@ -61,7 +66,7 @@ test('requires an explicit matching channel and rejects unsafe versions or extra }); test('registry preflight accepts only explicit 404s as unpublished versions', async () => { - const calls = []; + const calls: string[] = []; await checkRegistry(manifests(), { request: async (url) => { calls.push(url); @@ -138,7 +143,7 @@ test('artifact verification rejects missing or changed bytes and unexpected path }); test('the repository declares one Node baseline, channel and build lifecycle', async () => { - const { readPlan } = await import('../release.mjs'); + const { readPlan } = await import('../release.ts'); const plan = await readPlan(); assert.deepEqual( plan.map((p) => p.name), diff --git a/bin/test/sync-agent-plugin.test.ts b/bin/test/sync-agent-plugin.test.ts new file mode 100644 index 0000000000..f8712a9f8d --- /dev/null +++ b/bin/test/sync-agent-plugin.test.ts @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { markdownFiles, syncAgentPlugin, verifyLocalLinks } from '../sync-agent-plugin.ts'; +const repo = fileURLToPath(new URL('../../', import.meta.url)); + +test('the installed plugin has a closed offline Markdown reference graph without repository files', () => { + const isolated = mkdtempSync(path.join(tmpdir(), 'ran-plugin-portable-')); + try { + cpSync(path.join(repo, 'plugins/ran'), path.join(isolated, 'ran'), { recursive: true }); + const skills = path.join(isolated, 'ran/skills'); + verifyLocalLinks(skills, markdownFiles(skills)); + assert.deepEqual( + ['design', 'ranui', 'ranuts'].map( + (name) => readFileSync(path.join(skills, name, 'SKILL.md'), 'utf8').match(/^name: (.+)$/m)?.[1], + ), + ['design', 'ranui', 'ranuts'], + ); + } finally { + rmSync(isolated, { recursive: true, force: true }); + } +}); + +test('generation is deterministic and detects changed source, tampered output and removed sources', () => { + const root = mkdtempSync(path.join(tmpdir(), 'ran-plugin-generation-')); + try { + mkdirSync(path.join(root, 'design')); + writeFileSync(path.join(root, 'design/README.md'), '[Motion](MOTION.md)\n'); + writeFileSync(path.join(root, 'design/MOTION.md'), '# Motion\n'); + syncAgentPlugin(root); + const manifest = path.join(root, 'plugins/ran/skills/design/references/manifest.json'); + const first = readFileSync(manifest, 'utf8'); + syncAgentPlugin(root); + assert.equal(readFileSync(manifest, 'utf8'), first); + syncAgentPlugin(root, true); + writeFileSync(path.join(root, 'design/MOTION.md'), '# Motion\nChanged source\n'); + assert.throws(() => syncAgentPlugin(root, true), /Stale agent reference/); + syncAgentPlugin(root); + writeFileSync(path.join(root, 'plugins/ran/skills/design/references/MOTION.md'), 'tampered'); + assert.throws(() => syncAgentPlugin(root, true), /Stale agent reference/); + syncAgentPlugin(root); + rmSync(path.join(root, 'design/MOTION.md')); + writeFileSync(path.join(root, 'design/README.md'), '# Design\n'); + assert.throws(() => syncAgentPlugin(root, true), /Stale agent reference/); + syncAgentPlugin(root); + syncAgentPlugin(root, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('generic references reject unresolved repository dependencies', () => { + const root = mkdtempSync(path.join(tmpdir(), 'ran-plugin-closure-')); + try { + mkdirSync(path.join(root, 'design')); + writeFileSync(path.join(root, 'design/README.md'), '[API](../packages/ranui/API.md)'); + assert.throws(() => syncAgentPlugin(root), /Unresolved portable reference/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/bin/test/sync-design-docs.test.ts b/bin/test/sync-design-docs.test.ts new file mode 100644 index 0000000000..c1ac31e6db --- /dev/null +++ b/bin/test/sync-design-docs.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { renderDesignDocs, syncDesignDocs } from '../sync-design-docs.ts'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); + +test('generated npm guides exactly match the canonical methods and adapter', async () => { + await syncDesignDocs(root, true); +}); + +test('portable method reading dependencies remain inside its distributed directory', async () => { + const design = resolve(root, 'design'); + const files = await readdir(design); + for (const file of files.filter((name) => name.endsWith('.md'))) { + const content = await readFile(resolve(design, file), 'utf8'); + for (const match of content.matchAll(/\]\(([^)]+)\)/g)) { + const link = match[1]!; + if (/^(?:https?:|#)/.test(link)) continue; + const target = resolve(dirname(resolve(design, file)), link.split('#')[0]!); + assert.ok(target.startsWith(`${design}/`), `${file}: escaping dependency ${link}`); + const targetContent = await readFile(target, 'utf8'); + const fragment = link.split('#')[1]; + if (fragment) { + const anchors = new Set( + [...targetContent.matchAll(/^#{1,6} (.+)$/gm)].map((heading) => + heading[1]! + .toLowerCase() + .replace(/[^\p{L}\p{N}_\- ]/gu, '') + .replaceAll(' ', '-'), + ), + ); + assert.ok(anchors.has(decodeURIComponent(fragment)), `${file}: missing heading ${link}`); + } + } + } +}); + +test('freshness gate rejects an edited distribution and regeneration repairs it', async () => { + const fixture = await mkdtemp(resolve(tmpdir(), 'ran-design-docs-')); + try { + for (const directory of ['design', 'packages/ranui/docs/adapters']) { + await mkdir(resolve(fixture, directory), { recursive: true }); + for (const file of await readdir(resolve(root, directory))) { + if (file.endsWith('.md')) + await writeFile(resolve(fixture, directory, file), await readFile(resolve(root, directory, file))); + } + } + await syncDesignDocs(fixture, false); + await syncDesignDocs(fixture, true); + const target = resolve(fixture, 'packages/ranui/docs/INTERACTION.md'); + await writeFile(target, (await readFile(target, 'utf8')) + 'Independent duplicate rule\n'); + await assert.rejects(syncDesignDocs(fixture, true), /stale/); + await syncDesignDocs(fixture, false); + await syncDesignDocs(fixture, true); + // Changing a source must invalidate its generated output too. + const source = resolve(fixture, 'design/INTERACTION.md'); + await writeFile(source, (await readFile(source, 'utf8')) + '\nNew canonical decision\n'); + await assert.rejects(syncDesignDocs(fixture, true), /stale/); + await syncDesignDocs(fixture, false); + assert.equal( + await readFile(target, 'utf8'), + (await renderDesignDocs(fixture)).get('packages/ranui/docs/INTERACTION.md'), + ); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); diff --git a/bin/tooling-json.ts b/bin/tooling-json.ts new file mode 100644 index 0000000000..90b5f65f43 --- /dev/null +++ b/bin/tooling-json.ts @@ -0,0 +1,85 @@ +/** Narrow untrusted JSON before repository tooling acts on paths or release metadata. */ +import assert from 'node:assert/strict'; + +export type JsonRecord = Record; +export function object(value: unknown, label = 'JSON object'): JsonRecord { + assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), `Expected ${label}`); + return value as JsonRecord; +} +export function text(value: unknown, label: string): string { + assert.equal(typeof value, 'string', `Expected ${label} to be a string`); + return value as string; +} +export function strings(value: unknown, label: string): string[] { + assert.ok(Array.isArray(value), `Expected ${label} to be an array`); + return value.map((entry) => text(entry, label)); +} +export function stringRecord(value: unknown, label: string): Record { + return Object.fromEntries( + Object.entries(object(value, label)).map(([key, entry]) => [key, text(entry, `${label}.${key}`)]), + ); +} +export interface ExportConditions { + import?: string; + types?: string; + default?: string; + [condition: string]: string | ExportConditions | undefined; +} +function exportTarget(value: unknown): string | ExportConditions | null { + if (typeof value === 'string' || value === null) return value; + return Object.fromEntries( + Object.entries(object(value, 'export conditions')).map(([key, entry]) => { + const target = exportTarget(entry); + assert.ok(target !== null, 'Nested export conditions must name a target'); + return [key, target]; + }), + ); +} +export interface PackageManifest { + name: string; + version: string; + type?: string; + engines?: { node?: string }; + scripts?: Record; + dependencies?: Record; + exports?: Record; + packageManager?: string; + license?: string; + repository?: string | { url: string }; +} +export function packageManifest(value: unknown): PackageManifest { + const data = object(value, 'package manifest'); + const result: PackageManifest = { + name: text(data.name, 'package name'), + version: text(data.version, 'package version'), + }; + for (const key of ['type', 'packageManager', 'license'] as const) + if (data[key] !== undefined) result[key] = text(data[key], key); + if (data.engines !== undefined) { + const engines = object(data.engines, 'engines'); + result.engines = engines.node === undefined ? {} : { node: text(engines.node, 'engines.node') }; + } + for (const key of ['scripts', 'dependencies'] as const) + if (data[key] !== undefined) result[key] = stringRecord(data[key], key); + if (data.exports !== undefined) + result.exports = Object.fromEntries( + Object.entries(object(data.exports, 'exports')).map(([key, entry]) => [key, exportTarget(entry)]), + ); + if (data.repository !== undefined) + result.repository = + typeof data.repository === 'string' + ? data.repository + : { url: text(object(data.repository, 'repository').url, 'repository.url') }; + return result; +} +export function hasErrorCode(error: unknown, code: string): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === code; +} +export function printCommandError(error: unknown): void { + if (error === null || typeof error !== 'object') return; + for (const key of ['stdout', 'stderr'] as const) { + const value = + key === 'stdout' ? ('stdout' in error ? error.stdout : undefined) : 'stderr' in error ? error.stderr : undefined; + if (typeof value === 'string' || Buffer.isBuffer(value)) process.stderr.write(value); + } +} diff --git a/bin/verify-md-links.mjs b/bin/verify-md-links.ts similarity index 96% rename from bin/verify-md-links.mjs rename to bin/verify-md-links.ts index e21a29d2ba..12c8ded3cb 100644 --- a/bin/verify-md-links.mjs +++ b/bin/verify-md-links.ts @@ -42,7 +42,7 @@ const FENCE = /^```[\s\S]*?^```/gm; * @param out Accumulator. * @returns Absolute paths. */ -function collect(dir, out = []) { +function collect(dir: string, out: string[] = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (SKIP.has(entry.name)) continue; const path = join(dir, entry.name); @@ -65,14 +65,14 @@ function collect(dir, out = []) { * @param target The link target, fragment already stripped. * @returns Whether the target exists. */ -function resolves(file, target) { +function resolves(file: string, target: string) { const path = resolve(dirname(file), target); // Extensionless is how the documentation site links between pages: `./throttle` is // served from `throttle.md`, and `../utils/` from `utils/index.md`. return existsSync(path) || existsSync(`${path}.md`) || existsSync(join(path, 'index.md')); } -const broken = []; +const broken: string[] = []; for (const file of collect(ROOT)) { const source = readFileSync(file, 'utf8').replace(FENCE, (block) => block.replace(/[^\n]/g, ' ')); const lines = source.split('\n'); diff --git a/bin/verify-package-consumers.mjs b/bin/verify-package-consumers.ts similarity index 90% rename from bin/verify-package-consumers.mjs rename to bin/verify-package-consumers.ts index 8786616b1a..b80fbbfd58 100644 --- a/bin/verify-package-consumers.mjs +++ b/bin/verify-package-consumers.ts @@ -1,3 +1,5 @@ +import { packageManifest, hasErrorCode, printCommandError } from './tooling-json.ts'; +import type { PackageManifest } from './tooling-json.ts'; /** Exercise actual packed artifacts, including pnpm's published workspace metadata. Build first. */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; @@ -7,13 +9,13 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Script } from 'node:vm'; -import { verifyComponentBundles } from './component-bundle-size.mjs'; +import { verifyComponentBundles } from './component-bundle-size.ts'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fixture = await mkdtemp(join(tmpdir(), 'ran-packages-')); -const run = (command, args, cwd = fixture) => execFileSync(command, args, { cwd, stdio: 'pipe' }); +const run = (command: string, args: string[], cwd = fixture) => execFileSync(command, args, { cwd, stdio: 'pipe' }); const packs = ['ranuts', 'ranview', 'ranui']; -async function assertESMOnly(directory) { +async function assertESMOnly(directory: string): Promise { for (const entry of await readdir(directory, { withFileTypes: true })) { assert.ok(!/\.(?:cjs|cts)(?:\.map)?$/.test(entry.name), `Unexpected CommonJS artifact: ${entry.name}`); assert.ok(!['umd', 'cjs-types'].includes(entry.name), `Unexpected compatibility directory: ${entry.name}`); @@ -22,23 +24,23 @@ async function assertESMOnly(directory) { new Script(await readFile(join(directory, entry.name), 'utf8'), { filename: entry.name }); } } -function assertNoRequire(exports) { +function assertNoRequire(exports: unknown): void { if (!exports || typeof exports !== 'object') return; assert.ok(!Object.hasOwn(exports, 'require'), 'Published exports must not retain require conditions'); for (const value of Object.values(exports)) assertNoRequire(value); } -const manifests = new Map(); +const manifests = new Map(); try { await mkdir(join(fixture, 'node_modules'), { recursive: true }); for (const directory of packs) { const source = join(root, 'packages', directory); - const manifest = JSON.parse(await readFile(join(source, 'package.json'), 'utf8')); + const manifest = packageManifest(JSON.parse(await readFile(join(source, 'package.json'), 'utf8'))); const tarball = join(fixture, `${directory}.tgz`); run('pnpm', ['--config.ignore-scripts=true', 'pack', '--out', tarball], source); const installed = join(fixture, 'node_modules', manifest.name); await mkdir(installed, { recursive: true }); run('tar', ['-xzf', tarball, '--strip-components=1', '-C', installed]); - const packed = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')); + const packed = packageManifest(JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'))); assertNoRequire(packed.exports); assert.equal(packed.type, 'module'); await assertESMOnly(join(installed, 'dist')); @@ -54,7 +56,7 @@ try { try { await symlink(join(source, 'node_modules', dependency), target, 'junction'); } catch (error) { - if (error.code !== 'EEXIST') throw error; + if (!hasErrorCode(error, 'EEXIST')) throw error; } } } @@ -64,8 +66,8 @@ try { const imports = []; let count = 0; for (const [name, manifest] of manifests) { - for (const [entry, conditions] of Object.entries(manifest.exports)) { - if (typeof conditions !== 'object' || entry.includes('*')) continue; + for (const [entry, conditions] of Object.entries(manifest.exports ?? {})) { + if (!conditions || typeof conditions !== 'object' || entry.includes('*')) continue; const specifier = name + (entry === '.' ? '' : entry.slice(1)); if (conditions.import) imports.push(`import * as p${count} from '${specifier}'; void p${count};`); count++; @@ -202,8 +204,7 @@ assert.equal(rgbToHex(1,2,3), '#010203'); run(process.execPath, ['runtime.mjs']); console.log('Packed libraries: ESM helpers, component identity and SSR round trips passed'); } catch (error) { - if (error.stdout) process.stderr.write(error.stdout); - if (error.stderr) process.stderr.write(error.stderr); + printCommandError(error); throw error; } finally { await rm(fixture, { recursive: true, force: true }); diff --git a/bin/verify-packages.mjs b/bin/verify-packages.ts similarity index 93% rename from bin/verify-packages.mjs rename to bin/verify-packages.ts index 9d4ffb3b96..6b21eed7ae 100644 --- a/bin/verify-packages.mjs +++ b/bin/verify-packages.ts @@ -11,7 +11,7 @@ import { readDirectories, readManifest, readPackageJson, -} from './packages.mjs'; +} from './packages.ts'; const failures = []; const manifest = readManifest(); @@ -67,7 +67,10 @@ if (failures.length > 0) { process.exit(1); } -const counts = manifest.reduce((out, entry) => ({ ...out, [entry.status]: (out[entry.status] ?? 0) + 1 }), {}); +const counts = manifest.reduce>( + (out, entry) => ({ ...out, [entry.status]: (out[entry.status] ?? 0) + 1 }), + {}, +); const summary = Object.entries(counts) .map(([status, n]) => `${n} ${status}`) .join(' · '); diff --git a/bin/verify-release-install.mjs b/bin/verify-release-install.ts similarity index 92% rename from bin/verify-release-install.mjs rename to bin/verify-release-install.ts index 7a229408d7..07593afe43 100644 --- a/bin/verify-release-install.mjs +++ b/bin/verify-release-install.ts @@ -1,3 +1,4 @@ +import { packageManifest, printCommandError } from './tooling-json.ts'; /** Install verified tarballs with npm outside the workspace, without dependency symlinks. */ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -6,15 +7,16 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { currentTag, readPlan, ROOT, verifyArtifacts } from './release.mjs'; -import { assertSizeBudgets, measureInstallation } from './package-size.mjs'; +import { currentTag, readPlan, ROOT, verifyArtifacts } from './release.ts'; +import { assertSizeBudgets, measureInstallation } from './package-size.ts'; const tag = process.argv[2] ?? (await currentTag()); const plan = await readPlan(ROOT, tag); const artifacts = join(ROOT, '.release'); const receipt = await verifyArtifacts(artifacts, plan, tag); const fixture = await mkdtemp(join(tmpdir(), 'ran-release-install-')); -const run = (name, args) => execFileSync(name, args, { cwd: fixture, stdio: 'pipe', timeout: 300000 }); +const run = (name: string, args: string[]) => + execFileSync(name, args, { cwd: fixture, stdio: 'pipe', timeout: 300000 }); try { await writeFile(join(fixture, 'package.json'), '{"name":"ran-release-consumer","private":true,"type":"module"}'); run('npm', [ @@ -42,13 +44,15 @@ try { (await realpath(installed)).startsWith(await realpath(fixture)), 'Installed package escaped the isolated fixture', ); - const manifest = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')); + const manifest = packageManifest(JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'))); assert.equal(manifest.version, pkg.version); - assert.equal(manifest.engines.node, '>=24.0.0'); + assert.equal(manifest.engines?.node, '>=24.0.0'); if (pkg.name === 'ranuts') { const license = await readFile(join(installed, 'dist/third-party/jschardet/LICENSE.txt'), 'utf8'); const notice = await readFile(join(installed, 'dist/third-party/jschardet/NOTICE.txt'), 'utf8'); - const dependency = JSON.parse(await readFile(join(fixture, 'node_modules/jschardet/package.json'), 'utf8')); + const dependency = packageManifest( + JSON.parse(await readFile(join(fixture, 'node_modules/jschardet/package.json'), 'utf8')), + ); assert.equal(license, await readFile(join(fixture, 'node_modules/jschardet/LICENSE'), 'utf8')); assert.ok(notice.includes(`jschardet@${dependency.version}`)); assert.ok(notice.includes('Netscape Communications Corporation')); @@ -97,8 +101,8 @@ try { } } } - for (const [path, conditions] of Object.entries(manifest.exports)) { - if (typeof conditions !== 'object' || !conditions.import || path.includes('*')) continue; + for (const [path, conditions] of Object.entries(manifest.exports ?? {})) { + if (!conditions || typeof conditions !== 'object' || !conditions.import || path.includes('*')) continue; const name = pkg.name + (path === '.' ? '' : path.slice(1)); imports.push(`import * as p${imports.length} from ${JSON.stringify(name)}; void p${imports.length};`); } @@ -178,8 +182,7 @@ assert.match(await renderHTMLToString('Ready'), /shadowroot 'Release install: real npm dependency resolution, strict NodeNext/Bundler declarations, ESM/SSR execution and browser production build passed', ); } catch (error) { - if (error.stdout) process.stderr.write(error.stdout); - if (error.stderr) process.stderr.write(error.stderr); + printCommandError(error); throw error; } finally { await rm(fixture, { recursive: true, force: true }); diff --git a/design/ACCEPTANCE.md b/design/ACCEPTANCE.md new file mode 100644 index 0000000000..714a1eb709 --- /dev/null +++ b/design/ACCEPTANCE.md @@ -0,0 +1,154 @@ +# UI acceptance — evidence and decisions + +Use this contract for a changed visual or interaction outcome. [DESIGN](./DESIGN.md) owns +appearance and composition; [INTERACTION](./INTERACTION.md) owns observable behavior. +The [interface index](./INTERFACE_GUIDE.md) routes the task. Read only applicable sections. + +## Agree on the observable result + +Before editing, settle the [design brief](./DESIGN.md#style-coherence-across-components-and-pages), +and state the affected task, views/states and independent expectations in that same note. Name the relevant owner, supported public API and host capability limits. Select +widths, themes, languages and input paths from the actual change; do not invent unrelated work. +For composition, state content/text/icon spines, intentional navigation columns, hit bounds +and vertical relationships. Do not derive the expected value from the implementation under test. + +Review in order: task/hierarchy, static composition, reachable combined states, then motion. +Do not add animation to compensate for unresolved proportions or unclear navigation. When a review +changes the intended design, update the existing brief first and identify the evidence that prompted it. +This is a decision record, not a new approval ceremony for work the user already authorized. + +Keep one rule owner. An API/token check verifies source contracts; an image comparison verifies +pixels; an agent run verifies whether guidance was followed. None substitutes for the others. +A fixture demonstrates its own result, not acceptance of a host application it did not render. + +## Browser proof + +Capture the actual running surface before the fix when reproducing a visible defect. After the +fix, capture the same relevant state and inspect the full pane with realistic copy and surrounding +chrome. A mockup, source diff, changed assertion or screenshot of an unrelated build is not proof. +Inspect the composition as well as the link count: relabeling a redundant return as a breadcrumb +can still repeat the same context and add an unnecessary header row. Trace ancestor reachability +through the parent before deciding another visible shortcut is needed. + +- Wait for component registration, pending content, fonts and rendered frames before measuring. + Fail on missing/zero-sized targets. Record any subpixel tolerance; keep it below a full CSS pixel + for exact spines (the source example uses 0.5px). + For live overlays, verify that capture preserves the viewport and includes the full trigger/panel; + a full-page screenshot can invalidate a crop by causing responsive reflow or portal repositioning. +- Measure the defect's layer: wrapper, inner content, rendered text Range, icon viewport and + actual pointer target are different. A Range is not exact glyph ink. Account for shadow parts + through supported test seams; do not introduce production shadow queries for acceptance. +- Exercise affected reflow, wrapped translations, optional content and input paths. A static + screenshot cannot prove focus return, keyboard navigation, touch discovery, interruptibility + or async settlement. Record a continuous interaction when the defect unfolds over time. +- For a layout assertion, prove it rejects the relevant defect: move only label text while the + frame stays fixed, introduce a doubled margin/phantom helper, or expand a title target into + filler. Confirm the mutation won the cascade, observe failure, restore and observe success. + Use changes relevant to the task; do not repeat the entire example suite for a tiny fix. +- Verify raw data when changing presentation. For code, copy the source payload, not highlighted + DOM text: preserve tabs, blank lines, final newline and streamed additions. Verify clipboard + success/failure, native selection and long-line scrolling independently. Document browser + selection normalization without allowing it to alter the raw clipboard payload. + +The source-only [layout example](https://github.com/chaxus/ran/blob/main/packages/ranui/test/e2e/layout-contract.md) +contains runnable commands and geometry utilities. Consumers adapt its relationships to their +host; these helpers are not new ranui exports. CSS zoom stress does not establish native browser +zoom or text-size accessibility support. Say which paths remain unverified. + +### Review motion over time + +For changed motion, inspect a continuous action at normal speed, then replay slowly where a +transition breaks. Include activation, intermediate state, completion and the reverse path; repeat +or reverse input before completion. Verify the final state, focus, event count and reading position +independently of the animation. A slow replay helps diagnose discontinuity; it is not a frame-rate +benchmark. Reduced motion needs its own visible-state check, not just a shorter recording. + +Report a temporal defect as `element → input → observed frame/state → expected continuity`. +Use one representative real task across affected siblings to detect inconsistent motion language; +check the full composition again after local fixes. Preserve failed iterations as diagnostics, +separate from accepted captures. These checks complement the existing evidence binding below. + +## Walk through the task + +For a changed workflow or composition, start with a realistic goal expressed without naming the +control to use. Complete it from the actual entry point through result, recovery and return, as +relevant. Record the first choice, any ambiguous label, hesitation or backtracking, and whether +the result makes the next step clear. A shorter click path is not useful if the choices are unclear. +Use realistic content, including a representative long or failing case, rather than a polished +empty state alone. Fix task structure and content hierarchy before adding decorative effects. + +Report the most consequential observed problems (up to three), with the state and evidence that +show each one; do not invent findings to fill a quota. Repeat the affected path after correction. +An agent walkthrough can expose defects but is not a human usability study. When observing a real +participant, let them attempt the goal before explaining the interface; preserve that distinction +in the results below. + +## Bind evidence to what actually ran + +For each reviewed state, retain a compact record of: + +- **Runtime:** tested source revision/build and relevant dirty inputs or their fingerprint; + include shared styles/components and incoming data when they affect the result. A Git HEAD + alone is insufficient if the running build or working tree differs. +- **Scope/environment:** affected view/group membership, route/state, viewport, theme, locale, + browser and input or reduced-motion settings where relevant. +- **Media:** capture/recording locations and content identity (for example SHA-256), tied to + that runtime and state. Keep before and after distinct; never overwrite the only before image. +- **Review:** what the automated check and agent inspection actually established, separate + from any human decision and any unverified state. + +A short Markdown record is enough; an existing manifest may supply these fields. Do not add a +new evidence service or configuration framework for an ordinary change. Keep hashes beside the +files or in a referenced manifest so the claimed binding can be checked independently. + +**A review applies only to the evidence and runtime scope it inspected.** Replacing an image, +editing a recording, changing group membership or changing relevant runtime inputs makes the +affected review stale. After a shared style/component change, refresh every affected state, +rebuild if needed, then inspect the new evidence. Old captures may remain as history, not current +proof. A docs-only edit or unrelated source change does not invalidate identical runtime evidence; +record why its dependency scope is unaffected rather than recapturing everything by HEAD alone. + +## Report authority truthfully + +Keep these results separate, even in a one-paragraph report: + +| Result | Who/what can establish it | What it does not establish | +| ------------------- | ------------------------------------------------------------------------------- | ------------------------------------ | +| Automated checks | Fresh command output or browser assertions for the declared runtime | Visual quality or a human decision | +| Agent visual review | Agent inspected current captures and the affected interaction | User acceptance | +| Human decision | An actual user's decision, with its message/action reference and evidence scope | Acceptance of later changed evidence | + +Use explicit outcomes such as `automated: passed`, `agent review: passed with limits`, +`human: not requested`. If human review was requested and is outstanding, say `pending`; +if a user rejected it, preserve `rejected` and the finding until that user reviews the updated +scope. An agent may report its correction and fresh checks, but cannot invent a user decision +or clear a human rejection. Avoid an unattributed “accepted”, “approved” or “验收通过”. + +User authorization controls whether to push, update a PR or take another external action. +This document adds no blanket human approval gate: finish and deliver authorized work while +reporting `not requested`/`pending` truthfully. Ask only when the actual authorization or a +necessary product decision is missing. Never label the agent's own visual check “user accepted”. + +A minimal delivery note can be: + +> Runtime and scope: build/source …; state/environment …; relevant input fingerprint … +> Evidence: before/after/interaction …; identities recorded in … +> Automated: commands/results … +> Agent review: observed outcome, remaining finding or limit … +> Human decision: not requested / pending / actual decision and reference … +> Unverified: …; authorized PR/update: … + +## Regression of the guidance itself + +For opt-in or task-authorized live evaluation of skill/routing/acceptance changes, use the source-only +[six agent scenarios](https://github.com/chaxus/ran/tree/main/plugins/ran/evals). +Deterministic controls check the assessor without model calls; live runs require an available agent harness +and are not a mandatory gate for every prose edit. Freeze tasks, fixtures, tools and independent rubrics before live runs. Use fresh sessions with +matching model/settings; pin baseline and candidate guidance in separate workspaces. Retain +actual agent work, prompts, outcome checks and evidence. Record baseline passes as controls and +candidate failures as failures. Do not show workers the assessor's thresholds or repair patches. + +Seeded-defect rejection/restoration checks establish the assessor's sensitivity. Only live agent +runs establish behavior on those tasks; prose matching, hand-authored answers or corrected test +expectations do not. Inspect visual judgment independently of mechanical checks. Report sample +size, tools and uncovered conditions; six successful tasks do not prove reliability across models. diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000000..4a9398cc99 --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,530 @@ +# Ran Design + +Design methods for the host product and its existing technology. Read only the relevant +sections through [the task index](./INTERFACE_GUIDE.md). Library tokens and API mechanics belong +to their implementation adapter, not this portable method. + +## 1. Color — a state ladder, not a palette + +Choose semantic roles for content, surfaces, boundaries, actions and status. Define default, +hover, active, disabled and focus states together. Resolve light/dark values through the host’s +authoritative tokens; verify contrast in the actual composition. Brand accents are contextual. + +## 2. Spacing — a limited, rhythmic scale + +Use the host’s existing spacing scale. Tight gaps group related parts; larger gaps separate +tasks or regions. Establish inside padding before outside gaps. Do not copy a reference +product’s dimensions without evaluating content, density and input method. + +## 3. Typography — choose a role, not a size + +Choose heading, label, body, action and code roles with coherent size, weight and line height. +Verify actual scripts, fallback metrics and long content. Typography establishes hierarchy +before decorative containers. Keep a single authority for each role. + +## 4. Radius & elevation + +Coordinate nested corner radii with their real inset. Elevation explains a floating layer, +not importance; use the host’s stacking roles and verify overlays against surrounding chrome. + +## 5. Motion — prefer none + +Timing below is a starting heuristic. Use the host system’s motion tokens and verify frequency, continuity and reduced motion in the actual task. + +| Duration | Use | +| -------- | ----------------------------------------------------------------------- | +| 0ms | Frequent hover, focus and keyboard feedback; changes already understood | +| ~150ms | Optional, purposeful small motion, not a mandatory hover delay | +| ~200ms | Menu/popover motion when it clarifies the layer change | +| ~300ms | Dialog motion when orientation benefits from it | + +**Principle:** purpose and frequency determine whether motion earns time. Repeated interaction defaults +to immediate feedback; a larger change is not automatically a reason to animate. Use existing tokens +for purposeful motion, keep it quick and interruptible, and respect `prefers-reduced-motion`. + +### Functional motion contract + +Specify the trigger, state change and destination before choosing an effect. Preserve unrelated +content, drafts and reading position. Sibling components with the same interaction meaning should +share a motion language; consistency does not require every component to animate. + +When motion is useful, allow a new intent to interrupt or reverse it from the current presentation, +without a jump, stale completion or duplicate activation. Keep feedback immediate; animation is not +an input lock. During insertion/removal, avoid visibly squeezing text or passing exiting controls +across live actions; coordinate opacity and layout so the result stays readable. Reduced motion +must retain a visible, understandable final state. A spring is optional and need not overshoot. + +See the optional [component craft research](./REFERENCE_RESEARCH.md#component-craft-设计垂点--wxaddesign) +for examples and limits, and [temporal acceptance](./ACCEPTANCE.md#review-motion-over-time) for proof. + +Theme palettes should settle coherently. Avoid implicit palette fades or `transition: all`; +explicitly opt in only where the host intends and verifies a coordinated effect. +Do not replay decorative title entrances or animate factual counts on each return to a working +surface. Keep readable content and actual values available immediately. A loading demonstration +may move to demonstrate loading; a decorative status pulse must not imply live activity. + +## 6. Content — copy is part of the system + +- **Buttons:** name the outcome. Include the object when context does not establish it: "Deploy project", "Delete member". In a clearly named object or dialog, "Save" or "Delete" is sufficient; avoid generic "OK". See [contextual wording](#words-are-part-of-the-interface). +- **Errors:** say **what happened**, then **how to fix it**. ✅ "Build failed: the bundle exceeds the size limit. Reduce it or raise the limit." ❌ "Operation failed, please try again." +- **Confirmations / toasts:** state the completed **change**. ✅ "Project deleted". ❌ "Operation successful". Show completion feedback only after the operation is confirmed. +- Be specific; every sentence should remove guesswork, not add it. + +## 7. Accessibility — the system serves everyone + +- Maintain sufficient text-to-background contrast (**WCAG AA**). +- **Never signal state with color alone** — pair it with an icon or text (e.g. ✓ / ✕ labels, not just green/red). +- Every interactive element has a **visible focus ring** appropriate to its surface. Never remove it for "cleanliness". +- **Full keyboard navigation** — nothing is mouse-only. +- Respect `prefers-reduced-motion` and `prefers-color-scheme`. + +## 8. Input & viewport — mobile and desktop, both + +- Support mouse, keyboard and touch at narrow and wide widths. Reflow controls instead of + hiding the only route to an action. A hover affordance needs a click/tap alternative. +- Drags use Pointer Events including cancellation; scope `touch-action` to the actual drag + surface and pair it with a real handler. Preserve ordinary scrolling elsewhere. +- Prefer content/viewport-relative bounds (`min()`, `max()`, `clamp()`) to arbitrary + breakpoints. Use existing host breakpoints or introduce a justified shared role only when repeated needs emerge. +- A measured position becomes stale after resize, container reflow or scroll. Exercise + resizing into a narrow layout, not only loading there. Overlay placement must track its + trigger while open. The host owns lifecycle mechanics; [ranui engineering](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md#3-state-and-ownership) is one implementation adapter. +- Test actual pointer/touch gestures, hover fallback and keyboard routes where affected; + checking two color schemes does not establish input parity. + +## 11. Composition — the page, not the component + +Choose the page structure in §12 first; then compose its components with these rules. + +### Style coherence across components and pages + +Before implementation, record a short design brief in the existing task note or product design +owner. Use the existing system; a small fix only needs the affected decisions. "Premium", "modern" +or "like Claude" is a direction to investigate, not an implementable specification. + +| Decision | What the brief must settle | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| Task and hierarchy | The real content, primary action and first reading focus; what can recede or disappear. | +| Proportion | Type roles/line heights, text measure, control density, icon weight and alignment spines using host tokens. | +| Surface language | Which regions need containment, nested radius/inset relationships, semantic accent and elevation roles. | +| State composition | A representative default plus long content, pending/failure and narrow input paths; include combined states. | +| Motion | The state change it explains, trigger and frequency, interruption/reverse behavior and reduced-motion result; "none" is valid. | +| Reference decision | Where a reference is used, link its source, the principle adopted and the adaptation required by the host task. | +| Acceptance | The observable result and actual views/recordings that can demonstrate or reject it. | + +Reuse the brief across sibling components and docs chrome; change emphasis according to the task. +Uniformity does not mean equal-sized controls or boxed sections everywhere. An expressive demo +must remain visibly distinct from its documentation chrome. Update the brief before changing the +visual direction; do not silently rewrite the standard to justify an implementation that failed review. + +Compare a representative composed still with different real content before animating it. Then +inspect the state change and return path at normal speed. A shared color alone cannot reconcile +inconsistent proportions, icon weight, containment or motion. When an example looks imported from +another product, fix that relationship at its owning token/component/style rather than adding a +page-specific patch. Keep reference observations and validation limits in the optional +[research index](./REFERENCE_RESEARCH.md#style-coherence-and-transition-research). + +### 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 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. + +**Avoid redundant nested containers and doubled padding.** A card inside a padded panel +does not need another decorative frame or inset. Keep functionally required nested controls or +surfaces, and coordinate their radii with the actual inset; containment alone is not a reason to add a box. + +**Every persistent boundary needs a distinct job.** Start with headings, alignment and proximity +to group content. Add a border, surface fill or elevation only when that structure leaves a real +boundary unclear. Do not repeat the same grouping with a panel frame, a nested card, row rules +and another inset at the same time. Faint lines are still extra hierarchy; reducing their contrast +or shortening them does not justify a redundant enclosure. + +| Boundary | Keep when it communicates | Remove when it only repeats | +| ----------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Field/control frame | Where values can be entered or a selection made | A decorative wrapper around already legible commands | +| Region frame or divider | An independent viewport, resize boundary or major change of task | A title and gap that already make the same group clear | +| Nested surface | A distinct floating layer, preview, drop target or interaction boundary | Another container inside an established settings pane | +| Focus/selection outline | Keyboard focus or persistent selection state | Never remove this state cue merely to make the resting layout quieter | + +Review the full pane or page, not only the cropped group. For every remaining stable outline, +fill or separator, name the relationship or interaction it communicates and check what becomes +ambiguous if it is removed. A frame is optional; a reachable, recognisable control is not. + +### 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 | Relative spacing | +| ------------------------ | ------------------------------- | +| Parts of one control | Tightest deliberate gap | +| Closely related controls | Compact group gap | +| One content group | Group rhythm | +| Separate groups | Larger separation | +| Major region boundary | Strongest deliberate separation | + +**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. + +### Vertical rhythm across sibling views + +Repeat relationships, not fixed heights. Choose title/description, field/hint, group and region gaps +from the host scale according to their relationship. These are composition decisions, not universal +component dimensions. Translations grow naturally; missing descriptions leave no vacant row. + +Give each gap one owner: parent stack or explicit child margin. Reset authored local heading/ +paragraph margins rather than adding them to the parent's gap. Account for owned control padding, +collapsing margins and inline baseline space; do not compensate with transforms or smaller type. +Place hints with the field/action they explain. Hidden helpers leave no accidental gap; a reserved +async status line needs a stated purpose and bounds. + +Measure both axes after content settles. `next.top - previous.bottom` measures frame separation, +not glyph/line-box space. Keep leading, padding and hairlines distinct: 16px padding plus a 1px +rule is not a 17px spacing token. Use independent expectations and exercise wrapping, resizing, +helper/error visibility and relevant density. [ACCEPTANCE](./ACCEPTANCE.md#browser-proof) owns +settling and mutation checks for doubled margins, phantom rows and text-only drift. + +### The measure governs text, not the column + +**Choose a readable measure for the actual script and font.** For Latin prose, 60–75 +characters per line is a starting heuristic, not a universal range for every writing system. +Review realistic text at supported widths, zoom levels and font fallbacks; CJK and mixed-script +text need their own judgment. [WCAG visual presentation](https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html) +also distinguishes CJK in its Level AAA criterion; it is not a mandatory library default. +A table, code fence, demo stage or image should retain the width its content needs. +Cap the text elements, not the column that holds them. + +**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. + +**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. + +**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. + +### Alignment is structure, not polish + +**Name each alignment spine and the geometry it aligns.** A page content spine, navigation +column and nested form-label spine can differ intentionally. State which siblings share each one; +include headers, body copy, rows, actions and empty/loading/error content. A back button plus gap +may establish a separate title column: do not silently treat that title as aligned to body text. +A missing optional icon or badge must not move labels out of their declared column. A nested level +returns exactly to its parent's spine when it ends. + +Compare the right boxes. These are separate measurements, even when they happen to coincide: + +| Geometry | What to measure | +| ------------- | ------------------------------------------------------------------------------------------------- | +| Frame | The wrapper/host border box from `getBoundingClientRect()` | +| Content | The inner edge after the actual border and padding, including nested wrappers | +| Text | A DOM `Range` around the rendered label's text nodes; use first/last line rectangles for wrapping | +| Icon viewport | The SVG/viewbox or image box; painted glyph ink may be asymmetric inside it | +| Hit area | The actual event target and its hover/focus surface; verify points near its edges | + +A button frame aligned with a paragraph does not prove its text is aligned: internal padding can +move the label while the frame stays fixed. `Range` gives typographic geometry, not exact glyph ink; +inspect optical glyph corrections separately. For web components, measure the host, documented shadow parts and slotted text through supported +test seams; do not add production shadow queries or assume document CSS crosses the boundary. + +**Keep a header command's hit zone separate from layout filler.** A title/menu trigger should hug +its content plus deliberate padding, bounded by the available width. Let a separate flex/grid track +absorb remaining space. Define truncation/wrapping and adjacent action behavior for long translations. +Do not make an entire unused header strip clickable merely by giving the title button `flex: 1`. +A row that intentionally selects its whole width is a different role; record that contract explicitly. +Coarse-pointer targets may grow without stretching the hover fill across unrelated content. + +**Count every inset from the shared ancestor to the visible text.** Parent padding, nested panel +padding, a control's internal padding, borders and navigation columns all contribute. Remove a +redundant inset at its owner instead of cancelling it with an unexplained negative margin. Empty +states retain their region's content spine and width; extra breathing room must not introduce a +second left edge or make a compact action fill the region. Product-specific insets and target sizes +remain application aliases, not new foundation values or universal component defaults. + +**When two layout edges or gaps are meant to be equal, a one-pixel difference is a defect.** +Optical correction for an asymmetric glyph is a separate, documented asset/component decision: +reuse the correction at supported sizes while preserving exact sibling gaps, baselines and alignment +spines. It is not permission to nudge page margins until a screenshot looks balanced. + +### 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. + +**Action and link roles remain distinguishable.** Verify inline links against surrounding prose, +including themes where action and body colors happen to match. + +**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. + +### Command hierarchy in constrained panes + +**Remove redundant group enclosures without erasing control affordance.** Headings, +alignment and proximity establish groups inside a settings pane; they do not automatically +make an icon and label look clickable. Do not require flat rows in every context. Use them +when a recognizable menu/list pattern already conveys action. Standalone commands may need +their own restrained button surface or functional stroke, visible before hover in every +supported theme. A button's own shape identifies its target; an enclosing card, muted group +fill and row rules must still pass the distinct-boundary test above. + +Do not rely on hover, pointer cursors, verb labels or keyboard focus alone for initial +discovery. Inspect the resting full pane with the pointer away, including touch use. Keep +hover, press, disabled and keyboard focus distinguishable after the control is discovered. +Long translated labels may wrap inside a single command column rather than creating a +ragged horizontal button cloud. Do not add navigation chevrons to immediate commands. + +Declare which visible columns are shared with adjacent checkboxes or controls: measure +icon/checkbox edges and text Ranges, not only button rectangles or alignment within the +command group. A hit target may extend into an owned gutter to preserve those columns; +name the extension, keep it inside its pane and verify non-overlap and focus visibility. + +Separate destructive commands from recovery and transfer actions. A danger label and icon +can communicate intent without a large red outline on every resting command; reserve the +strongest danger fill for explicit confirmation. Keep confirmation, cancellation, focus +return and busy guards. Acceptance covers resting discoverability, declared shared columns, +full-pane boundary hierarchy, target bounds, label clipping, contrast and interaction states. +An automated surface-difference check catches invisible control fills but does not prove +that users recognize the action: inspect the actual render as well. Reuse foundation roles +and coarse-input target requirements; this guidance changes no component defaults. + +### One product, one token set + +Use the product’s authoritative semantic tokens. Aliases may improve readability, but resolve +from that source rather than inventing a competing palette. Coordinate third-party components +through supported theme APIs and verify the composed result. + +### Density follows control roles + +Spacing, intrinsic dimensions and hit targets solve different problems. A small glyph can have +a larger native button target. Measure actual hit bounds, focus and adjacent gaps; derive repeated +host aliases from foundation tokens, not one application's dimensions copied across the library. + +Choose target bounds for the actual input method without enlarging every glyph or reserving +space for hidden controls. Use supported public theme APIs. Name icon-only controls accessibly +and document legitimate viewport, hairline, artwork and optical geometry exceptions. + +### Evaluate craft through evidence + +Polish follows task clarity, not a preset of cards, blur or entrances. Choose a deliberate +visual direction using realistic objects, copy and states; a generic empty shell cannot establish +product quality. Working interfaces need sustained reading and frequent actions; marketing pages +may need a different composition. Evaluate the smallest intervention against real content, language, +device and repeated use. Primary references answer +specific questions: [Linear hierarchy](https://linear.app/now/behind-the-latest-design-refresh), +[Geist typography](https://vercel.com/geist/typography), +[optical alignment](https://craft.gustavofior.com/optical-alignment) and +[interruptibility](https://craft.gustavofior.com/interruptibility). Adapt their principles with +the host’s tokens and actual capabilities; do not copy a product's pixel sizes or import recipes as rules. +[Don't Make Me Think](https://sensible.com/dont-make-me-think/) informs scanning, hierarchy and +clear choices; [taste-skill](https://github.com/leonxlnx/taste-skill) informs deliberate direction +and critique; [ryugi](https://github.com/Innei/ryugi) informs executable work and fresh evidence. +These references inform decisions, not additional instruction sets. A stylistic preference needs +context; a requirement must identify the task or observable contract it protects. + +Choose references by the job they support: logo/app identity, marketing composition and frequent +working interactions solve different problems. Record the observed pattern and its adaptation; +verify the result with real content. The optional [research index](./REFERENCE_RESEARCH.md) maps +sources to questions and demonstrates this translation without adding another rule set. + +When direction is unresolved, compare two small real-host variants with the same content, width +and theme, changing one dimension at a time. Explain what each difference does for the task. +A clear direction or small correction proceeds directly; there is no mandatory approval ritual. + +- Balance short headings progressively with `text-wrap: balance`, preserving normal wrapping. + Do not insert locale-dependent manual breaks or clip text to preserve a screenshot. Check cost + and support before using `pretty` on long live content. +- Tabular figures stabilize digit advances if the font supports them; reserve a meaningful + column for digit-count changes such as 99 → 100 without truncating values. +- Correct asymmetric glyphs in a reusable asset/component, preserving exact layout spines. + Derive nested corner geometry from actual inset; remove an unnecessary nested surface first. +- Motion needs a job: immediate frequent feedback, interruptible transitions, reduced motion and + instant themes. Optional delight must not delay typing or obscure status. Pause offscreen + decoration and profile realistic content before adopting costly effects. See §5. + +### Words are part of the interface + +Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining. +A heading, grouping, proximity or obvious control should not need another sentence repeating +its purpose. Remove copy with no new decision, constraint or consequence; do not replace it +with a decorative badge or tooltip. Preserve accessible names, field labels, units, errors, +risk disclosures and useful first-use guidance. Test whether the task remains understandable. + +Let context carry context: `Users` rather than `User Management`; do not repeat a dialog's +question in its body. Name results (`Save`, `Move`, `Delete`), not gestures (`Click to save`). +Include the object only when context does not establish it. `Cancel` leaves without committing. +Use sentence case, no period on short labels, and a period on explanatory sentences. +Use one `…` when an action needs more input before completing; reserve ALL CAPS for short +statuses, eyebrows and acronyms. + +### Review checklist for a composed surface + +Apply these questions to the affected surface and reachable changed states. Skip irrelevant variants; +a small patch does not require redesigning unrelated pages. A "no" within scope is a finding: + +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. Are named spines exact at the visible text/content layer, including nested and empty states, + with intentional navigation columns and no doubled padding? Do adjacent controls share their declared + icon/text columns? Are title hit zones content-hugging? Do repeated vertical relationships use + consistent gaps with one owner, including wrapped copy and hidden helper/error content? +6. Is running text comfortable in its actual scripts, fonts and zoom levels, and is + everything that is **not** running text free of that measure 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? Does each persistent frame, + fill or divider communicate a distinct boundary instead of nesting the same group again? +8. Does it hold in relevant changed states: empty, loading, failure, long translations, narrow + width, supported themes, reduced motion and **RTL**, where affected? +9. For layout or interaction changes, has the affected surface been checked in a real browser + at the relevant widths and themes? Are standalone actions recognizable before hover, including + touch use, without redundant group enclosures? + +A happy-path screenshot does not prove keyboard behavior, focus, dynamic content, +resizing or failure recovery. Follow [ACCEPTANCE](./ACCEPTANCE.md) for independent expectations, +actual runtime/capture identity, browser measurements and separate automated, agent and human +results. The source-only [browser example](https://github.com/chaxus/ran/blob/main/packages/ranui/test/e2e/layout-contract.md) +contains setup commands and test utility details; they are not public package APIs. + +## 12. Information architecture — the shape before the composition + +Choose structure before composition for information-dense consoles, dashboards and workbenches. +Complete API data is not a page design: the reader still needs a clear order for understanding +objects, their state and relationships, then acting. Single-conversion and landing pages need +their own task structure rather than a dense-console template. + +### Answer three questions before naming a component + +1. **What must the reader see on arrival?** That is the primary information. +2. **What else has to be visible for it to make sense?** Related resources, related models, + context. +3. **What do they do next?** Judge, act, or keep thinking. + +**A page has exactly one primary model.** Supporting models may help the reader understand or +operate it; they may not compete for the first screen. Prefer a clear, unambiguous next choice +over an arbitrary click budget. Test whether readers recognize the next action, not merely +whether the destination is reachable. + +### The shape follows the task, not the payload + +Choose a skeleton from the reader's question, not an API array or route ID: + +| Task | Useful shape | +| --------------------------------------------- | -------------------------------------------------------- | +| Compare values/differences | Table or diff | +| Recognize/open a resource | List; image grid only when images identify it | +| Understand one object's current state | Sectioned detail | +| Understand ownership/dependencies | Tree or adjacency list; graph only when the path matters | +| Plan the next step / move work through stages | Step flow / kanban when moving is the actual work | +| Audit what happened, when and by whom | Timeline or trace drill-down | +| Discuss or read continuous text | Thread or document | +| Judge health, trends or anomalies | Status wall or dashboard | +| Find occupancy/conflicts or location | Calendar or map/canvas | +| Work on the next item / change rules | Master-detail workbench / configuration form | + +Do not confuse historical events with future steps, a filter with kanban, or ordered prose +with a field grid. A date alone does not justify a calendar. Ship multiple views only when +each serves a distinct task worth its filter/state/action maintenance. + +### Each kind of information has a place + +| Information | Belongs | Must not end up | +| ------------- | ------------------------------------------------------------ | ---------------------------------------- | +| Identity | Title, object summary | The last column, or behind a tab | +| Status | Title or summary region | Findable only in a detail field | +| Attributes | Detail body, grouped as people think about it | Flattened in API field order | +| Relationships | Own region or tab; ownership, dependency, reference distinct | Mixed into the attribute table | +| Changes | Diff region, timeline | Shown as the new value only | +| Evidence | Beside the judgement, expandable | A log page elsewhere | +| Actions | Primary in the title region, the rest beside their object | Buried under "more" | +| Feedback | Beside the action, keeping task context | A global toast detached from its subject | + +**Every fact has exactly one authoritative location.** Elsewhere shows a summary or an entry +point that links back to it. + +Default reading order — page identity → current status or exception → primary task and primary +action → the information the judgement needs → relationships, changes, evidence → secondary +information and low-frequency actions. **One primary action per task region**, and it is the +most likely next step, not the most destructive one (§11, "Emphasis is a budget", governs how +much weight it may take). + +### Density is effective information, not controls per inch + +Tightening spacing raises visual density and leaves effective density where it was. Removing +irrelevant fields and putting the comparison in one place raises the real thing. + +**Use at most two adjacent density levels on one page** — spacious for first use and risky +confirmation, standard for most lists and details, compact for expert workbenches. Container +padding and line height may tighten; readable body text, a visible focus ring and pointer +target size may not (§8). + +### Containers do not fix structure + +**One contextual return per view.** Do not show an outer “Back to chat” and an inner +“Back to artifacts” together. The detail view returns to its immediate parent; ancestors remain +reachable through the route hierarchy or clearly named breadcrumbs. Verify the full journey, +including focus return and long translated destination names. If text does not fit, reflow or +use a meaningful shorter destination; an ellipsis or tooltip must not hide the only destination cue. +Choose one composition for the parent context. Do not add a breadcrumb merely to relabel a +redundant return shortcut, or repeat that parent as an inert crumb above its return command. +When returning through the parent already reaches the ancestor, that journey preserves access +without adding another detail-header row. Use breadcrumbs when the hierarchy itself helps the task. + +**`r-modal` is not a navigation layer.** Anything needing a copyable link, history, a +side-by-side comparison, or work that survives a refresh gets a route. Reserve the modal for a +short confirmation or a single field, `r-popover` / `r-dropdown` for transient context, and +`r-disclosure-row` for progressive disclosure. + +**`r-tabs` carries peer views of one object** — its conversation, its checks, its diff — never +unrelated modules; that is navigation's job. + +If the chosen library lacks a suitable primitive, use existing semantic elements and host tokens; +verify the required interaction rather than inventing unsupported APIs. + +## Verification checklist (before shipping UI) + +Select checks for the affected surface, supported variants and changed behavior; do not create +unrelated flows merely to satisfy the list. Repository CI still runs its complete gates. + +- [ ] Primary task and primary action are unmistakable. +- [ ] Affected layout/theme changes work at relevant **narrow and wide** widths and supported themes. +- [ ] Works with **mouse and touch** — any drag/gesture uses Pointer Events, not mouse-only; any + `trigger="hover"` has a tap fallback. +- [ ] All changed **states** exercised (hover, active, focus, disabled, loading, empty, error). +- [ ] **Keyboard / focus** behavior verified; visible focus everywhere. +- [ ] Relevant edge cases: long text, large numbers and the host's supported scripts/locales. +- [ ] Spacing comes from the scale; type uses a role; color uses semantic tokens. +- [ ] Run the host’s applicable checks and inspect actual browser evidence. +- [ ] Copy follows §6; nothing signals state by color alone (§7). +- [ ] For a dense surface: one primary model, the skeleton chosen from the question rather + than the payload, and every fact with one authoritative location (§12). diff --git a/design/INTERACTION.md b/design/INTERACTION.md new file mode 100644 index 0000000000..d469472955 --- /dev/null +++ b/design/INTERACTION.md @@ -0,0 +1,121 @@ +# Interaction standard + +This file owns **observable behavior**, independent of the screen's domain or visual treatment. +[DESIGN.md](./DESIGN.md) owns appearance; the host owns implementation. For ranui contributors, +[CODING.md](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md) supplies the engineering adapter. +Use [WORKFLOW_PATTERNS.md](./WORKFLOW_PATTERNS.md) only for a relevant multi-step task. +No workflow infrastructure is implied by these general rules. + +## 1. Inputs, drafts and intent + +Keep labels, prerequisites and validation near the affected field. Distinguish a missing value from +an unavailable service. Explain why submission is unavailable when the prerequisite is not obvious. +Use appropriate native input semantics and autocomplete; preserve platform editing conventions. + +| Transition | Required decision | +| ------------------------------- | -------------------------------------------------------------------------------- | +| Empty → valid form | Enable the relevant submit action; leave context controls usable | +| Submit → running | Dispatch once; expose busy state without unexpectedly moving focus | +| Running → new edits | Specify whether edits affect this job, a later job or a retained draft | +| Failed → restore | Restore only repopulates fields; it must not submit them | +| Failed → retry | Retry dispatches again; preserve the original scope or ask about changed context | +| Settings/source change → return | Keep unfinished input and the user's selection where possible | + +Enter submits only where the field's documented convention calls for it, outside IME composition; +check composition state and `isComposing`. Preserve multiline entry and expose shortcuts when useful. +Dictation updates text and never submits by itself; retain the text that preceded capture. +Do not rebuild a focused input on each search response, stream update, resize or status change. + +The latest intent remains authoritative: an older query must not replace newer results, and an +import preview for one file must not appear under another. Use the host’s async identity and cancellation mechanisms; ranui contributors can consult +[CODING §3](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md#3-state-and-ownership). Retaining a draft does not prove it was saved: +promise persistence only after it succeeds. + +Search highlights literal matches in titles or snippets using safe text nodes or equivalent +escaping, never query-as-HTML. Keep enough surrounding text to explain why an item matched. + +## 2. Reachable controls and predictable focus + +Use native semantics and supported component contracts. Name icon-only actions accessibly. +Secondary actions may appear on hover **and keyboard focus**, with a persistent tap route on +coarse input. Keep essential recovery visible. Disabled controls remain quiet on hover and +cannot dispatch. Preserve a stable target box and focus ring as labels or status change. + +Use public attributes/events/slots/variables/parts. A keyboard-safe primitive does not prove a composed +dialog is safe: a focus trap may not discover a closed-shadow button in a footer. Verify initial focus, +Tab/Shift+Tab, Escape/Cancel, Enter and focus restoration. Fix the supported integration or use native +buttons in its supported slot; do not patch private shadow roots or add misleading host tab stops. + +A collapsed region must still identify its contents and state; disclosure must not hide an +unresolved actionable error. Collapsing a region containing focus returns focus to its toggle. +Use the collapsed summary to help readers decide whether to expand. When the expanded body +already displays that content, omit the repeated summary and keep the title and state visible. +Removing the focused object moves focus to the next meaningful action or a stable parent control. +Do not move focus merely because a background request completed. + +## 3. Feedback and reading position + +Associate feedback with the object acted on. A “Copied” acknowledgment beside Copy is easier +to associate than an unrelated global notification. Show it only after clipboard success; handle +rejection locally, preserving button geometry and focus. Use a toast for changes beyond the current +object or when local feedback would not be noticed. Do not announce every streamed character. +Bind async feedback to the operation's source snapshot. If code changes while Copy is pending, +the older completion must not mark the new content “Copied”; clear or identify stale feedback. +Verify delayed success and failure across an intervening stream update, not just an isolated delay. + +For live logs or streaming results, follow only while the reader is at the latest content. Scrolling +away suspends following; a “Jump to latest” action restores it without stealing reading position. +Use a supported follow/pin contract from the host or chosen component. Keep job and persistence +state in the application; an append-only reading log is not a job manager. + +## 4. Progress, failure and recovery + +Show a known phase: “Reading files”, “Checking permissions” or “Writing export”. A spinner signals +activity, not a percentage. Show percentage only with a measurable denominator; elapsed time is +secondary and must not masquerade as a completion estimate. Announce meaningful phase changes, +not every timer tick or streamed character. + +An error answers **what failed, whether anything changed, and what to do next**. For example: +“The import stopped before saving. Your selected files are still here. Retry the import.” +Only make that promise if the host can establish it. Preserve in-memory input if storage fails; +explain that persistence failed separately. Put credential-free diagnostics in optional details. + +| Condition | Useful response | Avoid | +| ------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------ | +| No readable file contents | Choose another file, or continue without it if supported | Treating every action as requiring document text | +| Network interrupted | Preserve inputs/partial output; retry when reachable | Clearing the form or declaring the entire application unusable | +| Permission or configuration rejected | Explain the category; link to the relevant account/settings | Automatic retry loops for a missing grant or invalid configuration | +| User cancelled | Mark cancelled; retain recoverable output | Red failure styling or restarting paused work | +| Context changed | Identify stale output; refresh against current data | Applying an old result to a newer document silently | + +One failure should have one coherent recovery, not duplicate rows and toasts. A timeout does not +prove rollback: establish whether a mutating operation committed before retrying, using the host's +idempotency/deduplication contract where available. Do not promise that Cancel undoes a completed write. + +## 5. Cancel, retry and completion are different outcomes + +Keep Cancel and Submit independently reachable when both are supported, with clear accessible +names and stable positions. A cancellation request is not an acknowledged cancellation: show its +pending state until confirmed. If the service keeps running and the client can only stop waiting, +name that limitation instead of claiming the job stopped. Distinguish cancellation from failure. + +Retry dispatches work again; Restore only repopulates editable inputs. Do not silently broaden scope +or switch destinations on retry. Confirm a completed write before reporting saved/applied success; +retain recoverable partial output. Offer Undo only when the host can actually reverse the operation. + +## 6. Verify what a person can observe + +Choose evidence by the defect, rather than requiring every possible test for a small change: + +| Evidence layer | Relevant regression | +| -------------- | ------------------------------------------------------------------------------------------- | +| Unit/state | Cancellation, late responses, scope identity and retry versus restore | +| Integration | Draft survives a context round trip; one coherent failure; overlay keyboard path | +| Real browser | Computed dimensions/baselines/overflow, target boxes, focus, disabled hover and IME | +| Cross-surface | Affected forms, search, history, settings, import/export, previews and confirmations | +| Variants | Relevant themes, widths, resize, keyboard/coarse input, translations/RTL and reduced motion | + +Include offline/failure paths when the task depends on external work. Static audits cannot prove +focus restoration, the winning cascade or preserved reading position. Report the tested commit and +remaining external prerequisites. Library contributors use [CODING's checklist](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md#checklist-before-opening-a-pull-request); +consumer applications use the host's relevant commands, not ranui's source-checkout commands. diff --git a/design/INTERFACE_GUIDE.md b/design/INTERFACE_GUIDE.md new file mode 100644 index 0000000000..72a26dbd18 --- /dev/null +++ b/design/INTERFACE_GUIDE.md @@ -0,0 +1,57 @@ +# Interface guidance — reading index + +Read the owner of the decision, not every document. This index adds no second standard. +General rules apply across forms, search, uploads, editing and content pages; optional +workflow recipes apply only when a task needs them. + +## Choose the reference + +| Decision | Owner | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Visual hierarchy, composition, type, spacing, density, color and motion | [DESIGN.md](./DESIGN.md), [composition](./DESIGN.md#11-composition--the-page-not-the-component) and [density](./DESIGN.md#density-follows-control-roles) | +| Observable input, focus, keyboard, drafts, feedback, progress and recovery | [INTERACTION.md](./INTERACTION.md#1-inputs-drafts-and-intent) (inputs), [focus](./INTERACTION.md#2-reachable-controls-and-predictable-focus), [recovery](./INTERACTION.md#4-progress-failure-and-recovery) | +| Optional batches/queues, capability-aware settings, account/data access or result revisions | [WORKFLOW_PATTERNS.md](./WORKFLOW_PATTERNS.md) | +| Actual browser proof, evidence freshness and review authority | [ACCEPTANCE.md](./ACCEPTANCE.md) | +| Component architecture, state ownership, events, async lifecycle and tests | [CODING.md](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md) | +| Exact attributes, events, slots and parts | [COMPONENTS.md](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/COMPONENTS.md), generated from source | +| Available component CSS variables | [style-tokens-public.md](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/style-tokens-public.md), generated from source | +| Component implementation recipes | [CLAUDE.md](https://github.com/chaxus/ran/blob/main/packages/ranui/CLAUDE.md) | + +Use the host’s installed design system and public APIs. Library-specific engineering and token +links above are optional ranui adapters; they are not prerequisites for a different technology. +Jobs, credentials, persistence and document mutation belong to the host application. + +## UI task lookup + +For a visual change, start with the matching row rather than reading all of DESIGN. +These links route to existing rules; they introduce no additional standard or default dimensions. + +| Observed problem or request | Relevant DESIGN sections | Evidence that answers it | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| New view, text roles, colors or accessibility | [color](./DESIGN.md#1-color--a-state-ladder-not-a-palette), [type](./DESIGN.md#3-typography--choose-a-role-not-a-size), [copy](./DESIGN.md#6-content--copy-is-part-of-the-system), [accessibility](./DESIGN.md#7-accessibility--the-system-serves-everyone), [input/viewport](./DESIGN.md#8-input--viewport--mobile-and-desktop-both) | State the task and role mapping; inspect real copy, contrast, focus and narrow/touch reflow | +| “Premium”, “like Linear”, busy or visually flat | [craft](./DESIGN.md#evaluate-craft-through-evidence), [emphasis](./DESIGN.md#emphasis-is-a-budget), [containers](./DESIGN.md#structure-before-containers) | Compare the whole resting pane before/after; explain the task served by each remaining frame, fill and effect | +| Uneven vertical spacing, wrapped copy, hidden helper leaving a hole | [vertical rhythm](./DESIGN.md#vertical-rhythm-across-sibling-views) | Independently expected relationships; show/hide and resize; distinguish frame gap from text leading and baseline space | +| Controls line up but labels or icons drift | [alignment](./DESIGN.md#alignment-is-structure-not-polish) | Frame, text Range, icon and hit-area measurements; reverse-check text-only drift | +| Oversized commands, weak hierarchy or cramped touch controls | [command hierarchy](./DESIGN.md#command-hierarchy-in-constrained-panes), [density](./DESIGN.md#density-follows-control-roles) | Full adjacent control group at actual mouse/touch density, with resting affordance and reachable targets | +| Redundant navigation or misplaced actions | [information architecture](./DESIGN.md#12-information-architecture--the-shape-before-the-composition) | Complete the route and return path; verify labels, action placement and focus against INTERACTION | +| Add animation or subtle effects | [motion](./DESIGN.md#5-motion--prefer-none), [craft](./DESIGN.md#evaluate-craft-through-evidence) | Repeat and interrupt the action; inspect reduced motion and theme switching | + +For deliberate visual exploration, use the optional [reference research index](./REFERENCE_RESEARCH.md) +to choose material by question and turn an observed pattern into a host-specific decision. It is +not a requirement to browse inspiration galleries for routine fixes. + +## Before implementing + +Identify the task, affected object, destination and observable outcome. Read only the relevant +owners above. Establish host capabilities before offering an action, and verify its public API. +Reuse existing semantic controls, framework and local token mappings. Read only applicable sections; +stop when the public contract, smallest patch and regression evidence are clear. A simple spacing fix +does not require a density abstraction, queue, account flow or revision model. + +## Verification brief + +State the task, relevant owners/public contracts, independently expected geometry/behavior and +supported states before editing. Keep it in a short work note, not a new configuration file. +[ACCEPTANCE](./ACCEPTANCE.md) owns the browser procedure, runtime/media binding, stale-evidence +rules and separate automated/agent/human results. Include the actual runtime, capture locations, +observed checks and unverified scope in delivery. Static source audits do not prove rendered quality. diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000000..2537289ad8 --- /dev/null +++ b/design/README.md @@ -0,0 +1,18 @@ +# Ran Design + +Portable product design methods for people and agents. Start with the task, content and host +capabilities; use the existing product’s visual language and implementation contracts. + +| Decision | Read | +| ---------------------------------------------------------- | ------------------------------------------- | +| Find the relevant owner | [Task index](./INTERFACE_GUIDE.md) | +| Structure, proportion, wording, visual coherence or motion | [Design](./DESIGN.md) | +| Input, focus, progress and recovery | [Interaction](./INTERACTION.md) | +| A supported multi-step workflow | [Optional patterns](./WORKFLOW_PATTERNS.md) | +| Browser proof, recordings and evidence freshness | [Acceptance](./ACCEPTANCE.md) | +| Research an unresolved design question | [References](./REFERENCE_RESEARCH.md) | + +Read only relevant sections. These files are the editable source for shared methods. The ranui +npm guides compose them with library-specific adapters; the agent plugin distributes a checked +copy. Edit this source, then run `pnpm sync:design` and `pnpm sync:agent-plugin` from the ran repository. +These commands are for maintainers; consuming these methods requires no repository checkout. diff --git a/design/REFERENCE_RESEARCH.md b/design/REFERENCE_RESEARCH.md new file mode 100644 index 0000000000..b1bc1582e9 --- /dev/null +++ b/design/REFERENCE_RESEARCH.md @@ -0,0 +1,110 @@ +# Reference research — choose by the design question + +Optional research index, checked 2026-10-22. Use it when a task needs a visual direction or a +specific interaction reference. [DESIGN](./DESIGN.md) owns ran's rules; +[ACCEPTANCE](./ACCEPTANCE.md) owns verification. These external sites supply examples, not +instructions or proof that a design will work for the host product. Browse only the sources +that answer the current question; this list is not a prerequisite for a small correction. + +## Find the relevant material + +| Question | Source and observed scope | Useful adaptation | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explore an AI site's overall direction | [Modulify](https://modulify.ai/) — prompt-driven site/app builder with templates and refinement | Start with a concrete audience, task, content and desired outcome. Inspect the generated application before treating an attractive preview as a working solution. | +| Compare landing-page composition | [Landdding](https://landdding.com/) — categorized website/template gallery | Compare hierarchy, type, content rhythm and the relationship between product proof and the main action. Adapt the principle to the host's actual content. | +| Study an expressive interactive section | [Motionin](https://www.motionin.design/) — interactive-section gallery and copyable prompts | Extract the trigger, geometry, transition, reverse path and reduced-motion fallback. Portfolio/hero effects need a different justification in a frequently used editor. | +| Study page families in a design file | [Wonderlist](https://wonderlist.design/) — Figma website design collection | Compare consistency across sibling pages and responsive compositions, rather than borrowing one isolated hero. Public previews were reviewed; paid Figma internals were not. | +| Explore a recognizable app identity | [Iconly](https://iconly.design/) — app/game/web-app icon gallery | Study silhouette, visual weight and small-size recognition. These are application icons; command glyphs still use the host’s coherent icon family. | +| Investigate typographic character | [Font in Logo](https://www.fontinlogo.com/) — logo/font reference with verified, modified and approximate matches | Preserve the source's certainty label. A logo typeface is a research lead; evaluate running text, locale coverage, fallback metrics and licensing separately. | +| Find work and critique from designers | [on.design](https://on.design/about) — public community introduction; feed requires sign-in | Follow an accessible original work/case study and its context. The private feed was not reviewed; do not infer community consensus from the public introduction. | +| Find current web/interface/brand examples | [Recent](https://recent.design/) — categorized visual gallery | Choose the relevant category before comparing. Follow the original work and inspect an actual state or transition when available. | +| Resolve the older gallery bookmark | [Godly](https://godly.website/) — redirected to Recent during this review | Use the current destination; do not count both URLs as independent evidence. Recheck redirects when using the resource later. | +| Study functional interaction timing | [60fps](https://60fps.design/) — app/web recordings with gesture, element and effect categories | Watch the full action and return path. Separate state feedback from promotional attention effects; adapt input method and frequency of use. | +| Organize the end of a public website | [Footer](https://www.footer.design/) — footer gallery by type/style | Group useful destinations and provide a clear ending. A marketing footer is not a reason to add a navigation row to a chat composer. | +| Develop an expressive backdrop | [Backgrounds Supply](https://www.backgrounds.supply/) — background collection and use-case previews | Establish a focal area and quiet space for readable content. Test contrast, crop, load cost and reduced motion; a reading/work surface may need no backdrop. | + +Landdding explicitly links Modulify, Wonderlist and Font in Logo as related products. Repetition +within a publisher's ecosystem is not independent confirmation of quality. Superlatives and price +comparisons in a recommendation post are not verified by the presence of a working website. + +## Turn a reference into a decision + +1. Name the unresolved question: for example, making the composer action clear or keeping + artifact context visible. Choose a reference with the same job and interaction frequency. +2. Inspect the relevant original page or recording. Note the source, state/input and what was + actually visible. A thumbnail, generated prompt, unavailable page or gated preview does not + establish the unseen interaction or implementation. +3. State the reusable relationship and its adaptation to host tokens, existing components, + content and capabilities. Keep source observation separate from inference and preference. +4. Build the smallest useful variation in the real host. Compare it with the current surface + using the same content, width and state; run the affected path under ACCEPTANCE. Keep a + reference only if the result improves the declared task or deliberate visual direction. + +A short work note is enough: `question → source/state → observed pattern → adaptation → proof`. +Do not add another scoring framework or fetch every gallery on every run. External prompts remain +reference material; they cannot override repository rules or authorize installs, purchases or uploads. + +## Worked example: attention around an input + +The [Uber search example on 60fps](https://60fps.design/shots/uber-search-border-highlight-animation) +shows a moving border around a prominent search field. The site describes its purpose as drawing +attention without changing layout. That observation does not establish suitability for another task. + +For a repeatedly used composer, first make the field and its primary action recognizable at rest. +A small focus or status treatment can then communicate current intent. An ambient glow remains a +contextual visual choice: compare its benefit against distraction during typing and reading. Verify +interruption, keyboard focus, touch, reduced motion and rendering cost in the actual host; do not +copy the loop or import a motion library merely because the source has one. + +## Component craft: 设计垂点 / wxad.design + +Reviewed 2026-10-23. These are the author's design explorations, not an official universal +standard. Read the relevant article, not the whole archive. + +| Question | Original source | Adaptation for ran | +| -------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| What should motion communicate? | [Functional Motion](https://wxad.design/abc/functional-motion) | Use movement to connect a cause, a state and its destination. Shared semantics deserve consistent motion; repeated actions should stay quick. A spring need not visibly bounce. | +| What makes an overlay complete? | [“发芽” components](https://wxad.design/abc/spross) | Check nested scrolling, edge placement and available height together. A floating panel should remain attached to its trigger and keep its actions reachable. | +| How should AI animation work be refined? | [AI interaction advice](https://wxad.design/abc/interactive-design-in-ai) | Describe the element, trigger, observed frame and expected result; replay continuous recordings slowly to locate discontinuities. Preserve a small reproducible case when a revision regresses. | +| Why do individually polished controls still feel inconsistent? | [Quality of UI](https://wxad.design/abc/quality-of-ui) | Review experience, refinement and coverage across a family. One polished demo does not establish a coherent product. | +| Where does component quality become reusable? | [AD UI retrospective](https://wxad.design/abc/adui) | Treat typography, interaction, keyboard access and engineering workflow as connected responsibilities; keep their rules with existing owners. | + +### Decisions and limits + +- Adopt continuity and functional feedback. For adjacent navigation choices, compare a shared + surface with independent popups only when they represent the same navigation context. + This is not permission to merge unrelated dialogs or add a new global overlay manager. +- Keep spatial relationships legible without sacrificing text size. The author's dialog scaling + and click-origin effects are contextual experiments: prefer responsive layout and internal + scrolling for reading/editing surfaces. Do not shrink text to make a dialog fit. +- Long-press treatment, stacked notifications, glass and playful effects need a real use case. + Do not copy a 200ms hold threshold into normal click activation or hide recovery behind hover. +- Existing ran rules still apply: explicit transition properties, instant theme palettes, + reachable keyboard/touch actions and a visible reduced-motion result. Do not copy article CSS + verbatim or infer current browser support from historical compatibility notes. + +Owner rules: [motion](./DESIGN.md#functional-motion-contract) and +[temporal acceptance](./ACCEPTANCE.md#review-motion-over-time). These additions guide ongoing +component review; they do not claim that ran has implemented or passed every reference pattern. + +## Style coherence and transition research + +Reviewed 2026-10-23. Keep individual source URLs with observations so the next agent can +revisit the material, distinguish a research lead from tested guidance, and avoid a second +unmaintained bookmark collection. + +| Source | What was actually accessible | Reusable method / limit | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [X: 审美积累](https://x.com/hashtag/%E5%AE%A1%E7%BE%8E%E7%A7%AF%E7%B4%AF?src=hashtag_click) | Topic URL redirects to sign-in; individual posts were not available in this review | Discovery lead only. Save an individual post and its original product/work when accessible; do not infer a common style or community consensus from a hashtag. | +| [huashu-art-motion](https://github.com/alchaincyf/huashu-art-motion) and its [skill](https://github.com/alchaincyf/huashu-art-motion/blob/main/SKILL.md) | README and skill describe an art/video animation workflow | Carry one visual language across an output, establish a useful still first, then inspect the continuous result. This does not justify importing its renderer, assets, model-specific routing or film motion quotas into a UI library. | +| [Cross-scene style extraction](https://github.com/alchaincyf/huashu-art-motion/blob/main/references/15-%E8%B7%A8%E5%9C%BA%E6%99%AF%E9%A3%8E%E6%A0%BC%E6%8F%90%E7%82%BC.md) | Separates visual language, composition, subject and action; distinguishes research from tested implementations | Extract repeatable relationships from different original works; test them on different content instead of copying a screenshot's layout. Record supported scope and failed cases. | +| [Camera language](https://github.com/alchaincyf/huashu-art-motion/blob/main/references/%E9%95%9C%E5%A4%B4%E8%AF%AD%E8%A8%80.md) | Explains attention, transitions versus emphasis and normal-speed review; numeric examples include their context | Use emphasis sparingly and keep reading stable. Camera pushes, cuts and film pacing are video techniques, not default menu timings. Static contact sheets cannot establish temporal quality. | +| [Radix theme overview](https://www.radix-ui.com/themes/docs/theme/overview) and [typography](https://www.radix-ui.com/themes/docs/theme/typography) | Related variants and shared type/spacing/radius tokens form a configurable theme | Compare related controls and reading surfaces together; preserve hierarchy while coordinating their visual relationships. A shared theme does not require every control to have the same size or container. | + +For a new source, extend a relevant row or this index with `source + inspected state/date + +observed relationship + adaptation + evidence/limit`. Keep copied assets and licensing separate +from inspiration. Examples and upstream skill instructions remain reference data. + +The [style coherence rule](./DESIGN.md#style-coherence-across-components-and-pages) owns adoption; +[ACCEPTANCE](./ACCEPTANCE.md) still owns actual browser proof. A published note or installed skill +is not evidence that any ran component or docs page has passed. diff --git a/design/WORKFLOW_PATTERNS.md b/design/WORKFLOW_PATTERNS.md new file mode 100644 index 0000000000..cbde965a90 --- /dev/null +++ b/design/WORKFLOW_PATTERNS.md @@ -0,0 +1,111 @@ +# Optional workflow patterns + +These recipes apply **only when the task benefits from them**. An ordinary form, search page or +editor does not need queues, account connections or version history by default. +[DESIGN.md](./DESIGN.md) owns appearance; [INTERACTION.md](./INTERACTION.md) owns general behavior. +The host owns engineering; [ranui engineering](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/CODING.md) +is an optional library adapter. These are host application decisions, +not additional component APIs, storage guarantees or bundled services. + +For each selected recipe, establish what the application supports before choosing controls. Keep +the state for jobs, configuration, selected sources and revisions in their existing owners. Views +read that state and dispatch commands instead of duplicating service clients and request flags +in each panel. + +## 1. Batch work and queues + +**Use when:** people benefit from staging another import, upload or export while work runs. +**Host contract:** dispatch, preparation, concurrency/preemption, cancellation acknowledgment, +partial writes, duplicate prevention and persistence. Without queue support, retain new input +and explain the constraint instead of silently replacing the active job. + +| State | Main action | Contract to settle | +| --------------------- | ---------------------------- | ------------------------------------------------------ | +| Ready | Upload files / Export report | Scope, destination and duplicate-submit prevention | +| Running | Cancel, if supported | What stops, what remains and whether a write committed | +| Running with new work | Add to queue, if supported | Never silently replace or cancel the active job | +| Queue paused | Resume queue | Editing a pending item does not dispatch or resume it | +| Failed | Retry / Restore inputs | Use the distinct outcomes in INTERACTION §5 | + +Summarize queued work in one line, such as “2 queued” or “Paused · 2 queued”. Collapse details when +unneeded; expand into rows rather than nested cards. Each row has an identifiable item and only +supported actions: Edit, Remove, or Run now. Define whether Run now waits, preempts or runs concurrently. +Represent preparation within the row instead of adding a large panel full of disabled commands. + +Queue editing preserves the current draft; cancelling editing restores it. Reconnection does not +auto-dispatch unless the user chose that policy. Apply [general focus and cancel rules](./INTERACTION.md) +when removing a row, collapsing the queue or stopping work. + +## 2. Capability-aware settings and offline work + +**Use when:** a task offers distinct providers, modes, devices or network-dependent capabilities. +**Host contract:** configuration storage, a real verification operation, reachability and local +runtime dependencies. Saving configuration and checking availability must be separate outcomes. + +Derive controls from capability and state, not a single `connected` boolean. A remote storage account, +a database service or a local converter can be configured without currently being usable. + +| State | What the user should understand | +| ------------------------------ | --------------------------------------------------------------------- | +| Unconfigured | A required choice or credential is missing | +| Configured, unverified | Settings are saved; availability has not been established | +| Verified | A real check succeeded for this configuration; record when | +| Authorization required/expired | Reconnect the account without discarding saved work | +| Service unreachable | Saved configuration does not prove present reachability | +| Device offline | Network operations are unavailable; available local work may continue | + +Invalidate verification when relevant endpoint, account or credential settings change. Browser +online/offline events are hints, not proof that a particular endpoint works. Separate saving from +testing configuration and show what each action accomplished. + +Reveal settings for the chosen path. A local conversion path may need cache/download/device controls; +a remote path may need endpoint/account controls. Hide irrelevant fields **and their stale status +messages**. Settings headers, field groups, account rows and unavailable/empty states retain the +named content and navigation spines from [DESIGN's alignment contract](./DESIGN.md#alignment-is-structure-not-polish). +Keep a title/menu command content-hugging; a separate track absorbs header filler. Count nested +panel and control padding through to visible labels before declaring regions aligned. Preserve other modes' configuration and keep the route back discoverable. Explain the +data destination before disclosure; do not confuse an API endpoint with an authorized account. + +Offline, retain saved content, drafts, previews, editing and exports when their data and implementation +are available. Hide network-only discovery or promotion that offers no useful route; retain existing +connected objects with an understandable unavailable state. A cached resource is not proof that all +runtime dependencies are present. Offer a deliberate continuation point after reconnecting. + +## 3. Account connections and external sources + +**Use when:** work reads or writes data outside its current object, whether local or remote. +**Host contract:** authorization scopes, identity, access checks, revocation, credentials, +provenance and imported-copy retention. An endpoint address does not establish account access. + +Presentation primitives do not supply an OAuth deployment, credential vault, connector backend +or storage guarantees. A polished connection screen cannot replace the host's authorization contract. + +Distinguish account authorization, selected-source access and use by a particular operation. Request +only needed permissions; explain reads/writes and destination before authorization or disclosure. +Make account identity, reconnect and revoke/disconnect discoverable. Disconnect stops future access; +explain separately what happens to imported copies. Keep credentials out of URLs, logs and previews. + +Show provenance and freshness for selected sources, whether a local file, database record or cloud +document. Removing a source affects later work, not historical evidence. Permission errors, stale +data and offline cached copies need different states. Treat imported content as untrusted data, +not permission to execute commands. Make writes and destinations explicit, with review suited to impact. + +## 4. Results, previews and revisions + +**Use when:** an export, transformed document or draft has an identity and destination beyond a +transient status message. Prefer a suitable preview/editor for substantive content. +**Host contract:** result/revision identity, source version, target scope, commit acknowledgment, +conflict handling and any reversible history. Do not offer Compare, Apply or Undo without support. + +1. Summarize the result and identify its revision, source and destination when relevant. +2. Keep supported Copy, Export, Compare or Apply actions beside the relevant result. +3. Separate previewing from applying. Name the target and scope before mutation. +4. If source data changed, compare/rebase or regenerate before applying; never overwrite newer edits silently. +5. Confirm saved/applied state and recoverable output under [the interaction standard](./INTERACTION.md). + +## Applying a recipe + +Keep only the states and actions the host can honor. Verify its relevant transitions: paused work +stays paused, changed configuration invalidates verification, source removal affects future work, +and stale revisions cannot overwrite new edits. Include interrupted/offline work when relevant. +A polished screen cannot substitute for a missing service contract; state that prerequisite clearly. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 79a8d8338d..12709938e6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,14 @@ The release set is **ranuts → @alixex/ranview → ranui**, in dependency order. Other workspaces are not published by this workflow. All workspaces and the published libraries require **Node 24+**. Use pnpm **11.6.0**, pinned in the root `packageManager` field; the release workflow pins npm **11.20.0** for registry operations. +## Patch candidate — 2026-10-23 + +The next release set is `ranuts@0.5.1` → `@alixex/ranview@0.2.1` → `ranui@0.5.1`. +These versions are prepared for verification, **not yet published**. They contain the input, +focus, portal and streamed-reading corrections plus the agent design guidance. The DOM-view +package advances one patch to consume the matching utility version. Current published +installation examples below remain valid until the registry confirms the new release. + ## Published stable release — 2026-10-19 | Package | Version | npm tag | @@ -78,7 +86,7 @@ for a separate `npm dist-tag` command. Create the matching GitHub `npm` environment and configure its protection/approval rules as appropriate. No long-lived npm token is required by this workflow. Local preview works without these publication settings. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) for account-side setup. -Actual publication uses `node bin/release.mjs publish --tag latest --confirm` in the workflow. That explicit command uploads packages; use `release:dry-run` for inspection. +Actual publication uses `node bin/release.ts publish --tag latest --confirm` in the workflow. That explicit command uploads packages; use `release:dry-run` for inspection. ## Failed or partial publication diff --git a/docs/superpowers/plans/2026-10-22-agent-acceptance.md b/docs/superpowers/plans/2026-10-22-agent-acceptance.md new file mode 100644 index 0000000000..01b32ff10f --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-agent-acceptance.md @@ -0,0 +1,33 @@ +# Agent Guidance Implementation Plan + +> **For agentic workers:** Use subagent-driven-development for isolated fixtures and independent review. Preserve existing tests and repository owners. + +**Goal:** Shorter guidance with truthful, fresh acceptance evidence and six demonstrated agent regressions. + +**Architecture:** One canonical acceptance document; thin routing skill/index; source-only evaluation fixtures and independent browser checks. Baseline/candidate runs use identical scenarios and separate workspaces. + +**Tech Stack:** Markdown, Node, Playwright, existing pnpm/Vitest checks. + +## Chunk 1: Baseline and regression harness + +- [x] Define six fixed fixture/task/rubric contracts in `plugins/ranui/evals/` and pin baseline guidance. +- [x] Run isolated agents on baseline; save actual files, exact prompts, reports, captures and assessor outcomes (native tool history remains in the chat). +- [x] Demonstrate each browser oracle rejects its seeded defect and accepts an independent correction; do not alter expectations to match agent output. + +## Chunk 2: Canonical guidance + +- [x] Simplify `packages/ranui/docs/DESIGN.md`, preserving linked anchors and core rules; route existing engineering details to their owners. +- [x] Add `packages/ranui/docs/ACCEPTANCE.md` for runtime/media freshness, scope, automated/agent/human result separation and honest unverified states. +- [x] Update the ranui skill/index and mirrored public entry points with links, not copied policy. +- [x] Re-run the same six tasks against candidate guidance. Inspect actual browser captures, interaction recordings and raw payloads; record limitations and controls. + +## Chunk 3: Review and delivery + +- [x] Independent spec review, then quality review; address findings and re-run affected proof. +- [x] Run `pnpm verify:links`, `pnpm verify:docs`, `pnpm -F ranui verify:design`, guide/package Vitest and changed-file formatting plus harness negative controls. +- [x] Save a concise evidence report under the task outputs with provenance and actual results. +- [x] Prepare the verified follow-up branch and reviewable evidence for merged PR #436. Commit/push, PR creation and head-specific CI status are final external delivery actions recorded in the task and PR; do not merge or publish. + +## Observed follow-ups + +The first candidate failed independent review for redundant parent context (02) and stale delayed Copy feedback after a stream update (06), despite mechanical passes. Preserve both failures; scoped owner clarifications and two fresh original-input sessions separately resolve them. The frozen v1 rubric remains unchanged. A supplemental real-browser delayed-success/rejection probe has its own negative/restored control. See [the live evidence record](../../../plugins/ran/evals/results/2026-10-22/README.md) for scope and limits. diff --git a/docs/superpowers/plans/2026-10-22-component-foundation.md b/docs/superpowers/plans/2026-10-22-component-foundation.md new file mode 100644 index 0000000000..998ed377a1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-component-foundation.md @@ -0,0 +1,23 @@ +# Component foundation implementation plan + +Use subagent-driven-development for independent component owners, followed by independent +review. Root owns integration, generated references, conversation following, Markdown styles, +actual media review and PR delivery. Do not build shared outputs concurrently. + +- [x] Reproduce actual primitive, portal/disclosure and rendered-content failures before editing. +- [x] Submit the separately verified select exit/reopen correction to the existing ran PR. +- [x] Independently review the paired foundation spec and resolve concrete scope/ownership gaps. +- [x] Input owner: failing tests, disabled pointer guard, retained icon/label semantics, pending + speech cancellation/state, focused browser proof and mirrored documentation. +- [x] Interaction owner: failing tests, portaled/nested Escape, disclosure focus restoration, + stale entrance-frame rejection, focused browser proof and mirrored documentation. +- [x] Content owner: failing tests, per-source clipboard lifetime and failure feedback, stable + streamed code/table viewports and selection-aware highlighting, focused browser proof and docs. +- [x] Root: gate automatic conversation following, refine token-based table/code defaults and + align code header/text. Preserve explicit navigation and safe rendering. +- [x] Integrate owners; regenerate API/token references if public contracts changed. Run types, + relevant unit/integration/SSR/browser, design, docs, links and formatting checks. +- [x] Inspect source-bound before/after screenshots and continuous normal/reduced-motion flows + at applicable widths/themes, then independent code and visual review. Correct findings. +- [x] Update ran PR with actual outcomes and limits. Only then resume Document integration; + its draft PR remains a reviewable snapshot, without final product acceptance claims. diff --git a/docs/superpowers/plans/2026-10-23-complete-component-review.md b/docs/superpowers/plans/2026-10-23-complete-component-review.md new file mode 100644 index 0000000000..7e9f002989 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-complete-component-review.md @@ -0,0 +1,132 @@ +# Complete component review implementation plan + +> For agentic workers: use subagent-driven-development for bounded owners and independent +> spec/code/visual review. Root owns integration, shared utilities, generated references, +> build outputs, evidence and commits. Never run competing builds or mutate the Git index +> from several workers. The user already authorized this direction and one PR/many commits. + +**Goal:** Review every public ranui component and improve concrete visual/interaction defects +under the current design system before publishing0.5.1 or integrating Document. + +**Architecture:** Preserve existing component owners and the canonical standards. Review shared +roles first, then families; fix shared causes once. Real browser/evidence and published package +contracts remain separate from source/static coverage. + +**Stack:** Existing TypeScript Web Components, LESS/tokens, Vitest, Playwright and release tools. + +## Chunk1: Complete inventory and trustworthy baseline + +- [x] Enumerate40 public registrations across37 owners, including child elements and nonvisual routes. +- [x] Record user scope, approved reference direction, family ownership and publication pause in the paired spec. +- [x] Reconcile API/export/demo/unit/SSR/browser coverage with the independent inventory. +- [x] Independently review spec/plan and record missing ownership or acceptance requirements. + All40 tags are accounted for; attached variant/adapter/Glass paths and four existing + SSR exclusions now have explicit scope in the spec. Reviewed runtime evidence and + remaining platform limits are recorded in the paired spec. +- [x] Finish the current mobile popup capture correction before accepting new visual results. + Preserve failed CI and local iterations; correct viewport coordinates and settled positioning; + retain full target content and zero capture-induced resize checks. Do not loosen baselines. +- [ ] Capture and inspect family baselines using realistic states/compositions; mark missing states. +- [x] Commit inventory/scope and accepted capture correction as separately reviewable changes. + +## Chunk2: Foundation and actions + +Owners: `components/{button,link,icon,image,card,section,glass}/` and relevant theme/base defaults. + +- [ ] Review real default/state compositions and narrow/light/dark behavior for every owner. +- [ ] Reproduce findings before edits; cover optical icon/text balance, heading/extra content, + interactive vs inert surfaces, image loading/failure and visible keyboard/touch targets. +- [ ] Fix smallest common causes; preserve actual events, links and safe text/content. +- [ ] Run relevant browser/unit/SSR/type/design checks; inspect before/after and changed motion. +- [ ] Update exact APIs/docs if changed; independent review, then one coherent family commit. + +## Chunk3: Forms and composition + +Owners: `components/{input,checkbox,select,colorpicker,theme-switch,voice-button,attachments}/`, +including Select's option/dropdown-item children. + +- [ ] Review labels/help/errors, values/forms, selected/checked/disabled/busy states and control proportions. +- [ ] Exercise native typing/IME, keyboard selection/clear, cancellation, permission/retry and removal focus. +- [ ] Inspect large labels, wrapping, narrow/coarse and both themes; truthful pending feedback. +- [ ] Fix reproduced defects; preserve drafts, values and pending request ownership. +- [ ] Verify APIs/docs and meaningful browser checks; inspect recordings; independent review and commit. + +## Chunk4: Navigation and layers + +Owners: `components/{tab,tabpane,router,route,popover,dropdown,modal}/`, including Popover's content. + +- [ ] Inspect navigation emphasis, active vs focus, nested layers, placement, scrolling and command hierarchy. +- [ ] Test full open/close/reverse/reopen, nested Escape, outside input, focus return, resize and reconnect. +- [x] Review nonvisual router/route navigation and SSR contracts without decorative changes. +- [ ] Fix observed causes and verify every affected consumer; preserve reachability and authored children. +- [ ] Inspect normal/reduced transitions, narrow translations and both themes; review and commit. + +## Chunk5: Reading and AI content + +Owners: `components/{markdown,math,mermaid,conversation,disclosure-row,reasoning,tool-card,token-meter,state-dot}/`. + +- [ ] Inspect realistic prose, tables, code, math/diagrams and agent status groups at reading widths. +- [ ] Exercise exact copy/failure/retry, native selection, long overflow, streaming, lazy highlighting and follow intent. +- [ ] Review disclosure density, progress truthfulness, state/color semantics, empty/error/interruption and keyboard focus. +- [ ] Preserve safe/raw data and renderer limits; do not fabricate reasoning/provider/media capability. +- [ ] Verify and inspect source-bound media; independent review and family commit. + +## Chunk6: Feedback and interactive visuals/media + +Owners: `components/{loading,skeleton,message,progress,radar,scratch,player}/`, including variants/adapters. + +- [ ] Review default/loading/empty/error/disabled/completion, typography and visual hierarchy. +- [ ] Exercise determinate vs indeterminate feedback, announcements, dismissal, pointer/touch/keyboard and interruption. +- [ ] Inspect media controls and real available playback paths; distinguish simulated events from actual codec/network proof. +- [ ] Verify reduced motion preserves information, no gratuitous loops, and teardown releases observers/frames/resources. +- [ ] Fix reproduced issues; inspect actual states/continuous transitions; independent review and coherent commits. + +## Chunk7: Whole-system review and delivery + +- [x] Reconcile all40 rows with actual reviewed outcomes, evidence and honest limits; no implied full pass from unit coverage. +- [ ] Inspect sibling components together for type, spacing, shape, icon weight and control/state consistency. +- [x] Regenerate API/token/changelog references and mirrored docs; keep agent routing thin and canonical. +- [ ] Run applicable full unit/SSR/type/design/docs/links/format/package-consumer gates and3-engine CI. +- [x] Review intentional screenshot changes individually, retain unrelated reviewed images and unchanged tolerances, + then run two fresh strict canonical comparisons. Demonstrate capture/acceptance guards reject real defects. +- [ ] Update existing draft PR437 with grouped commits, final evidence and unresolved conditions. +- [ ] After this phase passes, rebuild/verify the clean0.5.1 release set, follow the existing trusted release workflow, + verify registry artifacts, then resume Document using published dependencies and its separate UI acceptance. + +## Follow-up work requested during implementation + +The user requested these after the current component pass; they do not replace incomplete +runtime fixes or permit treating bug-free controls as a finished visual direction. + +- [ ] Compare excellent current product component libraries with the supplied references, + using their official live examples. Assess proportions, density, control sizing, + typography, optical balance, hierarchy and interaction choreography in realistic + compositions. Document concrete adoption decisions and prototype before/after options; + do not collect another undifferentiated link list or claim a copied brand is a design system. +- [ ] Migrate repository-owned tracked `.mjs` tooling to typed `.ts` source, as explicitly + requested by the user during implementation. Preserve ESM and native Node24+ execution; + use erasable types, explicit local extensions and a separate strict no-emit tooling gate. + Update all package/CI/plugin/test/documentation invocations and measured harness inputs. + Do not rename third-party/generated JavaScript or rewrite historical evidence as fresh. + Published runtime artifacts remain JavaScript. Keep this migration in its own commit; + verify equivalent script behavior, Node24/26 execution, types and package boundaries. +- [ ] Reconcile these follow-ups with the final release scope before publication; no version + is published merely because the earlier package candidates were prepared. + +## Integration checkpoint — 2026-10-24 + +The paired spec's forty-owner table is the current evidence index. Family evidence includes +composed controls, route/history behavior, reading/AI rendering, all29 Loading variants, +Glass lifecycle and actual decoding on six Player adapters. Remaining unchecked family +items denote broader acceptance scope, not an absence of the bounded reviews linked there. +Optional illustration art direction and hardware/provider limits remain explicit. + +Production fixes and source-bound recordings are committed separately. Two strict canonical +comparisons passed148/148 each. The expanded full browser run passed1032/1035; correcting +the final Select fixture's Tab order retained its expectations and the complete layer suite +passed30/30 across all three engines. CI is still separate from these local results. +Repository lint/type/build, generated docs, SSR and packed consumer checks passed. npm12's +package-keyed pack output required a typed parser repair; nine regressions pass on Node24/26. + +Keep delivery/CI and release status separate. Do not convert this review into a claim of +hardware coverage, universal illustration uniformity or Document acceptance. diff --git a/docs/superpowers/plans/2026-10-23-ran-product-architecture.md b/docs/superpowers/plans/2026-10-23-ran-product-architecture.md new file mode 100644 index 0000000000..1de6dae805 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-ran-product-architecture.md @@ -0,0 +1,95 @@ +# Ran product architecture implementation plan + +**Goal:** Deliver independently consumable ranui and ranuts packages, reusable Ran Design guidance and one product website at https://ran.chaxus.com. + +**Authorization:** The user approved the three-pillar direction, confirmed the existing ran.chaxus.com origin and authorized removing the chaxus.com personal-site application on 2026-10-23. New public routes below are a target information architecture, not claims that those pages already exist. + +**Architecture:** Keep the existing monorepo and `packages/docs` application. ranpress owns static-generation mechanisms; the application owns navigation, presentation, search and localization. Neutral design sources feed portable skills and public pages without independently maintained copies. + +**Tech stack:** TypeScript ESM, pnpm workspaces, ranpress, ranui and the current static hosting arrangement. + +## Evidence and scope + +| Fact | Evidence | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| The current product site is ran.chaxus.com | `packages/docs/build/common/index.ts`, `packages/docs/CLAUDE.md` | +| The personal site is a separate application | `packages/site/package.json` and `packages/site/README.md` before this removal; retained in Git history | +| Site checks are manifest-driven | `packages/manifest.json`, `bin/run-checks.ts` | +| The root build explicitly builds the personal site | `bin/build.sh` before removal | +| General guidance currently lives under ranui | `AGENTS.md`, `packages/ranui/docs/INTERFACE_GUIDE.md` | +| Claude and Codex package metadata currently differ | `.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `plugins/ranui/`, `plugins/ranuts/` | + +Allowed changes: product positioning; personal-site removal and its direct callers; neutral design ownership and plugin packaging; product website navigation/content/presentation with its tests. Existing component work remains a separate set of commits and evidence. + +Do not alter DNS, hosting projects, external accounts, published releases or unrelated experiments. Do not rewrite historical evidence to claim that a future structure was already tested. Keep ranpress and the ran product's GitHub Pages redirect: the latter points to ran.chaxus.com and is not the personal-site deployment. + +## Chunk 1: Product boundary and personal-site removal + +- [x] Update `PRODUCT.md`; move its former demo-only brief to `packages/ranui/demo/PRODUCT.md`. +- [x] Remove `packages/site/` and `bin/build-site.sh`; remove the package's manifest and lockfile entries and its root build step. +- [x] Update current ranpress descriptions and docs page-model comments; leave dated audit history intact. +- [x] Run `pnpm verify:packages`, `pnpm verify:links`, `pnpm -F ranpress test`, and the built documentation site's verification. +- [x] Search active source/configuration for remaining personal-site callers. Confirm no npm dependency graph changes beyond the removed importer. +- [x] Commit this boundary independently of component and visual-baseline changes. + +Removal verification: root `pnpm build` passed and generated 1,321 website pages; links, anchors, canonicals, assets and sitemap passed. ranpress passed 81 tests, ranpress and docs typechecks passed, repository links and the 10-package manifest passed. The first docs typecheck overlapped a library rebuild and saw missing transient declaration files; the sequential post-build check passed. The lockfile changed only by removing the personal-site importer. Commit: `54fc34e`. + +## Chunk 2: Neutral design owner and portable plugin + +- [x] Extract general sections of `packages/ranui/docs/{DESIGN,INTERACTION,ACCEPTANCE,WORKFLOW_PATTERNS,REFERENCE_RESEARCH}.md` into `design/`; keep library-specific tokens and contracts with ranui. Inventory all callers before moving sections. +- [x] Replace duplicated prose with focused links in `AGENTS.md`, package guides and public pages; retain original reference URLs, adaptation notes and evidence limitations. +- [x] Create one `plugins/ran/` distribution with design, ranui and ranuts skills. Update both marketplace entries and installation instructions together. +- [x] Bundle needed references from canonical owners with deterministic typed tooling and provenance. An installed plugin must work outside this checkout: no unresolved links to `../../design`, repository-only symlinks or dependence on another plugin's presence. +- [ ] Record the exact npm package version and source provenance for bundled API/token references; match guidance to the host's installed version. Do not describe an unpublished component API as available to users. +- [x] Move the six existing eval scenarios with their actual provenance, update root scripts and CI paths, and run packaging plus behavior checks. Historical screenshots remain historical. +- [ ] Test the plugin from an isolated installation directory, including a non-ranui host and a host using the published ranui version. + +## Chunk 3: One website for the three pillars + +Keep source under `packages/docs`. Do not create another frontend or rename directories merely for branding. Target navigation is Design, Components and Utilities, with a clear agent-install action; Examples and reference links support these entries. + +Target routes (implementation pending): + +| Route | Purpose | +| ------------ | ------------------------------------------------------------------------ | +| `/` | Product introduction, a usable composed example and the three next steps | +| `/design/` | Design, interaction and motion methods; examples and acceptance evidence | +| `/ranui/` | Components, themes, states and generated API reference | +| `/ranuts/` | Task-oriented utility discovery, runtime constraints and API reference | +| `/agents/` | Verified installation instructions and task examples | +| `/examples/` | A small set of complete compositions, linked to rationale and source | + +- [ ] Implement explicit public route mapping in `packages/docs/build/content.ts`; update `nav.ts`, `langs/structure.ts`, links, canonical/hreflang, search, sitemap and `llms` generation together. Source directory names must not leak into the new public URLs. +- [ ] Rework the home renderer and `styles/home.css` around a real composition and concise next actions. Reuse theme foundations; do not add another palette or duplicate component CSS. +- [ ] Build design and agent entry pages from the canonical sources and bundled-reference contract. Honor the existing locale registry; language switches must lead to real pages. +- [ ] Connect component demonstrations to realistic states and readable code, and utility examples to their actual browser/Node/worker boundaries. Keep page chrome as lightweight HTML where no interactive component is needed. +- [ ] Verify production build, internal links/anchors, canonicals, search and no-JavaScript access to installation and reference text. +- [ ] Inspect light/dark and narrow/wide screenshots; record actual keyboard navigation, menus, example interactions and reduced-motion behavior continuously. Fix products rather than weakening assertions. +- [ ] Update the existing PR with separate commits only after the affected implementation has matching evidence. + +## Simplest alternatives + +- Existing `packages/docs` versus a new app: reuse the existing app, generator, locale registry and search; no second deployment or framework is needed. +- Common methodology versus duplicated per-platform documents: maintain neutral sources once, generate self-contained installable references, keep platform metadata thin. +- A broad example gallery versus a few complete examples: begin with a few complete compositions that expose state, responsive layout and recovery. +- Simultaneous release versus independent artifacts: publish npm and plugin versions independently; do not make a docs change require an npm release. + +## Completion criteria + +The website identifies and links all three pillars; its build remains self-contained after personal-site removal. Each skill can resolve its own references after installation. Generic design guidance works without ranui; ranui guidance matches the chosen version. Fresh screenshots and recordings demonstrate the changed website and components. A passing structural test alone does not constitute visual acceptance. + +## Implemented review checkpoint — 2026-10-23 + +The unified plugin and neutral owners are committed in `6113e2a`; design-before-implementation +brief/routing refinements are in `5a8a530`. Canonical references remain independently editable only +in design/ and ranui adapters. Both generated distributions pass freshness and portable-link checks. +The installed-package skills resolve the host's real package version rather than bundle unpublished +API facts. Isolated directory tests pass; no actual Codex/Claude plugin installation or new live +six-agent trial is claimed. Six deterministic evaluator groups pass, with historical live evidence +retained under its original provenance. + +The website has localized Design and Agents entries in all eight languages and the three-pillar +homepage. Existing /src/ public paths remain; clean-route remapping and the complete examples +section are deliberately pending. The website evidence is in +[the production-page review](../../../packages/docs/test/evidence/2026-10-23-product-site/README.md). +This checkpoint is a bounded implementation, not completion of every architecture item above. diff --git a/docs/superpowers/specs/2026-10-22-agent-acceptance-design.md b/docs/superpowers/specs/2026-10-22-agent-acceptance-design.md new file mode 100644 index 0000000000..4990182a99 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-agent-acceptance-design.md @@ -0,0 +1,23 @@ +# Agent guidance and acceptance design + +Approved direction: simplify ranui's canonical guidance, make evidence and decision authority explicit, and validate six real agent regression tasks before creating a follow-up to merged PR #436. + +## Scope lock + +- Observed: DESIGN has 1,049 lines; the skill is already a small router. Existing API, token, package and browser geometry checks are valuable and remain intact. +- Inferred risk: repeated prose and engineering recipes obscure visual decisions; agent review can be confused with human acceptance, and captures can outlive the runtime they show. +- Owners: DESIGN owns visual composition; INTERACTION owns observable behavior; new ACCEPTANCE (included in the npm files allowlist) owns evidence freshness and decision reporting; the skill/index only route. +- Remove duplicated engineering recipes from DESIGN in favor of existing CODING/theme owners. Preserve normative geometry, spacing, accessibility, hierarchy and motion contracts and existing anchors used by consumers. +- No component redesign, application change, package release, mandatory human approval gate, compatibility layer, general agent platform or external service. + +## Acceptance + +Six isolated tasks: visible text drift with aligned frames; duplicate Back levels; narrow translated labels; premium effects overload; stale captures after shared styles change; code copy/selection/scroll preservation. + +Pin baseline 7544bce before editing guidance. Each task uses a fresh agent session with the same model/settings, defective fixture, task prompt and tools for baseline/candidate, with only guidance changed. Freeze prompt/fixture/rubric/tool manifests before runs and hash each guidance snapshot. For freshness, capture first, then stage a shared-style change; the affected review must become stale. Agents act on real files, render the fixture and retain evidence. The assessor checks observable behavior and artifacts independently, not exact wording or implementation. Honest baseline passes remain controls. A single sample demonstrates these tasks, not model-wide reliability. + +Acceptance evidence identifies runtime inputs/build, state/environment, media hashes and review scope. Automated results, agent visual review and human decision are separate. Changed relevant runtime/media invalidates affected reviews. Docs-only changes need no unrelated recapture. User authorization controls PR updates; absent human acceptance is reported truthfully without blocking authorized work. + +## Smallest alternative + +Only adding stronger prose is smaller but provides no evidence that agents follow it. A full approval/artboard platform is unnecessary. Use shipped Markdown contracts plus source-only fixtures and a repeatable browser assessor; live agent runs are opt-in, deterministic negative controls run locally/CI. diff --git a/docs/superpowers/specs/2026-10-22-component-foundation.md b/docs/superpowers/specs/2026-10-22-component-foundation.md new file mode 100644 index 0000000000..ff4103e0f8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-component-foundation.md @@ -0,0 +1,65 @@ +# Component foundation before host redesign + +The user prioritized improving ran's reusable components before continuing the Document +redesign and authorized submitting reviewable PR progress before final product acceptance. +Keep the component and application acceptance scopes distinct. No merge or package publication. + +## Observed baseline + +Actual registered components at `6551c69` reproduced these failures in Chromium: + +- A disabled button still delivered a real pointer click; an input's leading icon removed its + visible/native label; pending microphone permission could not be canceled with Escape. +- Escape from a portaled popover action left it open; collapsing a focused disclosure lost + focus to the document; a queued entrance frame could overwrite an already closing popup. +- Two code copies shared one feedback timer, and a detached component emitted late copy + success. Appending to an open fence replaced its PRE, clearing selection and local scroll. + A conversation with `follow=false` still scrolled to its floor after content resized. +- Default Markdown wrapped both tables and code in rounded frames. Its code language header + and text had different left insets. Review these defaults with actual content and tokens. + +Independent browser review also exposed label/control height overflow, invisible keyboard +focus, pending Copy blur, table math/link enhancement loss, pointer opener differences and +borrowed-content loss on reconnect. These findings stay within the owners below; correct the +component contracts and refresh affected evidence before delivery. + +Before captures, recordings and reproduction inputs are retained in the task's component +audit outputs. Clipboard and speech completion are deterministic fixtures; component DOM, +pointer/keyboard input, native selection and layout are real. These are component proofs. + +## Ownership and decisions + +1. **Input primitives:** button owns disabled dispatch, input owns icon/label composition, and + voice-button owns recognition intent and truthful pending/cancel feedback. Preserve native + form semantics and existing public labels/events. Any necessary new label is overridable. +2. **Interactive surfaces:** popover/FloatingController own portal dismissal and interruptible + transitions; disclosure-row owns focus before its body becomes inert. Respect nested popup + ownership, handled Escape and IME. Do not add a host-specific keyboard workaround. +3. **Rendered content:** markdown owns per-source copy operations and stable code/table + viewports. Late success/failure cannot affect replaced or disconnected content. Copy raw + source exactly. Preserve prefix text nodes on streamed code growth; avoid disruptive async + highlighting while native selection is active. Preserve safe rendering and lazy dependencies. +4. **Reader intent:** conversation/bottom-follower own following and paging anchors. Public + `follow=false` gates every automatic scroll write; explicit navigation remains available. +5. **Visual defaults:** quiet table row/header structure, aligned code chrome/text, readable + token typography and functional focus/hit bounds. Compare before/after on the same content, + light/dark and narrow/wide surfaces. Avoid decorative enclosures, new palettes or motion. + +Foundation tokens retain their existing owner. Product width, workspace navigation, storage, +queue, consent, inference and native document editing remain host concerns. Do not create a +second Markdown engine, replace FloatingController, add infrastructure, or copy application +dimensions into component defaults. Unproven tool-card selection concerns remain outside scope. + +## Acceptance + +Keep independent expectations and demonstrate each behavioral regression fails before the fix. +Preserve raw clipboard tabs/newlines, native selection, scroll, focus, event payloads, reconnect, +SSR and sanitizer behavior. Cover immediate reversal with normal and reduced motion; a timeout +that waits out the original defect is not a fix. For new copy feedback, test concurrent blocks, +delayed success/rejection, source replacement and disconnect. + +Capture actual components with realistic content and surrounding controls, then inspect current +screenshots and continuous interactions. Bind source/dependency/runtime/state/media identity. +Independent review follows bounded implementation and integration. Report automated checks, +agent review and human decisions separately. Document application acceptance stays pending until +the verified components are integrated and its actual editor flows are freshly reviewed. diff --git a/docs/superpowers/specs/2026-10-23-complete-component-review.md b/docs/superpowers/specs/2026-10-23-complete-component-review.md new file mode 100644 index 0000000000..e604e14787 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-complete-component-review.md @@ -0,0 +1,249 @@ +# Ranui complete component design and interaction review + +## Authorized direction and evidence + +The user requested reviewing and improving **all ran components** using the latest ran standards +and their supplied design references, keeping one existing PR with several coherent commits. +This expands the earlier focused component foundation work. Publication and Document integration +are paused until this phase is complete. The prepared0.5.1 patch remains unpublished. + +| Assumption | Evidence | Status | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------- | +| The scope is all public components, not only chat-related elements | User instructions,2026-10-23 | Authorized | +| One existing PR, multiple commits | User instructions,2026-10-23; PR437 | Authorized | +| There are40 registered elements across37 top-level owners | `defineSSR` declarations under `packages/ranui/components/`; generated `docs/COMPONENTS.md` | Source fact | +| Prior input/portal/reading fixes have bounded real component evidence | `packages/ranui/test/evidence/2026-10-22-components/` and `2026-10-23-focus-return/` | Does not certify the whole library | +| 83bd9bf passed5CI jobs and all3 functional browser jobs | GitHub CI39888266924 and E2E39888266941 | Measured; its mobile popup screenshot failed | +| Fixed-environment local passes alone do not establish capture stability | Later CI failure, preserved image and geometry probes; `test/visual/baseline-review.md` | Capture correction still in progress | + +## Visual direction + +Use the existing ran token system and canonical DESIGN, INTERACTION and ACCEPTANCE owners. +The intended result is deliberate, calm product UI with clear hierarchy, consistent optical +weight, readable content and precise feedback. Work with real content and composed groups, +not isolated attractive controls alone. A component that already serves its role well can +remain unchanged after review; all components must have an explicit reviewed outcome. + +Adapt reference relationships, not brand pixels: Claude/ChatGPT inform reading, composition, +progress and result workflows; Don't Make Me Think informs recognition and recovery; taste-skill +and ryugi inform restraint, coherent composition and evidence; the supplied galleries inform +appropriate type, proportions and visual character;60fps and Motionin inform triggers, +interruption and reverse paths. `REFERENCE_RESEARCH.md` owns source scope/certainty. Marketing +backdrops and promotional motion need their own product-context justification. + +Every family is inspected for typography, icon/content balance, shape, spacing, borders and +surface hierarchy; default/hover/pressed/focus/selected/disabled/busy/empty/error states; +light/dark, narrow/wide and applicable coarse input; lifecycle and draft/reading preservation; +interruptible transitions and reduced motion. Preserve useful labels, errors and consequences. + +## Scope lock + +- May change: affected files under `packages/ranui/components/`, shared theme/base/floating/ + accessibility utilities when a reproduced common defect requires it, actual component demos, + meaningful tests, generated API/token references and mirrored public docs. Exact families + and owners are enumerated below. Variant and media-adapter files belong to their parent. +- May change ranuts only for a reproduced shared utility defect; enumerate its consumers and + verify them. Root owns changes spanning families and generated/build outputs. +- Keep Document unchanged. Keep release publication/merge disabled during this review. + Do not add a new design system, renderer, backend, application workflow or motion dependency. +- Earlier user authorization permits incompatible presentation choices; still preserve raw + data, explicit intent, native accessibility, supported events, safe rendering, cancellation, + form values and lifecycle/SSR contracts. A required contract change must be made explicit. +- Do not force decorative edits on working controls, hide difficult states, change assertions + merely to pass, enlarge comparison tolerances or raise design/size debt budgets. + +## Exhaustive review ledger + +A bounded-evidence link means the named behavior and states were reviewed, not that every +state/capability passed. The per-row links describe measured scope; release-wide gates and concrete remaining findings are recorded below. +Existing focused proof is a useful baseline, not an automatic full-row pass. Record findings, actual environments/media, fixes and uncovered conditions per family. +Router/Route are nonvisual infrastructure: inspect navigation/ownership/SSR contracts rather +than inventing decorative screenshots for them. + +### Foundation and actions + +| Public element | Source owner | Full review status | +| -------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `r-button` | `packages/ranui/components/button/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-composed-controls/README.md) | +| `r-link` | `packages/ranui/components/link/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-navigation/README.md) | +| `r-icon` | `packages/ranui/components/icon/index.ts` | Size/color/spin screenshots reviewed; full browser gate tracked below | +| `r-img` | `packages/ranui/components/image/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-foundations/README.md) | +| `r-card` | `packages/ranui/components/card/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-foundations/README.md) | +| `r-section` | `packages/ranui/components/section/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-checkbox/README.md) | +| `r-glass` | `packages/ranui/components/glass/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-glass/README.md); WebGPU adapter unavailable | + +### Forms and composition + +| Public element | Source owner | Full review status | +| ----------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `r-input` | `packages/ranui/components/input/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-composed-controls/README.md) | +| `r-checkbox` | `packages/ranui/components/checkbox/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-checkbox/README.md) | +| `r-select` | `packages/ranui/components/select/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/README.md); clear/hint and page-scroll regressions exercised separately | +| `r-option` | `packages/ranui/components/select/option/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-controls/README.md) | +| `r-dropdown-item` | `packages/ranui/components/select/dropdown-item/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-controls/README.md) | +| `r-colorpicker` | `packages/ranui/components/colorpicker/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-controls/README.md) | +| `r-theme-switch` | `packages/ranui/components/theme-switch/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-navigation/README.md) | +| `r-voice-button` | `packages/ranui/components/voice-button/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading-current/README.md); provider mocked, Firefox pending-state failure under investigation | +| `r-attachments` | `packages/ranui/components/attachments/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading-current/README.md) | + +### Navigation and layers + +| Public element | Source owner | Full review status | +| -------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `r-tabs` | `packages/ranui/components/tab/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-navigation/README.md) | +| `r-tab` | `packages/ranui/components/tabpane/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-navigation/README.md) | +| `r-router` | `packages/ranui/components/router/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-navigation/README.md) | +| `r-route` | `packages/ranui/components/route/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-navigation/README.md) | +| `r-popover` | `packages/ranui/components/popover/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/README.md) | +| `r-content` | `packages/ranui/components/popover/content/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/README.md) | +| `r-dropdown` | `packages/ranui/components/dropdown/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/README.md) | +| `r-modal` | `packages/ranui/components/modal/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/README.md); editor Escape follow-up under final cross-engine verification | + +### Reading and AI content + +| Public element | Source owner | Full review status | +| ------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `r-markdown` | `packages/ranui/components/markdown/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading-current/README.md) | +| `r-math` | `packages/ranui/components/math/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-renderers/README.md) | +| `r-mermaid` | `packages/ranui/components/mermaid/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-renderers/README.md) | +| `r-conversation` | `packages/ranui/components/conversation/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading-current/README.md) | +| `r-disclosure-row` | `packages/ranui/components/disclosure-row/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading/README.md) | +| `r-reasoning` | `packages/ranui/components/reasoning/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading/README.md) | +| `r-tool-card` | `packages/ranui/components/tool-card/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading/README.md) | +| `r-token-meter` | `packages/ranui/components/token-meter/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading/README.md) | +| `r-state-dot` | `packages/ranui/components/state-dot/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-reading/README.md) | + +### Feedback + +| Public element | Source owner | Full review status | +| -------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `r-loading` | `packages/ranui/components/loading/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-loading-variants/README.md); illustrative variants retain separate art direction | +| `r-skeleton` | `packages/ranui/components/skeleton/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-composed-controls/README.md) | +| `r-message` | `packages/ranui/components/message/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-feedback/README.md) | +| `r-progress` | `packages/ranui/components/progress/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-feedback/README.md) | + +### Interactive visuals and media + +| Public element | Source owner | Full review status | +| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `r-radar` | `packages/ranui/components/radar/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-feedback/README.md) | +| `r-scratch` | `packages/ranui/components/scratch/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-23-loading-scratch/README.md) | +| `r-player` | `packages/ranui/components/player/index.ts` | [Bounded state review](../../../packages/ranui/test/evidence/2026-10-24-player-adapters/README.md); six real local decoding paths; external TURN/DRM excluded | + +## Acceptance and simplest workable approach + +### Attached implementation coverage + +The40 element rows do not certify their attached implementations. Record these separately: + +- Loading variants: `arc`, `circle`, `circle-fold`, `circle-line`, `circle-rotate`, + `circle-spin`, `circle-turn`, `cube`, `cube-dim`, `cube-fold`, `cube-grid`, `dot`, + `dot-bar`, `dot-circle`, `dot-line`, `dot-pulse`, `double-bounce`, `drop`, `line`, + `line-scale`, `pacman`, `pulse`, `rotate`, `scale-out`, `solar`, `square`, `stretch`, + `text`, `triple-bounce`. Reduced-motion visibility and narrow geometry were reviewed for the changed circle, + circle-fold, cube-dim, cube-fold, dot-bar, dot-line, text and triple-bounce variants. + The original recordings show the gallery, but that does not establish all29 as a refined, + coherent product palette; the other variants retain their pending design outcome. +- Player paths: native, HLS, DASH, FLV (including TS), WebRTC. Each needs an explicit outcome + and available runtime/codec/network limits; simulated events are not playback proof. +- Glass paths: CSS, WebGL and WebGPU, including unsupported/failure fallback. State actual + environment capability instead of claiming unsupported accelerated paths passed. +- Existing SSR exclusions: `r-content` (MutationObserver), `r-link` (document), `r-modal` + (mock assignedNodes), `r-radar` (ResizeObserver). Resolve or retain each explicitly; + the currently green suite does not establish SSR for these four elements. + +The independent source inventory and plan review found all40 tags accounted for. Existing +owner unit tests and docs routes establish discoverability, not state completeness. Inspect +source when the generated API is incomplete (currently image, option, tab and progress). +Dedicated browser coverage is missing or incidental for glass, scratch, theme switch, +token meter and routing/link flows. Correct stale demo/card/icon guidance at its owner. + +### Review and delivery + +Use the existing demo/docs surfaces and real registered components, with small bounded +fixtures only where a state is absent. Clearly label those as component proof. Compare realistic +compositions at1280px and360px, plus declared stress cases such as long translations, large +content or320px when relevant. Inspect current screenshots and continuous recordings for +stateful flows. Do not claim physical devices, real media/provider access or human acceptance +from automation. Capture must preserve its own viewport and contain the full target. + +Each family gets a self-contained commit after its relevant checks and image/interaction review. +Fix a shared cause once and recheck every affected family. Regenerate public references from +source; keep multilingual structure in sync. Functional defects require meaningful failing +behavior before repair. Visual-only changes need actual browser review, not mirrored CSS tests. + +The simplest approach is to review existing defaults and repair observed defects family by +family. Adopt that. A blanket reskin would erase working distinctions and expand verification +without demonstrated benefit. Documentation-only changes would leave runtime defects intact. + +Before release: reconcile all40 ledger rows, resolve material findings, review affected complete +compositions, run appropriate unit/SSR/type/design/docs/package checks and full3-engine CI, +then complete the pinned visual gate without weakened assertions. Only then prepare/publish +0.5.1 through the existing release workflow and resume Document's separate product acceptance. + +## Checkpoint after specification-first review — 2026-10-23 + +The canonical style brief now precedes implementation and is reused by acceptance and the agent +plugin. It settles task/hierarchy, proportions, surfaces, combined states, purposeful motion, +reference decisions where applicable and observable proof. ranui's default visual direction +belongs to its adapter; the portable design skill still respects each host's language. + +Evidence-backed fixes in this batch are committed separately. 1691 unit and 76 SSR tests passed; +168 related three-engine functional cases passed before the final menu correction, followed by +54 final renderer cases in an isolated run. A preceding 53/54 renderer run failed on a transient +success icon in WebKit; its cause is not proven and remains recorded with the renderer evidence. +The website passed154 unit and63 three-engine browser cases. These counts establish their tests, +not complete visual acceptance of all40 public elements. Publication and Document integration +remain gated on resolving the open rows and capability limits above. + +## Follow-up from actual gallery review — 2026-10-24 + +The normal and reduced-motion gallery exposes low-contrast fluorescent status marks on white. +Keep variant geometry and public overrides, but use existing text semantic colors for circle-line, +pulse, dot-bar and dot-line; remove the broad neon glow from dot-line. The circle core follows +the current surface. Move drop's small status label below its animated cyan illustration so its +text remains readable throughout the wave, in both themes. Arc and dot-pulse also need their +already reproduced reduced-motion visibility fixes. Inspect all29 variants after these changes; +these corrections do not certify every illustrative variant as a single unified product palette. + +The real gallery must not initiate external media before user intent. An explicit localized load +action initializes its sample Player; existing playback error/retry handling remains available +after that request. Verify browsing and theme changes while the external stream is unavailable. + +## Current integration checkpoint — 2026-10-24 + +All forty registered elements now have a bounded evidence entry above; this does not +certify arbitrary compositions, every optional illustration's art direction or hardware. + +- The final source passed two strict pinned Linux arm64 visual runs:148/148 and148/148, + with updates/retries disabled. All142 reviewed PNG hashes match their manifest. +- The expanded1035-case three-engine run passed1032 cases. Its three failures were the + same newly added Select sibling-field fixture assuming the wrong native Tab order. + Placing the field immediately after Select preserves the real Tab/Escape assertions; + the complete final layer suite then passed30/30 across Chromium, Firefox and WebKit. + This is full-suite coverage plus a focused repair verification, not one uninterrupted + green1035-case run. CI remains a separate delivery gate. +- 1705 ranui unit cases passed; after the final nested-layer change,101 affected unit + cases and76 SSR cases passed. Repository lint/type/build, tooling types, eight-language + API/token/docs synchronization, link checks and packed consumer checks passed. +- Six agent regressions and36 script checks passed earlier in this review; nine new + npm-pack parser regressions pass on Node24 and26. Real npm12 tarballs pass strict + NodeNext/Bundler/browser types, ESM imports, component identity and SSR round trips. +- Independent native-key review reproduced and rechecked editor Escape priority, + sibling focus, automatic-focus preservation and nested Popover dismissal. Refreshed + [final ownership media](../../../packages/ranui/test/evidence/2026-10-24-overlay-composition/final-ownership/README.md) + binds five source files,18 screenshots and two recordings to the final implementation. + +The fixture repair uses zero transition duration instead of creating microtransitions +on static layout, colors and layers. Normal-motion interruption cases create their browser +context with that preference; this does not certify dynamic OS preference changes in WebKit. +Failed logs and the old-helper red regression are retained; no behavior expectation, +comparison tolerance or retry budget was relaxed. + +Loading has all29 gallery/normal/reduced evidence and targeted readability repairs, not +one newly unified illustration palette. Glass WebGPU pixels remain unverified because +requestAdapter returned null. Real microphone/provider, physical assistive technology, +external WHEP/TURN, DRM and cast hardware are not claimed. See per-family limitations. + +Ranui/ranuts0.5.1 and ranview0.2.1 release metadata validates. No npm publication, merge +or Document integration is performed as part of this review delivery. diff --git a/package.json b/package.json index ba5bf2acd6..43698e4513 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,23 @@ "lint:prettier": "prettier --check --cache .", "format": "prettier --write --cache .", "cpd": "jscpd ./packages", - "verify:docs": "pnpm -F ranui doc:api:check && pnpm -F ranui doc:style:check && pnpm -F ranui doc:changelog:check && pnpm -F ranuts doc:api:check && pnpm -F docs check:langs", - "verify:design": "node bin/run-checks.mjs verify:design", - "verify:packages": "node bin/verify-packages.mjs", - "verify:consumers": "pnpm -F ranuts verify:package && node bin/verify-package-consumers.mjs", - "verify:links": "node bin/verify-md-links.mjs", + "verify:docs": "node bin/sync-design-docs.ts --check && pnpm verify:agent-plugin && pnpm -F ranui doc:api:check && pnpm -F ranui doc:style:check && pnpm -F ranui doc:changelog:check && pnpm -F ranuts doc:api:check && pnpm -F docs check:langs", + "verify:design": "node bin/run-checks.ts verify:design", + "verify:packages": "node bin/verify-packages.ts", + "verify:consumers": "pnpm -F ranuts verify:package && node bin/verify-package-consumers.ts", + "verify:links": "node bin/verify-md-links.ts", "publint": "pnpm exec publint packages/ranui && pnpm exec publint packages/ranuts && pnpm exec publint packages/ranview", - "verify:scripts": "node --test bin/test/*.test.mjs", - "release:check": "node bin/release.mjs check", - "release:prepare": "node bin/release.mjs prepare", - "release:dry-run": "node bin/release.mjs dry-run", - "release:publish": "node bin/release.mjs publish", - "verify:release": "node bin/verify-release-install.mjs" + "verify:agent-evals": "node --test --test-concurrency=1 plugins/ran/evals/*.test.ts", + "verify:scripts": "node --test bin/test/*.test.ts", + "release:check": "node bin/release.ts check", + "release:prepare": "node bin/release.ts prepare", + "release:dry-run": "node bin/release.ts dry-run", + "release:publish": "node bin/release.ts publish", + "verify:release": "node bin/verify-release-install.ts", + "typecheck:tooling": "tsc --project tsconfig.tooling.json", + "sync:design": "node bin/sync-design-docs.ts", + "sync:agent-plugin": "node bin/sync-agent-plugin.ts", + "verify:agent-plugin": "node bin/sync-agent-plugin.ts --check" }, "homepage": "https://ran.chaxus.com/", "keywords": [], @@ -38,7 +43,11 @@ "jscpd": "5.0.14", "oxlint": "1.77.0", "prettier": "3.9.6", - "publint": "0.3.24" + "publint": "0.3.24", + "typescript": "catalog:", + "@types/node": "catalog:", + "@playwright/test": "1.62.1", + "vite": "catalog:" }, "packageManager": "pnpm@11.6.0" } diff --git a/packages/docs/CLAUDE.md b/packages/docs/CLAUDE.md index 31a29fd6ca..60e0a0527c 100644 --- a/packages/docs/CLAUDE.md +++ b/packages/docs/CLAUDE.md @@ -285,11 +285,8 @@ request or an unavailable observer must leave every server-rendered section visi ## Design -The site is dressed in ranui's design system. `packages/ranui/docs/DESIGN.md` is the spec; -§11 ("Composition — the page, not the component") is the part that governs pages rather than -widgets, and every rule in it came from a failure on this site. §12 ("Information -architecture") sits one level above it: which shape a dense page should take before any of -§11 arranges it. +The site is dressed in ranui's design system. `design/DESIGN.md` is the shared design spec; ranui component contracts remain in `packages/ranui/docs/`. +Use the shared composition and information architecture rules for pages, and component-specific contracts when selecting or configuring a live demo. `pnpm -F docs verify:design` runs ranui's checker over `styles/` against `design-baseline.json`, which is a **ratchet**: a violation count that rises fails as a new @@ -324,6 +321,6 @@ invisible and expensive: | Translating a `` block's labels inside a component page | They mirror the code block printed right under them — `Primary Button` has to read the same in both. Same rule for the playground's slider labels, which are `r-glass` attribute names. Only chrome the reader is _told_ in their own language gets translated. | | Adding `isolation`/`z-index` to the `` box "just in case" a demo opens an overlay | `ran-demo` stays `isolation: isolate` unconditionally (cheap, contains decorative high-z-index content) but only escalates via `:has(r-modal[open]), :has(r-modal[closing])` — exactly when a real fixed-position overlay is open or fading out. A blanket `z-index` promotes the _whole box_ above the sticky nav for its entire scroll lifetime; that shipped once and painted a static `r-math` demo over the nav on ordinary scroll. | | Matching only `:has(r-modal[open])` for that escalation | `close()` removes `open` synchronously but the mask keeps painting for `getTransitionTimeout()` (~0.3s) more. `r-modal` reflects `closing` for exactly that tail — match both, or the still-fading mask repaints under whatever the escalation was holding it above. | -| Adding a product switcher / dropdown next to the logo | The nav is deliberately flat: Home · ranui · ranuts · Articles · Doc Editor. A `ProductSwitcher` used to live beside the logo; it needed pixel measuring to sit there and a `pointer-events` hack to be clickable at all. Removed in favour of plain nav items. | +| Adding a product switcher / dropdown next to the logo | The nav is deliberately flat: Design · ranui · ranuts · Articles · Agents · Doc Editor. A `ProductSwitcher` used to live beside the logo; it needed pixel measuring to sit there and a `pointer-events` hack to be clickable at all. Removed in favour of plain nav items. | | Adding a custom PWA install prompt | The site is installable — `public/manifest.json` is linked and the SW is registered — but a `` element used to be appended on every page load, interrupting reading, and its "remember the dismissal" attribute did not actually stop it recurring. Installation still works through the browser's own affordance. | | Assuming generated output is ignored by tooling | Generated artefacts must be added to `.prettierignore` explicitly, or `lint:prettier` goes red the first time someone runs the generator. | diff --git a/packages/docs/build/common/index.ts b/packages/docs/build/common/index.ts index e50625105a..d2ff3a50a4 100644 --- a/packages/docs/build/common/index.ts +++ b/packages/docs/build/common/index.ts @@ -41,7 +41,7 @@ export const PREVIEW_CODE = ` `; export const DESCRIPTION = - 'ran is an open-source front-end ecosystem by chaxus: ranui, a native Web Components UI library, and ranuts, a tree-shakeable TypeScript utility library, plus low-level web-infrastructure experiments.'; + 'ran provides product design methods, native Web Components through ranui, and typed utilities through ranuts, with guidance for developers and agents.'; export const HOME = 'https://ran.chaxus.com/'; diff --git a/packages/docs/build/components.ts b/packages/docs/build/components.ts index 0805d6b465..adc0946e30 100644 --- a/packages/docs/build/components.ts +++ b/packages/docs/build/components.ts @@ -6,7 +6,7 @@ * and stay exactly where they are. A template over static data does not need a framework * or a runtime; it needs to be run once, at build time, which is what happens here. * - * The behaviour that *is* real — clipboard, hover tilt, scroll reveal, count-up, the + * The behaviour that *is* real — clipboard, theme selection, the * glass sliders, icon copy — moves to `client/home.ts` as progressive enhancement. So the * markup is in the server-rendered HTML, indexable and correct with no JavaScript, and * the script only adds motion and interaction on top. @@ -71,8 +71,6 @@ const ICONS: Record = { const icon = (kind: string): string => ICONS[kind] ?? ''; /** Staggered entrance delay, as a CSS custom property on the element. */ -const hd = (i: number): string => `style="--hd:${i * 55}ms"`; -const rd = (i: number): string => `style="--rd:${i * 60}ms"`; const ARROW = ''; @@ -129,32 +127,31 @@ const liveSource = (t: ReturnType): string => export const renderHome = (locale: LocaleDef): string => { const t = homeCopy(locale.dir); const href = (path: string): string => localeHref(path, locale); - // Languages that opt out of word splitting — the CJK headlines and Persian — rise as + // Languages that opt out of word splitting — the CJK headlines and Persian — render as // one segment: word units either do not exist there or are not worth reordering an // RTL line for. const words = t.splitWords ? t.headline.split(/\s+/) : [t.headline]; - const n = words.length; return ( `
` + `` + // ── hero ── `
` + - `${esc(t.eyebrow)}` + + `${esc(t.eyebrow)}` + `

` + - words.map((w, i) => `${esc(w)}`).join('') + + words.map((w) => `${esc(w)}`).join('') + `

` + - `

${esc(t.subtitle)}

` + - `
npm i ranui ranuts` + + `

${esc(t.subtitle)}

` + + `
npm i ranui ranuts` + `
` + - `` + // The live panel holds real ranui elements. They are inert custom elements until the // bundle upgrades them, so this is the markup either way — no skeleton swap, and // therefore none of the hydration mismatch the Vue version had to avoid. - `
` + + `
` + `
${esc(t.liveLabel)}` + `html
` + /* @@ -176,11 +173,11 @@ export const renderHome = (locale: LocaleDef): string => { `${esc(t.liveCheck)}
` + `
${esc(t.liveNote)}
` + // ── stats ── - `
` + + `
` + t.stats .map( - (s, i) => - `
${esc(s.num)}` + + (s) => + `
${esc(s.num)}` + `${esc(s.label)}
`, ) .join('') + @@ -189,8 +186,8 @@ export const renderHome = (locale: LocaleDef): string => { `
` + t.pillars .map( - (p, i) => - `` + + (p) => + `` + `${icon(p.kind)}` + `

${esc(p.title)}

${esc(p.desc)}

` + `${esc(p.more)} ${ARROW_SM}
`, @@ -198,9 +195,9 @@ export const renderHome = (locale: LocaleDef): string => { .join('') + `
` + // ── capabilities ── - `
` + + `
` + `${esc(t.capsKicker)}

${esc(t.capsTitle)}

` + - `

${esc(t.capsSub)}

` + + `

${esc(t.capsSub)}

` + t.caps .map( (col) => @@ -209,8 +206,8 @@ export const renderHome = (locale: LocaleDef): string => { `
    ` + col.items .map( - (item, ii) => - `
  • ${icon(item.kind)}
    ` + + (item) => + `
  • ${icon(item.kind)}
    ` + `${esc(item.name)} ${esc(item.api)}` + `${esc(item.desc)}
  • `, ) @@ -220,19 +217,19 @@ export const renderHome = (locale: LocaleDef): string => { .join('') + `
` + // ── get started ── - `
` + + `
` + `${esc(t.startKicker)}

${esc(t.startTitle)}

` + `

${esc(t.startDesc)}

` + - `
` + + `
` + `
${esc(t.startStep1)}
${SNIPPET_INSTALL}
` + `
${esc(t.startStep2)}
${SNIPPET_USE}
` + `
` + // ── feature strip ── - `
` + + `
` + t.features .map( - (f, i) => - `
` + + (f) => + `
` + `${icon(f.kind)}

${esc(f.title)}

` + `

${esc(f.desc)}

`, ) diff --git a/packages/docs/build/content.ts b/packages/docs/build/content.ts index 480191e77e..09e2aacbef 100644 --- a/packages/docs/build/content.ts +++ b/packages/docs/build/content.ts @@ -1,7 +1,7 @@ /** * The documentation site's page model. * - * The shape that matters here and does not exist on chaxus.com is **the locale**. Every + * Each page model includes **the locale**. Every * page belongs to one, its URL carries that locale's prefix, and the same page in * another language is a sibling reached by swapping the prefix. Getting that wrong is * not a visible bug — it is a set of hreflang tags pointing at 404s, which nobody diff --git a/packages/docs/build/langs/home-copy.ts b/packages/docs/build/langs/home-copy.ts index 38028c7eca..9c6419ab75 100644 --- a/packages/docs/build/langs/home-copy.ts +++ b/packages/docs/build/langs/home-copy.ts @@ -1,7 +1,7 @@ /** * Home page copy, per language. * - * It lives outside `HomeCinematic.vue` because eight languages of hero, pillars, + * It lives outside the home renderer because eight languages of hero, pillars, * capabilities and features is more prose than component — and because the invariant half * (which capability sits in which column, what its API signature is, where a pillar links) * must not be retyped per language, where it would drift. @@ -42,7 +42,7 @@ const STAT_NUMS = ['40', '90+', '8', 'MIT'] as const; const PILLAR_STRUCTURE = [ { kind: 'ui', link: '/src/ranui/' }, { kind: 'utils', link: '/src/ranuts/' }, - { kind: 'article', link: '/src/article/doc_preview' }, + { kind: 'article', link: '/src/design/' }, ] as const; const FEATURE_KINDS = ['agnostic', 'typed', 'pwa', 'i18n'] as const; @@ -66,7 +66,7 @@ export interface HomeStrings { ctaSecondary: string; /** Labels under `STAT_NUMS`, in that order. */ statLabels: [string, string, string, string]; - /** `{ title, desc, more }` for ranui, ranuts and the articles pillar, in that order. */ + /** `{ title, desc, more }` for ranui, ranuts and the Ran Design pillar, in that order. */ pillars: [Pair3, Pair3, Pair3]; capsKicker: string; capsTitle: string; @@ -113,11 +113,11 @@ interface Pair3 extends Pair2 { export const HOME_STRINGS: Record = { '': { eyebrow: 'Open source · MIT Licensed', - headline: 'A Web Components UI library and a typed utility library', + headline: 'Design methods, components and tools', splitWords: true, subtitle: - 'ranui ships 40 framework-agnostic r- elements; ranuts packs 90+ tree-shakeable TypeScript helpers. Use them in Vue, React, or plain HTML, with no build step required.', - ctaPrimary: 'Explore Components', + 'Build products with Ran Design, ranui components and ranuts utilities. Use each independently, with guidance for developers and agents.', + ctaPrimary: 'Build with agents', ctaSecondary: 'Star on GitHub', statLabels: ['Components', 'Utilities', 'Languages', 'Licensed'], pillars: [ @@ -132,9 +132,9 @@ export const HOME_STRINGS: Record = { more: 'Read the docs', }, { - title: 'Articles', - desc: 'Longer write-ups on rendering engines, functional programming, sorting algorithms and the web platform.', - more: 'Start reading', + title: 'Ran Design', + desc: 'Methods for layout, interaction, motion and evidence-based review, independent of any component library.', + more: 'Explore design', }, ], capsKicker: 'Capabilities', @@ -194,11 +194,10 @@ export const HOME_STRINGS: Record = { }, cn: { eyebrow: '开源 · MIT 协议', - headline: 'Web Components 组件库与 TypeScript 工具集', + headline: '设计方法、组件与工具', splitWords: false, - subtitle: - 'ranui 提供 40 个框架无关的 r- 元素,ranuts 收录 90+ 个可 Tree-shaking 的类型化工具函数,在 Vue、React 或纯 HTML 中直接使用,无需构建步骤。', - ctaPrimary: '浏览组件', + subtitle: '使用 Ran Design、ranui 组件和 ranuts 工具构建产品。三者可独立使用,为开发者和 agent 提供指引。', + ctaPrimary: '使用 agent 构建', ctaSecondary: '前往 GitHub', statLabels: ['组件', '工具函数', '语言', '协议'], pillars: [ @@ -209,10 +208,14 @@ export const HOME_STRINGS: Record = { }, { title: 'ranuts', - desc: '90+ 个带类型的工具函数,覆盖字符串、对象、颜色、时间、文件与 DOM。支持 Tree-shaking,零依赖。', - more: '阅读文档', + desc: '提供字符串、对象、颜色、时间、文件与 DOM 等 TypeScript 工具,支持按需引入。', + more: '查看工具', + }, + { + title: 'Ran Design', + desc: '涵盖布局、交互、动效和实际验收的方法,不依赖特定组件库。', + more: '探索设计方法', }, - { title: '文章', desc: '关于渲染引擎、函数式编程、排序算法与 Web 平台的长文。', more: '开始阅读' }, ], capsKicker: '特色能力', capsTitle: '多数库没有的能力', @@ -252,11 +255,10 @@ export const HOME_STRINGS: Record = { }, ja: { eyebrow: 'オープンソース · MIT ライセンス', - headline: 'Web Components の UI ライブラリと型付きユーティリティ', + headline: '設計方法、コンポーネント、ツール', splitWords: false, - subtitle: - 'ranui はフレームワークに依存しない r- 要素を 40 個、ranuts は tree-shaking 可能な型付きユーティリティを 90 以上そろえています。Vue でも React でも素の HTML でも、ビルド工程なしでそのまま使えます。', - ctaPrimary: 'コンポーネントを見る', + subtitle: 'Ran Design、ranui、ranuts で製品を構築。各機能を独立して使い、開発者とエージェントを支援します。', + ctaPrimary: 'エージェントで構築', ctaSecondary: 'GitHub でスターする', statLabels: ['コンポーネント', 'ユーティリティ', '言語', 'ライセンス'], pillars: [ @@ -271,9 +273,9 @@ export const HOME_STRINGS: Record = { more: 'ドキュメントを読む', }, { - title: '記事', - desc: 'レンダリングエンジン、関数型プログラミング、ソートアルゴリズム、Web プラットフォームについての長文。', - more: '読みはじめる', + title: 'Ran Design', + desc: 'レイアウト、操作、モーション、実際の検証を扱う、ライブラリに依存しない設計方法。', + more: '設計方法を見る', }, ], capsKicker: '特色ある機能', @@ -339,11 +341,11 @@ export const HOME_STRINGS: Record = { }, es: { eyebrow: 'Código abierto · Licencia MIT', - headline: 'Una biblioteca de UI en Web Components y utilidades tipadas', + headline: 'Métodos de diseño, componentes y herramientas', splitWords: true, subtitle: - 'ranui trae 40 elementos r- independientes de cualquier framework; ranuts reúne más de 90 utilidades TypeScript compatibles con tree-shaking. Úsalos en Vue, en React o en HTML puro, sin ningún paso de compilación.', - ctaPrimary: 'Ver los componentes', + 'Crea productos con Ran Design, ranui y ranuts. Úsalos por separado, con orientación para desarrolladores y agentes.', + ctaPrimary: 'Crear con agentes', ctaSecondary: 'Dar una estrella en GitHub', statLabels: ['Componentes', 'Utilidades', 'Idiomas', 'Licencia'], pillars: [ @@ -358,9 +360,9 @@ export const HOME_STRINGS: Record = { more: 'Leer la documentación', }, { - title: 'Artículos', - desc: 'Textos largos sobre motores de renderizado, programación funcional, algoritmos de ordenación y la plataforma web.', - more: 'Empezar a leer', + title: 'Ran Design', + desc: 'Métodos de composición, interacción, movimiento y revisión con evidencia, independientes de una biblioteca.', + more: 'Explorar diseño', }, ], capsKicker: 'Capacidades', @@ -426,11 +428,11 @@ export const HOME_STRINGS: Record = { }, pt: { eyebrow: 'Código aberto · Licença MIT', - headline: 'Uma biblioteca de UI em Web Components e utilitários tipados', + headline: 'Métodos de design, componentes e ferramentas', splitWords: true, subtitle: - 'O ranui traz 40 elementos r- independentes de framework; o ranuts reúne mais de 90 utilitários TypeScript compatíveis com tree-shaking. Use-os no Vue, no React ou em HTML puro, sem nenhuma etapa de build.', - ctaPrimary: 'Ver os componentes', + 'Crie produtos com Ran Design, ranui e ranuts. Use cada um de forma independente, com orientação para desenvolvedores e agentes.', + ctaPrimary: 'Criar com agentes', ctaSecondary: 'Dar uma estrela no GitHub', statLabels: ['Componentes', 'Utilitários', 'Idiomas', 'Licença'], pillars: [ @@ -445,9 +447,9 @@ export const HOME_STRINGS: Record = { more: 'Ler a documentação', }, { - title: 'Artigos', - desc: 'Textos longos sobre motores de renderização, programação funcional, algoritmos de ordenação e a plataforma web.', - more: 'Começar a ler', + title: 'Ran Design', + desc: 'Métodos de composição, interação, movimento e revisão com evidências, independentes de bibliotecas.', + more: 'Explorar design', }, ], capsKicker: 'Recursos', @@ -513,11 +515,11 @@ export const HOME_STRINGS: Record = { }, ko: { eyebrow: '오픈소스 · MIT 라이선스', - headline: '웹 컴포넌트 UI 라이브러리와 타입이 붙은 유틸리티', + headline: '디자인 방법, 컴포넌트와 도구', splitWords: true, subtitle: - 'ranui는 프레임워크에 얽매이지 않는 r- 엘리먼트 40개를, ranuts는 트리 셰이킹이 되는 타입 유틸리티 90여 개를 제공합니다. Vue에서도 React에서도 순수 HTML에서도 빌드 단계 없이 그대로 씁니다.', - ctaPrimary: '컴포넌트 둘러보기', + 'Ran Design, ranui, ranuts로 제품을 만드세요. 개발자와 에이전트를 위한 지침과 함께 각각 독립적으로 사용할 수 있습니다.', + ctaPrimary: '에이전트로 만들기', ctaSecondary: 'GitHub에서 스타 주기', statLabels: ['컴포넌트', '유틸리티', '언어', '라이선스'], pillars: [ @@ -532,9 +534,9 @@ export const HOME_STRINGS: Record = { more: '문서 읽기', }, { - title: '아티클', - desc: '렌더링 엔진, 함수형 프로그래밍, 정렬 알고리즘, 웹 플랫폼을 다루는 긴 글.', - more: '읽기 시작하기', + title: 'Ran Design', + desc: '특정 라이브러리에 종속되지 않는 레이아웃, 상호작용, 모션 및 실제 검증 방법입니다.', + more: '디자인 살펴보기', }, ], capsKicker: '주요 기능', @@ -594,11 +596,11 @@ export const HOME_STRINGS: Record = { }, de: { eyebrow: 'Open Source · MIT-Lizenz', - headline: 'Eine Web-Components-UI-Bibliothek und typisierte Utilities', + headline: 'Designmethoden, Komponenten und Werkzeuge', splitWords: true, subtitle: - 'ranui liefert 40 frameworkunabhängige r--Elemente, ranuts über 90 tree-shaking-fähige TypeScript-Helfer. Nutzbar in Vue, in React oder in reinem HTML — ganz ohne Build-Schritt.', - ctaPrimary: 'Komponenten ansehen', + 'Produkte mit Ran Design, ranui und ranuts entwickeln. Unabhängig nutzbar, mit Anleitungen für Entwickler und Agenten.', + ctaPrimary: 'Mit Agenten entwickeln', ctaSecondary: 'Auf GitHub mit Stern versehen', statLabels: ['Komponenten', 'Utilities', 'Sprachen', 'Lizenz'], pillars: [ @@ -613,9 +615,9 @@ export const HOME_STRINGS: Record = { more: 'Dokumentation lesen', }, { - title: 'Artikel', - desc: 'Längere Texte über Rendering-Engines, funktionale Programmierung, Sortieralgorithmen und die Web-Plattform.', - more: 'Zu lesen beginnen', + title: 'Ran Design', + desc: 'Bibliotheksunabhängige Methoden für Layout, Interaktion, Bewegung und Prüfung anhand konkreter Ergebnisse.', + more: 'Design erkunden', }, ], capsKicker: 'Fähigkeiten', @@ -681,11 +683,11 @@ export const HOME_STRINGS: Record = { }, fa: { eyebrow: 'متن‌باز · مجوز MIT', - headline: 'کتابخانهٔ رابط کاربری وب‌کامپوننت و ابزارهای تایپ‌شده', + headline: 'روش\u200cهای طراحی، مؤلفه\u200cها و ابزارها', splitWords: false, subtitle: - 'ranui چهل عنصر ‎r-‎ مستقل از فریم‌ورک دارد و ranuts بیش از ۹۰ ابزار تایپ‌شده با پشتیبانی از tree-shaking. در Vue، React یا HTML ساده و بدون هیچ مرحلهٔ ساخت به کار می‌روند.', - ctaPrimary: 'دیدن کامپوننت‌ها', + 'با Ran Design، ranui و ranuts محصول بسازید. هر بخش به\u200cصورت مستقل با راهنمای توسعه\u200cدهندگان و عامل\u200cها قابل استفاده است.', + ctaPrimary: 'ساخت با عامل\u200cها', ctaSecondary: 'ستاره دادن در گیت‌هاب', statLabels: ['کامپوننت', 'ابزار', 'زبان', 'مجوز'], pillars: [ @@ -700,9 +702,9 @@ export const HOME_STRINGS: Record = { more: 'خواندن مستندات', }, { - title: 'مقاله‌ها', - desc: 'نوشته‌های بلند دربارهٔ موتورهای رندر، برنامه‌نویسی تابعی، الگوریتم‌های مرتب‌سازی و بستر وب.', - more: 'شروع خواندن', + title: 'Ran Design', + desc: 'روش\u200cهای چیدمان، تعامل، حرکت و ارزیابی با شواهد، مستقل از کتابخانهٔ مؤلفه\u200cها.', + more: 'کاوش طراحی', }, ], capsKicker: 'توانمندی‌ها', diff --git a/packages/docs/build/langs/locales.ts b/packages/docs/build/langs/locales.ts index 54d4b7d5a2..e389f89fc7 100644 --- a/packages/docs/build/langs/locales.ts +++ b/packages/docs/build/langs/locales.ts @@ -33,9 +33,9 @@ export interface LocaleDef { } /** Every content tree — what a fully mirrored locale carries. */ -const ALL = ['/src/ranui/', '/src/ranuts/', '/src/article/', '/src/note/'] as const; +const ALL = ['/src/design/', '/src/agents/', '/src/ranui/', '/src/ranuts/', '/src/article/', '/src/note/'] as const; /** The library reference only; articles and notes stay English. */ -const LIBS = ['/src/ranui/', '/src/ranuts/'] as const; +const LIBS = ['/src/design/', '/src/agents/', '/src/ranui/', '/src/ranuts/'] as const; /** * Menu order is deliberate: Chinese and English first (the two fully mirrored languages, diff --git a/packages/docs/build/langs/messages/cn.ts b/packages/docs/build/langs/messages/cn.ts index 12f7d95751..17bba0cb2c 100644 --- a/packages/docs/build/langs/messages/cn.ts +++ b/packages/docs/build/langs/messages/cn.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const cn: LocaleMessages = { labels: { + ran_design: '设计', + agents: 'Agents', ranui: 'ranui', ranuts: 'ranuts', articles: '文章', diff --git a/packages/docs/build/langs/messages/de.ts b/packages/docs/build/langs/messages/de.ts index 70b0e37238..dfb18827ac 100644 --- a/packages/docs/build/langs/messages/de.ts +++ b/packages/docs/build/langs/messages/de.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const de: LocaleMessages = { labels: { + ran_design: 'Design', + agents: 'Agenten', ranui: 'ranui', ranuts: 'ranuts', articles: 'Artikel', diff --git a/packages/docs/build/langs/messages/en.ts b/packages/docs/build/langs/messages/en.ts index 42b5a353b2..c3ba24fab9 100644 --- a/packages/docs/build/langs/messages/en.ts +++ b/packages/docs/build/langs/messages/en.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const en: LocaleMessages = { labels: { + ran_design: 'Design', + agents: 'Agents', ranui: 'ranui', ranuts: 'ranuts', articles: 'articles', diff --git a/packages/docs/build/langs/messages/es.ts b/packages/docs/build/langs/messages/es.ts index bc1e737a67..ecdb9f088a 100644 --- a/packages/docs/build/langs/messages/es.ts +++ b/packages/docs/build/langs/messages/es.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const es: LocaleMessages = { labels: { + ran_design: 'Diseño', + agents: 'Agentes', ranui: 'ranui', ranuts: 'ranuts', articles: 'artículos', diff --git a/packages/docs/build/langs/messages/fa.ts b/packages/docs/build/langs/messages/fa.ts index 0745fda62c..dd05a93428 100644 --- a/packages/docs/build/langs/messages/fa.ts +++ b/packages/docs/build/langs/messages/fa.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const fa: LocaleMessages = { labels: { + ran_design: 'طراحی', + agents: 'عامل\u200cها', ranui: 'ranui', ranuts: 'ranuts', articles: 'مقاله‌ها', diff --git a/packages/docs/build/langs/messages/ja.ts b/packages/docs/build/langs/messages/ja.ts index 192673b48a..fd86868163 100644 --- a/packages/docs/build/langs/messages/ja.ts +++ b/packages/docs/build/langs/messages/ja.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const ja: LocaleMessages = { labels: { + ran_design: 'デザイン', + agents: 'エージェント', ranui: 'ranui', ranuts: 'ranuts', articles: '記事', diff --git a/packages/docs/build/langs/messages/ko.ts b/packages/docs/build/langs/messages/ko.ts index bae11e7af0..c8fb6fc857 100644 --- a/packages/docs/build/langs/messages/ko.ts +++ b/packages/docs/build/langs/messages/ko.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const ko: LocaleMessages = { labels: { + ran_design: '디자인', + agents: '에이전트', ranui: 'ranui', ranuts: 'ranuts', articles: '아티클', diff --git a/packages/docs/build/langs/messages/pt.ts b/packages/docs/build/langs/messages/pt.ts index 3003e14c30..449ef0b5d1 100644 --- a/packages/docs/build/langs/messages/pt.ts +++ b/packages/docs/build/langs/messages/pt.ts @@ -9,6 +9,8 @@ import type { LocaleMessages } from '../types.ts'; */ const pt: LocaleMessages = { labels: { + ran_design: 'Design', + agents: 'Agentes', ranui: 'ranui', ranuts: 'ranuts', articles: 'artigos', diff --git a/packages/docs/build/langs/structure.ts b/packages/docs/build/langs/structure.ts index 7c99aa3629..ec8f75dfbc 100644 --- a/packages/docs/build/langs/structure.ts +++ b/packages/docs/build/langs/structure.ts @@ -61,9 +61,11 @@ const articleSidebar: SidebarNode[] = [ ]; export const NAV: SidebarNode[] = [ + { kind: 'full', key: 'ran_design', link: '/src/design/', activeMatch: '/src/design/' }, { kind: 'full', key: 'ranui', link: '/src/ranui/', activeMatch: '/src/ranui/' }, { kind: 'full', key: 'ranuts', link: '/src/ranuts/', activeMatch: '/src/ranuts/' }, { kind: 'full', key: 'articles', link: '/src/article/doc_preview', activeMatch: '/src/(article|note)/' }, + { kind: 'full', key: 'agents', link: '/src/agents/', activeMatch: '/src/agents/' }, { kind: 'full', key: 'doc_editor', link: EDITOR }, ]; diff --git a/packages/docs/client/home.ts b/packages/docs/client/home.ts index 3111cba482..86de7c5885 100644 --- a/packages/docs/client/home.ts +++ b/packages/docs/client/home.ts @@ -38,50 +38,6 @@ const mountHome = (): void => { for (const button of root.querySelectorAll('[data-copy]')) { wireCopy(button, button.dataset.copy ?? ''); } - - const reveals = [...root.querySelectorAll('[data-reveal]')]; - const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (reduce || typeof IntersectionObserver === 'undefined') { - // Reduced motion means the end state, immediately — not "no animation, and also - // never visible", which is what skipping the observer would have produced. - for (const el of reveals) el.classList.add('in'); - return; - } - - /** Count a numeric stat up from zero. Non-numeric values ("MIT") are left alone. */ - const countUp = (el: HTMLElement): void => { - const match = /^(\d+)(.*)$/.exec(el.textContent ?? ''); - if (!match) return; - const target = Number(match[1]); - const suffix = match[2]; - const duration = 1100; - let start = 0; - const tick = (now: number): void => { - start ||= now; - const p = Math.min((now - start) / duration, 1); - const eased = 1 - (1 - p) ** 3; - el.textContent = `${Math.round(target * eased)}${suffix}`; - if (p < 1) requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }; - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - entry.target.classList.add('in'); - if (entry.target.classList.contains('stats')) { - entry.target.querySelectorAll('[data-count]').forEach(countUp); - } - observer.unobserve(entry.target); - } - }, - { threshold: 0.18, rootMargin: '0px 0px -8% 0px' }, - ); - for (const el of reveals) observer.observe(el); - // The head bootstrap alone cannot prove the client can reveal hidden content. - root.dataset.revealReady = 'true'; }; const mountGlass = (): void => { diff --git a/packages/docs/cn/src/agents/index.md b/packages/docs/cn/src/agents/index.md new file mode 100644 index 0000000000..73ea92f03e --- /dev/null +++ b/packages/docs/cn/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: Agents +--- + +# Agents + +一个 ran 插件提供三个 skill:design 指导产品设计,ranui 指导组件使用,ranuts 指导工具选择。 + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +在仓库根目录注册当前工作副本并安装插件。 + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +新建会话后,Codex 使用 `$design`、`$ranui` 或 `$ranuts`;Claude Code 使用 `/ran:design`、`/ran:ranui` 或 `/ran:ranuts`。 + +发布后,将市场命令中的 `.` 替换为 `chaxus/ran`。下方仓库链接指向合并后 main 分支的正式位置。 + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/cn/src/design/index.md b/packages/docs/cn/src/design/index.md new file mode 100644 index 0000000000..55114a3093 --- /dev/null +++ b/packages/docs/cn/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +涵盖布局、交互、动效和实际验收的方法,不依赖特定组件库。 + +先明确任务和真实内容,为整个产品确定统一的视觉语言,再在浏览器中验收完整状态,包括键盘操作和动效。具体契约见下方参考。 + +- [视觉与布局](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [交互](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [验收](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [产品场景](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [参考资料](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [Agents](../agents/) diff --git a/packages/docs/cn/src/ranui/api.md b/packages/docs/cn/src/ranui/api.md index 05d360e570..7d9d31979d 100644 --- a/packages/docs/cn/src/ranui/api.md +++ b/packages/docs/cn/src/ranui/api.md @@ -194,9 +194,9 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - **插槽**: — - **Part**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -211,7 +211,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/image/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `alt`, `fallback`, `sheet`, `src` - **属性值(property)**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -273,7 +273,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/markdown/index.ts` -- **属性(attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **属性(attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **属性值(property)**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -292,7 +292,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **插槽**: — -- **Part**: `block`, `body`, `error`, `markdown` +- **Part**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -306,7 +306,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **插槽**: — -- **Part**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Part**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -321,13 +321,13 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **插槽**: — -- **Part**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Part**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` 源码: `components/message/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `content: string | null`, `sheet: string`, `type: string | null` - **属性值(property)**: `content: string | null`, `sheet: string`, `type: string | null` - **事件**: — - **插槽**: — @@ -362,7 +362,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/select/option/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **属性值(property)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **事件**: — - **插槽**: `默认插槽` @@ -419,7 +419,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/progress/index.ts` -- **属性(attribute)**: `drag`, `primary` +- **属性(attribute)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **属性值(property)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **事件**: - `change` · element-only · detail `{ percent, total, value }` @@ -430,7 +430,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/radar/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **属性值(property)**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **事件**: — - **插槽**: — @@ -483,11 +483,11 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/scratch/index.ts` -- **属性(attribute)**: `disabled: boolean`, `sheet: string` -- **属性值(property)**: `disabled: boolean`, `sheet: string` +- **属性(attribute)**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **属性值(property)**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **事件**: — - **插槽**: `默认插槽` -- **Part**: `award` +- **Part**: `award`, `reveal-button` ## `` @@ -503,7 +503,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/select/index.ts` -- **属性(attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **属性(attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **属性值(property)**: - `defaultValue: string` - `disabled: boolean` @@ -511,6 +511,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -557,7 +558,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/tabpane/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **属性值(property)**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **事件**: — - **插槽**: `默认插槽` @@ -578,7 +579,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 源码: `components/theme-switch/index.ts` -- **属性(attribute)**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **属性(attribute)**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **属性值(property)**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -625,7 +626,7 @@ description: 'ranui 的全部自定义元素 —— 40 个元素的属性、属 - **属性(attribute)**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **属性值(property)**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/cn/src/ranui/button/index.md b/packages/docs/cn/src/ranui/button/index.md index cc8e59cf0f..f940cdf4bf 100644 --- a/packages/docs/cn/src/ranui/button/index.md +++ b/packages/docs/cn/src/ranui/button/index.md @@ -53,6 +53,10 @@ description: 'ranui Button()用于触发即时操作,支持多 ### 禁用状态 `disabled` +禁用按钮会阻止指针、键盘和程序化 `click()` 激活;重新启用后保留原控件及其可访问名称。 + +`focus(options?: FocusOptions)` 会聚焦可用的键盘操作区域,并转发原生聚焦选项。浮层可在关闭后调用它恢复触发按钮的焦点;禁用按钮不会获得焦点。 + 添加 `disabled` 属性可以让按钮处于不可用状态,同时按钮样式也会相应改变 diff --git a/packages/docs/cn/src/ranui/card/index.md b/packages/docs/cn/src/ranui/card/index.md index d03d05ff37..19aa8e8c7b 100644 --- a/packages/docs/cn/src/ranui/card/index.md +++ b/packages/docs/cn/src/ranui/card/index.md @@ -57,7 +57,7 @@ description: '带有页眉、主体和页脚区域的结构化内容容器,Gei ### 描述 `description` -渲染在标题下方的副标题。为空时隐藏。当 `title` 和 `description` 都未设置时,整个页眉都会隐藏。 +渲染在标题下方的副标题。为空时隐藏。只有当 `heading` 和 `description` 都为空,且 `extra` 插槽没有分配的元素时,整个页眉才会隐藏。 @@ -140,9 +140,9 @@ r-card::part(header) { ## 最佳实践 -- **标题与描述**:用 `title` 承载标题、`description` 承载简短的辅助副标题;两者都不设置时整个页眉会被隐藏。 +- **标题与描述**:用 `heading` 承载标题、`description` 承载简短的辅助副标题。两者都为空且 `extra` 插槽为空时,整个页眉会被隐藏。 - **主体内容**:把主要内容放在默认插槽里。 -- **页眉操作**:用 `extra` 插槽放置徽标、链接或对齐到页眉右侧的操作按钮。 +- **页眉操作**:用 `extra` 插槽放置徽标、链接或操作按钮;没有标题时也可单独显示。卡片较窄、同一行放不下时,操作区会换行到标题下方。 - **页脚**:用 `footer` 插槽放置次要操作或链接;在插入内容之前它会保持隐藏。 - **悬停反馈**:只给真正可点击的卡片加 `hoverable`,非交互卡片不应该响应悬停。 - **主题定制**:优先使用 CSS 变量和 `::part()` 而不是 `sheet` 属性,以获得可复用的样式方案。 diff --git a/packages/docs/cn/src/ranui/changelog.md b/packages/docs/cn/src/ranui/changelog.md index 55543f0f8b..e124475cab 100644 --- a/packages/docs/cn/src/ranui/changelog.md +++ b/packages/docs/cn/src/ranui/changelog.md @@ -9,6 +9,18 @@ description: 'ranui 的变更记录——新增、变更、修复与移除及其 ::: v-pre +## [0.5.1] — 2026-10-23 + +发布准备版本;通过验证后再发布到 npm。 + +- 保留带图标输入框的标签,修复字段布局、禁用按钮行为及键盘焦点可见性,包括弹层关闭后的焦点返回。 +- 统一嵌套弹层逐层关闭、重新连接时的节点保留,以及 Select 退出期间立即重新打开的行为。 +- 流式内容保留代码复制原文、局部失败重试反馈、原生文字选择、表格增强和阅读滚动位置。 +- 优化代码与表格的排版、内边距、焦点和触屏操作目标;产品页面布局仍由宿主负责。 +- 减少展开推理头部的重复信息,并尊重明确关闭的自动跟随。 +- 提供规范、交互和验收的唯一入口,以轻量 agent 插件按任务路由,并提供有范围说明的参考案例。 +- 协同 `ranuts@0.5.1` 和 `@alixex/ranview@0.2.1`,打包后的内部依赖使用精确版本。继续支持 Node 24+、ESM 和独立 IIFE。 + ## [0.5.0] — 2026-10-19 [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) 已正式发布到 npm 的 `latest` 标签。 diff --git a/packages/docs/cn/src/ranui/conversation/index.md b/packages/docs/cn/src/ranui/conversation/index.md index 22038e145b..469fb87774 100644 --- a/packages/docs/cn/src/ranui/conversation/index.md +++ b/packages/docs/cn/src/ranui/conversation/index.md @@ -65,7 +65,7 @@ chat.addEventListener('pinnedchange', (e) => { }); ``` -`follow="false"` 让用户从一开始就掌握控制权;`scrollToBottom()` 把控制权收回。加载更早的内容时,在前插之前调用 `captureAnchor()`、之后调用 `restoreAnchor()`,用户就会一直看着他们正在看的那一行。 +`follow="false"` 从首次挂载到重新连接都会禁用自动跟随,包括内容和底部区域尺寸变化。显式禁用时,`pinned` 返回 `false`;切换 `follow` 导致有效状态变化时触发 `pinnedchange`。`scrollToBottom()` 显式跳到最新内容,并把 `follow` 恢复为 `true`。加载更早的内容时,在前插之前调用 `captureAnchor()`、之后调用 `restoreAnchor()`,用户就会一直看着他们正在看的那一行。 ## API 参考 diff --git a/packages/docs/cn/src/ranui/design-guides/index.md b/packages/docs/cn/src/ranui/design-guides/index.md index ecfebe8124..aa3f7846ed 100644 --- a/packages/docs/cn/src/ranui/design-guides/index.md +++ b/packages/docs/cn/src/ranui/design-guides/index.md @@ -127,12 +127,20 @@ description: '使用 ranui 构建界面:根据元素的用途选择设计令 吸顶导航之上。这个 bug 在本站上真实发生过。记得同时匹配 `closing`:`open` 被移除后,遮罩还会按过渡 时长继续绘制。 +相关组件与文档外框应共享一套视觉语言:文字角色、控件密度、圆角与内边距的关系、语义强调和动效相互协调; +表达角色与优先级的差异仍须保留。先用不同真实内容比较完整布局,再添加效果;颜色一致不能补救比例失调。 +原始来源、实际观察与验证边界集中记录在[参考索引](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/REFERENCE_RESEARCH.md)。 + ## 动效 {#motion} 根据目的和使用频率决定是否需要动效。高频的悬停、聚焦和键盘反馈默认即时响应。只有动效能解释变化时, 才使用已有动效令牌:小幅过渡约 150ms、菜单约 200ms、对话框约 300ms,都是可选参考,而非必须等待的时长。 动效应能被新操作打断,并尊重 `prefers-reduced-motion`。 +修改动效前,说明触发操作、状态变化和目标位置。新操作应能打断或反向衔接,避免过期回调和重复触发; +保留草稿与阅读位置,插入或移除时避免可见文字被挤压。用连续录屏检查操作和返回路径,先正常播放, +再慢放定位不连续的瞬间。减弱动效后仍须有清晰可见的最终状态;相关组件的可选动效应保持语义一致。 + **绝不要让调色属性参与过渡。** CSS 分不清颜色**为什么**变了,所以给 `background-color`、`color`、 `border-color`、`box-shadow`、`fill`、`stroke` 加的 `transition`,在**主题**翻转时同样会触发:每个 元素按各自的时长淡变,而页面其余部分早已切换完毕。要动就动运动属性(`transform`、`opacity`、几何 @@ -213,3 +221,10 @@ display 规则、引用未声明令牌的兜底、组件查询自己的 shadow - [ ] 间距取自尺度、排版按角色、颜色用语义令牌。 - [ ] `transition` 里没有调色属性;没有 `transition: all`。 - [ ] 文案与上下文共同明确操作及对象;没有任何状态只靠颜色表达。 + +## 证据与验收 {#evidence-and-acceptance} + +[验收契约](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) +统一维护实际浏览器验证、运行版本与截图的新鲜度,以及自动检查、agent 视觉审查和用户决定的区别。 +共享样式变化后,应更新受影响的证据;agent 自检不等于用户验收。 +删除没有信息增量的次级标签和说明,让布局解释显而易见的关系;保留无障碍名称、有用的字段标签、单位、错误和风险说明。 diff --git a/packages/docs/cn/src/ranui/design-system/index.md b/packages/docs/cn/src/ranui/design-system/index.md index 2b940f9301..b581b6e152 100644 --- a/packages/docs/cn/src/ranui/design-system/index.md +++ b/packages/docs/cn/src/ranui/design-system/index.md @@ -200,6 +200,8 @@ ranui 把这套阶梯落成 `--ran-*` 色阶,在其上叠加语义令牌,并 `--ran-color-primary-hover` / `-active` 是语义层里仅有的两个字面量:它们是朝页面背景方向走的,而不 是沿着某条色阶走,所以暗色模式直接重定义了它们。 +`--ran-media-background` 在两种主题下均为 `#000`。视频画布需要保持深色,以承托固定白色的播放控件;Player 的 `--ran-player-background` 派生自此令牌。这个媒体角色不会随页面配色反转。 + ### 每个色彩语义只有一个含义 - **主操作是无彩色的**:浅色下黑底白字,暗色下白底黑字(Geist 的品牌调性, diff --git a/packages/docs/cn/src/ranui/disclosure-row/index.md b/packages/docs/cn/src/ranui/disclosure-row/index.md index 89bc8a9432..4e6ca7d33c 100644 --- a/packages/docs/cn/src/ranui/disclosure-row/index.md +++ b/packages/docs/cn/src/ranui/disclosure-row/index.md @@ -133,6 +133,10 @@ row.addEventListener('disclosurebeforetoggle', async (event) => { 收起时正文是被裁切而不是被移除,这样才能做动画。同时它会被标记为 `inert`,内容用 `content-visibility: hidden` 跳过渲染,因此收起期间既不在 Tab 顺序里,也不占渲染开销。 +收起时若焦点位于正文内,会先返回该行,再把正文设为 inert;程序控制和分组联动收起也遵守此规则。 +焦点位于其他位置时不会被移动。存在 leading 内容时,键盘聚焦与悬停一样,会以箭头替换原图标, +不会把两者叠画在一起。 + 行高 24px,正好卡在 WCAG 2.5.8 的最小值上,而且行与行之间没有间隔。在粗指针(触摸)设备上默认行高会 变成 32px:命中区无法在不与上一行重叠的前提下继续扩大,而重叠只会把「目标偏小」换成「点错行」。设置 `--ran-disclosure-row-height` 可以在任何输入方式下固定行高。 diff --git a/packages/docs/cn/src/ranui/input/index.md b/packages/docs/cn/src/ranui/input/index.md index c3f1974cdf..733536ff46 100644 --- a/packages/docs/cn/src/ranui/input/index.md +++ b/packages/docs/cn/src/ranui/input/index.md @@ -53,6 +53,8 @@ description: 'ranui Input()是用于键盘输入的基础表单控 ``` +标签、输入控件和提示文案各占一行。显式设置宿主高度时,输入控件填满标签和提示文案之外的剩余空间,并保留自身的最小高度。 + ### 占位提示 `placeholder` 与原生 `placeholder` 属性一致。 @@ -87,6 +89,8 @@ description: 'ranui Input()是用于键盘输入的基础表单控 ### 图标 `icon` +前置图标可以与静态 `label` 同时使用。添加或更改 `icon` 会保留可见标签及其与原生输入框的关联。 + diff --git a/packages/docs/cn/src/ranui/loading/index.md b/packages/docs/cn/src/ranui/loading/index.md index 1cef3cf762..7b0ec0fe97 100644 --- a/packages/docs/cn/src/ranui/loading/index.md +++ b/packages/docs/cn/src/ranui/loading/index.md @@ -24,10 +24,10 @@ description: 'ranui Loading()在内容或操作进行中展示旋 ### 属性 -| 属性 | 类型 | 默认值 | 说明 | -| ------- | -------- | ---------- | ------------------------------------------------ | -| `name` | `string` | `'circle'` | 动画类型;未设置或无法识别时回退为 `circle` | -| `sheet` | `string` | `''` | 注入组件 shadow DOM、用于外部样式定制的 CSS 文本 | +| 属性 | 类型 | 默认值 | 说明 | +| ------- | -------- | ---------- | ------------------------------------------------- | +| `name` | `string` | `'circle'` | 动画类型;未设置时为 `circle`,未知名称不渲染内容 | +| `sheet` | `string` | `''` | 注入组件 shadow DOM、用于外部样式定制的 CSS 文本 | ### 加载动画类型 `name` @@ -184,3 +184,5 @@ r-loading::part(rotate) { - **按需加载**:每个动画都是独立的懒加载 chunk(自带 JS + CSS),设置某个 `name` 只会加载它用到的那一个 variant,引用一个动画绝不会把其余 28 个也打进来。默认的 `circle` 和高频的 `dot` 内置为同步渲染,首帧即时无闪烁;其余在首次使用时异步加载。用法完全不变,设置 `name` 即可。 - **一致性**:在应用中保持一致的 CSS 变量命名模式 - **主题适配**:基础颜色跟随 `--ran-color-*` 系列主题 token,动画会自动适配浅色/深色模式 + +加载指示器是视觉反馈,宿主应为实际加载区域提供适当的 `aria-busy` 或状态通知,避免把多个动画分别设为 live region。减少动态效果时,circle、circle-fold、cube-fold、dot-bar 和 triple-bounce 保留静态可见形状;text 保持可读的字号和主题文字色。cube-dim 与 dot-line 的默认几何区域在窄父容器中收缩;已有尺寸/颜色 hooks 仍可覆盖。装饰变体保留各自的颜色,不保证每款都自动转为单色主题。 diff --git a/packages/docs/cn/src/ranui/markdown/index.md b/packages/docs/cn/src/ranui/markdown/index.md index 9046f214c7..3623dc11d4 100644 --- a/packages/docs/cn/src/ranui/markdown/index.md +++ b/packages/docs/cn/src/ranui/markdown/index.md @@ -50,7 +50,8 @@ container.append(el); ``` - **光标**:`caret` 在最后一块后显示闪烁的 `▋`,`caret="circle"` 显示 `●`。当代码围栏尚未闭合或最后一块是表格时自动隐藏。 -- **未闭合的代码围栏** 在闭合前保持纯文本(不会高亮闪烁、不会渲染半个图表),期间容器带 `data-incomplete`。 +- **未闭合的代码围栏** 在文本到达时保留同一个代码视口;图表在围栏闭合前仍显示为代码,期间容器带 `data-incomplete`。 +- 代码和表格更新会保留滚动视口及未变化的前缀文本节点。选中代码期间,待处理的高亮会延后,取消选区后再完成。 ## 代码块 @@ -66,6 +67,8 @@ container.append(el); ``` +复制和下载使用围栏内的源文本,保留制表符、空行与末尾换行。每个代码块独立显示复制成功状态;剪贴板拒绝时在代码块内显示可重试的提示,可通过 `label-copy-error` 覆盖。内容发生变化或组件断开连接后,迟到的剪贴板或高亮结果不会更新它。代码和表格视口支持键盘聚焦。 + ## Mermaid 与公式 @@ -79,20 +82,20 @@ container.append(el); ### 属性 -| 属性 | 类型 | 默认值 | 说明 | -| -------------- | ------------------------------ | ------------- | ---------------------------------------------------------------------------------------------------- | -| `content` | `string` | — | Markdown 源。`content` **property** 优先且不反射;否则回退到元素文本内容。 | -| `mode` | `'streaming' \| 'static'` | `'streaming'` | `streaming` 补全未闭合 Markdown 并按块 diff;`static` 原样整体渲染一次。 | -| `caret` | boolean / `'circle'` | 关 | 最后一块后的闪烁光标(`▋`,`circle` 时为 `●`)。 | -| `copy` | boolean | 关 | 代码块复制按钮(也透传给内嵌的 ``)。 | -| `download` | boolean | 关 | 代码块下载按钮(按语言生成 `code.`)。 | -| `line-numbers` | boolean | 关 | 代码块行号。 | -| `highlight` | boolean / `"亮色 暗色"` 主题名 | 关 | shiki 语法高亮。空值 → `github-light github-dark`;一个名字 → 两者相同;两个名字 → 亮色 / 暗色。 | -| `inline-math` | boolean | 关 | 把 `$…$` 当作行内公式(`\(…\)` 始终是)。 | -| `link-target` | `string` | `'_blank'` | 外部链接的 `target`(附带 `rel="noopener noreferrer"`)。`_self` 不处理链接。页内 `#锚点` 永远不加。 | -| `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | 高亮 / 图表主题。`auto` 跟随页面(`.dark`、`[data-ran-theme]`,否则 `prefers-color-scheme`)。 | -| `sheet` | `string` | — | 注入 shadow root 的额外 CSS。 | -| `label-*` | `string` | 英文 | 覆盖控件文案:`label-copy`、`label-download`。 | +| 属性 | 类型 | 默认值 | 说明 | +| -------------- | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------- | +| `content` | `string` | — | Markdown 源。`content` **property** 优先且不反射;否则回退到元素文本内容。 | +| `mode` | `'streaming' \| 'static'` | `'streaming'` | `streaming` 补全未闭合 Markdown 并按块 diff;`static` 原样整体渲染一次。 | +| `caret` | boolean / `'circle'` | 关 | 最后一块后的闪烁光标(`▋`,`circle` 时为 `●`)。 | +| `copy` | boolean | 关 | 代码块复制按钮(也透传给内嵌的 ``)。 | +| `download` | boolean | 关 | 代码块下载按钮(按语言生成 `code.`)。 | +| `line-numbers` | boolean | 关 | 代码块行号。 | +| `highlight` | boolean / `"亮色 暗色"` 主题名 | 关 | shiki 语法高亮。空值 → `github-light github-dark`;一个名字 → 两者相同;两个名字 → 亮色 / 暗色。 | +| `inline-math` | boolean | 关 | 把 `$…$` 当作行内公式(`\(…\)` 始终是)。 | +| `link-target` | `string` | `'_blank'` | 外部链接的 `target`(附带 `rel="noopener noreferrer"`)。`_self` 不处理链接。页内 `#锚点` 永远不加。 | +| `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | 高亮 / 图表主题。`auto` 跟随页面(`.dark`、`[data-ran-theme]`,否则 `prefers-color-scheme`)。 | +| `sheet` | `string` | — | 注入 shadow root 的额外 CSS。 | +| `label-*` | `string` | 英文 | 覆盖控件文案:`label-copy`、`label-download`、`label-copy-error`(默认 “Could not copy. Try again.”)。 | 对应的 property:`content`、`mode`、`caret`、`copyable`、`downloadable`、`lineNumbers`、`highlight`、`inlineMath`、`linkTarget`、`theme`、`sheet`。 @@ -109,18 +112,19 @@ container.append(el); ## CSS Parts -| Part | 说明 | -| -------------- | ----------------------- | -| `markdown` | 最外层容器。 | -| `body` | 块容器。 | -| `block` | 每个渲染出的块。 | -| `code` | 代码块容器。 | -| `code-header` | 代码块的语言 / 操作栏。 | -| `code-lang` | 语言标签。 | -| `code-actions` | 操作按钮组。 | -| `button` | 每个复制 / 下载按钮。 | -| `table` | 可横向滚动的表格外层。 | -| `error` | 渲染失败时的错误框。 | +| Part | 说明 | +| -------------- | -------------------------- | +| `markdown` | 最外层容器。 | +| `body` | 块容器。 | +| `block` | 每个渲染出的块。 | +| `code` | 代码块容器。 | +| `code-header` | 代码块的语言 / 操作栏。 | +| `code-lang` | 语言标签。 | +| `code-actions` | 操作按钮组。 | +| `button` | 每个复制 / 下载按钮。 | +| `copy-status` | 就地显示的剪贴板失败提示。 | +| `table` | 可横向滚动的表格外层。 | +| `error` | 渲染失败时的错误框。 | ```css r-markdown::part(code) { @@ -137,7 +141,7 @@ r-markdown::part(code) { `--ran-markdown-code-radius`、`--ran-markdown-code-font-size`、`--ran-markdown-mono-font`、 `--ran-markdown-blockquote-border`、`--ran-markdown-table-border`、 `--ran-markdown-table-header-bg`、`--ran-markdown-caret`、`--ran-markdown-caret-color`、 -`--ran-markdown-button-color`、`--ran-markdown-error-color`。 +`--ran-markdown-button-touch-size`、`--ran-markdown-button-color`、`--ran-markdown-error-color`。 ## 说明 diff --git a/packages/docs/cn/src/ranui/math/index.md b/packages/docs/cn/src/ranui/math/index.md index c61b5cf228..279ab6d9b1 100644 --- a/packages/docs/cn/src/ranui/math/index.md +++ b/packages/docs/cn/src/ranui/math/index.md @@ -35,7 +35,7 @@ description: '使用 Temml 把 LaTeX 数学公式直接编译为原生 MathML, | `download` | `boolean` | `false` | 显示下载按钮/菜单,用于下载源码(`.tex`)和/或 MathML(`.mml`)。 | | `sheet` | `string` | `''` | 注入组件 shadow DOM 的 CSS。 | -> 💡 **提示**:`latex` 属性的 getter 会用 `decodeURIComponent` 解码后再渲染,因此支持传入 URI 编码后的公式。以 slotted 文本内容传入公式不会生效,只有 `latex` 属性的内容才会渲染。 +> 💡 **提示**:`latex` 属性的 getter 会用 `decodeURIComponent` 解码后再渲染,因此支持传入 URI 编码后的公式。没有该属性时,会使用元素的文本内容作为公式。 ### 公式 `latex` @@ -87,3 +87,9 @@ Part:`button` · `error` · `math` · `menu` · `render` · `toolbar` - **在 JavaScript 中转义反斜杠**:从 JS 字符串字面量赋值 `latex` 时,记得转义 `\`(如 `'\\frac{1}{2}'`)。 - **处理解析失败**:监听 `error` 事件(或检查渲染出的 `::part(error)` 区块),不要假设每个公式都是合法的 LaTeX。 - **通过 `sheet` 自定义布局**:需要覆盖内部 `.ran-math` 布局时使用 `sheet` 属性。 + +### 复制与窄屏 + +已开启的控件在内容上方保持可见:精细指针目标为 32px,触屏为 44px,与内容间隔 8px;未开启控件时不预留工具条空间。复制期间保留焦点并阻止重复请求;拒绝或不可用时在局部 `::part(copy-status)`(`role="status"`)显示错误,可用 `label-copy-error` 本地化,原按钮可立即重试。只有当前内容成功写入剪贴板才发出 `copied`;内容或复制目标更改后,旧请求不会再报告成功。 仅更新内容时保留原按钮及焦点,新内容可立即复制。 + +宽公式在 `::part(render)` 内独立横向滚动,不会撑宽页面;工具条不随公式滚动。 diff --git a/packages/docs/cn/src/ranui/mermaid/index.md b/packages/docs/cn/src/ranui/mermaid/index.md index 4de6a86024..28717e72ab 100644 --- a/packages/docs/cn/src/ranui/mermaid/index.md +++ b/packages/docs/cn/src/ranui/mermaid/index.md @@ -31,7 +31,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // 属性 setter 会自动 URI 编 ## 控件 -所有控件都通过布尔属性**按需开启**;不加任何控件属性的 `` 就是一张干净的静态图,工具栏仅在鼠标悬停时显示(右上角)。 +所有控件都通过布尔属性**按需开启**;不加任何控件属性的 `` 就是一张干净的静态图,已开启的工具栏在图表上方保持可见。 graph TD; A[开始] --> B[处理]; B --> C[结束] @@ -77,7 +77,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // 属性 setter 会自动 URI 编 | --------- | ------------------------ | | `mermaid` | 最外层容器。 | | `diagram` | 渲染图表的容器。 | -| `toolbar` | hover 显示的控件栏。 | +| `toolbar` | 保持可见的操作栏。 | | `button` | 每个工具栏图标按钮。 | | `error` | 渲染失败时的错误信息框。 | @@ -99,3 +99,7 @@ r-mermaid::part(toolbar) { - **懒加载**:mermaid(以及全屏功能用到的 r-modal)都是动态 import,只有在图表渲染或打开全屏时,才会作为独立的 async chunk 加载。 - **渲染保真**:`` 直接使用 mermaid 自身的渲染逻辑,支持全部图表类型与主题。 - **PNG 导出**:使用 HTML label(mermaid 的 `htmlLabels`)的图表会通过 `` 渲染,可能污染 canvas,导致 PNG 导出失败,此时会派发 `error` 事件。SVG 与源码导出不受影响,始终可用。 + +### 复制与窄屏 + +已开启的控件在内容上方保持可见:精细指针目标为 32px,触屏为 44px,与内容间隔 8px;未开启控件时不预留工具条空间。复制期间保留焦点并阻止重复请求;拒绝或不可用时在局部 `::part(copy-status)`(`role="status"`)显示错误,可用 `label-copy-error` 本地化,原按钮可立即重试。只有当前内容成功写入剪贴板才发出 `copied`;内容或复制目标更改后,旧请求不会再报告成功。 仅更新内容时保留原按钮及焦点,新内容可立即复制。 diff --git a/packages/docs/cn/src/ranui/popover/index.md b/packages/docs/cn/src/ranui/popover/index.md index 1b97bd7806..dfaafc3315 100644 --- a/packages/docs/cn/src/ranui/popover/index.md +++ b/packages/docs/cn/src/ranui/popover/index.md @@ -218,6 +218,11 @@ description: 'ranui Popover()在悬停或点击时弹出浮动气 - **打开**:`mouseenter`(当 `trigger` 包含 `hover` 时)、`click`,或聚焦时按下 `Enter` / `Space`。 - **关闭**:`mouseleave`(hover 模式)、按下 `Escape`,或点击文档中的其他位置。 +焦点位于传送后的面板控件内时,Escape 同样能关闭面板,并把焦点还给打开它的触发器。 +嵌套弹层先处理自己的 Escape;已被处理的 Escape 或输入法组合输入不会关闭父弹层。 +立即关闭会取消排队中的入场工作,包括首个绘制帧之前。Tab 顺序与模态焦点约束仍由组合界面 +负责;Popover 不是模态对话框。 + 在内部,配套的 `` 元素会用 `MutationObserver` 监视自身子树,并派发一个 `change` `CustomEvent`(`detail: { type, value: { content, mutation } }`),popover 消费这个事件以保持面板同步。这是一个实现细节,而非公开 API。 无障碍访问是自动接好的:host 元素会带上 `tabindex="0"`、`aria-haspopup="dialog"`,以及会随面板开关在 `"false"` 和 `"true"` 之间切换的 `aria-expanded`。 diff --git a/packages/docs/cn/src/ranui/scratch/index.md b/packages/docs/cn/src/ranui/scratch/index.md index 54120d876d..52f82fe164 100644 --- a/packages/docs/cn/src/ranui/scratch/index.md +++ b/packages/docs/cn/src/ranui/scratch/index.md @@ -4,7 +4,7 @@ description: '一个实验性的刮刮卡组件:拖动擦除画布覆盖层, # Scratch 刮刮卡 -一个实验性的刮刮卡组件。它在 Shadow DOM 内渲染一个铺满的 `` 刮层,覆盖在揭晓层之上;在画布上拖动会以 `destination-out` 合成方式沿指针实际划过的路径擦除覆盖层,擦除足够面积后即可显露下方内容。宿主会填满自身盒子(`display: block`),因此请为其指定明确的宽度和高度。 +一个实验性的刮刮卡组件。它在 Shadow DOM 内渲染一个 `` 刮层,覆盖在揭晓层之上,并在刮擦区域上方保留独立的操作行;在画布上拖动会以 `destination-out` 合成方式沿指针实际划过的路径擦除覆盖层,擦除足够面积后即可显露下方内容。宿主会填满自身盒子(`display: block`),因此请为其指定明确的宽度和高度。 > **适用场景**:需要一个实验性的刮刮卡效果,在画布上拖动擦除覆盖层,显露下方任意内容,基于 Pointer Events 实现,鼠标、触摸、触控笔都能用。 @@ -28,10 +28,16 @@ description: '一个实验性的刮刮卡组件:拖动擦除画布覆盖层, ### 属性 -| 属性 | 类型 | 默认值 | 说明 | -| ---------- | --------- | ------- | ---------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | `false` | 禁用刮除交互(覆盖层画布上加 `pointer-events: none`,指针事件 handler 里也有对应判断)。 | -| `sheet` | `string` | `''` | 注入组件 shadow DOM 的 CSS。 | +| 属性 | 类型 | 默认值 | 说明 | +| ---------------- | --------- | ---------- | ---------------------------------------------- | +| `disabled` | `boolean` | `false` | 禁用刮除交互和原生揭晓按钮;已有刮除进度保持。 | +| `sheet` | `string` | `''` | 注入组件 shadow DOM 的 CSS。 | +| `reveal-label` | `string` | `Reveal` | 揭晓按钮的本地化文案。 | +| `revealed-label` | `string` | `Revealed` | 完成按钮和状态通知的本地化文案。 | + +键盘用户可聚焦原生 Reveal 按钮,按 Enter 或 Space 完整揭晓。使用 `reveal-label`(默认 `Reveal`)和 `revealed-label`(默认 `Revealed`)本地化文案。揭晓前默认插槽处于 inert 状态,遮盖下的链接不会提前进入 Tab 顺序;揭晓后恢复内容交互,按钮保留当前焦点但退出后续 Tab 顺序,下一次 Tab 可进入揭晓内容。完成状态通过 status 区域通知。禁用、取消手势或等尺寸断连重连均保留已刮像素;真正的尺寸变化仍重置覆盖层。 + +操作按钮独立放在刮擦区域上方,不覆盖奖励内容;宿主高度包含按钮行(精细指针至少 32px、粗略指针至少 44px)和 8px 间距,剩余高度用于画布和奖励。实际尺寸变化重置遮罩时,如果焦点位于已揭晓的奖励内容中,会无滚动地返回 Reveal 按钮;组件外的焦点保持不变。 ### 禁用态 `disabled` @@ -64,7 +70,7 @@ description: '一个实验性的刮刮卡组件:拖动擦除画布覆盖层, - `pointerdown`:进入待刮除状态,并在指针落下的位置直接擦出一个小圆点(哪怕只是点一下没有拖动,也能看到效果)。 - `pointermove`:处于待刮除状态时,以 `globalCompositeOperation = 'destination-out'` 从上一个点到当前点画一条**连续的线**(不是一个个孤立的圆点),所以快速拖动也能刮出一条连贯的痕迹,同时累计已刮除面积。 -- `pointerup` / `pointercancel`:退出待刮除状态;一旦累计面积超过画布像素面积的 **35%**,整个覆盖层就会被 `clearRect` 清空,完全显露下方内容(这个阈值故意设得比较宽松,"刮一点就自动刮完"是刮刮卡常见的交互习惯,不需要把整个覆盖层手动刮干净)。 +- `pointerup`:退出待刮除状态;一旦累计面积超过画布像素面积的 **35%**,整个覆盖层就会被 `clearRect` 清空,完全显露下方内容(这个阈值故意设得比较宽松,"刮一点就自动刮完"是刮刮卡常见的交互习惯,不需要把整个覆盖层手动刮干净)。 指针坐标会按画布实际的绘图分辨率(见下文)做换算,所以不管元素的 CSS 尺寸多大、屏幕的设备像素比是多少,刮除轨迹都能准确跟随手指/鼠标。`disabled` 时以上 handler 都会直接跳过;画布上的 `touch-action: none` 保证触摸拖动只会刮除,不会顺带滚动页面。 diff --git a/packages/docs/cn/src/ranui/select/index.md b/packages/docs/cn/src/ranui/select/index.md index 5e7a3d94ff..a9399022e9 100644 --- a/packages/docs/cn/src/ranui/select/index.md +++ b/packages/docs/cn/src/ranui/select/index.md @@ -8,6 +8,8 @@ description: 'ranui Select()是从选项中选值的下拉选择 > **适用场景**:需要一个由 `` 子元素构建的单值下拉选择器,可选支持搜索和原生表单参与:`` 负责展开、过滤,以及 `FormData` 上报。 +使用 `placeholder="选择成员"` 提供空值提示,使用 `label="成员"` 提供字段的可访问名称。修改提示不会改变选中值;清空 `value` 后会恢复提示。占位提示不能代替字段标签。 + ## 快速开始 ### 基础用法 @@ -186,7 +188,7 @@ token 与布局,所以并排放置的带标题 select 和带标题 input 会 ### 展开状态 `open` -`open` 就是下拉框的状态,像 `
`、`` 一样反射为属性。没有任何地方再从面板的 `display` 反推状态(那个值比状态滞后一整段退场动画),所以属性、`aria-expanded` 和屏幕上看到的三者不会互相矛盾。 +`open` 表示下拉框的交互状态,像 `
`、`` 一样反射为属性。关闭时即使退场动画仍然可见,它也与 `aria-expanded` 保持一致。选中后可立即用方向键、Home/End、Enter、空格或输入字符重新展开;上一轮退场动画不会隐藏重新打开的面板。 因此它既是驱动组件的正式方式,也是可以拿来写样式、写断言的东西: diff --git a/packages/docs/cn/src/ranui/style-tokens.md b/packages/docs/cn/src/ranui/style-tokens.md index 91df18fe62..4bc4c3c5db 100644 --- a/packages/docs/cn/src/ranui/style-tokens.md +++ b/packages/docs/cn/src/ranui/style-tokens.md @@ -63,11 +63,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -115,6 +115,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -187,11 +188,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -229,6 +230,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -367,11 +369,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -398,7 +400,6 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -592,14 +593,16 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -634,14 +637,16 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -760,14 +765,15 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -788,14 +794,15 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -830,14 +837,16 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -975,10 +984,10 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -999,11 +1008,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1013,7 +1022,6 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1024,6 +1032,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1060,10 +1069,8 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1091,11 +1098,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1123,7 +1130,6 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1133,6 +1139,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1142,12 +1149,13 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1167,7 +1175,6 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1176,6 +1183,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1302,11 +1310,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1321,6 +1329,8 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1605,19 +1615,35 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1659,16 +1685,17 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1737,13 +1764,14 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1865,11 +1893,11 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1891,6 +1919,7 @@ description: 'ranui 每个元素暴露的 CSS 自定义属性与 ::part() 名称 - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/cn/src/ranui/tab/index.md b/packages/docs/cn/src/ranui/tab/index.md index 7065120c9b..378360341a 100644 --- a/packages/docs/cn/src/ranui/tab/index.md +++ b/packages/docs/cn/src/ranui/tab/index.md @@ -299,3 +299,5 @@ Part:`content` · `content-wrap` · `header` · `indicator` · `nav` · `tabs` - **对齐**:在宽容器里用 `align="center"` 或 `align="end"` 重新定位标题行。 - **禁用面板**:用 `disabled` 标记不可用的面板;它们会同时跳过点击选择和默认选中逻辑。 - **键盘导航**:标题行是一个 WAI-ARIA tablist,方向键在标签间移动(配合 `Home`/`End`),只有激活的标签在 tab 顺序里。 + +- **长标签**:标题栏横向滚动,保留完整标签;键盘切换会显示当前选项,不改变页面滚动位置。 diff --git a/packages/docs/cn/src/ranui/theme-switch/index.md b/packages/docs/cn/src/ranui/theme-switch/index.md index 05efe9c765..960c6f6253 100644 --- a/packages/docs/cn/src/ranui/theme-switch/index.md +++ b/packages/docs/cn/src/ranui/theme-switch/index.md @@ -84,6 +84,9 @@ toolbar.append(themeSwitch); - **浏览器界面色**:强制浅色/深色时会把 `` 更新为解析后的页面背景色, 让浏览器 / PWA 的界面色匹配;选回 `system` 时恢复每个 meta 原本(可能带媒体查询)的内容。 +控件组与按钮的本地化标签支持挂载后通过属性动态更新。 +精细指针下选项按钮保持 28px 的紧凑尺寸,粗指针下提供至少 44px 的点击目标。 + ## CSS Parts | Part | 说明 | diff --git a/packages/docs/cn/src/ranui/voice-button/index.md b/packages/docs/cn/src/ranui/voice-button/index.md index ea8919b000..0ad7a9b9ed 100644 --- a/packages/docs/cn/src/ranui/voice-button/index.md +++ b/packages/docs/cn/src/ranui/voice-button/index.md @@ -62,10 +62,12 @@ Firefox 不提供语音识别,任何缺少该 API 的浏览器也一样。不 ### 无障碍 -**可访问名称随状态改变**,而不只是图标变化,`aria-pressed` 承载切换态:屏幕阅读器读出的是「停止语音输入,已按下」,而不是一个图标。**Esc 放弃**当前录音而不是提交它,这正是说到一半意识到说错了的人想要的。 +**可访问名称随状态改变**,而不只是图标变化,`aria-pressed` 承载当前采集意图:屏幕阅读器读出的是「停止语音输入,已按下」,而不是一个图标。**Esc 放弃**当前录音而不是提交它,这正是说到一半意识到说错了的人想要的。 监听状态由边框、填充**和**光环共同表达,不依赖颜色单独传达。光环是唯一的动效且属于装饰,`prefers-reduced-motion` 下直接去掉,不损失任何信息。 +等待麦克风权限时,按钮使用已有且可覆盖的 `active-label` 命名取消操作,设置 `aria-busy="true"`,并支持 Esc 取消。只有平台报告录音已经开始后,`listening` 才变为 true,因此等待权限时不会显示录音光环。 + ### 语言跟随页面 `lang` **每次录音时读取**,默认取文档的语言,因此会话中途切换语言的应用,会用它当前展示的语言进行听写。 @@ -80,7 +82,7 @@ Firefox 不提供语音识别,任何缺少该 API 的浏览器也一样。不 | `continuous` | `boolean` | `true` | 跨停顿持续监听,而不是在第一次停顿时结束。 | | `disabled` | `boolean` | `false` | 禁用按钮:`start()` 会被忽略,内部按钮也一并禁用;不会中止已经在进行的录音。 | | `label` | `string` | `'Start voice input'` | 空闲时的可访问名称。 | -| `activeLabel` | `string` | `'Stop voice input'` | 监听时的可访问名称。 | +| `activeLabel` | `string` | `'Stop voice input'` | 等待权限或监听时的取消/停止操作名称。 | | `listening` | `boolean` | `false` | 只读并反射,可用 `:host([listening])` 设样式。 | | `supported` | `boolean` | — | 只读。当前平台能否识别语音。 | | `sheet` | `string` | `''` | 注入元素 Shadow DOM 的 CSS。 | diff --git a/packages/docs/de/src/agents/index.md b/packages/docs/de/src/agents/index.md new file mode 100644 index 0000000000..34f7a9d2bf --- /dev/null +++ b/packages/docs/de/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: Agenten +--- + +# Agenten + +Ein ran-Plugin bietet drei Skills: design für Produktentscheidungen, ranui für Komponenten und ranuts für Hilfsfunktionen. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +Im Stammverzeichnis des Repositorys diesen Checkout registrieren und das Plugin installieren. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +In einer neuen Sitzung `$design`, `$ranui` oder `$ranuts` in Codex verwenden; `/ran:design`, `/ran:ranui` oder `/ran:ranuts` in Claude Code. + +Nach Veröffentlichung `.` im Marketplace-Befehl durch `chaxus/ran` ersetzen. Die Links verweisen auf die kanonischen Orte auf main nach dem Merge. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/de/src/design/index.md b/packages/docs/de/src/design/index.md new file mode 100644 index 0000000000..6299788fa2 --- /dev/null +++ b/packages/docs/de/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +Bibliotheksunabhängige Methoden für Layout, Interaktion, Bewegung und Prüfung anhand konkreter Ergebnisse. + +Mit der Aufgabe und echten Inhalten beginnen. Eine gemeinsame visuelle Sprache festlegen und vollständige Zustände im Browser prüfen, einschließlich Tastatur und Bewegung. Die detaillierten Anforderungen stehen unten. + +- [Visuelle Gestaltung](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [Interaktion](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [Abnahme](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [Produktmuster](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [Referenzen](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [Agenten](../agents/) diff --git a/packages/docs/de/src/ranui/api.md b/packages/docs/de/src/ranui/api.md index e33861fc17..736a5e3edf 100644 --- a/packages/docs/de/src/ranui/api.md +++ b/packages/docs/de/src/ranui/api.md @@ -198,9 +198,9 @@ Quelltext: `components/icon/index.ts` - **Slots**: — - **Parts**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -215,7 +215,7 @@ Quelltext: `components/icon/index.ts` Quelltext: `components/image/index.ts` -- **Attribute**: — +- **Attribute**: `alt`, `fallback`, `sheet`, `src` - **Eigenschaften**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -277,7 +277,7 @@ Quelltext: `components/loading/index.ts` Quelltext: `components/markdown/index.ts` -- **Attribute**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **Attribute**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **Eigenschaften**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -296,7 +296,7 @@ Quelltext: `components/markdown/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **Slots**: — -- **Parts**: `block`, `body`, `error`, `markdown` +- **Parts**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -310,7 +310,7 @@ Quelltext: `components/math/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Parts**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Parts**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -325,13 +325,13 @@ Quelltext: `components/mermaid/index.ts` - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Parts**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Parts**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` Quelltext: `components/message/index.ts` -- **Attribute**: — +- **Attribute**: `content: string | null`, `sheet: string`, `type: string | null` - **Eigenschaften**: `content: string | null`, `sheet: string`, `type: string | null` - **Events**: — - **Slots**: — @@ -366,7 +366,7 @@ Quelltext: `components/modal/index.ts` Quelltext: `components/select/option/index.ts` -- **Attribute**: — +- **Attribute**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Eigenschaften**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Events**: — - **Slots**: `Standard` @@ -423,7 +423,7 @@ Quelltext: `components/popover/index.ts` Quelltext: `components/progress/index.ts` -- **Attribute**: `drag`, `primary` +- **Attribute**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Eigenschaften**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Events**: - `change` · element-only · detail `{ percent, total, value }` @@ -434,7 +434,7 @@ Quelltext: `components/progress/index.ts` Quelltext: `components/radar/index.ts` -- **Attribute**: — +- **Attribute**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **Eigenschaften**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **Events**: — - **Slots**: — @@ -487,11 +487,11 @@ Quelltext: `components/router/index.ts` Quelltext: `components/scratch/index.ts` -- **Attribute**: `disabled: boolean`, `sheet: string` -- **Eigenschaften**: `disabled: boolean`, `sheet: string` +- **Attribute**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **Eigenschaften**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **Events**: — - **Slots**: `Standard` -- **Parts**: `award` +- **Parts**: `award`, `reveal-button` ## `` @@ -507,7 +507,7 @@ Quelltext: `components/section/index.ts` Quelltext: `components/select/index.ts` -- **Attribute**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **Attribute**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **Eigenschaften**: - `defaultValue: string` - `disabled: boolean` @@ -515,6 +515,7 @@ Quelltext: `components/select/index.ts` - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -561,7 +562,7 @@ Quelltext: `components/state-dot/index.ts` Quelltext: `components/tabpane/index.ts` -- **Attribute**: — +- **Attribute**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **Eigenschaften**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **Events**: — - **Slots**: `Standard` @@ -582,7 +583,7 @@ Quelltext: `components/tab/index.ts` Quelltext: `components/theme-switch/index.ts` -- **Attribute**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **Attribute**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **Eigenschaften**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -629,7 +630,7 @@ Quelltext: `components/voice-button/index.ts` - **Attribute**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **Eigenschaften**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/de/src/ranui/changelog.md b/packages/docs/de/src/ranui/changelog.md index 6fba40cd23..42d39304ac 100644 --- a/packages/docs/de/src/ranui/changelog.md +++ b/packages/docs/de/src/ranui/changelog.md @@ -9,6 +9,23 @@ Aus `packages/ranui/CHANGELOG.md` im Repository erzeugt. Die Einträge bleiben i ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/de/src/ranui/design-guides/index.md b/packages/docs/de/src/ranui/design-guides/index.md index 5bfd702413..aead139d7b 100644 --- a/packages/docs/de/src/ranui/design-guides/index.md +++ b/packages/docs/de/src/ranui/design-guides/index.md @@ -147,3 +147,7 @@ Wähle Prüfungen für die betroffene Oberfläche und das geänderte Verhalten; - [ ] Abstände aus der Skala, Schrift nach Rolle, Farbe aus semantischen Tokens. - [ ] Keine Paletteneigenschaft in einer `transition`; kein `transition: all`. - [ ] Text und Kontext machen Aktion und Objekt klar; nichts signalisiert einen Zustand allein über Farbe. + +## Nachweise und Abnahme {#evidence-and-acceptance} + +[Der Abnahmevertrag](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) regelt echte Browserprüfungen, die Aktualität von Laufzeit und Aufnahmen sowie getrennte Ergebnisse für automatische Prüfungen, visuelle Agentenprüfung und Nutzerentscheidung. Änderungen an gemeinsamen Stilen erfordern neue betroffene Nachweise; Agentenprüfung ist keine Nutzerabnahme. Vermeide zusätzliche Beschriftungen und Beschreibungen ohne Informationsgewinn; die Anordnung soll offensichtliche Beziehungen erklären. Erhalte zugängliche Namen, hilfreiche Feldbeschriftungen, Einheiten, Fehler und Risikohinweise. diff --git a/packages/docs/de/src/ranui/math/index.md b/packages/docs/de/src/ranui/math/index.md index a3468bcec4..d329ab9da1 100644 --- a/packages/docs/de/src/ranui/math/index.md +++ b/packages/docs/de/src/ranui/math/index.md @@ -86,3 +86,9 @@ Die vollständige Liste steht in den [Style-Tokens](/de/src/ranui/style-tokens#m - **Backslashes in JavaScript maskieren**: Weist du `latex` aus einem JS-String zu, denk daran, dass `\` maskiert werden muss (z. B. `'\\frac{1}{2}'`). - **Parse-Fehler behandeln**: Höre auf `error` (oder prüfe die gerenderte `::part(error)`-Box), statt anzunehmen, jede Formel sei gültiges LaTeX. - **Eigenes Layout über `sheet`**: Nutze das Attribut `sheet`, um das interne Layout von `.ran-math` bei Bedarf zu überschreiben. + +### Kopieren und schmale Ansichten + +Aktivierte Bedienelemente bleiben in einer ruhigen Zeile über dem Inhalt sichtbar: 32px für präzise Zeiger, 44px für Touch und 8px Abstand zum Inhalt. Ohne Bedienelemente wird kein Platz reserviert. Kopieren behält den Fokus und verhindert doppelte ausstehende Schreibvorgänge. Ablehnung oder fehlende Zwischenablage erscheint lokal in `::part(copy-status)` (`role="status"`); `label-copy-error` lokalisiert den Hinweis. Ein erneuter Versuch nutzt denselben Button. `copied` wird nur nach erfolgreichem Kopieren des aktuellen Inhalts ausgelöst. Änderungen an Quelle oder Kopierziel verwerfen Rückmeldungen älterer Anfragen. Bei Quellenänderungen bleibt der fokussierte Button erhalten; die neue Quelle kann sofort kopiert werden. + +Breite Formeln scrollen innerhalb von `::part(render)` horizontal, ohne die Seite zu verbreitern oder die Werkzeugleiste zu verschieben. diff --git a/packages/docs/de/src/ranui/mermaid/index.md b/packages/docs/de/src/ranui/mermaid/index.md index e479010a3a..26bc0585ec 100644 --- a/packages/docs/de/src/ranui/mermaid/index.md +++ b/packages/docs/de/src/ranui/mermaid/index.md @@ -39,8 +39,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // der Property-Setter kodiert für ## Bedienelemente Jedes Bedienelement wird **ausdrücklich** über ein boolesches Attribut eingeschaltet; ein nacktes -`` ist ein sauberes statisches Diagramm. Die Leiste erscheint beim Überfahren (oben -rechts). +`` ist ein sauberes statisches Diagramm. Aktivierte Bedienelemente bleiben über dem Diagramm sichtbar. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -89,7 +88,7 @@ Alle Events bubbeln und überschreiten die Shadow-Grenze (`composed`). | --------- | ---------------------------------------------------- | | `mermaid` | Der äußere Wrapper. | | `diagram` | Der Container des gerenderten Diagramms. | -| `toolbar` | Die beim Überfahren erscheinende Bedienleiste. | +| `toolbar` | Die sichtbare Aktionszeile. | | `button` | Jede Icon-Schaltfläche der Leiste. | | `error` | Die Fehlermeldungsbox (bei fehlgeschlagenem Render). | @@ -115,3 +114,7 @@ zurück): `--ran-mermaid-padding`, `--ran-mermaid-toolbar-background`, `--ran-me - **PNG-Export**: Diagramme mit HTML-Beschriftungen (mermaids `htmlLabels`) werden über `` gezeichnet, was das Canvas verunreinigen und den PNG-Export scheitern lassen kann; in dem Fall wird ein `error`-Event ausgelöst. SVG- und Quell-Export funktionieren immer. + +### Kopieren und schmale Ansichten + +Aktivierte Bedienelemente bleiben in einer ruhigen Zeile über dem Inhalt sichtbar: 32px für präzise Zeiger, 44px für Touch und 8px Abstand zum Inhalt. Ohne Bedienelemente wird kein Platz reserviert. Kopieren behält den Fokus und verhindert doppelte ausstehende Schreibvorgänge. Ablehnung oder fehlende Zwischenablage erscheint lokal in `::part(copy-status)` (`role="status"`); `label-copy-error` lokalisiert den Hinweis. Ein erneuter Versuch nutzt denselben Button. `copied` wird nur nach erfolgreichem Kopieren des aktuellen Inhalts ausgelöst. Änderungen an Quelle oder Kopierziel verwerfen Rückmeldungen älterer Anfragen. Bei Quellenänderungen bleibt der fokussierte Button erhalten; die neue Quelle kann sofort kopiert werden. diff --git a/packages/docs/de/src/ranui/style-tokens.md b/packages/docs/de/src/ranui/style-tokens.md index 794dc0df6a..a88ba48ae8 100644 --- a/packages/docs/de/src/ranui/style-tokens.md +++ b/packages/docs/de/src/ranui/style-tokens.md @@ -66,11 +66,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -118,6 +118,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -190,11 +191,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -232,6 +233,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -370,11 +372,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -401,7 +403,6 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -595,14 +596,16 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -637,14 +640,16 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -763,14 +768,15 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -791,14 +797,15 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -833,14 +840,16 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -978,10 +987,10 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1002,11 +1011,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1016,7 +1025,6 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1027,6 +1035,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1063,10 +1072,8 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1094,11 +1101,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1126,7 +1133,6 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1136,6 +1142,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1145,12 +1152,13 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1170,7 +1178,6 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1179,6 +1186,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1305,11 +1313,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1324,6 +1332,8 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1608,19 +1618,35 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1662,16 +1688,17 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1740,13 +1767,14 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1868,11 +1896,11 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1894,6 +1922,7 @@ die Namensregel lautet `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/design-baseline.json b/packages/docs/design-baseline.json index 165299d660..4a8718e910 100644 --- a/packages/docs/design-baseline.json +++ b/packages/docs/design-baseline.json @@ -14,7 +14,7 @@ "spacing-scale": { "styles/demos.css": 23, "styles/docs.css": 41, - "styles/home.css": 57 + "styles/home.css": 56 }, "sizing-scale": {}, "mouse-only-drag": {}, diff --git a/packages/docs/es/src/agents/index.md b/packages/docs/es/src/agents/index.md new file mode 100644 index 0000000000..8d0da7d0b6 --- /dev/null +++ b/packages/docs/es/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: Agentes +--- + +# Agentes + +Un plugin ran ofrece tres habilidades: design para decisiones de producto, ranui para componentes y ranuts para utilidades. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +Desde la raíz del repositorio, registra esta copia e instala el plugin. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +En una nueva sesión, usa `$design`, `$ranui` o `$ranuts` en Codex; `/ran:design`, `/ran:ranui` o `/ran:ranuts` en Claude Code. + +Tras publicar, sustituye `.` por `chaxus/ran` en el comando del marketplace. Los enlaces apuntan a las ubicaciones canónicas en main después de integrar los cambios. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/es/src/design/index.md b/packages/docs/es/src/design/index.md new file mode 100644 index 0000000000..435cc33de9 --- /dev/null +++ b/packages/docs/es/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +Métodos de composición, interacción, movimiento y revisión con evidencia, independientes de una biblioteca. + +Empieza con la tarea y el contenido real. Define un lenguaje visual compartido y revisa los estados completos en el navegador, incluido el teclado y el movimiento. Consulta los contratos detallados abajo. + +- [Composición visual](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [Interacción](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [Validación](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [Patrones de producto](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [Referencias](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [Agentes](../agents/) diff --git a/packages/docs/es/src/ranui/api.md b/packages/docs/es/src/ranui/api.md index 0f9fbf48a2..44eba10ea8 100644 --- a/packages/docs/es/src/ranui/api.md +++ b/packages/docs/es/src/ranui/api.md @@ -198,9 +198,9 @@ Código fuente: `components/icon/index.ts` - **Slots**: — - **Partes**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -215,7 +215,7 @@ Código fuente: `components/icon/index.ts` Código fuente: `components/image/index.ts` -- **Atributos**: — +- **Atributos**: `alt`, `fallback`, `sheet`, `src` - **Propiedades**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -277,7 +277,7 @@ Código fuente: `components/loading/index.ts` Código fuente: `components/markdown/index.ts` -- **Atributos**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **Atributos**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **Propiedades**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -296,7 +296,7 @@ Código fuente: `components/markdown/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **Slots**: — -- **Partes**: `block`, `body`, `error`, `markdown` +- **Partes**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -310,7 +310,7 @@ Código fuente: `components/math/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Partes**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Partes**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -325,13 +325,13 @@ Código fuente: `components/mermaid/index.ts` - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Partes**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Partes**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` Código fuente: `components/message/index.ts` -- **Atributos**: — +- **Atributos**: `content: string | null`, `sheet: string`, `type: string | null` - **Propiedades**: `content: string | null`, `sheet: string`, `type: string | null` - **Eventos**: — - **Slots**: — @@ -366,7 +366,7 @@ Código fuente: `components/modal/index.ts` Código fuente: `components/select/option/index.ts` -- **Atributos**: — +- **Atributos**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Propiedades**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Eventos**: — - **Slots**: `por defecto` @@ -423,7 +423,7 @@ Código fuente: `components/popover/index.ts` Código fuente: `components/progress/index.ts` -- **Atributos**: `drag`, `primary` +- **Atributos**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Propiedades**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Eventos**: - `change` · element-only · detail `{ percent, total, value }` @@ -434,7 +434,7 @@ Código fuente: `components/progress/index.ts` Código fuente: `components/radar/index.ts` -- **Atributos**: — +- **Atributos**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **Propiedades**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **Eventos**: — - **Slots**: — @@ -487,11 +487,11 @@ Código fuente: `components/router/index.ts` Código fuente: `components/scratch/index.ts` -- **Atributos**: `disabled: boolean`, `sheet: string` -- **Propiedades**: `disabled: boolean`, `sheet: string` +- **Atributos**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **Propiedades**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **Eventos**: — - **Slots**: `por defecto` -- **Partes**: `award` +- **Partes**: `award`, `reveal-button` ## `` @@ -507,7 +507,7 @@ Código fuente: `components/section/index.ts` Código fuente: `components/select/index.ts` -- **Atributos**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **Atributos**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **Propiedades**: - `defaultValue: string` - `disabled: boolean` @@ -515,6 +515,7 @@ Código fuente: `components/select/index.ts` - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -561,7 +562,7 @@ Código fuente: `components/state-dot/index.ts` Código fuente: `components/tabpane/index.ts` -- **Atributos**: — +- **Atributos**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **Propiedades**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **Eventos**: — - **Slots**: `por defecto` @@ -582,7 +583,7 @@ Código fuente: `components/tab/index.ts` Código fuente: `components/theme-switch/index.ts` -- **Atributos**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **Atributos**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **Propiedades**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -629,7 +630,7 @@ Código fuente: `components/voice-button/index.ts` - **Atributos**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **Propiedades**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/es/src/ranui/changelog.md b/packages/docs/es/src/ranui/changelog.md index 876071d422..e6e1d51b01 100644 --- a/packages/docs/es/src/ranui/changelog.md +++ b/packages/docs/es/src/ranui/changelog.md @@ -9,6 +9,23 @@ Generado desde `packages/ranui/CHANGELOG.md` del repositorio. Las entradas conse ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/es/src/ranui/design-guides/index.md b/packages/docs/es/src/ranui/design-guides/index.md index 686ae788e0..f3e16a5447 100644 --- a/packages/docs/es/src/ranui/design-guides/index.md +++ b/packages/docs/es/src/ranui/design-guides/index.md @@ -147,3 +147,7 @@ Elige comprobaciones para la superficie afectada y el comportamiento cambiado; C - [ ] Espacios de la escala, tipografía por rol, color de tokens semánticos. - [ ] Ninguna propiedad de la paleta en una `transition`; ningún `transition: all`. - [ ] El texto y el contexto aclaran la acción y el objeto; nada señala un estado solo con color. + +## Evidencia y aceptación {#evidence-and-acceptance} + +[El contrato de aceptación](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) define la verificación real en el navegador, la vigencia de las capturas y la separación entre comprobaciones automáticas, revisión visual del agente y decisión del usuario. Tras cambiar estilos compartidos, actualiza la evidencia afectada; la revisión del agente no equivale a aceptación del usuario. Evita etiquetas secundarias y descripciones que no aportan información: deja que la disposición explique las relaciones evidentes. Conserva nombres accesibles, etiquetas útiles, unidades, errores y riesgos. diff --git a/packages/docs/es/src/ranui/math/index.md b/packages/docs/es/src/ranui/math/index.md index 9010bbf461..35db21e7af 100644 --- a/packages/docs/es/src/ranui/math/index.md +++ b/packages/docs/es/src/ranui/math/index.md @@ -86,3 +86,9 @@ La lista completa está en [tokens de estilo](/es/src/ranui/style-tokens#math); - **Escapa las barras invertidas en JavaScript**: al asignar `latex` desde una cadena JS, recuerda que `\` debe escaparse (por ejemplo `'\\frac{1}{2}'`). - **Gestiona los fallos de análisis**: escucha `error` (o mira el cuadro `::part(error)` renderizado) en lugar de dar por hecho que toda fórmula es LaTeX válido. - **Maquetación propia con `sheet`**: usa el atributo `sheet` para sobrescribir la disposición interna de `.ran-math` cuando haga falta. + +### Copiar y pantallas estrechas + +Los controles activados permanecen visibles en una fila discreta sobre el contenido: objetivos de 32px con puntero preciso, 44px con entrada táctil y una separación de 8px. Sin controles no se reserva espacio. Copiar conserva el foco e impide escrituras duplicadas mientras están pendientes. Los errores o la falta de acceso al portapapeles se muestran localmente en `::part(copy-status)` (`role="status"`), traducible con `label-copy-error`; el reintento usa el mismo botón. `copied` solo se emite tras copiar el contenido actual. Cambiar la fuente o el destino de copia invalida las respuestas anteriores. Al cambiar la fuente, el botón conserva el foco y permite copiar la nueva fuente de inmediato. + +Las ecuaciones anchas se desplazan horizontalmente dentro de `::part(render)`, sin ensanchar la página ni mover la barra. diff --git a/packages/docs/es/src/ranui/mermaid/index.md b/packages/docs/es/src/ranui/mermaid/index.md index 3c9da869e9..0299c00c8f 100644 --- a/packages/docs/es/src/ranui/mermaid/index.md +++ b/packages/docs/es/src/ranui/mermaid/index.md @@ -40,7 +40,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // el setter de la propiedad codifi ## Controles Cada control se activa **explícitamente** con un atributo booleano; un `` a secas es un -diagrama estático y limpio. La barra aparece al pasar el ratón (arriba a la derecha). +diagrama estático y limpio. Los controles activados permanecen visibles sobre el diagrama. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -88,7 +88,7 @@ Todos los eventos burbujean y cruzan el límite del shadow (`composed`). | --------- | -------------------------------------------- | | `mermaid` | El envoltorio exterior. | | `diagram` | El contenedor del diagrama renderizado. | -| `toolbar` | La barra de controles que aparece al pasar. | +| `toolbar` | La fila de acciones visible. | | `button` | Cada botón de icono de la barra. | | `error` | El cuadro de error (cuando falla el render). | @@ -115,3 +115,7 @@ Sobrescríbelas en el elemento (cada una recurre a un token semántico y luego a - **Exportar a PNG**: los diagramas con etiquetas HTML (`htmlLabels` de mermaid) se dibujan con ``, lo que puede contaminar el canvas y hacer fallar la exportación a PNG; en ese caso se despacha un evento `error`. Exportar SVG y el origen siempre funciona. + +### Copiar y pantallas estrechas + +Los controles activados permanecen visibles en una fila discreta sobre el contenido: objetivos de 32px con puntero preciso, 44px con entrada táctil y una separación de 8px. Sin controles no se reserva espacio. Copiar conserva el foco e impide escrituras duplicadas mientras están pendientes. Los errores o la falta de acceso al portapapeles se muestran localmente en `::part(copy-status)` (`role="status"`), traducible con `label-copy-error`; el reintento usa el mismo botón. `copied` solo se emite tras copiar el contenido actual. Cambiar la fuente o el destino de copia invalida las respuestas anteriores. Al cambiar la fuente, el botón conserva el foco y permite copiar la nueva fuente de inmediato. diff --git a/packages/docs/es/src/ranui/style-tokens.md b/packages/docs/es/src/ranui/style-tokens.md index 2ee27a03cb..cfc84a0990 100644 --- a/packages/docs/es/src/ranui/style-tokens.md +++ b/packages/docs/es/src/ranui/style-tokens.md @@ -66,11 +66,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -118,6 +118,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -190,11 +191,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -232,6 +233,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -370,11 +372,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -401,7 +403,6 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -595,14 +596,16 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -637,14 +640,16 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -763,14 +768,15 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -791,14 +797,15 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -833,14 +840,16 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -978,10 +987,10 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1002,11 +1011,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1016,7 +1025,6 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1027,6 +1035,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1063,10 +1072,8 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1094,11 +1101,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1126,7 +1133,6 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1136,6 +1142,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1145,12 +1152,13 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1170,7 +1178,6 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1179,6 +1186,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1305,11 +1313,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1324,6 +1332,8 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1608,19 +1618,35 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1662,16 +1688,17 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1740,13 +1767,14 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1868,11 +1896,11 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1894,6 +1922,7 @@ nomenclatura es `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/fa/src/agents/index.md b/packages/docs/fa/src/agents/index.md new file mode 100644 index 0000000000..77586445c1 --- /dev/null +++ b/packages/docs/fa/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: عامل‌ها +--- + +# عامل‌ها + +افزونهٔ ran سه مهارت دارد: design برای تصمیم‌های محصول، ranui برای مؤلفه‌ها و ranuts برای ابزارها. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +از ریشهٔ مخزن، این نسخهٔ کاری را ثبت و افزونه را نصب کنید. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +در نشست جدید Codex از `$design`، `$ranui` یا `$ranuts` و در Claude Code از `/ran:design`، `/ran:ranui` یا `/ran:ranuts` استفاده کنید. + +پس از انتشار، `.` را در فرمان بازار با `chaxus/ran` جایگزین کنید. پیوندهای مخزن به جایگاه رسمی در main پس از ادغام اشاره دارند. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/fa/src/design/index.md b/packages/docs/fa/src/design/index.md new file mode 100644 index 0000000000..4c3007b60c --- /dev/null +++ b/packages/docs/fa/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +روش‌های چیدمان، تعامل، حرکت و ارزیابی با شواهد، مستقل از کتابخانهٔ مؤلفه‌ها. + +از کار و محتوای واقعی شروع کنید. زبان بصری مشترکی برای محصول تعیین کنید و حالت‌های کامل، از جمله صفحه‌کلید و حرکت، را در مرورگر بررسی کنید. قراردادهای دقیق در منابع زیر آمده‌اند. + +- [ترکیب‌بندی بصری](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [تعامل](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [ارزیابی](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [الگوهای محصول](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [منابع](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [عامل‌ها](../agents/) diff --git a/packages/docs/fa/src/ranui/api.md b/packages/docs/fa/src/ranui/api.md index 5781464297..24b8a8e487 100644 --- a/packages/docs/fa/src/ranui/api.md +++ b/packages/docs/fa/src/ranui/api.md @@ -195,9 +195,9 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - **اسلات‌ها**: — - **Part‌ها**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -212,7 +212,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/image/index.ts` -- **ویژگی‌ها (attribute)**: — +- **ویژگی‌ها (attribute)**: `alt`, `fallback`, `sheet`, `src` - **خصیصه‌ها (property)**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -274,7 +274,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/markdown/index.ts` -- **ویژگی‌ها (attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **ویژگی‌ها (attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **خصیصه‌ها (property)**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -293,7 +293,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **اسلات‌ها**: — -- **Part‌ها**: `block`, `body`, `error`, `markdown` +- **Part‌ها**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -307,7 +307,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **اسلات‌ها**: — -- **Part‌ها**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Part‌ها**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -322,13 +322,13 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **اسلات‌ها**: — -- **Part‌ها**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Part‌ها**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` کد منبع: `components/message/index.ts` -- **ویژگی‌ها (attribute)**: — +- **ویژگی‌ها (attribute)**: `content: string | null`, `sheet: string`, `type: string | null` - **خصیصه‌ها (property)**: `content: string | null`, `sheet: string`, `type: string | null` - **رویدادها**: — - **اسلات‌ها**: — @@ -363,7 +363,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/select/option/index.ts` -- **ویژگی‌ها (attribute)**: — +- **ویژگی‌ها (attribute)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **خصیصه‌ها (property)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **رویدادها**: — - **اسلات‌ها**: `پیش‌فرض` @@ -420,7 +420,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/progress/index.ts` -- **ویژگی‌ها (attribute)**: `drag`, `primary` +- **ویژگی‌ها (attribute)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **خصیصه‌ها (property)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **رویدادها**: - `change` · element-only · detail `{ percent, total, value }` @@ -431,7 +431,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/radar/index.ts` -- **ویژگی‌ها (attribute)**: — +- **ویژگی‌ها (attribute)**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **خصیصه‌ها (property)**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **رویدادها**: — - **اسلات‌ها**: — @@ -484,11 +484,11 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/scratch/index.ts` -- **ویژگی‌ها (attribute)**: `disabled: boolean`, `sheet: string` -- **خصیصه‌ها (property)**: `disabled: boolean`, `sheet: string` +- **ویژگی‌ها (attribute)**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **خصیصه‌ها (property)**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **رویدادها**: — - **اسلات‌ها**: `پیش‌فرض` -- **Part‌ها**: `award` +- **Part‌ها**: `award`, `reveal-button` ## `` @@ -504,7 +504,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/select/index.ts` -- **ویژگی‌ها (attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **ویژگی‌ها (attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **خصیصه‌ها (property)**: - `defaultValue: string` - `disabled: boolean` @@ -512,6 +512,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -558,7 +559,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/tabpane/index.ts` -- **ویژگی‌ها (attribute)**: — +- **ویژگی‌ها (attribute)**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **خصیصه‌ها (property)**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **رویدادها**: — - **اسلات‌ها**: `پیش‌فرض` @@ -579,7 +580,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه کد منبع: `components/theme-switch/index.ts` -- **ویژگی‌ها (attribute)**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **ویژگی‌ها (attribute)**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **خصیصه‌ها (property)**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -626,7 +627,7 @@ description: 'همهٔ عناصر سفارشی ranui — 40 عنصر همراه - **ویژگی‌ها (attribute)**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **خصیصه‌ها (property)**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/fa/src/ranui/changelog.md b/packages/docs/fa/src/ranui/changelog.md index d8ab22892d..2b433a9417 100644 --- a/packages/docs/fa/src/ranui/changelog.md +++ b/packages/docs/fa/src/ranui/changelog.md @@ -9,6 +9,23 @@ description: 'چه چیزی در ranui تغییر کرد — افزوده، تغ ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/fa/src/ranui/design-guides/index.md b/packages/docs/fa/src/ranui/design-guides/index.md index 5923db2805..23bb1e607f 100644 --- a/packages/docs/fa/src/ranui/design-guides/index.md +++ b/packages/docs/fa/src/ranui/design-guides/index.md @@ -146,3 +146,7 @@ description: 'قواعد طراحی برای ساختن صفحه با ranui: ن - [ ] فاصله‌ها از مقیاس، تایپوگرافی بر پایه نقش، رنگ از توکن‌های معنایی. - [ ] هیچ ویژگی پالتی در `transition` نیست؛ هیچ `transition: all` نیست. - [ ] متن و بافت، کنش و مفعول را روشن می‌کنند؛ هیچ‌چیز وضعیت را تنها با رنگ اعلام نمی‌کند. + +## شواهد و پذیرش {#evidence-and-acceptance} + +[قرارداد پذیرش](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) بررسی واقعی در مرورگر، تازگی نسخهٔ اجرا و تصاویر، و جداسازی نتایج بررسی خودکار، بازبینی بصری عامل و تصمیم کاربر را مشخص می‌کند. پس از تغییر سبک‌های مشترک، شواهد بخش‌های متأثر را به‌روز کنید؛ بازبینی عامل به‌معنای پذیرش کاربر نیست. برچسب‌ها و توضیحات فرعی بدون اطلاعات تازه را حذف کنید و بگذارید چیدمان روابط روشن را توضیح دهد. نام‌های دسترس‌پذیر، برچسب‌های مفید، واحدها، خطاها و توضیحات خطر را حفظ کنید. diff --git a/packages/docs/fa/src/ranui/math/index.md b/packages/docs/fa/src/ranui/math/index.md index 718aaa0f02..a783cb7c44 100644 --- a/packages/docs/fa/src/ranui/math/index.md +++ b/packages/docs/fa/src/ranui/math/index.md @@ -86,3 +86,9 @@ Part‌ها: `button` · `error` · `math` · `menu` · `render` · `toolbar` - **در جاوااسکریپت بک‌اسلش را escape کنید**: هنگام تخصیص `latex` از یک رشتهٔ JS، یادتان باشد `\` باید escape شود (مثلاً `'\\frac{1}{2}'`). - **شکست تجزیه را مدیریت کنید**: به‌جای فرض اینکه هر فرمولی LaTeX معتبر است، به `error` گوش بدهید (یا جعبهٔ رندرشدهٔ `()::part(error` را ببینید). - **چیدمان دلخواه با `sheet`**: هرجا لازم شد با ویژگی `sheet` چیدمان درونی `.ran-math` را بازنویسی کنید. + +### کپی و صفحه‌های باریک + +کنترل‌های فعال همیشه در ردیفی ساده بالای محتوا دیده می‌شوند: ناحیهٔ هدف برای نشانگر دقیق 32px و برای لمس 44px است و با محتوا 8px فاصله دارد. بدون کنترل، فضایی رزرو نمی‌شود. هنگام کپی، فوکوس حفظ می‌شود و نوشتن تکراری تا پایان درخواست مسدود است. رد دسترسی یا نبود کلیپ‌بورد به‌صورت محلی در `::part(copy-status)` با `role="status"` نمایش داده می‌شود و متن آن با `label-copy-error` قابل ترجمه است؛ تلاش دوباره از همان دکمه استفاده می‌کند. رویداد `copied` فقط پس از کپی موفق محتوای فعلی صادر می‌شود. تغییر منبع یا هدف کپی، بازخورد درخواست‌های قبلی را بی‌اعتبار می‌کند. با تغییر منبع، فوکوس دکمه حفظ می‌شود و منبع جدید را می‌توان بی‌درنگ کپی کرد. + +فرمول‌های عریض درون `::part(render)` به‌صورت افقی پیمایش می‌شوند، بدون اینکه عرض صفحه یا جای نوار ابزار تغییر کند. diff --git a/packages/docs/fa/src/ranui/mermaid/index.md b/packages/docs/fa/src/ranui/mermaid/index.md index cd3dd7b096..711519128f 100644 --- a/packages/docs/fa/src/ranui/mermaid/index.md +++ b/packages/docs/fa/src/ranui/mermaid/index.md @@ -39,7 +39,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // ستِرِ خصیصه، خودش ## کنترل‌ها هر کنترل با یک ویژگی بولی **به‌خواست خودتان** روشن می‌شود؛ `` خالی یک نمودار ایستای تمیز -است. نوار ابزار با نگه‌داشتن نشانگر (بالا سمت راست) پیدا می‌شود. +است. کنترل‌های فعال همیشه بالای نمودار دیده می‌شوند. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -83,13 +83,13 @@ el.code = 'classDiagram\n Dog --|> Animal'; // ستِرِ خصیصه، خودش ## Part‌های CSS -| Part | توضیح | -| --------- | ----------------------------------------- | -| `mermaid` | پوشش بیرونی. | -| `diagram` | نگه‌دارندهٔ نمودار رندرشده. | -| `toolbar` | نوار کنترل که با نگه‌داشتن نشانگر می‌آید. | -| `button` | هر دکمهٔ آیکون در نوار. | -| `error` | جعبهٔ پیام خطا (هنگام شکست رندر). | +| Part | توضیح | +| --------- | --------------------------------- | +| `mermaid` | پوشش بیرونی. | +| `diagram` | نگه‌دارندهٔ نمودار رندرشده. | +| `toolbar` | ردیف اقدامات قابل مشاهده. | +| `button` | هر دکمهٔ آیکون در نوار. | +| `error` | جعبهٔ پیام خطا (هنگام شکست رندر). | ```css r-mermaid::part(toolbar) { @@ -113,3 +113,7 @@ r-mermaid::part(toolbar) { - **خروجی PNG**: نمودارهایی که برچسب HTML دارند (`htmlLabels` در mermaid) از راه `` ترسیم می‌شوند و این می‌تواند canvas را آلوده کند و خروجی PNG را شکست دهد؛ در آن حالت رویداد `error` ارسال می‌شود. خروجی SVG و منبع همیشه کار می‌کند. + +### کپی و صفحه‌های باریک + +کنترل‌های فعال همیشه در ردیفی ساده بالای محتوا دیده می‌شوند: ناحیهٔ هدف برای نشانگر دقیق 32px و برای لمس 44px است و با محتوا 8px فاصله دارد. بدون کنترل، فضایی رزرو نمی‌شود. هنگام کپی، فوکوس حفظ می‌شود و نوشتن تکراری تا پایان درخواست مسدود است. رد دسترسی یا نبود کلیپ‌بورد به‌صورت محلی در `::part(copy-status)` با `role="status"` نمایش داده می‌شود و متن آن با `label-copy-error` قابل ترجمه است؛ تلاش دوباره از همان دکمه استفاده می‌کند. رویداد `copied` فقط پس از کپی موفق محتوای فعلی صادر می‌شود. تغییر منبع یا هدف کپی، بازخورد درخواست‌های قبلی را بی‌اعتبار می‌کند. با تغییر منبع، فوکوس دکمه حفظ می‌شود و منبع جدید را می‌توان بی‌درنگ کپی کرد. diff --git a/packages/docs/fa/src/ranui/style-tokens.md b/packages/docs/fa/src/ranui/style-tokens.md index b6f1413241..0b1ed90bb8 100644 --- a/packages/docs/fa/src/ranui/style-tokens.md +++ b/packages/docs/fa/src/ranui/style-tokens.md @@ -66,11 +66,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -118,6 +118,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -190,11 +191,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -232,6 +233,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -370,11 +372,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -401,7 +403,6 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -595,14 +596,16 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -637,14 +640,16 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -763,14 +768,15 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -791,14 +797,15 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -833,14 +840,16 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -978,10 +987,10 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1002,11 +1011,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1016,7 +1025,6 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1027,6 +1035,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1063,10 +1072,8 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1094,11 +1101,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1126,7 +1133,6 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1136,6 +1142,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1145,12 +1152,13 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1170,7 +1178,6 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1179,6 +1186,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1305,11 +1313,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1324,6 +1332,8 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1608,19 +1618,35 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1662,16 +1688,17 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1740,13 +1767,14 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1868,11 +1896,11 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1894,6 +1922,7 @@ description: 'ویژگی‌های سفارشی CSS و نام‌های ()::part - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/ja/src/agents/index.md b/packages/docs/ja/src/agents/index.md new file mode 100644 index 0000000000..cd97c4e95b --- /dev/null +++ b/packages/docs/ja/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: エージェント +--- + +# エージェント + +ran プラグインは design、ranui、ranuts の三つのスキルで設計、コンポーネント、ツール選択を支援します。 + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +リポジトリのルートから、このチェックアウトを登録してプラグインをインストールします。 + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +新しいセッションで、Codex は `$design`、`$ranui`、`$ranuts`、Claude Code は `/ran:design`、`/ran:ranui`、`/ran:ranuts` を使用します。 + +公開後は市場コマンドの `.` を `chaxus/ran` に置き換えます。以下のリンクはマージ後の main 上の正式な場所を示します。 + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/ja/src/design/index.md b/packages/docs/ja/src/design/index.md new file mode 100644 index 0000000000..e0116295ac --- /dev/null +++ b/packages/docs/ja/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +レイアウト、操作、モーション、実際の検証を扱う、ライブラリに依存しない設計方法。 + +タスクと実際の内容から始めます。製品全体の視覚言語を決め、キーボード操作と動きを含めた各状態をブラウザーで確認します。詳細な契約は以下を参照してください。 + +- [視覚と構成](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [操作](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [検証](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [製品パターン](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [参考資料](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [エージェント](../agents/) diff --git a/packages/docs/ja/src/ranui/api.md b/packages/docs/ja/src/ranui/api.md index 8540aac7ed..e6b834b995 100644 --- a/packages/docs/ja/src/ranui/api.md +++ b/packages/docs/ja/src/ranui/api.md @@ -196,9 +196,9 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - **スロット**: — - **Part**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -213,7 +213,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/image/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `alt`, `fallback`, `sheet`, `src` - **プロパティ(property)**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -275,7 +275,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/markdown/index.ts` -- **属性(attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **属性(attribute)**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **プロパティ(property)**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -294,7 +294,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **スロット**: — -- **Part**: `block`, `body`, `error`, `markdown` +- **Part**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -308,7 +308,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **スロット**: — -- **Part**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Part**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -323,13 +323,13 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **スロット**: — -- **Part**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Part**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` ソース: `components/message/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `content: string | null`, `sheet: string`, `type: string | null` - **プロパティ(property)**: `content: string | null`, `sheet: string`, `type: string | null` - **イベント**: — - **スロット**: — @@ -364,7 +364,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/select/option/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **プロパティ(property)**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **イベント**: — - **スロット**: `デフォルトスロット` @@ -421,7 +421,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/progress/index.ts` -- **属性(attribute)**: `drag`, `primary` +- **属性(attribute)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **プロパティ(property)**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **イベント**: - `change` · element-only · detail `{ percent, total, value }` @@ -432,7 +432,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/radar/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **プロパティ(property)**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **イベント**: — - **スロット**: — @@ -485,11 +485,11 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/scratch/index.ts` -- **属性(attribute)**: `disabled: boolean`, `sheet: string` -- **プロパティ(property)**: `disabled: boolean`, `sheet: string` +- **属性(attribute)**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **プロパティ(property)**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **イベント**: — - **スロット**: `デフォルトスロット` -- **Part**: `award` +- **Part**: `award`, `reveal-button` ## `` @@ -505,7 +505,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/select/index.ts` -- **属性(attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **属性(attribute)**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **プロパティ(property)**: - `defaultValue: string` - `disabled: boolean` @@ -513,6 +513,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -559,7 +560,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/tabpane/index.ts` -- **属性(attribute)**: — +- **属性(attribute)**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **プロパティ(property)**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **イベント**: — - **スロット**: `デフォルトスロット` @@ -580,7 +581,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の ソース: `components/theme-switch/index.ts` -- **属性(attribute)**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **属性(attribute)**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **プロパティ(property)**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -627,7 +628,7 @@ description: 'ranui のすべてのカスタム要素 —— 40 個の要素の - **属性(attribute)**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **プロパティ(property)**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/ja/src/ranui/changelog.md b/packages/docs/ja/src/ranui/changelog.md index cf15862ff6..a3eb0318a0 100644 --- a/packages/docs/ja/src/ranui/changelog.md +++ b/packages/docs/ja/src/ranui/changelog.md @@ -9,6 +9,23 @@ description: 'ranui の変更点——追加・変更・修正・削除とその ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/ja/src/ranui/design-guides/index.md b/packages/docs/ja/src/ranui/design-guides/index.md index c0744bd385..521422b48e 100644 --- a/packages/docs/ja/src/ranui/design-guides/index.md +++ b/packages/docs/ja/src/ranui/design-guides/index.md @@ -146,3 +146,7 @@ ranui のコンポーネントで組み立てた画面が、部品の寄せ集 - [ ] 余白はスケールから、文字は役割で、色はセマンティックトークンから。 - [ ] `transition` にパレットのプロパティがない。`transition: all` もない。 - [ ] 文言と文脈で操作と対象が明確になっている。色だけで状態を示していない。 + +## 証拠と受け入れ {#evidence-and-acceptance} + +[受け入れ契約](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) は、実際のブラウザー検証、実行版と画像の鮮度、自動チェック・agent の視覚レビュー・ユーザーの判断を分けて管理します。共有スタイル変更後は影響する証拠を更新してください。agent の自己確認はユーザーの承認ではありません。情報を増やさない補助ラベルや説明を省き、配置で関係を伝えます。アクセシブルな名前、必要な項目ラベル、単位、エラー、リスク説明は残します。 diff --git a/packages/docs/ja/src/ranui/math/index.md b/packages/docs/ja/src/ranui/math/index.md index 19c4476b7b..b6e96fcf8d 100644 --- a/packages/docs/ja/src/ranui/math/index.md +++ b/packages/docs/ja/src/ranui/math/index.md @@ -86,3 +86,9 @@ Part:`button` · `error` · `math` · `menu` · `render` · `toolbar` - **JavaScript ではバックスラッシュをエスケープする**:JS の文字列リテラルから `latex` に代入するときは `\` のエスケープを忘れずに(例:`'\\frac{1}{2}'`)。 - **解析失敗に備える**:すべての式が正しい LaTeX だと決めてかからず、`error` を購読する(あるいは描画された `::part(error)` の箱を見る)ようにします。 - **`sheet` で独自のレイアウトに**:内部の `.ran-math` のレイアウトを上書きしたいときは `sheet` 属性を使います。 + +### コピーと狭い画面 + +有効なコントロールは内容の上に常に表示されます。操作領域は精密ポインターで 32px、タッチで 44px、内容との間隔は 8px です。コントロールがない場合は空間を確保しません。コピー中もフォーカスを維持し、重複した書き込みを防ぎます。クリップボードの拒否や利用不可は `::part(copy-status)`(`role="status"`)に表示され、`label-copy-error` で翻訳できます。再試行には同じボタンを使います。`copied` は現在の内容のコピー成功後にのみ発火します。ソースやコピー対象を変更すると、古い要求の通知は無効になります。ソース変更時もボタンのフォーカスを維持し、新しいソースをすぐにコピーできます。 + +幅の広い数式は `::part(render)` 内で横スクロールし、ページ幅やツールバーの位置を変えません。 diff --git a/packages/docs/ja/src/ranui/mermaid/index.md b/packages/docs/ja/src/ranui/mermaid/index.md index d2845be24d..cc0edaf2e0 100644 --- a/packages/docs/ja/src/ranui/mermaid/index.md +++ b/packages/docs/ja/src/ranui/mermaid/index.md @@ -38,7 +38,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // プロパティのセッター ## コントロール どのコントロールも真偽値の属性による**オプトイン**です。属性のない `` はすっきりした静的な -図になります。ツールバーはホバー時に右上へ現れます。 +図になります。有効なコントロールは図の上に常に表示されます。 graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -86,7 +86,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // プロパティのセッター | --------- | ------------------------------------ | | `mermaid` | 外側のラッパー。 | | `diagram` | 描画された図のコンテナ。 | -| `toolbar` | ホバーで出るコントロールバー。 | +| `toolbar` | 常に表示される操作行。 | | `button` | ツールバーの各アイコンボタン。 | | `error` | エラーメッセージの箱(描画失敗時)。 | @@ -112,3 +112,7 @@ r-mermaid::part(toolbar) { - **PNG の書き出し**:HTML ラベル(mermaid の `htmlLabels`)を使う図は `` 経由で 描画されます。これは canvas を汚染して PNG の書き出しを失敗させることがあり、その場合は `error` イベントが派発されます。SVG とソースの書き出しは常に動きます。 + +### コピーと狭い画面 + +有効なコントロールは内容の上に常に表示されます。操作領域は精密ポインターで 32px、タッチで 44px、内容との間隔は 8px です。コントロールがない場合は空間を確保しません。コピー中もフォーカスを維持し、重複した書き込みを防ぎます。クリップボードの拒否や利用不可は `::part(copy-status)`(`role="status"`)に表示され、`label-copy-error` で翻訳できます。再試行には同じボタンを使います。`copied` は現在の内容のコピー成功後にのみ発火します。ソースやコピー対象を変更すると、古い要求の通知は無効になります。ソース変更時もボタンのフォーカスを維持し、新しいソースをすぐにコピーできます。 diff --git a/packages/docs/ja/src/ranui/style-tokens.md b/packages/docs/ja/src/ranui/style-tokens.md index 2970c9249c..d65f55b0d4 100644 --- a/packages/docs/ja/src/ranui/style-tokens.md +++ b/packages/docs/ja/src/ranui/style-tokens.md @@ -65,11 +65,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -117,6 +117,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -189,11 +190,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -231,6 +232,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -369,11 +371,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -400,7 +402,6 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -594,14 +595,16 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -636,14 +639,16 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -762,14 +767,15 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -790,14 +796,15 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -832,14 +839,16 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -977,10 +986,10 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1001,11 +1010,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1015,7 +1024,6 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1026,6 +1034,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1062,10 +1071,8 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1093,11 +1100,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1125,7 +1132,6 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1135,6 +1141,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1144,12 +1151,13 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1169,7 +1177,6 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1178,6 +1185,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1304,11 +1312,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1323,6 +1331,8 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1607,19 +1617,35 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1661,16 +1687,17 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1739,13 +1766,14 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1867,11 +1895,11 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1893,6 +1921,7 @@ description: 'ranui の各要素が公開する CSS カスタムプロパティ - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/ko/src/agents/index.md b/packages/docs/ko/src/agents/index.md new file mode 100644 index 0000000000..fd8059a5a9 --- /dev/null +++ b/packages/docs/ko/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: 에이전트 +--- + +# 에이전트 + +ran 플러그인은 제품 설계를 위한 design, 컴포넌트를 위한 ranui, 유틸리티를 위한 ranuts 스킬을 제공합니다. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +저장소 루트에서 이 체크아웃을 등록하고 플러그인을 설치하세요. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +새 세션에서 Codex는 `$design`, `$ranui`, `$ranuts`를, Claude Code는 `/ran:design`, `/ran:ranui`, `/ran:ranuts`를 사용하세요. + +배포 후 marketplace 명령의 `.`을 `chaxus/ran`으로 바꾸세요. 아래 저장소 링크는 병합 후 main의 공식 위치를 가리킵니다. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/ko/src/design/index.md b/packages/docs/ko/src/design/index.md new file mode 100644 index 0000000000..e56580816b --- /dev/null +++ b/packages/docs/ko/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +특정 라이브러리에 종속되지 않는 레이아웃, 상호작용, 모션 및 실제 검증 방법입니다. + +작업과 실제 콘텐츠에서 시작하세요. 제품 전체의 시각 언어를 정한 뒤 키보드 조작과 모션을 포함한 전체 상태를 브라우저에서 검토하세요. 자세한 계약은 아래를 참고하세요. + +- [시각 구성](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [상호작용](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [검증](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [제품 패턴](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [참고 자료](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [에이전트](../agents/) diff --git a/packages/docs/ko/src/ranui/api.md b/packages/docs/ko/src/ranui/api.md index 480b30ee38..09eb96158b 100644 --- a/packages/docs/ko/src/ranui/api.md +++ b/packages/docs/ko/src/ranui/api.md @@ -195,9 +195,9 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - **슬롯**: — - **Part**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -212,7 +212,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/image/index.ts` -- **어트리뷰트**: — +- **어트리뷰트**: `alt`, `fallback`, `sheet`, `src` - **프로퍼티**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -274,7 +274,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/markdown/index.ts` -- **어트리뷰트**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **어트리뷰트**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **프로퍼티**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -293,7 +293,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **슬롯**: — -- **Part**: `block`, `body`, `error`, `markdown` +- **Part**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -307,7 +307,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **슬롯**: — -- **Part**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Part**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -322,13 +322,13 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **슬롯**: — -- **Part**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Part**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` 소스: `components/message/index.ts` -- **어트리뷰트**: — +- **어트리뷰트**: `content: string | null`, `sheet: string`, `type: string | null` - **프로퍼티**: `content: string | null`, `sheet: string`, `type: string | null` - **이벤트**: — - **슬롯**: — @@ -363,7 +363,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/select/option/index.ts` -- **어트리뷰트**: — +- **어트리뷰트**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **프로퍼티**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **이벤트**: — - **슬롯**: `기본 슬롯` @@ -420,7 +420,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/progress/index.ts` -- **어트리뷰트**: `drag`, `primary` +- **어트리뷰트**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **프로퍼티**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **이벤트**: - `change` · element-only · detail `{ percent, total, value }` @@ -431,7 +431,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/radar/index.ts` -- **어트리뷰트**: — +- **어트리뷰트**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **프로퍼티**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **이벤트**: — - **슬롯**: — @@ -484,11 +484,11 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/scratch/index.ts` -- **어트리뷰트**: `disabled: boolean`, `sheet: string` -- **프로퍼티**: `disabled: boolean`, `sheet: string` +- **어트리뷰트**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **프로퍼티**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **이벤트**: — - **슬롯**: `기본 슬롯` -- **Part**: `award` +- **Part**: `award`, `reveal-button` ## `` @@ -504,7 +504,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/select/index.ts` -- **어트리뷰트**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **어트리뷰트**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **프로퍼티**: - `defaultValue: string` - `disabled: boolean` @@ -512,6 +512,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -558,7 +559,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/tabpane/index.ts` -- **어트리뷰트**: — +- **어트리뷰트**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **프로퍼티**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **이벤트**: — - **슬롯**: `기본 슬롯` @@ -579,7 +580,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 소스: `components/theme-switch/index.ts` -- **어트리뷰트**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **어트리뷰트**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **프로퍼티**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -626,7 +627,7 @@ description: 'ranui의 모든 커스텀 엘리먼트 — 40개 엘리먼트의 - **어트리뷰트**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **프로퍼티**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/ko/src/ranui/changelog.md b/packages/docs/ko/src/ranui/changelog.md index 538bd84ec3..24b8feb862 100644 --- a/packages/docs/ko/src/ranui/changelog.md +++ b/packages/docs/ko/src/ranui/changelog.md @@ -9,6 +9,23 @@ description: 'ranui에서 무엇이 바뀌었는지 — 추가·변경·수정· ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/ko/src/ranui/design-guides/index.md b/packages/docs/ko/src/ranui/design-guides/index.md index 3d84d87f31..96f4d77f5d 100644 --- a/packages/docs/ko/src/ranui/design-guides/index.md +++ b/packages/docs/ko/src/ranui/design-guides/index.md @@ -146,3 +146,7 @@ ranui 컴포넌트로 만든 화면이 부품 더미가 아니라 **하나의 - [ ] 간격은 스케일에서, 글자는 역할로, 색은 시맨틱 토큰에서. - [ ] `transition`에 팔레트 속성이 없다. `transition: all`도 없다. - [ ] 문구와 문맥으로 동작과 대상이 명확하다. 색만으로 상태를 알리지 않는다. + +## 증거와 승인 {#evidence-and-acceptance} + +[검수 계약](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md)은 실제 브라우저 검증, 실행 버전과 캡처의 최신성, 자동 검사·에이전트 시각 검토·사용자 결정을 구분합니다. 공유 스타일이 바뀌면 영향받는 증거를 갱신하세요. 에이전트 검토는 사용자 승인이 아닙니다. 정보가 늘지 않는 보조 레이블과 설명은 줄이고 배치로 관계를 전달하세요. 접근 가능한 이름, 유용한 필드 레이블, 단위, 오류와 위험 설명은 유지하세요. diff --git a/packages/docs/ko/src/ranui/math/index.md b/packages/docs/ko/src/ranui/math/index.md index d6c7473fdc..43cf191d1c 100644 --- a/packages/docs/ko/src/ranui/math/index.md +++ b/packages/docs/ko/src/ranui/math/index.md @@ -86,3 +86,9 @@ Part: `button` · `error` · `math` · `menu` · `render` · `toolbar` - **JavaScript 에서는 역슬래시를 이스케이프하세요**: JS 문자열 리터럴로 `latex`를 대입할 때 `\`는 이스케이프해야 합니다 (예: `'\\frac{1}{2}'`). - **파싱 실패에 대비하세요**: 모든 식이 올바른 LaTeX 이라고 가정하지 말고 `error`를 구독하세요 (또는 렌더링된 `::part(error)` 상자를 확인하세요). - **`sheet`로 배치를 바꾸세요**: 내부 `.ran-math` 배치를 덮어써야 할 때 `sheet` 어트리뷰트를 쓰세요. + +### 복사와 좁은 화면 + +활성화된 컨트롤은 콘텐츠 위에 항상 표시됩니다. 조작 영역은 정밀 포인터에서 32px, 터치에서 44px이며 콘텐츠와의 간격은 8px입니다. 컨트롤이 없으면 공간을 예약하지 않습니다. 복사 중에도 포커스를 유지하고 중복 쓰기를 방지합니다. 클립보드 거부나 사용 불가는 `::part(copy-status)`(`role="status"`)에 표시되며 `label-copy-error`로 번역할 수 있습니다. 같은 버튼으로 다시 시도합니다. `copied`는 현재 내용의 복사가 성공한 뒤에만 발생합니다. 원본이나 복사 대상을 바꾸면 이전 요청의 피드백은 무효화됩니다. 원본이 바뀌어도 버튼의 포커스가 유지되며 새 원본을 즉시 복사할 수 있습니다. + +넓은 수식은 `::part(render)` 내부에서 가로로 스크롤되며 페이지 너비나 도구 모음 위치를 바꾸지 않습니다. diff --git a/packages/docs/ko/src/ranui/mermaid/index.md b/packages/docs/ko/src/ranui/mermaid/index.md index 4e1aa06331..8d2be7cec9 100644 --- a/packages/docs/ko/src/ranui/mermaid/index.md +++ b/packages/docs/ko/src/ranui/mermaid/index.md @@ -38,7 +38,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // 프로퍼티 세터가 URI 인 ## 컨트롤 모든 컨트롤은 불리언 어트리뷰트로 **직접 켜야** 합니다. 아무것도 붙이지 않은 ``는 깔끔한 -정적 다이어그램입니다. 툴바는 마우스를 올리면 오른쪽 위에 나타납니다. +정적 다이어그램입니다. 활성화된 컨트롤은 다이어그램 위에 항상 표시됩니다. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -82,13 +82,13 @@ el.code = 'classDiagram\n Dog --|> Animal'; // 프로퍼티 세터가 URI 인 ## CSS Part -| Part | 설명 | -| --------- | --------------------------------- | -| `mermaid` | 바깥 래퍼. | -| `diagram` | 렌더링된 다이어그램 컨테이너. | -| `toolbar` | 마우스를 올리면 나오는 컨트롤 바. | -| `button` | 툴바의 각 아이콘 버튼. | -| `error` | 오류 메시지 상자 (렌더 실패 시). | +| Part | 설명 | +| --------- | -------------------------------- | +| `mermaid` | 바깥 래퍼. | +| `diagram` | 렌더링된 다이어그램 컨테이너. | +| `toolbar` | 항상 표시되는 작업 행. | +| `button` | 툴바의 각 아이콘 버튼. | +| `error` | 오류 메시지 상자 (렌더 실패 시). | ```css r-mermaid::part(toolbar) { @@ -112,3 +112,7 @@ r-mermaid::part(toolbar) { - **PNG 내보내기**: HTML 레이블 (mermaid 의 `htmlLabels`) 을 쓰는 다이어그램은 ``로 그려지는데, 이는 canvas 를 오염시켜 PNG 내보내기를 실패하게 만들 수 있습니다. 그럴 때는 `error` 이벤트가 디스패치됩니다. SVG 와 원본 내보내기는 언제나 동작합니다. + +### 복사와 좁은 화면 + +활성화된 컨트롤은 콘텐츠 위에 항상 표시됩니다. 조작 영역은 정밀 포인터에서 32px, 터치에서 44px이며 콘텐츠와의 간격은 8px입니다. 컨트롤이 없으면 공간을 예약하지 않습니다. 복사 중에도 포커스를 유지하고 중복 쓰기를 방지합니다. 클립보드 거부나 사용 불가는 `::part(copy-status)`(`role="status"`)에 표시되며 `label-copy-error`로 번역할 수 있습니다. 같은 버튼으로 다시 시도합니다. `copied`는 현재 내용의 복사가 성공한 뒤에만 발생합니다. 원본이나 복사 대상을 바꾸면 이전 요청의 피드백은 무효화됩니다. 원본이 바뀌어도 버튼의 포커스가 유지되며 새 원본을 즉시 복사할 수 있습니다. diff --git a/packages/docs/ko/src/ranui/style-tokens.md b/packages/docs/ko/src/ranui/style-tokens.md index e09fdcc574..1c42849c39 100644 --- a/packages/docs/ko/src/ranui/style-tokens.md +++ b/packages/docs/ko/src/ranui/style-tokens.md @@ -65,11 +65,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -117,6 +117,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -189,11 +190,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -231,6 +232,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -369,11 +371,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -400,7 +402,6 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -594,14 +595,16 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -636,14 +639,16 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -762,14 +767,15 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -790,14 +796,15 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -832,14 +839,16 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -977,10 +986,10 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1001,11 +1010,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1015,7 +1024,6 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1026,6 +1034,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1062,10 +1071,8 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1093,11 +1100,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1125,7 +1132,6 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1135,6 +1141,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1144,12 +1151,13 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1169,7 +1177,6 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1178,6 +1185,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1304,11 +1312,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1323,6 +1331,8 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1607,19 +1617,35 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1661,16 +1687,17 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1739,13 +1766,14 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1867,11 +1895,11 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1893,6 +1921,7 @@ description: 'ranui의 각 엘리먼트가 노출하는 CSS 커스텀 프로퍼 - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/package.json b/packages/docs/package.json index a45a3914eb..07b1a802bf 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,9 +1,8 @@ { "name": "docs", "version": "1.0.0", - "description": "chaxus document", + "description": "ran.chaxus.com \u2014 product website, design methods and library documentation", "type": "module", - "main": "index.js", "scripts": { "dev": "tsx build/dev.ts", "preview": "tsx build/preview.ts", @@ -44,5 +43,6 @@ "ranpress": "workspace:*", "ranui": "workspace:*", "ranuts": "workspace:*" - } + }, + "private": true } diff --git a/packages/docs/pt/src/agents/index.md b/packages/docs/pt/src/agents/index.md new file mode 100644 index 0000000000..5672e688f8 --- /dev/null +++ b/packages/docs/pt/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: Agentes +--- + +# Agentes + +Um plugin ran oferece três habilidades: design para decisões de produto, ranui para componentes e ranuts para utilitários. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +Na raiz do repositório, registre esta cópia e instale o plugin. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +Em uma nova sessão, use `$design`, `$ranui` ou `$ranuts` no Codex; `/ran:design`, `/ran:ranui` ou `/ran:ranuts` no Claude Code. + +Após publicar, substitua `.` por `chaxus/ran` no comando do marketplace. Os links apontam para os locais canônicos em main após a integração. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/pt/src/design/index.md b/packages/docs/pt/src/design/index.md new file mode 100644 index 0000000000..95b9cc3e85 --- /dev/null +++ b/packages/docs/pt/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +Métodos de composição, interação, movimento e revisão com evidências, independentes de bibliotecas. + +Comece pela tarefa e pelo conteúdo real. Defina uma linguagem visual compartilhada e revise os estados completos no navegador, incluindo teclado e movimento. Consulte os contratos detalhados abaixo. + +- [Composição visual](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [Interação](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [Validação](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [Padrões de produto](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [Referências](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [Agentes](../agents/) diff --git a/packages/docs/pt/src/ranui/api.md b/packages/docs/pt/src/ranui/api.md index a0f5d0b288..2712436c41 100644 --- a/packages/docs/pt/src/ranui/api.md +++ b/packages/docs/pt/src/ranui/api.md @@ -198,9 +198,9 @@ Código-fonte: `components/icon/index.ts` - **Slots**: — - **Partes**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -215,7 +215,7 @@ Código-fonte: `components/icon/index.ts` Código-fonte: `components/image/index.ts` -- **Atributos**: — +- **Atributos**: `alt`, `fallback`, `sheet`, `src` - **Propriedades**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -277,7 +277,7 @@ Código-fonte: `components/loading/index.ts` Código-fonte: `components/markdown/index.ts` -- **Atributos**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **Atributos**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **Propriedades**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -296,7 +296,7 @@ Código-fonte: `components/markdown/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **Slots**: — -- **Partes**: `block`, `body`, `error`, `markdown` +- **Partes**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -310,7 +310,7 @@ Código-fonte: `components/math/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Partes**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Partes**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -325,13 +325,13 @@ Código-fonte: `components/mermaid/index.ts` - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Partes**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Partes**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` Código-fonte: `components/message/index.ts` -- **Atributos**: — +- **Atributos**: `content: string | null`, `sheet: string`, `type: string | null` - **Propriedades**: `content: string | null`, `sheet: string`, `type: string | null` - **Eventos**: — - **Slots**: — @@ -366,7 +366,7 @@ Código-fonte: `components/modal/index.ts` Código-fonte: `components/select/option/index.ts` -- **Atributos**: — +- **Atributos**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Propriedades**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Eventos**: — - **Slots**: `padrão` @@ -423,7 +423,7 @@ Código-fonte: `components/popover/index.ts` Código-fonte: `components/progress/index.ts` -- **Atributos**: `drag`, `primary` +- **Atributos**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Propriedades**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Eventos**: - `change` · element-only · detail `{ percent, total, value }` @@ -434,7 +434,7 @@ Código-fonte: `components/progress/index.ts` Código-fonte: `components/radar/index.ts` -- **Atributos**: — +- **Atributos**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **Propriedades**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **Eventos**: — - **Slots**: — @@ -487,11 +487,11 @@ Código-fonte: `components/router/index.ts` Código-fonte: `components/scratch/index.ts` -- **Atributos**: `disabled: boolean`, `sheet: string` -- **Propriedades**: `disabled: boolean`, `sheet: string` +- **Atributos**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **Propriedades**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **Eventos**: — - **Slots**: `padrão` -- **Partes**: `award` +- **Partes**: `award`, `reveal-button` ## `` @@ -507,7 +507,7 @@ Código-fonte: `components/section/index.ts` Código-fonte: `components/select/index.ts` -- **Atributos**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **Atributos**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **Propriedades**: - `defaultValue: string` - `disabled: boolean` @@ -515,6 +515,7 @@ Código-fonte: `components/select/index.ts` - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -561,7 +562,7 @@ Código-fonte: `components/state-dot/index.ts` Código-fonte: `components/tabpane/index.ts` -- **Atributos**: — +- **Atributos**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **Propriedades**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **Eventos**: — - **Slots**: `padrão` @@ -582,7 +583,7 @@ Código-fonte: `components/tab/index.ts` Código-fonte: `components/theme-switch/index.ts` -- **Atributos**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **Atributos**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **Propriedades**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -629,7 +630,7 @@ Código-fonte: `components/voice-button/index.ts` - **Atributos**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **Propriedades**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/pt/src/ranui/changelog.md b/packages/docs/pt/src/ranui/changelog.md index 2e211b484e..4a966db188 100644 --- a/packages/docs/pt/src/ranui/changelog.md +++ b/packages/docs/pt/src/ranui/changelog.md @@ -9,6 +9,23 @@ Gerado a partir de `packages/ranui/CHANGELOG.md` do repositório. As entradas ma ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/pt/src/ranui/design-guides/index.md b/packages/docs/pt/src/ranui/design-guides/index.md index 0c498aa91b..a6c42bef2c 100644 --- a/packages/docs/pt/src/ranui/design-guides/index.md +++ b/packages/docs/pt/src/ranui/design-guides/index.md @@ -147,3 +147,7 @@ Escolha verificações para a superfície afetada e o comportamento alterado; a - [ ] Espaços da escala, tipografia por papel, cor de tokens semânticos. - [ ] Nenhuma propriedade da paleta numa `transition`; nenhum `transition: all`. - [ ] O texto e o contexto esclarecem a ação e o objeto; nada sinaliza estado só com cor. + +## Evidências e aceitação {#evidence-and-acceptance} + +[O contrato de aceitação](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) define a verificação real no navegador, a validade das capturas e a separação entre verificações automáticas, revisão visual do agente e decisão do usuário. Após alterar estilos compartilhados, atualize as evidências afetadas; a revisão do agente não é aceitação do usuário. Evite rótulos secundários e descrições sem informação nova: deixe o layout explicar relações evidentes. Preserve nomes acessíveis, rótulos úteis, unidades, erros e riscos. diff --git a/packages/docs/pt/src/ranui/math/index.md b/packages/docs/pt/src/ranui/math/index.md index 583a6c6ba2..e3b50bffe0 100644 --- a/packages/docs/pt/src/ranui/math/index.md +++ b/packages/docs/pt/src/ranui/math/index.md @@ -86,3 +86,9 @@ A lista completa está em [tokens de estilo](/pt/src/ranui/style-tokens#math); q - **Escape as barras invertidas em JavaScript**: ao atribuir `latex` a partir de uma string JS, lembre que `\` precisa ser escapada (por exemplo `'\\frac{1}{2}'`). - **Trate falhas de análise**: escute `error` (ou verifique a caixa `::part(error)` renderizada) em vez de supor que toda fórmula é LaTeX válido. - **Layout próprio com `sheet`**: use o atributo `sheet` para sobrescrever o layout interno de `.ran-math` quando precisar. + +### Copiar e telas estreitas + +Os controles ativados permanecem visíveis em uma linha discreta acima do conteúdo: alvos de 32px para ponteiros precisos, 44px para toque e intervalo de 8px. Sem controles, nenhum espaço é reservado. Copiar mantém o foco e impede gravações duplicadas enquanto há uma pendente. Falhas ou indisponibilidade da área de transferência aparecem em `::part(copy-status)` (`role="status"`), traduzível com `label-copy-error`; a nova tentativa usa o mesmo botão. `copied` só é emitido após copiar o conteúdo atual com sucesso. Alterar a fonte ou o destino da cópia invalida respostas anteriores. Ao alterar a fonte, o botão mantém o foco e permite copiar a nova fonte imediatamente. + +Equações largas rolam horizontalmente dentro de `::part(render)`, sem alargar a página nem mover a barra. diff --git a/packages/docs/pt/src/ranui/mermaid/index.md b/packages/docs/pt/src/ranui/mermaid/index.md index 61ddc9fe51..248608b23a 100644 --- a/packages/docs/pt/src/ranui/mermaid/index.md +++ b/packages/docs/pt/src/ranui/mermaid/index.md @@ -39,7 +39,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // o setter da propriedade codifica ## Controles Cada controle é **opcional** e ligado por um atributo booleano; um `` sem nada é um -diagrama estático e limpo. A barra aparece ao passar o mouse (canto superior direito). +diagrama estático e limpo. Os controles ativados permanecem visíveis acima do diagrama. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -87,7 +87,7 @@ Todos os eventos borbulham e cruzam a fronteira do shadow (`composed`). | --------- | ------------------------------------------- | | `mermaid` | O invólucro externo. | | `diagram` | O contêiner do diagrama renderizado. | -| `toolbar` | A barra de controles que aparece ao passar. | +| `toolbar` | A linha de ações visível. | | `button` | Cada botão de ícone da barra. | | `error` | A caixa de mensagem de erro (quando falha). | @@ -114,3 +114,7 @@ Sobrescreva no elemento (cada uma recorre a um token semântico e depois a um li - **Exportar PNG**: diagramas que usam rótulos HTML (`htmlLabels` do mermaid) são desenhados com ``, o que pode contaminar o canvas e fazer a exportação PNG falhar; nesse caso um evento `error` é despachado. Exportar SVG e a fonte sempre funciona. + +### Copiar e telas estreitas + +Os controles ativados permanecem visíveis em uma linha discreta acima do conteúdo: alvos de 32px para ponteiros precisos, 44px para toque e intervalo de 8px. Sem controles, nenhum espaço é reservado. Copiar mantém o foco e impede gravações duplicadas enquanto há uma pendente. Falhas ou indisponibilidade da área de transferência aparecem em `::part(copy-status)` (`role="status"`), traduzível com `label-copy-error`; a nova tentativa usa o mesmo botão. `copied` só é emitido após copiar o conteúdo atual com sucesso. Alterar a fonte ou o destino da cópia invalida respostas anteriores. Ao alterar a fonte, o botão mantém o foco e permite copiar a nova fonte imediatamente. diff --git a/packages/docs/pt/src/ranui/style-tokens.md b/packages/docs/pt/src/ranui/style-tokens.md index 32b0146e0a..b889690c24 100644 --- a/packages/docs/pt/src/ranui/style-tokens.md +++ b/packages/docs/pt/src/ranui/style-tokens.md @@ -66,11 +66,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -118,6 +118,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -190,11 +191,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -232,6 +233,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -370,11 +372,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -401,7 +403,6 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -595,14 +596,16 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -637,14 +640,16 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -763,14 +768,15 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -791,14 +797,15 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -833,14 +840,16 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -978,10 +987,10 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1002,11 +1011,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1016,7 +1025,6 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1027,6 +1035,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1063,10 +1072,8 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1094,11 +1101,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1126,7 +1133,6 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1136,6 +1142,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1145,12 +1152,13 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1170,7 +1178,6 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1179,6 +1186,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1305,11 +1313,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1324,6 +1332,8 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1608,19 +1618,35 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1662,16 +1688,17 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1740,13 +1767,14 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1868,11 +1896,11 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1894,6 +1922,7 @@ a regra de nomenclatura é `--ran-{component}-{element}[-{state}]-{property}`. - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index d83b27ef54..51f82d327a 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -1,9 +1,6 @@ # ran -> ran is an open-source front-end ecosystem by chaxus: **ranui**, a Web Components UI -> library, and **ranuts**, a utility library — plus low-level web-infrastructure -> experiments (a JS parser, a JSX compiler, a WASM/2D rendering engine). This site -> documents ranui and ranuts. +> ran provides three independent pillars: Ran Design for product design and acceptance, ranui for native Web Components, and ranuts for typed utilities. The unified ran agent plugin routes tasks to design, ranui and ranuts skills. ranui is built on native Custom Elements (`` tags), so it works in any framework or plain HTML. It ships first-class TypeScript types, light/dark theming via Geist-based @@ -16,6 +13,9 @@ and a virtual DOM. ## Docs +- [Ran Design](https://ran.chaxus.com/src/design/): design methodology and canonical references +- [Agents](https://ran.chaxus.com/src/agents/): unified plugin skills, installation and publication status + - [ranui — Web Components UI library](https://ran.chaxus.com/src/ranui/): button, input, select, checkbox, radio, card, tabs, modal, popover, dropdown, progress, message, icon, and more `` elements, with theming, i18n, routing and SSR - [ranui design system](https://ran.chaxus.com/src/ranui/design-system/): the Geist-based token catalog — colour ladder, semantic tokens, spacing, typography, radius, elevation, stacking, motion - [ranui design guidelines](https://ran.chaxus.com/src/ranui/design-guides/): the rules for building a screen with it — pick a role, let the token supply the value, design every state diff --git a/packages/docs/src/agents/index.md b/packages/docs/src/agents/index.md new file mode 100644 index 0000000000..90acaf0a58 --- /dev/null +++ b/packages/docs/src/agents/index.md @@ -0,0 +1,31 @@ +--- +title: Agents +--- + +# Agents + +One ran plugin provides three skills: design for product decisions, ranui for components, and ranuts for utilities. + +[design](../design/) · [ranui](../ranui/) · [ranuts](../ranuts/) + +From the repository root, register this checkout and install the plugin. + +## Codex + +```sh +codex plugin marketplace add . +codex plugin add ran@ran +``` + +## Claude Code + +```sh +claude plugin marketplace add . +claude plugin install ran@ran +``` + +In a new session, invoke `$design`, `$ranui` or `$ranuts` in Codex; use `/ran:design`, `/ran:ranui` or `/ran:ranuts` in Claude Code. + +After publication, replace `.` in the marketplace command with `chaxus/ran`. Repository links below refer to the canonical locations on main after merge. + +[plugins/ran](https://github.com/chaxus/ran/tree/main/plugins/ran) diff --git a/packages/docs/src/design/index.md b/packages/docs/src/design/index.md new file mode 100644 index 0000000000..c18ec53734 --- /dev/null +++ b/packages/docs/src/design/index.md @@ -0,0 +1,17 @@ +--- +title: Ran Design +--- + +# Ran Design + +Methods for layout, interaction, motion and evidence-based review, independent of any component library. + +Start with the task and real content. Choose one visual language for the whole product, then review complete states in the browser, including keyboard use and motion. Use the references below for the detailed contracts. + +- [Visual composition](https://github.com/chaxus/ran/blob/main/design/DESIGN.md) +- [Interaction](https://github.com/chaxus/ran/blob/main/design/INTERACTION.md) +- [Acceptance](https://github.com/chaxus/ran/blob/main/design/ACCEPTANCE.md) +- [Product patterns](https://github.com/chaxus/ran/blob/main/design/WORKFLOW_PATTERNS.md) +- [References](https://github.com/chaxus/ran/blob/main/design/REFERENCE_RESEARCH.md) + +[ranui](../ranui/) · [ranuts](../ranuts/) · [Agents](../agents/) diff --git a/packages/docs/src/ranui/api.md b/packages/docs/src/ranui/api.md index eee26d8e32..1dd4e9dfd7 100644 --- a/packages/docs/src/ranui/api.md +++ b/packages/docs/src/ranui/api.md @@ -196,9 +196,9 @@ Source: `components/icon/index.ts` - **Slots**: — - **Parts**: `ran-icon` -> **Requires registration.** `` has no built-in icon set — it renders only SVGs -> registered into its in-memory registry, so `` is **blank** until `lock` -> is registered. Register once, in the browser, before the first `` connects: +> **Bundled icons are loaded on demand.** `` works without registration: +> its SVG is fetched on first use and cached. To register the entire bundled set eagerly, +> optionally call the following in the browser before the first `` connects: > > ```ts > import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons' @@ -213,7 +213,7 @@ Source: `components/icon/index.ts` Source: `components/image/index.ts` -- **Attributes**: — +- **Attributes**: `alt`, `fallback`, `sheet`, `src` - **Properties**: - `alt` — Alternative text forwarded to the inner ``. Defaults to `''` (empty), - `fallback` @@ -275,7 +275,7 @@ Source: `components/loading/index.ts` Source: `components/markdown/index.ts` -- **Attributes**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` +- **Attributes**: `caret: string`, `content: string`, `copy`, `download`, `highlight: string | null`, `inline-math`, `label-copy-error`, `line-numbers`, `link-target`, `mode: string`, `sheet: string`, `theme: string` - **Properties**: - `caret: string` - `content: string` — Markdown source. Resolution order: the `content` property (set via JS, not @@ -294,7 +294,7 @@ Source: `components/markdown/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ blocks, changed }` - **Slots**: — -- **Parts**: `block`, `body`, `error`, `markdown` +- **Parts**: `block`, `body`, `button`, `code`, `code-actions`, `code-header`, `code-lang`, `copy-status`, `error`, `markdown`, `table` ## `` @@ -308,7 +308,7 @@ Source: `components/math/index.ts` - `error` · bubbles, composed · detail `{ message }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Parts**: `button`, `error`, `math`, `menu`, `render`, `toolbar` +- **Parts**: `button`, `copy-status`, `error`, `math`, `menu`, `render`, `toolbar` ## `` @@ -323,13 +323,13 @@ Source: `components/mermaid/index.ts` - `fullscreenchange` · bubbles, composed · detail `{ open }` - `render` · bubbles, composed · detail `{ ok }` - **Slots**: — -- **Parts**: `button`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` +- **Parts**: `button`, `copy-status`, `diagram`, `error`, `menu`, `mermaid`, `toolbar` ## `` Source: `components/message/index.ts` -- **Attributes**: — +- **Attributes**: `content: string | null`, `sheet: string`, `type: string | null` - **Properties**: `content: string | null`, `sheet: string`, `type: string | null` - **Events**: — - **Slots**: — @@ -364,7 +364,7 @@ Source: `components/modal/index.ts` Source: `components/select/option/index.ts` -- **Attributes**: — +- **Attributes**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Properties**: `disabled: boolean | string | undefined | null`, `sheet`, `value` - **Events**: — - **Slots**: `default` @@ -421,7 +421,7 @@ Source: `components/popover/index.ts` Source: `components/progress/index.ts` -- **Attributes**: `drag`, `primary` +- **Attributes**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Properties**: `dot: string`, `percent: string`, `sheet: string`, `total: string`, `type: string` - **Events**: - `change` · element-only · detail `{ percent, total, value }` @@ -432,7 +432,7 @@ Source: `components/progress/index.ts` Source: `components/radar/index.ts` -- **Attributes**: — +- **Attributes**: `abilitys`, `colorline`, `colorpolygon`, `fillcolor`, `sheet`, `strokecolor` - **Properties**: `abilitys`, `colorLine`, `colorPolygon`, `fillColor`, `fontColor`, `sheet`, `strokeColor` - **Events**: — - **Slots**: — @@ -485,11 +485,11 @@ Source: `components/router/index.ts` Source: `components/scratch/index.ts` -- **Attributes**: `disabled: boolean`, `sheet: string` -- **Properties**: `disabled: boolean`, `sheet: string` +- **Attributes**: `disabled: boolean`, `reveal-label`, `revealed-label`, `sheet: string` +- **Properties**: `disabled: boolean`, `revealedLabel: string`, `revealLabel: string`, `sheet: string` - **Events**: — - **Slots**: `default` -- **Parts**: `award` +- **Parts**: `award`, `reveal-button` ## `` @@ -505,7 +505,7 @@ Source: `components/section/index.ts` Source: `components/select/index.ts` -- **Attributes**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` +- **Attributes**: `defaultvalue`, `disabled: boolean`, `dropdownclass: string`, `getpopupcontainerid`, `label: string`, `open: boolean`, `placeholder: string`, `placement: Placement`, `required: boolean`, `sheet: string`, `showsearch`, `trigger: string`, `type: string`, `value: string` - **Properties**: - `defaultValue: string` - `disabled: boolean` @@ -513,6 +513,7 @@ Source: `components/select/index.ts` - `getPopupContainerId: string` - `label: string` — 字段上方的静态说明文字(label)。 - `open: boolean` — Whether the dropdown is showing. + - `placeholder: string` — Empty-field hint; the field's accessible name remains its label. - `placement: Placement` — Which side of the trigger the panel opens on, with an optional alignment. - `required: boolean` - `sheet: string` @@ -559,7 +560,7 @@ Source: `components/state-dot/index.ts` Source: `components/tabpane/index.ts` -- **Attributes**: — +- **Attributes**: `disabled`, `effect`, `icon`, `iconsize`, `key`, `label`, `sheet` - **Properties**: `disabled`, `effect`, `icon`, `iconSize`, `key`, `label`, `sheet` - **Events**: — - **Slots**: `default` @@ -580,7 +581,7 @@ Source: `components/tab/index.ts` Source: `components/theme-switch/index.ts` -- **Attributes**: `label-dark`, `label-light`, `label-system`, `sheet: string` +- **Attributes**: `label`, `label-dark`, `label-light`, `label-system`, `sheet: string` - **Properties**: - `sheet: string` - `value: RanThemeName` — Current selection; falls back to 'system' when nothing is forced. @@ -627,7 +628,7 @@ Source: `components/voice-button/index.ts` - **Attributes**: `active-label`, `cancel-hint`, `cancelling`, `disabled: boolean`, `hold-hint`, `holding`, `label: string`, `listening: boolean`, `sheet: string` - **Properties**: - - `activeLabel: string` — Accessible name while listening; the name has to change, not only the icon. + - `activeLabel: string` — Accessible cancel/stop name while capture is pending or listening. - `cancelHint: string` — Replaces {@link holdHint} once the finger has slid far enough to discard. - `continuous: boolean` — Keep listening across pauses instead of stopping at the first one. - `disabled: boolean` diff --git a/packages/docs/src/ranui/button/index.md b/packages/docs/src/ranui/button/index.md index 5be2fde980..f33b753d8a 100644 --- a/packages/docs/src/ranui/button/index.md +++ b/packages/docs/src/ranui/button/index.md @@ -51,6 +51,10 @@ Button component for triggering instant actions with multiple styles and states. ### Disabled State `disabled` +Disabled buttons reject pointer, keyboard and programmatic `click()` activation. Re-enabling restores the same control and its accessible name. + +`focus(options?: FocusOptions)` focuses the enabled keyboard surface and forwards native focus options. Overlays can call it to restore their trigger after dismissal; a disabled button does not receive focus. + Primary Button Warning Button diff --git a/packages/docs/src/ranui/card/index.md b/packages/docs/src/ranui/card/index.md index b3aac54281..93c83d3ca7 100644 --- a/packages/docs/src/ranui/card/index.md +++ b/packages/docs/src/ranui/card/index.md @@ -57,7 +57,7 @@ The card heading, shown at the top of the header. Hidden when empty. ### Description `description` -A subtitle rendered below the title. Hidden when empty. When neither `title` nor `description` is set, the whole header is hidden. +A subtitle rendered below the heading. Hidden when empty. The whole header is hidden only when the heading and description are empty and the `extra` slot has no assigned elements. @@ -140,9 +140,9 @@ The card is a passive container and dispatches no custom events. ## Best Practices -- **Title & Description**: Use `title` for the heading and `description` for a short supporting subtitle; omit both to hide the header entirely. +- **Heading & Description**: Use `heading` for the heading and `description` for a short supporting subtitle. The header disappears when both are empty and the `extra` slot is empty. - **Body Content**: Place primary content in the default slot. -- **Header Actions**: Use the `extra` slot for badges, links, or actions aligned to the right of the header. +- **Header Actions**: Use the `extra` slot for badges, links, or actions. It can appear without a heading; narrow cards wrap it below the heading when the row does not fit. - **Footer**: Use the `footer` slot for secondary actions or links; it stays hidden until you slot content into it. - **Hover Feedback**: Add `hoverable` only to cards that are clickable; non-interactive cards must not react to hover. - **Theming**: Prefer CSS variables and `::part()` over the `sheet` attribute for reusable styling. diff --git a/packages/docs/src/ranui/changelog.md b/packages/docs/src/ranui/changelog.md index 031e514e1f..e8a85e8c5f 100644 --- a/packages/docs/src/ranui/changelog.md +++ b/packages/docs/src/ranui/changelog.md @@ -9,6 +9,23 @@ Generated from the repository changelog, `packages/ranui/CHANGELOG.md`. ::: v-pre +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/docs/src/ranui/conversation/index.md b/packages/docs/src/ranui/conversation/index.md index 08beb9d4eb..40627a5b29 100644 --- a/packages/docs/src/ranui/conversation/index.md +++ b/packages/docs/src/ranui/conversation/index.md @@ -82,7 +82,10 @@ chat.addEventListener('pinnedchange', (e) => { }); ``` -`follow="false"` leaves the reader in control from the start; `scrollToBottom()` takes it back. +`follow="false"` disables automatic following, including content/footer resize, from first mount +and after reconnect. `pinned` reports `false` while following is explicitly disabled. Changing +`follow` emits `pinnedchange` when the effective state changes. `scrollToBottom()` explicitly +scrolls to the latest content and sets `follow` back to `true`. For paging in older content, call `captureAnchor()` before the prepend and `restoreAnchor()` after, so the reader keeps looking at what they were looking at. diff --git a/packages/docs/src/ranui/design-guides/index.md b/packages/docs/src/ranui/design-guides/index.md index 7a42afdf1b..43374b0e97 100644 --- a/packages/docs/src/ranui/design-guides/index.md +++ b/packages/docs/src/ranui/design-guides/index.md @@ -148,6 +148,12 @@ content) over your sticky header for its whole scroll lifetime. That bug shipped site once. Match `closing` as well as `open`: the mask keeps painting for the length of its transition after `open` is removed. +Keep one visual language across related components and documentation chrome: coordinated type +roles, control density, radius/inset relationships, semantic accents and motion. Preserve differences +that communicate role or priority. Compare composed states with different real content before adding +effects; a shared color cannot compensate for inconsistent proportions. Source observations and +validation limits live in the [reference index](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/REFERENCE_RESEARCH.md). + ## Motion {#motion} Purpose and frequency decide whether to animate. Frequent hover, focus and keyboard feedback @@ -155,6 +161,12 @@ is immediate by default. Use existing motion tokens only when motion explains a transition may take ~150ms, menus ~200ms and dialogs ~300ms. These are optional examples, not required delays. Keep motion interruptible and respect `prefers-reduced-motion`. +For changed motion, describe its trigger, state and destination. New input may interrupt or +reverse it without stale completion or duplicate activation. Preserve drafts and reading position; +avoid squeezing visible text during insertion/removal. Inspect a continuous action and its return +path at normal speed, then replay discontinuities slowly. Check that reduced motion still leaves a +visible final state. Optional effects should form a consistent language across related components. + **Never let a palette property transition.** CSS cannot tell _why_ a color changed, so a `transition` on `background-color`, `color`, `border-color`, `box-shadow`, `fill` or `stroke` also fires when the **theme** flips, with every element fading at its own pace while the rest @@ -246,3 +258,11 @@ Choose checks for the affected surface and changed behavior; repository CI still - [ ] Spacing from the scale, type by role, color from semantic tokens. - [ ] No palette property in a `transition`; no `transition: all`. - [ ] Copy makes the action and its object clear from context; nothing signals state by color alone. + +## Evidence and acceptance {#evidence-and-acceptance} + +[The acceptance contract](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/ACCEPTANCE.md) +owns actual browser proof, runtime/capture freshness and separate automated, agent and human +results. Changed shared styles require fresh affected evidence; an agent review is not user acceptance. +Avoid unnecessary secondary labels and descriptions: let layout explain the obvious, while retaining +accessible names, useful field labels, units, errors and risks. diff --git a/packages/docs/src/ranui/design-system/index.md b/packages/docs/src/ranui/design-system/index.md index 418d002888..3a40b957ad 100644 --- a/packages/docs/src/ranui/design-system/index.md +++ b/packages/docs/src/ranui/design-system/index.md @@ -207,6 +207,8 @@ flips with the theme on its own. `--ran-color-primary-hover` / `-active` are the two literals in the semantic layer: they step toward the page background rather than along a scale, so dark mode redefines them directly. +`--ran-media-background` is `#000` in both themes. A video canvas stays dark beneath fixed white playback controls; Player derives `--ran-player-background` from it. This media role deliberately does not invert with the page palette. + ### What each accent means - **Primary is monochrome**: black-on-white in light, white-on-black in dark (the Geist brand diff --git a/packages/docs/src/ranui/disclosure-row/index.md b/packages/docs/src/ranui/disclosure-row/index.md index 82e4296e8f..6450494ccc 100644 --- a/packages/docs/src/ranui/disclosure-row/index.md +++ b/packages/docs/src/ranui/disclosure-row/index.md @@ -146,6 +146,10 @@ A collapsed body is clipped rather than removed, so that it can animate. It is a `inert` and its contents are skipped with `content-visibility: hidden`, which keeps it out of the tab order and off the render path while it is closed. +If focus is inside the body when it closes, focus returns to the row before the body becomes +inert, including programmatic and grouped collapse. Focus elsewhere is preserved. With leading +content, keyboard focus swaps its icon for the chevron just like hover, without painting both. + The row is 24px tall, which is the WCAG 2.5.8 minimum exactly, and rows stack with no gap. On a coarse pointer the default height goes to 32px, because the hit area cannot be grown past the row without overlapping the row above it, which trades a small target for a wrong diff --git a/packages/docs/src/ranui/input/index.md b/packages/docs/src/ranui/input/index.md index 28f9577213..f1db24f417 100644 --- a/packages/docs/src/ranui/input/index.md +++ b/packages/docs/src/ranui/input/index.md @@ -55,6 +55,8 @@ faster than inline/floating ones; see [Luke Wroblewski's eye-tracking research]( ``` +The label, control and helper occupy separate rows. When the host has an explicit height, the control fills the space left by its label and helper while retaining its minimum height. + ### Placeholder `placeholder` Consistent with the native `placeholder` attribute. @@ -89,6 +91,8 @@ Consistent with the native `placeholder` attribute. ### Icon `icon` +A leading icon composes with the static `label`. Adding or changing `icon` keeps the visible caption and native label association. + diff --git a/packages/docs/src/ranui/loading/index.md b/packages/docs/src/ranui/loading/index.md index f057659413..1e2b04acd8 100644 --- a/packages/docs/src/ranui/loading/index.md +++ b/packages/docs/src/ranui/loading/index.md @@ -24,10 +24,10 @@ Loading component providing a collection of animated indicators to signal in-pro ### Properties -| Property | Type | Default | Description | -| -------- | -------- | ---------- | ---------------------------------------------------------------------- | -| `name` | `string` | `'circle'` | Animation type. Falls back to `circle` when unset or unrecognized | -| `sheet` | `string` | `''` | CSS text injected into the component's shadow DOM for external styling | +| Property | Type | Default | Description | +| -------- | -------- | ---------- | ----------------------------------------------------------------------------- | +| `name` | `string` | `'circle'` | Animation type. Defaults to `circle` when unset; unknown names render nothing | +| `sheet` | `string` | `''` | CSS text injected into the component's shadow DOM for external styling | ### Loading Types `name` @@ -178,4 +178,6 @@ None. The component dispatches no custom events. - **Sizing**: Prefer `px` units over the default `em` sizing for predictable dimensions. - **Performance**: Avoid rendering many simultaneous animations on one screen. - **On-demand loading**: Each animation is a separate lazy chunk (its JS + CSS), so a `name` only ever loads the one variant it uses; referencing one animation never bundles the other 28. The default `circle` and the common `dot` are built in for instant, flash-free first render; the rest load asynchronously on first use. Usage is unchanged: just set `name`. -- **Theming**: The base colors follow `--ran-color-*` theme tokens, so animations adapt to light and dark modes automatically. +- **Theming**: Theme-aware variants follow `--ran-color-*`; decorative variants expose their own color hooks. + +Loading indicators provide visual feedback; the host should apply suitable `aria-busy` or status announcements to the actual loading region rather than making every animation a live region. Reduced motion retains static visible shapes for circle, circle-fold, cube-fold, dot-bar and triple-bounce; text keeps a readable type size and theme text color. The default geometric frames of cube-dim and dot-line shrink within narrow parents; existing size/color hooks remain available. Decorative variants retain their distinct colors and are not all automatically monochrome-themed. diff --git a/packages/docs/src/ranui/markdown/index.md b/packages/docs/src/ranui/markdown/index.md index f4ce34feff..a6dfd92c7b 100644 --- a/packages/docs/src/ranui/markdown/index.md +++ b/packages/docs/src/ranui/markdown/index.md @@ -64,8 +64,10 @@ on. Set `mode="static"` for finished documents to skip that pass and render in o - **caret**: `caret` shows a blinking `▋`, `caret="circle"` a `●`, after the last block. It hides automatically while a code fence is still open or the last block is a table. -- **incomplete code fences** stay plain (no highlighting flash, no half-rendered diagram) - until the closing fence arrives; the container carries `data-incomplete` meanwhile. +- **incomplete code fences** retain their code viewport as text arrives; diagrams stay code + until the closing fence arrives. The container carries `data-incomplete` meanwhile. +- Code and table updates retain their scroll viewport and unchanged prefix text nodes. + A pending highlight waits while code is selected, then settles after the selection is released. ## Code blocks @@ -83,6 +85,11 @@ load on demand; `github-light` / `github-dark` by default, following the page th ``` +Copy and download use the fenced source body, including tabs, blank lines and its final +newline. Copy success is local to each code block; clipboard rejection shows a local retry +message, overridable with `label-copy-error`. Late clipboard or highlight completion cannot +update content that changed or disconnected. Code and table viewports are keyboard-focusable. + ## Mermaid & math @@ -110,7 +117,7 @@ load on demand; `github-light` / `github-dark` by default, following the page th | `link-target` | `string` | `'_blank'` | `target` for external links (`rel="noopener noreferrer"` added). `_self` leaves links untouched. In-page `#anchors` never get it. | | `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Highlight / diagram theme. `auto` follows the page (`.dark`, `[data-ran-theme]`, else `prefers-color-scheme`). | | `sheet` | `string` | — | Extra CSS injected into the shadow root. | -| `label-*` | `string` | English | Override control labels: `label-copy`, `label-download`. | +| `label-*` | `string` | English | Override control labels: `label-copy`, `label-download`, `label-copy-error` (default: “Could not copy. Try again.”). | Property aliases: `content`, `mode`, `caret`, `copyable`, `downloadable`, `lineNumbers`, `highlight`, `inlineMath`, `linkTarget`, `theme`, `sheet`. @@ -138,6 +145,7 @@ All events bubble and cross the shadow boundary (`composed`). | `code-lang` | The language label. | | `code-actions` | The action-button group. | | `button` | Each copy / download button. | +| `copy-status` | Local clipboard failure feedback. | | `table` | The horizontally scrolling table wrapper. | | `error` | The error box (on render failure). | @@ -156,7 +164,7 @@ Override on the element (each falls back to a semantic token, then a literal): `--ran-markdown-code-radius`, `--ran-markdown-code-font-size`, `--ran-markdown-mono-font`, `--ran-markdown-blockquote-border`, `--ran-markdown-table-border`, `--ran-markdown-table-header-bg`, `--ran-markdown-caret`, `--ran-markdown-caret-color`, -`--ran-markdown-button-color`, `--ran-markdown-error-color`. +`--ran-markdown-button-color`, `--ran-markdown-button-touch-size`, `--ran-markdown-error-color`. ## Notes diff --git a/packages/docs/src/ranui/math/index.md b/packages/docs/src/ranui/math/index.md index ae154ba638..9f8e3a1703 100644 --- a/packages/docs/src/ranui/math/index.md +++ b/packages/docs/src/ranui/math/index.md @@ -35,7 +35,7 @@ Render high-quality LaTeX math formulas in HTML pages using Temml, compiled stra | `download` | `boolean` | `false` | Shows a download button/menu for the source (`.tex`) and/or MathML (`.mml`). | | `sheet` | `string` | `''` | CSS injected into the component's shadow DOM. | -> 💡 **Note**: The `latex` property getter decodes its value with `decodeURIComponent`, so URI-encoded formulas are decoded before rendering. Providing the formula as slotted text content has no effect; only the `latex` attribute is rendered. +> 💡 **Note**: The `latex` property getter decodes its value with `decodeURIComponent`, so URI-encoded formulas are decoded before rendering. When the attribute is absent, the element’s text content supplies the formula. ### Formula `latex` @@ -87,3 +87,9 @@ The full list is in [style tokens](/src/ranui/style-tokens#math); which token to - **Escape backslashes in JavaScript**: When assigning `latex` from a JS string literal, remember that `\` must be escaped (e.g. `'\\frac{1}{2}'`). - **Handle parse failures**: Listen for `error` (or check the rendered `::part(error)` box) rather than assuming every formula is valid LaTeX. - **Custom layout via `sheet`**: Use the `sheet` attribute to override the internal `.ran-math` layout when needed. + +### Copy and narrow screens + +Enabled controls stay visible in a quiet row above the content: 32px targets for fine pointers, 44px for touch, with an 8px gap. Without controls, no row space is reserved. Copy keeps focus and prevents duplicate writes while pending. Clipboard rejection or unavailability appears locally in `::part(copy-status)` (`role="status"`), localized with `label-copy-error`; retry uses the same button. `copied` fires only after the current content reaches the clipboard. A source or copy-target change invalidates feedback from older requests. A source-only update preserves the focused button and allows copying the new source immediately. + +Wide equations scroll horizontally inside `::part(render)` without widening the page or moving the toolbar. diff --git a/packages/docs/src/ranui/mermaid/index.md b/packages/docs/src/ranui/mermaid/index.md index 8d28ac86ff..5f93a81247 100644 --- a/packages/docs/src/ranui/mermaid/index.md +++ b/packages/docs/src/ranui/mermaid/index.md @@ -38,7 +38,7 @@ el.code = 'classDiagram\n Dog --|> Animal'; // property setter URI-encodes for ## Controls Every control is **opt-in** via a boolean attribute; a bare `` is a clean static -diagram. The toolbar appears on hover (top-right). +diagram. Enabled controls remain visible above the diagram. graph TD; A[Start] --> B[Do work]; B --> C[End] @@ -86,7 +86,7 @@ All events bubble and cross the shadow boundary (`composed`). | --------- | ------------------------------------------ | | `mermaid` | The outer wrapper. | | `diagram` | The rendered-diagram container. | -| `toolbar` | The hover control bar. | +| `toolbar` | The visible action row. | | `button` | Each toolbar icon button. | | `error` | The error message box (on render failure). | @@ -112,3 +112,7 @@ Override on the element (each falls back to a semantic token, then a literal): - **PNG export**: diagrams that use HTML labels (mermaid `htmlLabels`) render via ``, which can taint the canvas and make PNG export fail; an `error` event is dispatched in that case. SVG and source export always work. + +### Copy and narrow screens + +Enabled controls stay visible in a quiet row above the content: 32px targets for fine pointers, 44px for touch, with an 8px gap. Without controls, no row space is reserved. Copy keeps focus and prevents duplicate writes while pending. Clipboard rejection or unavailability appears locally in `::part(copy-status)` (`role="status"`), localized with `label-copy-error`; retry uses the same button. `copied` fires only after the current content reaches the clipboard. A source or copy-target change invalidates feedback from older requests. A source-only update preserves the focused button and allows copying the new source immediately. diff --git a/packages/docs/src/ranui/popover/index.md b/packages/docs/src/ranui/popover/index.md index 7a4ca59b92..cb680d29d6 100644 --- a/packages/docs/src/ranui/popover/index.md +++ b/packages/docs/src/ranui/popover/index.md @@ -220,6 +220,12 @@ It is otherwise driven by standard DOM interaction: - **Open**: `mouseenter` (when `trigger` includes `hover`), `click`, or pressing `Enter` / `Space` while focused. - **Close**: `mouseleave` (hover mode), pressing `Escape`, or a `click` elsewhere in the document. +Escape also works from interactive content in the portaled panel and returns focus to its +opening trigger. A nested popup handles its own Escape first; an already handled Escape or +IME composition does not dismiss the parent. An immediate close cancels queued entrance +work, including before the first painted frame. Tab order and modal focus trapping remain +the composed interface's responsibility; a popover is not a modal dialog. + Internally, the companion `` element watches its own subtree with a `MutationObserver` and emits a `change` `CustomEvent` (`detail: { type, value: { content, mutation } }`) that the popover consumes to keep the panel in sync. This is an implementation detail rather than a public API. Accessibility is wired automatically: the host receives `tabindex="0"`, `aria-haspopup="dialog"`, and an `aria-expanded` that toggles between `"false"` and `"true"` as the panel opens and closes. diff --git a/packages/docs/src/ranui/scratch/index.md b/packages/docs/src/ranui/scratch/index.md index 7cd34b7cd9..3c9aa9296a 100644 --- a/packages/docs/src/ranui/scratch/index.md +++ b/packages/docs/src/ranui/scratch/index.md @@ -4,7 +4,7 @@ description: 'An experimental scratch-ticket surface where dragging a canvas cov # Scratch -Experimental scratch-ticket surface that renders a full-size `` cover over a reveal layer inside its shadow DOM. Dragging across the canvas erases the covering with `destination-out` compositing along the actual path your pointer traces, and scratching away enough area reveals what sits underneath. The host is `display: block`, so give it an explicit width and height. +Experimental scratch-ticket surface that renders a `` cover over a reveal layer, with a separate quiet action row above the surface inside its shadow DOM. Dragging across the canvas erases the covering with `destination-out` compositing along the actual path your pointer traces, and scratching away enough area reveals what sits underneath. The host is `display: block`, so give it an explicit width and height. Its height includes the action row (32px minimum, 44px on coarse pointers) and an 8px gap; the remaining height belongs to the canvas and reward. The completion button never overlays reward content. > **Use when** you need an experimental scratch-ticket surface where dragging erases a cover canvas to reveal arbitrary content beneath. Works with mouse, touch, and pen alike via the Pointer Events API. @@ -28,10 +28,14 @@ Whatever you put inside `` is the reveal content (an amount, an image ### Properties -| Property | Type | Default | Description | -| ---------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | `false` | Disables scratch interaction (`pointer-events: none` on the cover canvas, plus a guard in the pointer handlers). | -| `sheet` | `string` | `''` | CSS injected into the component's shadow DOM. | +| Property | Type | Default | Description | +| ---------------- | --------- | ---------- | -------------------------------------------------------------------------------------------- | +| `disabled` | `boolean` | `false` | Disables pointer scratching and the native Reveal action while preserving existing progress. | +| `sheet` | `string` | `''` | CSS injected into the component's shadow DOM. | +| `reveal-label` | `string` | `Reveal` | Localized reveal action text. | +| `revealed-label` | `string` | `Revealed` | Localized completion button and status text. | + +Keyboard users can focus the native Reveal button and press Enter or Space for a complete reveal. Localize it with `reveal-label` (default `Reveal`) and `revealed-label` (default `Revealed`). Until revealed, the default slot is inert so covered links cannot receive early Tab focus. Completion restores content interaction and announces status; the same button retains its current focus while leaving subsequent Tab order, so the next Tab can enter the revealed content. Disabling, cancelling a gesture and reconnecting at the same dimensions preserve erased pixels; an actual resize still resets the cover. If revealed content owns focus during that reset, focus returns to Reveal without scrolling; focus outside this component stays unchanged. ### Disabled State `disabled` @@ -64,7 +68,7 @@ The component does **not** dispatch any custom events: there is nothing to bind - `pointerdown`: arms scratching and erases a small dab right where the pointer landed (so even a tap without a drag reveals something). - `pointermove`: while armed, strokes a **connected line** (not isolated dabs) from the previous point to the current one with `globalCompositeOperation = 'destination-out'`, so a fast drag reveals a continuous trail instead of a dotted one, and accumulates the scratched area as it goes. -- `pointerup` / `pointercancel`: disarms scratching; once the accumulated area exceeds **35% of the canvas pixel area**, the whole cover is cleared with `clearRect`, fully revealing the layer beneath (a deliberately generous "scratch a bit, then it finishes itself" threshold, a common scratch-card UX rather than requiring the entire cover to be manually cleared). +- `pointerup`: disarms scratching; once the accumulated area exceeds **35% of the canvas pixel area**, the whole cover is cleared with `clearRect`, fully revealing the layer beneath (a deliberately generous "scratch a bit, then it finishes itself" threshold, a common scratch-card UX rather than requiring the entire cover to be manually cleared). Pointer coordinates are mapped through the canvas's actual drawing-buffer resolution (see below), so scratching tracks correctly under your finger/cursor regardless of the element's CSS size or the screen's device pixel ratio. All handlers no-op while `disabled` is set, and `touch-action: none` on the canvas keeps a touch drag from also scrolling the page. diff --git a/packages/docs/src/ranui/select/index.md b/packages/docs/src/ranui/select/index.md index 09c54aec6d..43e8b78f98 100644 --- a/packages/docs/src/ranui/select/index.md +++ b/packages/docs/src/ranui/select/index.md @@ -8,6 +8,8 @@ Dropdown selector for choosing a single value from a list of options, with optio > **Use when** you need a single-value dropdown selector built from `` children, with optional search and native form participation. `` handles opening, filtering, and `FormData` reporting. +Use `placeholder="Pick a teammate"` for the empty-field hint and `label="Teammate"` for its accessible name. Changing the hint preserves the selected value; clearing `value` restores the hint. Placeholder text is not a field label. + ## Quick Start ### Basic Usage @@ -187,7 +189,7 @@ Note that the boundary shift outranks the alignment: a trigger close enough to a ### Open State `open` -`open` is the dropdown's state, reflected as an attribute the way `
` and `` are. Nothing infers the state from the panel's `display` (that trails the state by the length of the exit animation), so the attribute, `aria-expanded`, and what is on screen cannot disagree. +`open` is the dropdown's interaction state, reflected like `
` and ``. It stays aligned with `aria-expanded` even while a closed panel's exit animation is still visible. After selecting an option, Arrow keys, Home/End, Enter, Space or typeahead can reopen immediately; the earlier exit cannot hide the reopened panel. That makes it a supported way to drive the component, and something to style and to assert against: diff --git a/packages/docs/src/ranui/style-tokens.md b/packages/docs/src/ranui/style-tokens.md index 714ba3ea82..afff218fa5 100644 --- a/packages/docs/src/ranui/style-tokens.md +++ b/packages/docs/src/ranui/style-tokens.md @@ -65,11 +65,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `button` -- Tokens: 65 +- Tokens: 66 - Parts: 2 ### Tokens - + - `--ran-btn-active-background` - `--ran-btn-active-border-color` - `--ran-btn-active-transform` @@ -117,6 +117,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-color-border` - `--ran-color-border-hover` - `--ran-color-danger` +- `--ran-color-link` - `--ran-color-primary` - `--ran-color-primary-active` - `--ran-color-primary-hover` @@ -189,11 +190,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `checkbox` -- Tokens: 47 +- Tokens: 48 - Parts: 5 ### Tokens - + - `--ran-checkbox-border-radius` - `--ran-checkbox-checked-background-color` - `--ran-checkbox-checked-background-image` @@ -231,6 +232,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-primary` +- `--ran-color-primary-text` - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-focus-ring` @@ -369,11 +371,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `dropdown` -- Tokens: 27 +- Tokens: 26 - Parts: 1 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-text` @@ -400,7 +402,6 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-skin-font-family` - `--ran-text-label-1` - `--ran-text-label-weight` -- `--ran-z-dropdown` ### Parts - `dropdown` @@ -594,14 +595,16 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `loading/variants/arc` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-4` +- `--ran-text-copy-weight` ### Parts - `arc` @@ -636,14 +639,16 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `loading/variants/circle-line` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + +- `--ran-color-bg` - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `circle-line` @@ -762,14 +767,15 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `loading/variants/dot-bar` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-bar` @@ -790,14 +796,15 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `loading/variants/dot-line` -- Tokens: 3 +- Tokens: 4 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-color-text-secondary` ### Parts - `dot-line` @@ -832,14 +839,16 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `loading/variants/drop` -- Tokens: 3 +- Tokens: 5 - Parts: 1 ### Tokens - + - `--ran-color-primary` - `--ran-color-success` - `--ran-color-text` +- `--ran-space-2` +- `--ran-space-8` ### Parts - `drop` @@ -977,10 +986,10 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - Parts: 1 ### Tokens - -- `--ran-color-primary` -- `--ran-color-success` + - `--ran-color-text` +- `--ran-font-family` +- `--ran-text-label-1` ### Parts - `text` @@ -1001,11 +1010,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `markdown` -- Tokens: 78 +- Tokens: 76 - Parts: 4 ### Tokens - + - `--ran-color-bg-muted` - `--ran-color-border` - `--ran-color-border-secondary` @@ -1015,7 +1024,6 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-color-text` - `--ran-color-text-disabled` - `--ran-color-text-secondary` -- `--ran-focus-ring` - `--ran-font-family` - `--ran-font-mono` - `--ran-gray-alpha-200` @@ -1026,6 +1034,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-markdown-button-hover-background` - `--ran-markdown-button-hover-color` - `--ran-markdown-button-size` +- `--ran-markdown-button-touch-size` - `--ran-markdown-caret` - `--ran-markdown-caret-color` - `--ran-markdown-code-bg` @@ -1062,10 +1071,8 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-markdown-table-header-bg` - `--ran-markdown-table-header-color` - `--ran-markdown-table-radius` -- `--ran-radius-md` - `--ran-radius-sm` - `--ran-red-100` -- `--ran-size-4` - `--ran-size-7` - `--ran-skin-border-style` - `--ran-space-1` @@ -1093,11 +1100,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `math` -- Tokens: 34 -- Parts: 6 +- Tokens: 33 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` - `--ran-color-danger` @@ -1125,7 +1132,6 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1135,6 +1141,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ### Parts - `button` +- `copy-status` - `error` - `math` - `menu` @@ -1144,12 +1151,13 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `mermaid` - Tokens: 27 -- Parts: 6 +- Parts: 7 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-bg-muted` +- `--ran-color-danger` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-secondary` @@ -1169,7 +1177,6 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-radius-md` - `--ran-radius-sm` - `--ran-shadow-menu` -- `--ran-size-5` - `--ran-space-1` - `--ran-space-2` - `--ran-space-3` @@ -1178,6 +1185,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ### Parts - `button` +- `copy-status` - `diagram` - `error` - `menu` @@ -1304,11 +1312,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `player` -- Tokens: 167 +- Tokens: 169 - Parts: 0 ### Tokens - + - `--ran-color-` - `--ran-color-border` - `--ran-color-primary` @@ -1323,6 +1331,8 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-dropdown-option-item-content-text-align` - `--ran-dropdown-option-item-hover-background-color` - `--ran-focus-ring-inverse-color` +- `--ran-media-background` +- `--ran-player-background` - `--ran-player-clarity-border-radius` - `--ran-player-clarity-color` - `--ran-player-clarity-font-family` @@ -1607,19 +1617,35 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `scratch` -- Tokens: 5 -- Parts: 1 +- Tokens: 20 +- Parts: 2 ### Tokens - + +- `--ran-background-100` +- `--ran-color-bg` - `--ran-color-bg-elevated` +- `--ran-color-bg-hover` +- `--ran-color-border` +- `--ran-color-link` +- `--ran-color-text` - `--ran-color-text-secondary` +- `--ran-gray-1000` +- `--ran-gray-200` +- `--ran-gray-400` +- `--ran-gray-900` +- `--ran-radius-sm` - `--ran-scratch-award-background` - `--ran-scratch-cover-background` - `--ran-scratch-disabled-opacity` +- `--ran-size-7` +- `--ran-space-2` +- `--ran-space-3` +- `--ran-text-button-size` ### Parts - `award` +- `reveal-button` ## `section` @@ -1661,16 +1687,17 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `select` -- Tokens: 62 +- Tokens: 63 - Parts: 6 ### Tokens - + - `--ran-color-bg-elevated` - `--ran-color-border` - `--ran-color-primary` - `--ran-color-text` - `--ran-color-text-disabled` +- `--ran-color-text-secondary` - `--ran-focus-ring` - `--ran-radius-sm` - `--ran-select-background-color` @@ -1739,13 +1766,14 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `select/dropdown-item` -- Tokens: 19 +- Tokens: 20 - Parts: 2 ### Tokens - + - `--ran-blue-100` - `--ran-color-text` +- `--ran-color-text-disabled` - `--ran-dropdown-option-active-background-color` - `--ran-dropdown-option-active-font-weight` - `--ran-dropdown-option-active-hover-background-color` @@ -1867,11 +1895,11 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami ## `theme-switch` -- Tokens: 21 +- Tokens: 22 - Parts: 2 ### Tokens - + - `--ran-color-bg-hover` - `--ran-color-border` - `--ran-color-primary` @@ -1893,6 +1921,7 @@ once. The vocabulary is the [design system](/src/ranui/design-system/); the nami - `--ran-theme-switch-gap` - `--ran-theme-switch-icon-size` - `--ran-theme-switch-radius` +- `--ran-theme-switch-touch-size` ### Parts - `button ${choice}` diff --git a/packages/docs/src/ranui/tab/index.md b/packages/docs/src/ranui/tab/index.md index 2328718d7e..003334a60d 100644 --- a/packages/docs/src/ranui/tab/index.md +++ b/packages/docs/src/ranui/tab/index.md @@ -302,3 +302,5 @@ The full list is in [style tokens](/src/ranui/style-tokens#tab); which token to - **Alignment**: Use `align="center"` or `align="end"` to reposition the header row within wide containers. - **Disabled panes**: Mark unavailable panes with `disabled`; they are skipped for both clicks and default selection. - **Keyboard navigation**: The header row is a WAI-ARIA tablist: arrow keys move between tabs (with `Home`/`End`), and only the active tab is in the tab order. + +- **Long labels**: The header scrolls horizontally instead of clipping labels. Keyboard selection reveals the active header without changing the page scroll position. diff --git a/packages/docs/src/ranui/theme-switch/index.md b/packages/docs/src/ranui/theme-switch/index.md index 8e618dd4ec..0e5bfe5608 100644 --- a/packages/docs/src/ranui/theme-switch/index.md +++ b/packages/docs/src/ranui/theme-switch/index.md @@ -93,6 +93,9 @@ toolbar.append(themeSwitch); background so the browser/PWA chrome matches; choosing `system` restores each meta's original (possibly media-qualified) content. +The group and button localization labels also update when their attributes change after mounting. +Choice buttons retain a compact 28px size for fine pointers and a minimum 44px target for coarse pointers. + ## CSS Parts | Part | Description | diff --git a/packages/docs/src/ranui/voice-button/index.md b/packages/docs/src/ranui/voice-button/index.md index 6d574888d7..fa5ffce63a 100644 --- a/packages/docs/src/ranui/voice-button/index.md +++ b/packages/docs/src/ranui/voice-button/index.md @@ -77,10 +77,15 @@ them would show an error after every ordinary capture, not just real failures. ### Accessibility The accessible **name changes with the state**, not only the icon, and `aria-pressed` -carries the toggle: a screen reader announces "Stop voice input, pressed", not an icon. +carries the active capture intent: a screen reader announces "Stop voice input, pressed", not an icon. **Escape discards** a capture rather than committing it, which is what a speaker who realises mid-sentence that they said the wrong thing wants. +While microphone permission is pending, the button uses the existing overridable +`active-label` as its cancel action, sets `aria-busy="true"`, and accepts Escape. +`listening` stays false until the platform reports that recording actually started; +the recording ring therefore does not appear during permission waiting. + The listening state is carried by border, fill _and_ a ring, so it does not rely on colour alone. The ring is the only motion and it is decoration; `prefers-reduced-motion` drops it without losing information. @@ -100,7 +105,7 @@ locale mid-session dictates in the language it is showing. | `continuous` | `boolean` | `true` | Keep listening across pauses instead of stopping at the first. | | `disabled` | `boolean` | `false` | Disables the button: `start()` is ignored and the inner button is disabled. Does not stop a capture already in progress. | | `label` | `string` | `'Start voice input'` | Accessible name while idle. | -| `activeLabel` | `string` | `'Stop voice input'` | Accessible name while listening. | +| `activeLabel` | `string` | `'Stop voice input'` | Accessible cancel/stop name while permission is pending or capture is listening. | | `listening` | `boolean` | `false` | Read-only, reflected: style with `:host([listening])`. | | `supported` | `boolean` | — | Read-only. Whether this platform can recognize speech. | | `sheet` | `string` | `''` | CSS injected into the element's shadow DOM. | diff --git a/packages/docs/styles/home.css b/packages/docs/styles/home.css index 70342a5895..2c6a9a3ef1 100644 --- a/packages/docs/styles/home.css +++ b/packages/docs/styles/home.css @@ -148,6 +148,12 @@ letter-spacing: -0.035em; text-wrap: balance; } +/* CJK fallback fonts need a taller line box than the Latin display face. */ +.headline:lang(zh), +.headline:lang(ja), +.headline:lang(ko) { + line-height: 1.2; +} .headline .line { display: block; } @@ -155,25 +161,16 @@ display: inline-block; overflow: hidden; vertical-align: top; - padding-bottom: 0.16em; + /* Preserve the total allowance while accommodating ascenders as well as descenders. */ + padding-block: 0.08em; margin-right: 0.26em; } .headline .word-in { display: inline-block; - transform: translateY(115%); - opacity: 0; background: linear-gradient(180deg, var(--ink) 46%, color-mix(in srgb, var(--ink) 55%, transparent) 130%); -webkit-background-clip: text; background-clip: text; color: transparent; - animation: word-rise 0.8s cubic-bezier(0.16, 0.84, 0.24, 1) forwards; - animation-delay: var(--hd, 0ms); -} -@keyframes word-rise { - to { - transform: translateY(0); - opacity: 1; - } } .subtitle { margin: 24px 0 0; @@ -183,20 +180,6 @@ color: var(--fg-muted); } -/* hero copy blocks: fade-up with staggered delay */ -[data-hero] { - opacity: 0; - transform: translateY(18px); - animation: hero-in 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; - animation-delay: var(--hd, 0ms); -} -@keyframes hero-in { - to { - opacity: 1; - transform: none; - } -} - .cmd { margin: 28px 0 0; display: flex; @@ -407,33 +390,6 @@ color: var(--fg-faint); } -/* ---------- scroll 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. Only the observer's - * ready marker may enable hiding; the head bootstrap cannot prove the client loaded. - */ -.cine[data-reveal-ready] .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); -} -.cine[data-reveal-ready] .reveal.in { - opacity: 1; - transform: none; -} -.reveal .stat, -.reveal.pillar, -.reveal .caps-list li, -.reveal .feature { - transition-delay: var(--rd, 0ms); -} - /* ---------- stats ---------- */ .stats { margin-top: clamp(64px, 9vw, 104px); @@ -845,20 +801,6 @@ .live-dot { animation: none; } - .word-in { - animation: none; - transform: none; - opacity: 1; - } - [data-hero] { - animation: none; - opacity: 1; - transform: none; - } - .reveal { - opacity: 1; - transform: none; - } .live-skeleton span { animation: none; } diff --git a/packages/docs/test/browser/layout-audit.spec.ts b/packages/docs/test/browser/layout-audit.spec.ts index 06d7cac42c..ff5d78bd61 100644 --- a/packages/docs/test/browser/layout-audit.spec.ts +++ b/packages/docs/test/browser/layout-audit.spec.ts @@ -29,9 +29,27 @@ test.describe('native global navigation', () => { await expect(toggle).toBeChecked(); const navigation = page.locator('.sidebar .mobile-nav'); await expect(navigation).toBeVisible(); - await expect(navigation.locator('a')).toHaveCount(4); - for (const link of await navigation.locator('a').all()) await expect(link).toBeInViewport(); + const articlePrefix = ['', '/cn'].includes(prefix) ? prefix : ''; + const destinations = [ + `${prefix}/src/design/`, + `${prefix}/src/ranui/`, + `${prefix}/src/ranuts/`, + `${articlePrefix}/src/article/doc_preview`, + `${prefix}/src/agents/`, + 'https://edit.chaxus.com/', + ]; + expect( + await navigation.locator('a').evaluateAll((links) => links.map((link) => link.getAttribute('href'))), + ).toEqual(destinations); + await navigation.locator('a').first().focus(); + for (const destination of destinations) { + const link = navigation.locator(`a[href="${destination}"]`); + await expect(link).toBeFocused(); + await expect(link).toBeInViewport(); + await page.keyboard.press('Tab'); + } expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + await toggle.focus(); await page.keyboard.press('Space'); await expect(toggle).not.toBeChecked(); await language.focus(); @@ -112,3 +130,31 @@ test('a failed client request leaves the complete landing content readable', asy await page.keyboard.press('Space'); await expect(page.locator('.sidebar .mobile-nav')).toBeVisible(); }); + +for (const motion of ['reduce', 'no-preference'] as const) { + test(`headline remains readable without decorative entrance motion (${motion})`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: motion }); + await page.setViewportSize({ width: 1280, height: 800 }); + for (const path of ['/', '/cn/', '/fa/']) { + await page.goto(path); + const words = await page.locator('.headline .word-in').evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + const range = document.createRange(); + range.selectNodeContents(element); + const text = range.getBoundingClientRect(); + const clip = element.parentElement!.getBoundingClientRect(); + return { + animation: style.animationName, + opacity: style.opacity, + transform: style.transform, + visible: text.width > 0 && text.height > 0 && text.top >= clip.top - 1 && text.bottom <= clip.bottom + 1, + }; + }), + ); + expect(words.length).toBeGreaterThan(0); + for (const word of words) + expect(word).toEqual({ animation: 'none', opacity: '1', transform: 'none', visible: true }); + } + }); +} diff --git a/packages/docs/test/evidence/2026-10-23-product-site/README.md b/packages/docs/test/evidence/2026-10-23-product-site/README.md new file mode 100644 index 0000000000..c6b609773a --- /dev/null +++ b/packages/docs/test/evidence/2026-10-23-product-site/README.md @@ -0,0 +1,25 @@ +# Product website review — 2026-10-23 + +Eight actual built-page screenshots and two continuous recordings cover the new Design/Agents +entry points and homepage at 1280, 900 and 360 CSS pixels, light/dark, English/Chinese/Persian. +The manifest identifies Chromium and hashes each media file and loaded document/style/script/font. +This is browser automation and agent visual review, not a human study or Document acceptance. + +The product website remains at ran.chaxus.com, with existing /src/ route conventions. Clean-route +migration and a complete example gallery are pending; this evidence does not claim them complete. + +The review rejected repeated headline/hero/scroll entrances and numeric count-up because they +interrupt reading after navigation. Those decorations were removed; real loading demonstrations +still demonstrate loading. Normal-motion and reduced-motion recordings show keyboard navigation, +theme changes, return navigation and the narrow drawer closing with focus restored. +Static review found no clipping/overflow in these states. The CJK heading line height accounts +for fallback font metrics rather than accepting a clipped text range. + +Verification: production build 1,337 pages with link/anchor/canonical/asset/sitemap verification; +154 docs unit tests; 63 Chromium/Firefox/WebKit layout/navigation cases, no retries; types and +localization checks. A normal-motion headline assertion failed against the old build, then passed +against the implementation. Existing assertions were not weakened. + +Rejected local iterations retain the too-short drawer recording and decorative-motion capture. +The current videos include readable result dwell and a stable closed-drawer ending. This review +is scoped to the listed pages and states, not all website pages or all component capabilities. diff --git a/packages/docs/test/evidence/2026-10-23-product-site/agents-cn.png b/packages/docs/test/evidence/2026-10-23-product-site/agents-cn.png new file mode 100644 index 0000000000..d1600e8d7c Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/agents-cn.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/agents-wide.png b/packages/docs/test/evidence/2026-10-23-product-site/agents-wide.png new file mode 100644 index 0000000000..8ff7302c5e Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/agents-wide.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/design-rtl.png b/packages/docs/test/evidence/2026-10-23-product-site/design-rtl.png new file mode 100644 index 0000000000..0519127c57 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/design-rtl.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/design-wide.png b/packages/docs/test/evidence/2026-10-23-product-site/design-wide.png new file mode 100644 index 0000000000..036be3f9d1 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/design-wide.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/home-cn.png b/packages/docs/test/evidence/2026-10-23-product-site/home-cn.png new file mode 100644 index 0000000000..79a6c83ae2 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/home-cn.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/home-medium.png b/packages/docs/test/evidence/2026-10-23-product-site/home-medium.png new file mode 100644 index 0000000000..c430f27f7b Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/home-medium.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/home-rtl.png b/packages/docs/test/evidence/2026-10-23-product-site/home-rtl.png new file mode 100644 index 0000000000..36adaf7ea0 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/home-rtl.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/home-wide.png b/packages/docs/test/evidence/2026-10-23-product-site/home-wide.png new file mode 100644 index 0000000000..8b8772c577 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/home-wide.png differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/manifest.json b/packages/docs/test/evidence/2026-10-23-product-site/manifest.json new file mode 100644 index 0000000000..c4df6e9264 --- /dev/null +++ b/packages/docs/test/evidence/2026-10-23-product-site/manifest.json @@ -0,0 +1,257 @@ +{ + "browser": "151.0.7922.34", + "builtResources": { + "/": "1622bbeabb74a6ba70f6523f1e63b3a95f15c52b5b3b2f4d2c1d03443ae4d24f", + "/assets/a11y-ZSlIUH9B-CUY7Y1Pq.js": "dc188b3626497ccfe93875021ce96ca74ad02dc4c4f1fd84cf59294d68e4b629", + "/assets/button-D-C_6yVg.js": "10b78ca5965bafcfca47743b05593ffdf62308491acd51f4c5c658f96993d872", + "/assets/checkbox-COk0-sbp.js": "859da73e7f90428d132c952bf698eada70b55ce7f8c769444635d94029ff7ef6", + "/assets/circle-line-yVEnHOHI-Cixf5vtF.js": "2775431cab96192f8e6555084633901fd88d4963de6e2ce6870a16d7f8a3e245", + "/assets/device-BOlBFrLy.js": "b5922be34ee5a20a3757b9ee7ece74bf28a6cb13dc5cb37e60e98ef50561e9f9", + "/assets/docs.BASKAuWv.js": "88b849cd7064dd1448312553a6789a9658abf2d72c75a8d1e2bf98d88cc32d80", + "/assets/dom-CAbnYqPn-Bym6hqUt.js": "2094a7d02a162dded9939ea8addc15555fb8121f53c0d894023382b77edc2498", + "/assets/loading-sy3gto2W.js": "8a64fee19e7782656af5dc3461130a7bc7bd5d35c4a5972bdc10b460f0d604f7", + "/assets/number-_lj8_wBv.js": "4eaf9e8a600d590ab3ec95d7b67ba1bcac2a0cb11fcedab5796e5ceb9c8588b0", + "/assets/progress-C9czd53_.js": "1bc7ba0fe5d52bba14918458878192e7e2850749b7450bfd431adfbedb71d2eb", + "/assets/progress-F98vpLKq-COk0Kdvh.js": "15011c1244423310a066c4b4f69ae7dc311f631d91c90d3f6a9a15d6786926ff", + "/assets/rolldown-runtime-DK3Fl9T5.js": "a526d1bb3288ef04d001028843220a24b0867a4edf8c685b69506d6525af36ff", + "/assets/ssr-Dp7Ga_qy-DarS3i1S.js": "0099442bef3916c41c7ad3cf1e8e343ffb49866eec69388f87f7580af205c9cd", + "/assets/style.CMaDS7xg.css": "7c3338b9fd5a88df7a79a0d08fbbf963fa690d49ab5cda18f1f84a691ff9e102", + "/assets/theme-switch-DmdVX5Ha.js": "5f1e5527a0827de1ceb7870f43e4c33be022cb84faf232b05686c852a8073a8c", + "/assets/utils-Bleb5QYE-ugYmJC3k.js": "b7cc02df63dd04388e773a1ab27346f878653b2a90d7110402fe7fe0462590c9", + "/cn/": "2f5f1d8742f5b7ea495e7e127ee733315c40f9df8994ed09dd34f5509d919bc3", + "/cn/src/agents/": "3a74347c1cbda79bc7c4505aa869740ec633d012078c98a6f6e5d6ecf01ade09", + "/fa/": "1187793499ad685a31c9a9f8de3de8c2b2fd4bbad156b82764ea08d79963c0b1", + "/fa/src/design/": "bc1d1a4d3d8ae751bf9e0ccdbacaeeb8bf7c2b203dab74a1a1312616db30b284", + "/src/agents/": "10230237af34049aedb5c8176dd5bd9ed65342c14189786ccdcca7f82df3fdf1", + "/src/design/": "ca49c470068583acddf73e3d5833fdfda06fc14e458ee7607d57d6381607caf5" + }, + "records": [ + { + "kind": "static", + "path": "/", + "width": 1280, + "theme": "light", + "motion": "reduce", + "geometry": { + "width": 1280, + "scroll": 1280, + "h1": "Designmethods,componentsandtools", + "heading": { + "x": 80, + "y": 184.75, + "width": 587.84375, + "height": 128.9375, + "top": 184.75, + "right": 667.84375, + "bottom": 313.6875, + "left": 80 + } + }, + "file": "home-wide.png", + "sha256": "9d4f77189688a40ff29f93d34aecc7ae91e35a0fe50d730b12788962d03a3f77" + }, + { + "kind": "static", + "path": "/", + "width": 900, + "theme": "light", + "motion": "reduce", + "geometry": { + "width": 900, + "scroll": 900, + "h1": "Designmethods,componentsandtools", + "heading": { + "x": 40, + "y": 165.75, + "width": 820, + "height": 46.84375, + "top": 165.75, + "right": 860, + "bottom": 212.59375, + "left": 40 + } + }, + "file": "home-medium.png", + "sha256": "c0deb59021280e9ad1e7ee02fe1af1112b9b9ba249e76869291b771bd357036d" + }, + { + "kind": "static", + "path": "/cn/", + "width": 360, + "theme": "dark", + "motion": "reduce", + "geometry": { + "width": 360, + "scroll": 360, + "h1": "设计方法、组件与工具", + "heading": { + "x": 40, + "y": 152.75, + "width": 280, + "height": 81.875, + "top": 152.75, + "right": 320, + "bottom": 234.625, + "left": 40 + } + }, + "file": "home-cn.png", + "sha256": "9c52740438bd6898fefe2724ca8a6abac12f2585d6f5a68df4208a7bc75bce49" + }, + { + "kind": "static", + "path": "/fa/", + "width": 360, + "theme": "light", + "motion": "reduce", + "geometry": { + "width": 360, + "scroll": 360, + "h1": "روش‌های طراحی، مؤلفه‌ها و ابزارها", + "heading": { + "x": 40, + "y": 152.75, + "width": 280, + "height": 74.1875, + "top": 152.75, + "right": 320, + "bottom": 226.9375, + "left": 40 + } + }, + "file": "home-rtl.png", + "sha256": "df3ebc5590259f75a07a5b654705ebd1955c94a88f06cd3a09d6e77b4ca9630f" + }, + { + "kind": "static", + "path": "/src/design/", + "width": 1280, + "theme": "light", + "motion": "reduce", + "geometry": { + "width": 1280, + "scroll": 1280, + "h1": "Ran Design", + "heading": { + "x": 24, + "y": 92, + "width": 1232, + "height": 40, + "top": 92, + "right": 1256, + "bottom": 132, + "left": 24 + } + }, + "file": "design-wide.png", + "sha256": "6fb248fd6d7b521af74c125974821c131b6d7d1ba65bd90590b64319395ccb86" + }, + { + "kind": "static", + "path": "/src/agents/", + "width": 1280, + "theme": "dark", + "motion": "reduce", + "geometry": { + "width": 1280, + "scroll": 1280, + "h1": "Agents", + "heading": { + "x": 24, + "y": 92, + "width": 968, + "height": 40, + "top": 92, + "right": 992, + "bottom": 132, + "left": 24 + } + }, + "file": "agents-wide.png", + "sha256": "80a6649db7a37e02c8349618feb7e67319983b73679f7fdd15045a0c7f0a3f5a" + }, + { + "kind": "static", + "path": "/cn/src/agents/", + "width": 360, + "theme": "dark", + "motion": "reduce", + "geometry": { + "width": 360, + "scroll": 360, + "h1": "Agents", + "heading": { + "x": 16, + "y": 169, + "width": 328, + "height": 30, + "top": 169, + "right": 344, + "bottom": 199, + "left": 16 + } + }, + "file": "agents-cn.png", + "sha256": "7db50b73d744c4daf4e01a93a2ee5176a366056a73b8d5709f298182a035400b" + }, + { + "kind": "static", + "path": "/fa/src/design/", + "width": 360, + "theme": "light", + "motion": "reduce", + "geometry": { + "width": 360, + "scroll": 360, + "h1": "Ran Design", + "heading": { + "x": 16, + "y": 92, + "width": 328, + "height": 30, + "top": 92, + "right": 344, + "bottom": 122, + "left": 16 + } + }, + "file": "design-rtl.png", + "sha256": "32511cb27db413b2c6dc74598ad8c8c43e57e9d1723c688ed2d0f3714526ec0c" + }, + { + "kind": "continuous", + "width": 1280, + "motion": "no-preference", + "steps": [ + "home", + "keyboard Design", + "home", + "keyboard Agents", + "dark", + "light", + "home" + ], + "file": "video/wide-motion.webm", + "sha256": "313c651ee3918e749135ab5656d4c3630528692fb3ef8fcc6ade2c6fa5421a11" + }, + { + "kind": "continuous", + "width": 360, + "motion": "reduce", + "steps": [ + "home", + "keyboard Design", + "home", + "keyboard Agents", + "dark", + "light", + "home", + "drawer open", + "drawer reverse" + ], + "file": "video/narrow-reduced.webm", + "sha256": "a634f63664b21eb5150e9b2c1f11956445e894204b9bcc9d68355d08d6b65061" + } + ], + "scope": "Built ran product website; no app/component-wide visual acceptance claim" +} \ No newline at end of file diff --git a/packages/docs/test/evidence/2026-10-23-product-site/video/narrow-reduced.webm b/packages/docs/test/evidence/2026-10-23-product-site/video/narrow-reduced.webm new file mode 100644 index 0000000000..a40b4382a8 Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/video/narrow-reduced.webm differ diff --git a/packages/docs/test/evidence/2026-10-23-product-site/video/wide-motion.webm b/packages/docs/test/evidence/2026-10-23-product-site/video/wide-motion.webm new file mode 100644 index 0000000000..497eac105c Binary files /dev/null and b/packages/docs/test/evidence/2026-10-23-product-site/video/wide-motion.webm differ diff --git a/packages/docs/test/product-navigation.test.ts b/packages/docs/test/product-navigation.test.ts new file mode 100644 index 0000000000..1f2ba207f1 --- /dev/null +++ b/packages/docs/test/product-navigation.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { expect, it } from 'vitest'; +import { LOCALES, localeHref } from '../build/langs/locales.ts'; +import { navFor } from '../build/nav.ts'; +import { renderHome } from '../build/components.ts'; + +it.each(LOCALES)('makes all three pillars and agent guidance reachable in $lang without JavaScript', (locale) => { + document.body.innerHTML = renderHome(locale); + const links = [...document.querySelectorAll('a[href]')].map((link) => link.getAttribute('href')); + const navigation = navFor(locale); + const pillars = [...document.querySelectorAll('.pillar')].map((link) => ({ + title: link.querySelector('h3')?.textContent, + href: link.getAttribute('href'), + })); + expect(pillars).toEqual([ + { title: 'ranui', href: localeHref('/src/ranui/', locale) }, + { title: 'ranuts', href: localeHref('/src/ranuts/', locale) }, + { title: 'Ran Design', href: localeHref('/src/design/', locale) }, + ]); + for (const section of ['design', 'ranui', 'ranuts', 'agents']) { + const path = localeHref(`/src/${section}/`, locale); + expect(links, `${locale.lang}: home → ${section}`).toContain(path); + const entry = navigation.find((item) => item.link === path); + expect(entry?.text.trim(), `${locale.lang}: navigation → ${section}`).toBeTruthy(); + expect(new RegExp(entry!.activeMatch!).test(path)).toBe(true); + expect(existsSync(resolve(import.meta.dirname, '..', locale.dir, 'src', section, 'index.md'))).toBe(true); + } +}); + +it('keeps the installation guide explicit about checkout previews and release commands', () => { + const guide = readFileSync(resolve(import.meta.dirname, '../src/agents/index.md'), 'utf8'); + expect(guide).toContain('After publication'); + expect(guide).toContain('codex plugin marketplace add .'); + expect(guide).toContain('claude plugin marketplace add .'); + expect(guide).toContain('codex plugin add ran@ran'); + expect(guide).toContain('claude plugin install ran@ran'); + expect(guide).toContain('`$design`'); + expect(guide).toContain('`/ran:design`'); + expect(guide).not.toContain('ranui@ran'); +}); diff --git a/packages/manifest.json b/packages/manifest.json index f4b69e86e6..5142f5519f 100644 --- a/packages/manifest.json +++ b/packages/manifest.json @@ -1,5 +1,5 @@ { - "$comment": "Every directory under packages/ is declared here, with the checks it runs in CI. This file is executable, not descriptive: bin/run-checks.mjs runs a check across the packages that declare it, so a package listed here as checked really is checked, and one that is not says why. bin/verify-packages.mjs keeps it honest against what is on disk. Order is the order checks run in.", + "$comment": "Every directory under packages/ is declared here, with the checks it runs in CI. This file is executable, not descriptive: bin/run-checks.ts runs a check across the packages that declare it, so a package listed here as checked really is checked, and one that is not says why. bin/verify-packages.ts keeps it honest against what is on disk. Order is the order checks run in.", "packages": { "ranuts": { "status": "product", @@ -21,10 +21,6 @@ "status": "support", "checks": ["tsc", "test"] }, - "site": { - "status": "support", - "checks": ["tsc", "verify:design"] - }, "im": { "status": "experimental", "checks": ["tsc", "test"] diff --git a/packages/ranpress/README.md b/packages/ranpress/README.md index 412d54b8a5..135e90d564 100644 --- a/packages/ranpress/README.md +++ b/packages/ranpress/README.md @@ -1,12 +1,12 @@ # ranpress -The static site generator behind **chaxus.com** and **ran.chaxus.com**. +The static site generator behind **ran.chaxus.com**. It is mechanism, not policy. It knows how to parse frontmatter, render markdown to documentation-grade HTML, walk a content tree, put bytes on disk in the right order, serve them the way the host will, and refuse to publish output that contradicts itself. It has no opinion about what a page is, what URL it gets, or what it looks like — those live in -each site, which is why a personal blog and a 1,393-page multilingual reference share it. +the consuming application. The ran website supplies its navigation, locales and page layouts. ``` src/ diff --git a/packages/ranpress/package.json b/packages/ranpress/package.json index fd249d700a..8b826a6249 100644 --- a/packages/ranpress/package.json +++ b/packages/ranpress/package.json @@ -1,7 +1,7 @@ { "name": "ranpress", "version": "0.1.0", - "description": "The static site generator behind chaxus.com and ran.chaxus.com — mechanism only; each site supplies its own policy", + "description": "The static site generator behind ran.chaxus.com \u2014 mechanism only; the site supplies its own policy", "type": "module", "private": true, "exports": { diff --git a/packages/ranpress/src/index.ts b/packages/ranpress/src/index.ts index 0be47843ef..414a155e21 100644 --- a/packages/ranpress/src/index.ts +++ b/packages/ranpress/src/index.ts @@ -1,11 +1,11 @@ /** - * The static site generator behind chaxus.com and, in time, ran.chaxus.com. + * The static site generator behind ran.chaxus.com. * * It is mechanism, not policy. It knows how to parse frontmatter, render markdown to * documentation-grade HTML, walk a content tree, put bytes on disk in the right order, * serve them the way the host will, and refuse to publish output that contradicts * itself. It has no opinion about what a page is, what URL it gets, or what it looks - * like — those live in each site, which is why two very different sites can share this. + * like — those live in the consuming application. */ export { parseFrontmatter, readString, readList, readBoolean } from './frontmatter.ts'; export type { Frontmatter, FrontmatterValue, ParsedFile } from './frontmatter.ts'; diff --git a/packages/ranpress/src/verify.ts b/packages/ranpress/src/verify.ts index 4e9882d1d4..aa4a1ee24a 100644 --- a/packages/ranpress/src/verify.ts +++ b/packages/ranpress/src/verify.ts @@ -1,5 +1,5 @@ /** - * Post-build checks, run as part of `pnpm -F site build`. + * Post-build checks, run as part of the consuming application's build. * * Every check here corresponds to a failure that is invisible in a browser. A dead * internal link, a canonical naming a URL that redirects, a page with no description — diff --git a/packages/ranui/CHANGELOG.md b/packages/ranui/CHANGELOG.md index b5db6c791a..bd281bdff1 100644 --- a/packages/ranui/CHANGELOG.md +++ b/packages/ranui/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to `ranui` will be documented in this file. +## [0.5.1] — 2026-10-23 + +Release preparation; npm publication is pending verification. + +- Preserve icon-input labels, field geometry, disabled-button behavior and visible keyboard focus, including focus returned from a popup. +- Keep nested dismissal, reconnect identity and immediate Select reopening consistent across input methods. +- Show empty Select hints without changing field names or values, and keep keyboard option navigation from scrolling the surrounding page. +- Preserve compact composed controls, wrapped checkbox alignment, selected contrast, reachable overflowing tabs and dynamic theme controls. +- Keep Math and Mermaid actions reachable through content changes, copy failures and narrow layouts; anchor export menus below the actual action row. +- Keep loading indicators readable with reduced motion and scratch-card recovery available to keyboard and coarse-pointer users. +- Release Glass renderers on removal and failed initialization; isolate stale Route imports and canceled WHEP sessions from their replacements. +- Preserve exact fenced-code copy, local failure/retry feedback, native text selection, table enhancement and scroll intent during streamed content. +- Refine code/table typography, insets, focus and coarse-pointer targets; keep application layout with the host. +- Keep expanded reasoning headers quiet and respect explicit conversation follow-off. +- Ship canonical design/interaction/acceptance references and the thin agent plugin with task-based routing and scoped research examples. +- Coordinate `ranuts@0.5.1` and `@alixex/ranview@0.2.1`; packed internal dependencies resolve to those exact versions. Node 24+, ESM and standalone IIFEs remain supported. + ## [0.5.0] — 2026-10-19 Published to npm as [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) on the `latest` tag. diff --git a/packages/ranui/CHANGELOG.zh-CN.md b/packages/ranui/CHANGELOG.zh-CN.md index 109c2fdd6d..02753dddac 100644 --- a/packages/ranui/CHANGELOG.zh-CN.md +++ b/packages/ranui/CHANGELOG.zh-CN.md @@ -2,6 +2,18 @@ `ranui` 的所有重要变更都记录在本文件中。 +## [0.5.1] — 2026-10-23 + +发布准备版本;通过验证后再发布到 npm。 + +- 保留带图标输入框的标签,修复字段布局、禁用按钮行为及键盘焦点可见性,包括弹层关闭后的焦点返回。 +- 统一嵌套弹层逐层关闭、重新连接时的节点保留,以及 Select 退出期间立即重新打开的行为。 +- 流式内容保留代码复制原文、局部失败重试反馈、原生文字选择、表格增强和阅读滚动位置。 +- 优化代码与表格的排版、内边距、焦点和触屏操作目标;产品页面布局仍由宿主负责。 +- 减少展开推理头部的重复信息,并尊重明确关闭的自动跟随。 +- 提供规范、交互和验收的唯一入口,以轻量 agent 插件按任务路由,并提供有范围说明的参考案例。 +- 协同 `ranuts@0.5.1` 和 `@alixex/ranview@0.2.1`,打包后的内部依赖使用精确版本。继续支持 Node 24+、ESM 和独立 IIFE。 + ## [0.5.0] — 2026-10-19 [ranui@0.5.0](https://www.npmjs.com/package/ranui/v/0.5.0) 已正式发布到 npm 的 `latest` 标签。 diff --git a/packages/ranui/bin/build.sh b/packages/ranui/bin/build.sh index 2729c667e6..f7a440d139 100644 --- a/packages/ranui/bin/build.sh +++ b/packages/ranui/bin/build.sh @@ -31,7 +31,7 @@ cp fonts/fonts.css fonts/Geist-Variable.woff2 fonts/GeistMono-Variable.woff2 fon printf "\nimport './typings.js';\n" >> "$dist/index.d.ts" # Normalize ESM declaration imports for consumers. -node ../../bin/prepare-declarations.mjs dist . +node ../../bin/prepare-declarations.ts dist . # Include attributions embedded inside upstream media prebundles. -node ./bin/copy-bundled-notices.mjs +node ./bin/copy-bundled-notices.ts diff --git a/packages/ranui/bin/copy-bundled-notices.mjs b/packages/ranui/bin/copy-bundled-notices.ts similarity index 100% rename from packages/ranui/bin/copy-bundled-notices.mjs rename to packages/ranui/bin/copy-bundled-notices.ts diff --git a/packages/ranui/bin/generate-component-api.ts b/packages/ranui/bin/generate-component-api.ts index ad690f52a7..7bdc9dc6d6 100644 --- a/packages/ranui/bin/generate-component-api.ts +++ b/packages/ranui/bin/generate-component-api.ts @@ -64,12 +64,71 @@ function uniqSorted(values: string[]): string[] { return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); } -/** Strings inside the `observedAttributes` return array (comments tolerated). */ +/** + * Read inline arrays or same-file module const arrays, with or without return types. + * This bounded lexer skips comments and keeps strings whole; it does not evaluate + * expressions, resolve imports/spreads or infer attributes from runtime calls. + */ function extractAttributes(src: string): string[] { - const block = src.match(/observedAttributes\s*\([^)]*\)\s*:\s*string\[\]\s*\{[\s\S]*?return\s*\[([\s\S]*?)\]/); - if (!block) return []; - const items = block[1].match(/['"`]([^'"`]+)['"`]/g) || []; - return uniqSorted(items.map((s) => s.slice(1, -1))); + const tokens = ( + src.match( + /\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`|[A-Za-z_$][\w$]*|[^\s]/g, + ) ?? [] + ).filter((token) => !token.startsWith('//') && !token.startsWith('/*')); + const arrayAt = (start: number): { values: string[]; end: number } | undefined => { + if (tokens[start] !== '[') return; + const values: string[] = []; + let cursor = start + 1; + while (tokens[cursor] !== ']') { + // Escapes and interpolated templates require more than literal-name extraction. + const literal = /^(['"`])([^\\]*?)\1$/s.exec(tokens[cursor] ?? ''); + if (!literal || (literal[1] === '`' && literal[2].includes('${'))) return; + values.push(literal[2]); + cursor++; + if (tokens[cursor] === ']') break; + if (tokens[cursor++] !== ',') return; + } + return { values, end: cursor + 1 }; + }; + const constants = new Map(); + let depth = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === '{') depth++; + if (token === '}') depth--; + if (depth !== 0 || token !== 'const' || !/^[A-Za-z_$][\w$]*$/.test(tokens[i + 1] ?? '')) continue; + let cursor = i + 2; + // The repository uses unannotated or string[] module constants. Do not cross + // a statement/block while looking through an optional type annotation. + if (tokens[cursor] === ':') { + while (cursor < tokens.length && !['=', ';', '{', '}'].includes(tokens[cursor])) cursor++; + } + if (tokens[cursor++] !== '=') continue; + const array = arrayAt(cursor); + if (!array) continue; + cursor = array.end; + if (tokens[cursor] === 'as' && tokens[cursor + 1] === 'const') cursor += 2; + if (tokens[cursor] === ';') constants.set(tokens[i + 1], array.values); + } + for (let i = 0; i < tokens.length; i++) { + if (tokens.slice(i, i + 5).join(' ') !== 'static get observedAttributes ( )') continue; + let cursor = i + 5; + if (tokens[cursor] === ':') { + while (cursor < tokens.length && !['{', ';', '}'].includes(tokens[cursor])) cursor++; + } + if (tokens[cursor++] !== '{' || tokens[cursor++] !== 'return') { + throw new Error('observedAttributes must directly return a literal array or same-file const array'); + } + const array = arrayAt(cursor); + const values = array?.values ?? constants.get(tokens[cursor]); + cursor = array?.end ?? cursor + 1; + if (tokens[cursor] === ';') cursor++; + if (!values || tokens[cursor] !== '}') { + throw new Error('observedAttributes must directly return a literal array or same-file const array'); + } + return uniqSorted(values); + } + return []; } /** Resolve same-file `enum X { K = 'v' }` and `type X = …` to readable types, @@ -392,9 +451,9 @@ function renderInline(values: string[]): string { * extracted API surface can't convey — e.g. required setup calls). */ const ELEMENT_NOTES: Record = { 'r-icon': [ - '> **Requires registration.** `` has no built-in icon set — it renders only SVGs', - '> registered into its in-memory registry, so `` is **blank** until `lock`', - '> is registered. Register once, in the browser, before the first `` connects:', + '> **Bundled icons are loaded on demand.** `` works without registration:', + '> its SVG is fetched on first use and cached. To register the entire bundled set eagerly,', + '> optionally call the following in the browser before the first `` connects:', '>', '> ```ts', "> import { registerBuiltinIcons } from 'ranui'; // or 'ranui/icons'", @@ -566,10 +625,14 @@ async function main(): Promise { await Promise.all(utilFiles.map((file) => fs.readFile(file, 'utf8'))), ); const elements: ElementApi[] = []; + const sources = new Map( + await Promise.all(files.map(async (file) => [file, await fs.readFile(file, 'utf8')] as const)), + ); + const registration = /defineSSR\(\s*['"`]([^'"`]+)['"`]/; for (const file of files) { - const src = await fs.readFile(file, 'utf8'); - const tagMatch = src.match(/defineSSR\(\s*['"`]([^'"`]+)['"`]/); + const src = sources.get(file)!; + const tagMatch = src.match(registration); if (!tagMatch) continue; const { defaultSlot, namedSlots } = extractSlots(src); elements.push({ @@ -578,7 +641,17 @@ async function main(): Promise { attributes: extractAttributes(src), properties: extractProperties(src), events: extractEvents(src, sharedEventFlags), - parts: extractParts(src), + // Lazy renderer helpers can mount public parts in the owner's existing root. + // Registered neighbors own their own shadow roots and must not leak into this API. + parts: extractParts( + [...sources] + .filter( + ([candidate, source]) => + path.dirname(candidate) === path.dirname(file) && (candidate === file || !registration.test(source)), + ) + .map(([, source]) => source) + .join('\n'), + ), defaultSlot, namedSlots, }); diff --git a/packages/ranui/bin/visual.mjs b/packages/ranui/bin/visual.ts similarity index 82% rename from packages/ranui/bin/visual.mjs rename to packages/ranui/bin/visual.ts index 109446535c..9b8a96a889 100644 --- a/packages/ranui/bin/visual.mjs +++ b/packages/ranui/bin/visual.ts @@ -1,3 +1,4 @@ +import { object, text, stringRecord } from '../../../bin/tooling-json.ts'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { @@ -16,10 +17,13 @@ import { fileURLToPath } from 'node:url'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const root = resolve(packageRoot, '../..'); -const environment = JSON.parse(readFileSync(join(packageRoot, 'test/visual/environment.json'))); +const environment = stringRecord( + JSON.parse(readFileSync(join(packageRoot, 'test/visual/environment.json'), 'utf8')), + 'visual environment', +); const mode = process.argv[2]; if (!['check', 'update'].includes(mode) || process.argv.length !== 3) { - throw new Error('Usage: node bin/visual.mjs check|update (no arbitrary Playwright overrides)'); + throw new Error('Usage: node bin/visual.ts check|update (no arbitrary Playwright overrides)'); } if (mode === 'update' && process.env.CI) throw new Error('CI may compare baselines, but must never update them.'); const baseline = join(packageRoot, 'test/e2e/__screenshots__'); @@ -30,13 +34,16 @@ mkdirSync(baseline, { recursive: true }); mkdirSync(artifacts, { recursive: true }); const temp = mkdtempSync(join(tmpdir(), 'ranui-visual-')); const candidates = mode === 'update' ? mkdtempSync(join(artifacts, 'candidates-')) : baseline; -function run(args) { +function run(args: string[]) { const result = spawnSync('docker', args, { cwd: root, stdio: 'inherit' }); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`Visual container command failed (${result.status})`); } try { - const pnpm = JSON.parse(readFileSync(join(root, 'package.json'))).packageManager.replace('pnpm@', ''); + const pnpm = text( + object(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))).packageManager, + 'packageManager', + ).replace('pnpm@', ''); const iid = join(temp, 'image-id'); run([ 'build', @@ -77,7 +84,7 @@ try { `--update-snapshots=${mode === 'update' ? 'all' : 'none'}`, ]); if (mode === 'update') { - const files = readdirSync(join(candidates, 'linux-arm64'), { recursive: true }) + const files = readdirSync(join(candidates, 'linux-arm64'), { recursive: true, encoding: 'utf8' }) .filter((file) => file.endsWith('.png')) .sort(); const snapshots = Object.fromEntries( @@ -88,7 +95,14 @@ try { .digest('hex'), ]), ); - const metadata = { environment: JSON.parse(readFileSync(join(artifacts, 'environment.json'))), image, snapshots }; + const metadata = { + environment: object( + JSON.parse(readFileSync(join(artifacts, 'environment.json'), 'utf8')), + 'captured environment', + ), + image, + snapshots, + }; writeFileSync(join(candidates, 'manifest.json'), JSON.stringify(metadata, null, 2) + '\n'); // A failed generation leaves reviewed baselines intact. Successful replacement // starts from an empty candidate directory, so removed tests leave no orphans. diff --git a/packages/ranui/components/button/index.less b/packages/ranui/components/button/index.less index 94fe1fe6a7..c404ce1e26 100644 --- a/packages/ranui/components/button/index.less +++ b/packages/ranui/components/button/index.less @@ -68,9 +68,11 @@ } } - &(:focus-visible) { - outline: var(--ran-btn-focus-outline, 2px solid var(--ran-color-primary)); - outline-offset: var(--ran-btn-focus-outline-offset, 2px); + // The keyboard target is inside the closed root, so the host does not match + // :focus-visible. Keep its ring inset: the host clips ripple overflow. + .ran-btn:focus-visible { + outline: var(--ran-btn-focus-outline, 2px solid var(--ran-color-link)); + outline-offset: var(--ran-btn-focus-outline-offset, -2px); } /* Primary = the monochrome action (Vercel/Geist): black-on-white ↔ white-on-black. diff --git a/packages/ranui/components/button/index.ts b/packages/ranui/components/button/index.ts index fbd10c9938..62be41df93 100644 --- a/packages/ranui/components/button/index.ts +++ b/packages/ranui/components/button/index.ts @@ -127,6 +127,22 @@ export class Button extends RanElement { // ── Methods ──────────────────────────────────────────────────────────────── + /** Focus the enabled keyboard surface, including when an overlay restores its trigger. */ + focus(options?: FocusOptions): void { + if (!isDisabled(this)) this._btn.focus(options); + } + + /** Match native disabled buttons for programmatic activation as well as user input. */ + click(): void { + if (!isDisabled(this)) super.click(); + } + + private _guardDisabledClick = (event: Event): void => { + if (!isDisabled(this)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + /** * @description: 设置 button 的 icon */ @@ -227,6 +243,7 @@ export class Button extends RanElement { this.syncA11yState(); this.syncAriaLabel(); this._events + .on(this, 'click', this._guardDisabledClick, { capture: true }) .on(this._btn, 'mousedown', this.mousedown) .on(this._btn, 'mouseup', this.mouseup) .on(this._btn, 'keydown', this.keydown); diff --git a/packages/ranui/components/card/index.less b/packages/ranui/components/card/index.less index de5c262591..c5044cc414 100644 --- a/packages/ranui/components/card/index.less +++ b/packages/ranui/components/card/index.less @@ -39,14 +39,17 @@ &-header { display: flex; + flex-wrap: wrap; + min-width: 0; align-items: flex-start; justify-content: space-between; gap: var(--ran-card-header-gap, var(--ran-space-4, 16px)); } &-title-area { - flex: 1; + flex: 1 1 auto; min-width: 0; + overflow-wrap: anywhere; } &-title { @@ -82,7 +85,3 @@ min-width: 0; } } - -:host(:not([title]):not([description])) .ran-card-header { - display: none; -} diff --git a/packages/ranui/components/card/index.ts b/packages/ranui/components/card/index.ts index dac4f3f582..634f0e70ae 100644 --- a/packages/ranui/components/card/index.ts +++ b/packages/ranui/components/card/index.ts @@ -16,6 +16,7 @@ export class Card extends RanElement { _descriptionEl!: HTMLElement; _headerEl!: HTMLElement; _footerEl!: HTMLElement; + private _extraSlot!: HTMLSlotElement; static get observedAttributes(): string[] { return ['heading', 'description', 'sheet', 'hoverable']; @@ -29,6 +30,7 @@ export class Card extends RanElement { const descriptionElRef = createRef(); const footerElRef = createRef(); const footerSlotRef = createRef(); + const extraSlotRef = createRef(); const card = Div() .class('ran-card') @@ -45,7 +47,7 @@ export class Card extends RanElement { Div().class('ran-card-title').ref(titleElRef).attr('part', 'title'), Div().class('ran-card-description').ref(descriptionElRef).attr('part', 'description'), ), - Slot().attr('name', 'extra').attr('part', 'extra'), + Slot().attr('name', 'extra').attr('part', 'extra').ref(extraSlotRef), ), Div().class('ran-card-body').attr('part', 'body').children(Slot()), Div() @@ -61,6 +63,8 @@ export class Card extends RanElement { this._titleEl = shadowPart(titleElRef, 'title'); this._descriptionEl = shadowPart(descriptionElRef, 'description'); this._footerEl = shadowPart(footerElRef, 'footer'); + this._extraSlot = shadowPart(extraSlotRef, 'slot[name="extra"]'); + this._extraSlot.addEventListener('slotchange', () => this._syncHeader()); // Hide footer until content is slotted this._footerEl.style.display = 'none'; @@ -117,10 +121,20 @@ export class Card extends RanElement { private _syncTitle(): void { this._titleEl.textContent = this.getAttribute('heading') ?? ''; + this._syncHeader(); } private _syncDescription(): void { this._descriptionEl.textContent = this.getAttribute('description') ?? ''; + this._syncHeader(); + } + + private _syncHeader(): void { + const hasContent = + this._titleEl.textContent?.trim() || + this._descriptionEl.textContent?.trim() || + this._extraSlot.assignedElements().length > 0; + this._headerEl.style.display = hasContent ? '' : 'none'; } // ── Lifecycle ────────────────────────────────────────────────────────────── diff --git a/packages/ranui/components/checkbox/index.less b/packages/ranui/components/checkbox/index.less index 27a5c7945a..6070611406 100644 --- a/packages/ranui/components/checkbox/index.less +++ b/packages/ranui/components/checkbox/index.less @@ -7,7 +7,7 @@ padding: var(--ran-checkbox-host-padding, 0); box-sizing: var(--ran-checkbox-host-box-sizing, border-box); list-style: var(--ran-checkbox-host-list-style, none); - &([disabled]) { + &([disabled]:not([disabled='false'])) { .ran-checkbox-input { cursor: var(--ran-checkbox-disabled-cursor, not-allowed); pointer-events: var(--ran-checkbox-disabled-pointer-events, all); @@ -42,11 +42,27 @@ } .ran-checkbox-wrapper { - display: inline-flex; - align-items: center; - gap: var(--ran-checkbox-label-gap, 8px); + display: inline-grid; + grid-template-columns: auto minmax(0, 1fr); + // The first row is one label line; the label spans any remaining lines. + // Center the square in that row, never against the whole paragraph. + grid-template-rows: 1lh auto; + column-gap: var(--ran-checkbox-label-gap, 8px); + max-width: 100%; cursor: pointer; - line-height: 1; + font-size: var(--ran-checkbox-label-font-size, var(--ran-text-label-1, 14px)); + line-height: var(--ran-checkbox-label-line-height, 1.5); +} + +.ran-checkbox-wrapper > .ran-checkbox { + grid-column: 1; + grid-row: 1; + align-self: center; +} + +.ran-checkbox-wrapper > .ran-checkbox-label { + grid-column: 2; + grid-row: 1 / span 2; } .ran-checkbox-label { @@ -105,7 +121,7 @@ display: var(--ran-checkbox-tick-display, table); width: var(--ran-checkbox-tick-width, calc(16px / 14 * 5)); height: var(--ran-checkbox-tick-height, calc(16px / 14 * 8)); - border: var(--ran-checkbox-tick-border, 2px solid #fff); + border: var(--ran-checkbox-tick-border, 2px solid var(--ran-color-primary-text, #fff)); border-top: var(--ran-checkbox-tick-border-top, 0); border-inline-start: var(--ran-checkbox-tick-border-inline-start, 0); opacity: var(--ran-checkbox-tick-opacity, 0); diff --git a/packages/ranui/components/checkbox/index.ts b/packages/ranui/components/checkbox/index.ts index badb8d42ec..141bf48060 100644 --- a/packages/ranui/components/checkbox/index.ts +++ b/packages/ranui/components/checkbox/index.ts @@ -245,6 +245,13 @@ export class Checkbox extends RanElement { } attributeChangedCallback(name: string, oldValue: string, newValue: string): void { if (oldValue === newValue) return; + // Native form-associated elements treat any disabled attribute as disabled, + // even the string "false". Normalize the public false value so pointer + // activation and the browser's form state agree with our accessor and ARIA. + if (name === 'disabled' && newValue === 'false') { + this.removeAttribute('disabled'); + return; + } if (name === 'checked') { this.checked = newValue; this.value = newValue; diff --git a/packages/ranui/components/colorpicker/index.ts b/packages/ranui/components/colorpicker/index.ts index 701c6637fa..b75855e4ed 100644 --- a/packages/ranui/components/colorpicker/index.ts +++ b/packages/ranui/components/colorpicker/index.ts @@ -199,6 +199,19 @@ export class ColorPicker extends RanElement { } this.colorpicker?.setAttribute('aria-disabled', disabled ? 'true' : 'false'); this.colorpicker?.setAttribute('tabindex', disabled ? '-1' : '0'); + if (disabled) { + this.onPointerUp(); + const active = this._shadowDom.activeElement ?? this.ownerDocument?.activeElement; + if (active && (this._shadowDom.contains(active) || this.colorPickerInner?.contains(active))) + (active as HTMLElement).blur?.(); + // Close each owner through its reflected state; the nested Select owns a + // separate portal, outside the panel made inert below. + this.colorPickerFormatSelect?.removeAttribute('open'); + this.popoverBlock.removeAttribute('open'); + } + if (this.colorPickerInner) this.colorPickerInner.inert = disabled; + this.colorPickerFormatSelect?.toggleAttribute('disabled', disabled); + this.colorPickerValueInput?.toggleAttribute('disabled', disabled); }; /** Capture-phase guard: swallow clicks/keydowns on the swatch while disabled. */ @@ -298,6 +311,7 @@ export class ColorPicker extends RanElement { /** Sync the `value` attribute to the live color and emit a `change` event. */ emitChange = (): void => { + if (isDisabled(this)) return; const value = this.currentValue(); const { r, g, b, a } = this.currentRgba(); this.context?.value.setter(value); @@ -322,12 +336,14 @@ export class ColorPicker extends RanElement { // `touch-action: none` already set on the palette/sliders in panel.less, and // the `r-player`/`r-mermaid` pointer-drag idiom documented in CLAUDE.md. palettePointerDown = (e: PointerEvent): void => { + if (isDisabled(this)) return; e.preventDefault(); this.colorPickerPaletteSelect = true; this.updatePaletteFromEvent(e); }; updatePaletteFromEvent = (e: PointerEvent): void => { + if (isDisabled(this)) return; if (!this.colorPickerPalette) return; const { left, top, width, height } = this.colorPickerPalette.getBoundingClientRect(); if (!width || !height) return; @@ -341,12 +357,14 @@ export class ColorPicker extends RanElement { sliderPointerDown = (kind: 'hue' | 'alpha') => (e: PointerEvent): void => { + if (isDisabled(this)) return; e.preventDefault(); this._activeSlider = kind; this.updateSliderFromEvent(kind, e); }; updateSliderFromEvent = (kind: 'hue' | 'alpha', e: PointerEvent): void => { + if (isDisabled(this)) return; const el = kind === 'hue' ? this.colorPickerHueSlider : this.colorPickerAlphaSlider; if (!el) return; const { left, width } = el.getBoundingClientRect(); @@ -389,6 +407,7 @@ export class ColorPicker extends RanElement { sliderKeydown = (kind: 'hue' | 'alpha') => (e: KeyboardEvent): void => { + if (isDisabled(this)) return; const max = kind === 'hue' ? HUE : 100; const sig = kind === 'hue' ? this.context.hue : this.context.transparency; const next = sliderStepFromKeydown(e, { current: sig.getter(), min: 0, max, step: 1 }); @@ -414,6 +433,7 @@ export class ColorPicker extends RanElement { // ── Input row ───────────────────────────────────────────────────────────── onValueInput = (e: Event): void => { + if (isDisabled(this)) return; const target = e.target as HTMLElement & { value?: string }; const next = (e as CustomEvent<{ value?: string }>).detail?.value ?? target?.value; if (typeof next !== 'string') return; @@ -424,6 +444,7 @@ export class ColorPicker extends RanElement { }; onFormatChange = (e: Event): void => { + if (isDisabled(this)) return; const value = (e as CustomEvent<{ value?: string }>).detail?.value; this._format = value === 'RGB' ? 'RGB' : 'HEX'; this.syncValueInput(); diff --git a/packages/ranui/components/conversation/index.ts b/packages/ranui/components/conversation/index.ts index 77862e14bd..46cadd3b51 100644 --- a/packages/ranui/components/conversation/index.ts +++ b/packages/ranui/components/conversation/index.ts @@ -101,6 +101,7 @@ export class Conversation extends RanElement { private _engine: ConversationEngine | null = null; private _unsubscribe: (() => void) | null = null; private _follower: BottomFollower | null = null; + private _reportedPinned = true; /** Row element per node key, so a publication patches rather than rebuilds. */ private _rows = new Map(); private _emptyRow: HTMLElement | null = null; @@ -198,7 +199,7 @@ export class Conversation extends RanElement { /** Whether the view is currently following new content. */ get pinned(): boolean { - return this._follower?.pinned ?? true; + return this.follow && (this._follower?.pinned ?? true); } // ── Public API ───────────────────────────────────────────────────────── @@ -263,6 +264,7 @@ export class Conversation extends RanElement { /** Scrolls to the floor and resumes following, whatever the reader did. */ scrollToBottom(): void { + this.follow = true; this._follower?.toBottom(); } @@ -301,11 +303,10 @@ export class Conversation extends RanElement { // The list grows without appending a node whenever a row streams; the footer // resizes outside the list when a composer grows. observe: [this._list, this._footer], - onPinnedChange: (pinned) => { - this.dispatchEvent(new CustomEvent('pinnedchange', { detail: { pinned }, bubbles: true, composed: true })); - }, + enabled: () => this.follow, + onPinnedChange: () => this._reportPinned(), }); - if (!this.follow) this._follower.releaseAnchor(); + this._reportedPinned = this.pinned; } disconnectedCallback(): void { @@ -323,6 +324,10 @@ export class Conversation extends RanElement { if (name === 'sheet') this.handlerExternalCss(); if (name === 'empty') this._syncEmpty(); if (name === 'older' || name === 'loading-older') this._syncOlder(); + if (name === 'follow') { + this._reportPinned(); + if (this.follow) this._follower?.follow(); + } } handlerExternalCss = (): void => { @@ -331,6 +336,13 @@ export class Conversation extends RanElement { // ── Internals ────────────────────────────────────────────────────────── + private _reportPinned(): void { + const pinned = this.pinned; + if (!this.isConnected || pinned === this._reportedPinned) return; + this._reportedPinned = pinned; + this.dispatchEvent(new CustomEvent('pinnedchange', { detail: { pinned }, bubbles: true, composed: true })); + } + private _ensureEngine(): ConversationEngine { if (this._engine !== null) return this._engine; const engine = createConversationEngine({ diff --git a/packages/ranui/components/disclosure-row/index.less b/packages/ranui/components/disclosure-row/index.less index 909509801a..254aa57adf 100644 --- a/packages/ranui/components/disclosure-row/index.less +++ b/packages/ranui/components/disclosure-row/index.less @@ -112,6 +112,7 @@ hover, on focus, or while open. Sharing the cell means the swap costs no layout, so the title never shifts under the pointer. */ :host([expandable]) .has-leading .ran-disclosure-row:hover .ran-disclosure-icon, +:host([expandable]) .has-leading .ran-disclosure-row:focus-visible .ran-disclosure-icon, :host([expandable][open]) .has-leading .ran-disclosure-icon { opacity: 0; } @@ -123,7 +124,9 @@ } .ran-disclosure-title { - flex: none; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; font-weight: var(--ran-disclosure-title-weight, 400); /* Slotted markup must not push the line taller than the row it sits on. */ min-width: 0; @@ -141,7 +144,7 @@ } .ran-disclosure-summary { - flex: 1 1 auto; + flex: 1 1 0; min-width: 0; overflow: hidden; color: var(--ran-disclosure-summary-color, var(--ran-color-text-secondary)); @@ -149,6 +152,13 @@ white-space: nowrap; } +/* A long tool name must not consume the outcome. Reserve a short readable phrase + for an existing summary, bounded by half the row. Short titles still hug their text, + and title-only rows keep the full available measure. Both texts retain their complete accessible content. */ +.has-summary .ran-disclosure-summary { + min-width: min(50%, 16ch); +} + :host([tone='error']) .ran-disclosure-summary { color: var(--ran-disclosure-error-color, var(--ran-color-danger)); } diff --git a/packages/ranui/components/disclosure-row/index.ts b/packages/ranui/components/disclosure-row/index.ts index 362f32a585..8fbfdab862 100644 --- a/packages/ranui/components/disclosure-row/index.ts +++ b/packages/ranui/components/disclosure-row/index.ts @@ -447,6 +447,7 @@ export class DisclosureRow extends RanElement { // as one of the two, which is why this asks the slot rather than only the attribute. const hasHeading = heading !== '' || slotFilled(this._headingSlot); const hasSummary = summary !== '' || slotFilled(this._summarySlot); + this._root.classList.toggle('has-summary', hasSummary); this._sep.hidden = !hasHeading || !hasSummary; // Only a row with something to open is a control. Without a body it carries no role, // no tab stop and no expanded state: it is a line of text, and announcing it as a @@ -470,7 +471,9 @@ export class DisclosureRow extends RanElement { // A collapsed body is clipped, not removed, so that it can animate. Clipped content is // still focusable and still read out, so it is made inert instead — the previous // `display: none` did that for free. - this._bodyInner.inert = !(expandable && open); + const collapsed = !(expandable && open); + if (collapsed && this._bodyInner.matches(':focus-within')) this._row.focus({ preventScroll: true }); + this._bodyInner.inert = collapsed; } } diff --git a/packages/ranui/components/dropdown/index.less b/packages/ranui/components/dropdown/index.less index f273181ab6..f71ec5901c 100644 --- a/packages/ranui/components/dropdown/index.less +++ b/packages/ranui/components/dropdown/index.less @@ -156,10 +156,9 @@ :host { text-align: var(--ran-dropdown-host-text-align, left); - /* The host is positioned by the consumer (select/popover) and portaled to - , so it must carry the overlay z-index itself — the inner element's - z-index is trapped in the host's stacking context. */ - z-index: var(--ran-dropdown-host-z-index, var(--ran-z-dropdown, 1100)); + /* Inline dropdowns stay in document order, including when used as grid items. + FloatingController owns the layer for managed Select/Popover portals. */ + z-index: var(--ran-dropdown-host-z-index, auto); } /* `hidden` is how a caller hides an element, and the UA rule that implements it is a diff --git a/packages/ranui/components/glass/index.ts b/packages/ranui/components/glass/index.ts index e84be88943..0326cadfac 100644 --- a/packages/ranui/components/glass/index.ts +++ b/packages/ranui/components/glass/index.ts @@ -166,13 +166,11 @@ export class Glass extends RanElement { * Lazily create the rim renderer — only while `rim` is set, so a page that * never uses it never spends one of the browser's limited WebGL/WebGPU * context slots. `createRimRenderer` owns its own canvas(es) and resize - * tracking (see `rim.ts`) — this just mounts it into the specular layer and - * keeps it alive across disconnect/reconnect, same as the SVG displacement - * filter above; only `_teardownRim` frees it, when `rim` is explicitly - * turned back off. + * tracking (see `rim.ts`). Release those resources on disconnect and create + * a fresh renderer on reconnect; detached panes never allocate a backend. */ private _ensureRim(): void { - if (this._rimRenderer || typeof document === 'undefined') return; + if (!this.isConnected || this._rimRenderer || typeof document === 'undefined') return; this._rimRenderer = createRimRenderer(this._specular); this._rimRenderer.setRadius(Number(this.radius) || 20); } @@ -266,6 +264,7 @@ export class Glass extends RanElement { disconnectedCallback(): void { this._events.abort(); + this._teardownRim(); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { diff --git a/packages/ranui/components/glass/rim-webgpu.ts b/packages/ranui/components/glass/rim-webgpu.ts index 3f6aeda84d..15c04f1e8f 100644 --- a/packages/ranui/components/glass/rim-webgpu.ts +++ b/packages/ranui/components/glass/rim-webgpu.ts @@ -103,6 +103,8 @@ fn fs_main(@builtin(position) fragCoord: vec4) -> @location(0) vec4 { `; export interface RimBackend { + /** Resolves when an active device is lost; renderer restores its fallback. */ + lost?: Promise; /** Draws once at the given backing-store size (already dpr-scaled) plus CSS-px radius and dpr. */ draw(width: number, height: number, radius: number, dpr: number): void; /** Frees the GPU device — browsers cap how many WebGPU/WebGL contexts can be live at once. */ @@ -123,16 +125,31 @@ export async function createWebGpuRim(canvas: HTMLCanvasElement): Promise { + if (destroyed) return; + destroyed = true; + context?.unconfigure(); + uniformBuffer?.destroy(); + device?.destroy(); + }; + try { const adapter = await gpu.requestAdapter(); - const device = await adapter?.requestDevice(); + device = await adapter?.requestDevice(); if (!device) return null; // No canvas-specific `getContext("webgpu")` overload in every lib.dom.d.ts // snapshot either — the generic `getContext(id: string)` fallback types the // result as the full `RenderingContext` union, so this narrows it back down. - const context = canvas.getContext('webgpu') as GPUCanvasContext | null; - if (!context) return null; + context = canvas.getContext('webgpu') as GPUCanvasContext | null; + if (!context) { + destroy(); + return null; + } const format = gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: 'premultiplied' }); @@ -161,7 +178,7 @@ export async function createWebGpuRim(canvas: HTMLCanvasElement): Promise resolution (8B, align 8) + f32 radius (4B) + f32 dpr (4B) = 16B, // already a multiple of the largest member's alignment — no padding needed. - const uniformBuffer = device.createBuffer({ + uniformBuffer = device.createBuffer({ size: 16, usage: BUFFER_USAGE_UNIFORM | BUFFER_USAGE_COPY_DST, }); @@ -171,19 +188,23 @@ export async function createWebGpuRim(canvas: HTMLCanvasElement): Promise { + if (destroyed) return; const { width, height, dpr } = currentSize(); (gpu ?? gl)?.draw(width, height, radius, dpr); }; @@ -227,6 +228,16 @@ export function createRimRenderer(container: HTMLElement): RimRenderer { glCanvas.remove(); (gpuCanvas as HTMLCanvasElement).style.display = ''; redraw(); + void handle.lost?.then(() => { + if (destroyed || gpu !== handle) return; + handle.destroy(); + gpu = null; + gpuCanvas?.remove(); + gpuCanvas = null; + glCanvas = makeCanvas(); + gl = createWebGlRim(glCanvas); + redraw(); + }); }); } @@ -236,6 +247,7 @@ export function createRimRenderer(container: HTMLElement): RimRenderer { redraw(); }, destroy() { + if (destroyed) return; destroyed = true; resizeObserver?.disconnect(); gpu?.destroy(); diff --git a/packages/ranui/components/image/index.less b/packages/ranui/components/image/index.less index e69de29bb2..ecac25306d 100644 --- a/packages/ranui/components/image/index.less +++ b/packages/ranui/components/image/index.less @@ -0,0 +1,24 @@ +:host { + display: inline-block; + max-width: 100%; + vertical-align: middle; +} + +:host([hidden]) { + display: none; +} + +.ran-image { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-width: 0; + + img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} diff --git a/packages/ranui/components/image/index.ts b/packages/ranui/components/image/index.ts index 6b38c6a38b..37c0ee7801 100644 --- a/packages/ranui/components/image/index.ts +++ b/packages/ranui/components/image/index.ts @@ -67,20 +67,23 @@ export class ImageElement extends RanElement { }; connectedCallback() { this.handlerExternalCss(); - this._image = new Image(); + // Keep the actual image and its handlers when a keyed view is reattached. + // Recreating it leaves the previous loaded image in the container. + if (!this._image) { + const image = new Image(); + image.addEventListener('error', () => { + const fallback = this.fallback; + // A failing custom fallback must stop here rather than request itself forever. + if (fallback && image.getAttribute('src') !== fallback) image.src = fallback; + }); + image.addEventListener('load', () => { + if (!this._container.contains(image)) this._container.appendChild(image); + }); + this._image = image; + } // Always give the inner an alt (empty = decorative) so it is never // announced by its URL. this._image.alt = this.alt; - this._image.addEventListener('error', () => { - if (this._image && this.fallback) { - this._image.src = this.fallback; - } - }); - this._image.addEventListener('load', () => { - if (this._image) { - this._container.appendChild(this._image); - } - }); this.updateSrc(); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { diff --git a/packages/ranui/components/input/index.less b/packages/ranui/components/input/index.less index cfca39ad09..4214f3f352 100644 --- a/packages/ranui/components/input/index.less +++ b/packages/ranui/components/input/index.less @@ -1,6 +1,7 @@ :host { position: var(--ran-input-host-position, relative); - display: var(--ran-input-host-display, block); + display: var(--ran-input-host-display, flex); + flex-direction: column; box-sizing: var(--ran-input-host-box-sizing, border-box); } // A static caption above the field (Luke Wroblewski's/NN·g's top-aligned-label @@ -12,6 +13,7 @@ // content the way an absolutely-positioned floating label could. .ran-input-label { display: var(--ran-input-label-display, block); + flex: none; margin-bottom: var(--ran-input-label-margin-bottom, var(--ran-space-1, 4px)); font-size: var(--ran-input-label-font-size, var(--ran-text-label-1, 14px)); font-weight: var(--ran-input-label-font-weight, var(--ran-text-label-weight, 500)); @@ -40,11 +42,11 @@ // on :host) keeps icon + input + suffix vertically centred instead of top-aligned. align-items: var(--ran-input-align-items, center); width: var(--ran-input-width, 100%); - // Fill the host height (was `auto`, which left this flex row at its 32px - // min-height, top-aligned in a taller host → content rode high). With 100% - // the row stretches to the host; when the host height is auto, 100% collapses - // back to content height, so both fixed-height and content-sized hosts stay centred. - height: var(--ran-input-height, 100%); + // The host stacks caption, control and helper. Only the control takes the + // remaining fixed height; 100% of the whole host would count the caption twice. + // An automatic-height field retains the same native 32px control minimum. + flex: 1 1 auto; + height: var(--ran-input-height, auto); min-width: var(--ran-input-min-width, 0); padding: var(--ran-input-padding, 0px 0px 0px 4px); color: var(--ran-input-color, var(--ran-color-text, #000000d9)); @@ -215,6 +217,7 @@ /* Helper / validation message below the field — a text cue for status (DESIGN.md §7). Hidden when empty. */ .ran-input-message { + flex: none; margin-top: var(--ran-input-message-margin-top, 4px); font-size: var(--ran-input-message-font-size, var(--ran-text-label-3, 12px)); font-weight: var(--ran-input-message-font-weight, var(--ran-text-label-weight, 500)); @@ -225,8 +228,10 @@ } } :host([status='error']) .ran-input-message { + flex: none; color: var(--ran-input-message-error-color, var(--ran-color-danger-foreground, #d8001b)); } :host([status='warning']) .ran-input-message { + flex: none; color: var(--ran-input-message-warning-color, var(--ran-color-warning-foreground, #aa4d00)); } diff --git a/packages/ranui/components/input/index.ts b/packages/ranui/components/input/index.ts index b57948b7bd..101ae424a2 100644 --- a/packages/ranui/components/input/index.ts +++ b/packages/ranui/components/input/index.ts @@ -589,7 +589,6 @@ export class Input extends RanElement { */ listenIcon = (name: string, value: string, oldValue: string): void => { if (name === 'icon' && value && value !== oldValue) { - this.removeAttribute('label'); this.setAttribute('icon', value); this.dealIcon(); } diff --git a/packages/ranui/components/loading/variants/arc/index.less b/packages/ranui/components/loading/variants/arc/index.less index 508ca5d995..4554577941 100644 --- a/packages/ranui/components/loading/variants/arc/index.less +++ b/packages/ranui/components/loading/variants/arc/index.less @@ -82,9 +82,9 @@ } } h1 { - position: var(--loading-arc-h1-position, absolute); - margin: var(--loading-arc-h1-margin, auto); - top: var(--loading-arc-h1-top, 140px); + position: var(--loading-arc-h1-position, relative); + margin: var(--loading-arc-h1-margin, var(--ran-space-4) auto); + top: var(--loading-arc-h1-top, auto); left: var(--loading-arc-h1-left, 0); right: var(--loading-arc-h1-right, 0); bottom: var(--loading-arc-h1-bottom, 0); @@ -92,8 +92,8 @@ text-align: var(--loading-arc-h1-text-align, center); letter-spacing: var(--loading-arc-h1-letter-spacing, 0.1em); font-size: var(--loading-arc-h1-font-size, 14px); - font-weight: var(--loading-arc-h1-ont-weight, lighter); - color: var(--loading-arc-h1-color, white); + font-weight: var(--loading-arc-h1-ont-weight, var(--ran-text-copy-weight)); + color: var(--loading-arc-h1-color, var(--ran-color-text)); &::after { animation: var(--loading-arc-h1-after-animation, txt 5s infinite); animation-play-state: var(--loading-arc-h1-after-animation-play-state, running); @@ -106,3 +106,13 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .arc span { + display: inline; + } + .ran-loading .arc h1::after { + animation: none; + content: ''; + } +} diff --git a/packages/ranui/components/loading/variants/circle-fold/index.less b/packages/ranui/components/loading/variants/circle-fold/index.less index 78cb0fe1b6..8d09075488 100644 --- a/packages/ranui/components/loading/variants/circle-fold/index.less +++ b/packages/ranui/components/loading/variants/circle-fold/index.less @@ -104,3 +104,10 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .circle-fold-item::before { + animation: none; + opacity: 1; + } +} diff --git a/packages/ranui/components/loading/variants/circle-line/index.less b/packages/ranui/components/loading/variants/circle-line/index.less index 90f79278f8..81726abbbc 100644 --- a/packages/ranui/components/loading/variants/circle-line/index.less +++ b/packages/ranui/components/loading/variants/circle-line/index.less @@ -22,7 +22,7 @@ border-radius: var(--loading-circle-line-border-border-radius, 50%); background: var( --loading-circle-line-border-background, - linear-gradient(0deg, rgba(63, 249, 220, 0.1) 33%, rgba(63, 249, 220, 1) 100%) + linear-gradient(0deg, var(--ran-color-text-secondary) 33%, var(--ran-color-text) 100%) ); animation: var(--loading-circle-line-border-animation, circle-line 0.8s linear 0s infinite); animation-play-state: var(--loading-circle-line-border-animation-play-state, running); @@ -30,7 +30,7 @@ &-core { width: var(--loading-circle-line-core-width, 100%); height: var(--loading-circle-line-core-height, 100%); - background: var(--loading-circle-line-core-background, #fff); + background: var(--loading-circle-line-core-background, var(--ran-color-bg)); border-radius: var(--loading-circle-line-core-border-radius, 50%); } } diff --git a/packages/ranui/components/loading/variants/circle/index.less b/packages/ranui/components/loading/variants/circle/index.less index 8b73d0dd63..256a5af5c7 100644 --- a/packages/ranui/components/loading/variants/circle/index.less +++ b/packages/ranui/components/loading/variants/circle/index.less @@ -91,3 +91,10 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .circle-container div { + animation: none; + transform: scale(1); + } +} diff --git a/packages/ranui/components/loading/variants/cube-dim/index.less b/packages/ranui/components/loading/variants/cube-dim/index.less index 0044028c2b..2e30f5e30b 100644 --- a/packages/ranui/components/loading/variants/cube-dim/index.less +++ b/packages/ranui/components/loading/variants/cube-dim/index.less @@ -3,7 +3,10 @@ @gray-1: var(--ran-color-text, #333); .ran-loading { + container-type: inline-size; .cube-dim { + font-size: var(--loading-cube-dim-font-size, min(1em, 4.5cqi)); + margin-inline: var(--loading-cube-dim-margin-inline, auto); height: var(--loading-cube-dim-height, 9em); width: var(--loading-cube-dim-width, 9em); padding: var(--loading-cube-dim-padding, 3em); diff --git a/packages/ranui/components/loading/variants/cube-fold/index.less b/packages/ranui/components/loading/variants/cube-fold/index.less index 6d6623e6f4..17a44bbe15 100644 --- a/packages/ranui/components/loading/variants/cube-fold/index.less +++ b/packages/ranui/components/loading/variants/cube-fold/index.less @@ -65,3 +65,11 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .cube-fold-item::before { + animation: none; + opacity: 1; + transform: none; + } +} diff --git a/packages/ranui/components/loading/variants/dot-bar/index.less b/packages/ranui/components/loading/variants/dot-bar/index.less index 1daaa5454a..8f4d02eea2 100644 --- a/packages/ranui/components/loading/variants/dot-bar/index.less +++ b/packages/ranui/components/loading/variants/dot-bar/index.less @@ -18,8 +18,11 @@ } } &-item { - background-color: var(--loading-dot-bar-item-background-color, #8aff51); - background-image: var(--loading-dot-bar-item-background-image, linear-gradient(45deg, #8aff51 25%, #f1ffea)); + background-color: var(--loading-dot-bar-item-background-color, var(--ran-color-text)); + background-image: var( + --loading-dot-bar-item-background-image, + linear-gradient(45deg, var(--ran-color-text) 25%, var(--ran-color-text-secondary)) + ); width: var(--loading-dot-bar-item-width, 10px); height: var(--loading-dot-bar-item-height, 10px); float: var(--loading-dot-bar-item-float, left); @@ -46,3 +49,10 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .dot-bar-item { + animation: none; + opacity: 1; + } +} diff --git a/packages/ranui/components/loading/variants/dot-line/index.less b/packages/ranui/components/loading/variants/dot-line/index.less index 51bb768492..b51ddb61be 100644 --- a/packages/ranui/components/loading/variants/dot-line/index.less +++ b/packages/ranui/components/loading/variants/dot-line/index.less @@ -4,8 +4,11 @@ .ran-loading { .dot-line { + max-width: var(--loading-dot-line-max-width, 70%); + aspect-ratio: 1; + margin-inline: var(--loading-dot-line-margin-inline, auto); width: var(--loading-dot-line-width, 20em); - height: var(--loading-dot-line-height, 20em); + height: var(--loading-dot-line-height, auto); font-size: var(--loading-dot-line-font-size, 10px); position: var(--loading-dot-line-position, relative); display: var(--loading-dot-line-display, flex); @@ -25,7 +28,7 @@ &:nth-child(1) { width: var(--loading-dot-line-item-nth-child-1-width, 100%); height: var(--loading-dot-line-item-nth-child-1-height, 100%); - color: var(--loading-dot-line-item-nth-child-1-color, gold); + color: var(--loading-dot-line-item-nth-child-1-color, var(--ran-color-text)); border-color: var( --loading-dot-line-item-nth-child-1-border-color, currentColor transparent transparent currentColor @@ -37,7 +40,7 @@ &:nth-child(2) { width: var(--loading-dot-line-item-nth-child-2-width, 70%); height: var(--loading-dot-line-item-nth-child-2-height, 70%); - color: var(--loading-dot-line-item-nth-child-2-color, lime); + color: var(--loading-dot-line-item-nth-child-2-color, var(--ran-color-text-secondary)); border-color: var( --loading-dot-line-item-nth-child-2-border-color, currentColor currentColor transparent transparent @@ -64,15 +67,7 @@ height: var(--loading-dot-line-item-circle-before-height, 1em); background-color: var(--loading-dot-line-item-circle-before-background-color, currentColor); border-radius: var(--loading-dot-line-item-circle-before-border-radius, 50%); - box-shadow: var( - --loading-dot-line-item-circle-before-box-shadow, - 0 0 2em, - 0 0 4em, - 0 0 6em, - 0 0 8em, - 0 0 10em, - 0 0 0 0.5em rgba(255, 255, 0, 0.1) - ); + box-shadow: var(--loading-dot-line-item-circle-before-box-shadow, none); } } } diff --git a/packages/ranui/components/loading/variants/dot-pulse/index.less b/packages/ranui/components/loading/variants/dot-pulse/index.less index 0cb2fa10e0..afe488e5f9 100644 --- a/packages/ranui/components/loading/variants/dot-pulse/index.less +++ b/packages/ranui/components/loading/variants/dot-pulse/index.less @@ -105,3 +105,14 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .dot-pulse-item-dot { + animation: none; + transform: scale(1); + } + .ran-loading .dot-pulse-item-ball { + animation: none; + transform: scale(0); + } +} diff --git a/packages/ranui/components/loading/variants/drop/index.less b/packages/ranui/components/loading/variants/drop/index.less index fbcaae5fb1..249dd2903f 100644 --- a/packages/ranui/components/loading/variants/drop/index.less +++ b/packages/ranui/components/loading/variants/drop/index.less @@ -44,6 +44,13 @@ position: var(--loading-drop-position, relative); width: var(--loading-drop-position, 120px); height: var(--loading-drop-position, 120px); + margin-bottom: var(--ran-space-8, 32px); + &-visual { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + } &-item { position: var(--loading-drop-item-position, absolute); width: var(--loading-drop-item-width, 100%); @@ -62,9 +69,12 @@ animation-play-state: var(--loading-drop-item-animation-play-state, running); &-bg { text-align: var(--loading-drop-item-text-align, center); - line-height: var(--loading-drop-item-line-height, 120px); + position: absolute; + top: calc(100% + var(--ran-space-2, 8px)); + width: 100%; + line-height: var(--loading-drop-item-line-height, 1.5); font-family: var(--loading-drop-item-font-family, sans-serif); - color: var(--loading-drop-item-color, #ffffff); + color: var(--loading-drop-item-color, var(--ran-color-text)); font-size: var(--loading-drop-item-font-size, 16px); } } diff --git a/packages/ranui/components/loading/variants/drop/index.ts b/packages/ranui/components/loading/variants/drop/index.ts index 7de49d562f..3eadb4637f 100644 --- a/packages/ranui/components/loading/variants/drop/index.ts +++ b/packages/ranui/components/loading/variants/drop/index.ts @@ -2,21 +2,21 @@ import css from './index.less?inline'; import { Div, Span } from '@/utils/builder'; import type { LoadingVariant } from '../../types'; -const render = (): HTMLElement => { - const loading = Div() +const render = (): HTMLElement => + Div() .class('drop') .part('drop') .children( Div() - .class('drop-item') + .class('drop-visual') .children( - Div().class('drop-item-bg').children(Span().text('LOADING')), + Div() + .class('drop-item') + .children(Div().class('drop-dot').children(Div().class('drop-dot-1'), Div().class('drop-dot-2'))), Div().class('drop-dot').children(Div().class('drop-dot-1'), Div().class('drop-dot-2')), ), - Div().class('drop-dot').children(Div().class('drop-dot-1'), Div().class('drop-dot-2')), + Div().class('drop-item-bg').children(Span().text('LOADING')), ) .build(); - return loading; -}; export default { css, render } satisfies LoadingVariant; diff --git a/packages/ranui/components/loading/variants/pulse/index.less b/packages/ranui/components/loading/variants/pulse/index.less index f46003d1e3..d43b9a1c87 100644 --- a/packages/ranui/components/loading/variants/pulse/index.less +++ b/packages/ranui/components/loading/variants/pulse/index.less @@ -13,7 +13,7 @@ width: var(--loading-pulse-bubble-width, 20px); height: var(--loading-pulse-bubble-height, 20px); border-radius: var(--loading-pulse-bubble-border-radius, 50%); - background: var(--loading-pulse-bubble-background, #3ff9dc); + background: var(--loading-pulse-bubble-background, var(--ran-color-text)); @keyframes pulse { from { opacity: var(--loading-pulse-bubble-keyframes-from-opacity, 1); diff --git a/packages/ranui/components/loading/variants/text/index.less b/packages/ranui/components/loading/variants/text/index.less index eb989ef214..8f743cd57d 100644 --- a/packages/ranui/components/loading/variants/text/index.less +++ b/packages/ranui/components/loading/variants/text/index.less @@ -1,40 +1,19 @@ -@blue-1: var(--ran-color-primary, #4096ff); -@green-1: var(--ran-color-success, #67cf22); -@gray-1: var(--ran-color-text, #333); - .ran-loading { .text { text-align: var(--loading-text-text-align, center); text-transform: var(--loading-text-text-transform, uppercase); - font-family: var(--loading-text-font-family, 'Nunito', sans-serif); - font-size: var(--loading-text-font-size, 4em); - color: var(--loading-text-color, transparent); - letter-spacing: var(--loading-text-letter-spacing, 0.4em); + font-family: var(--loading-text-font-family, var(--ran-font-family, sans-serif)); + font-size: var(--loading-text-font-size, var(--ran-text-label-1, 14px)); + color: var(--loading-text-color, var(--ran-color-text, #171717)); + letter-spacing: var(--loading-text-letter-spacing, 0.04em); @keyframes letters { to { - text-shadow: var( - --loading-text-keyframes-to-text-shadow, - 0 0 2px rgba(204, 208, 212, 0.2), - 0 0 3px rgba(0, 0, 0, 0.02), - 0 0 0 rgba(0, 0, 0, 0), - 0 0 0 rgba(255, 255, 255, 0), - 0 0 0 rgba(0, 0, 0, 0), - 0 0 0 rgba(255, 255, 255, 0), - 0 0 0 rgba(255, 255, 255, 0) - ); + text-shadow: var(--loading-text-keyframes-to-text-shadow, none); + opacity: 0.65; } } &-item { - text-shadow: var( - --loading-text-item-text-shadow, - 0 0 2px rgba(22, 22, 22, 0.9), - 0 15px 25px rgba(0, 0, 0, 0.3), - 0 -2px 3px rgba(0, 0, 0, 0.1), - 0 -5px 10px rgba(22, 22, 22, 0.5), - 0 5px 10px rgba(0, 0, 0, 0.3), - 0 3px 4px rgba(22, 22, 22, 0.2), - 0 0 20px rgba(22, 22, 22, 0.45) - ); + text-shadow: var(--loading-text-item-text-shadow, none); animation: var(--loading-text-item-animation, letters 0.85s ease-in-out infinite alternate); animation-play-state: var(--loading-text-item-animation-play-state, running); animation-delay: var(--loading-text-item-animation-delay, 0); @@ -59,3 +38,9 @@ } } } +@media (prefers-reduced-motion: reduce) { + .ran-loading .text-item { + animation: none; + opacity: 1; + } +} diff --git a/packages/ranui/components/loading/variants/triple-bounce/index.less b/packages/ranui/components/loading/variants/triple-bounce/index.less index 29d2b08c95..edd2976e09 100644 --- a/packages/ranui/components/loading/variants/triple-bounce/index.less +++ b/packages/ranui/components/loading/variants/triple-bounce/index.less @@ -34,3 +34,10 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .ran-loading .triple-bounce div { + animation: none; + transform: scale(1); + } +} diff --git a/packages/ranui/components/markdown/index.less b/packages/ranui/components/markdown/index.less index 7c44be3e35..4672f7d2e9 100644 --- a/packages/ranui/components/markdown/index.less +++ b/packages/ranui/components/markdown/index.less @@ -186,7 +186,7 @@ img { margin: 0 0 var(--ran-markdown-paragraph-margin, var(--ran-space-3, 12px)); border: var(--ran-skin-border-width, 1px) var(--ran-skin-border-style, solid) var(--ran-markdown-code-border, var(--ran-color-border, #eaeaea)); - border-radius: var(--ran-markdown-code-radius, var(--ran-radius-md, 12px)); + border-radius: var(--ran-markdown-code-radius, var(--ran-radius-sm, 6px)); background: var(--ran-markdown-code-bg, var(--ran-color-bg-muted, #fafafa)); overflow: hidden; /* Off-screen blocks skip layout/paint — long answers stay cheap. */ @@ -199,7 +199,7 @@ img { justify-content: space-between; gap: var(--ran-space-2, 8px); min-height: var(--ran-markdown-code-header-height, var(--ran-size-7, 32px)); - padding: 0 var(--ran-space-2, 8px) 0 var(--ran-space-3, 12px); + padding: var(--ran-space-1, 4px) var(--ran-space-2, 8px) var(--ran-space-1, 4px) var(--ran-space-4, 16px); border-bottom: var(--ran-skin-border-width, 1px) var(--ran-skin-border-style, solid) var(--ran-markdown-code-border, var(--ran-color-border, #eaeaea)); background: var(--ran-markdown-code-header-bg, transparent); @@ -212,16 +212,18 @@ img { text-transform: lowercase; } .ran-md-code-actions { + min-width: 0; display: inline-flex; align-items: center; gap: var(--ran-space-1, 4px); } .ran-md-btn { + flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; - width: var(--ran-markdown-button-size, var(--ran-size-4, 24px)); - height: var(--ran-markdown-button-size, var(--ran-size-4, 24px)); + width: var(--ran-markdown-button-size, var(--ran-size-7, 32px)); + height: var(--ran-markdown-button-size, var(--ran-size-7, 32px)); padding: 0; border: none; border-radius: var(--ran-radius-sm, 6px); @@ -229,12 +231,16 @@ img { color: var(--ran-markdown-button-color, var(--ran-color-text-secondary, #666)); cursor: pointer; } -.ran-md-btn:hover { +.ran-md-btn:hover:not([aria-disabled='true']) { background: var(--ran-markdown-button-hover-background, var(--ran-gray-alpha-200, rgba(0, 0, 0, 0.06))); color: var(--ran-markdown-button-hover-color, var(--ran-color-text, #171717)); } +.ran-md-btn[aria-disabled='true'] { + color: var(--ran-color-text-disabled); + cursor: default; +} .ran-md-btn:focus-visible { - outline: var(--ran-focus-ring, 2px solid var(--ran-color-primary, #0070f3)); + outline: 2px solid var(--ran-color-link); outline-offset: 1px; } @@ -245,7 +251,7 @@ img { background: transparent !important; /* shiki's theme bg is dropped in favour of the token above */ color: var(--ran-markdown-code-color, var(--ran-color-text, #171717)); font-family: var(--ran-markdown-mono-font, var(--ran-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)); - font-size: var(--ran-markdown-code-font-size, var(--ran-text-label-2, 13px)); + font-size: var(--ran-markdown-code-font-size, var(--ran-text-copy-2, 14px)); line-height: var(--ran-markdown-code-line-height, 1.6); tab-size: 2; } @@ -294,9 +300,7 @@ img { /* ── Tables ────────────────────────────────────────────────────────────────── */ .ran-md-table-wrap { margin: 0 0 var(--ran-markdown-paragraph-margin, var(--ran-space-3, 12px)); - border: var(--ran-skin-border-width, 1px) var(--ran-skin-border-style, solid) - var(--ran-markdown-table-border, var(--ran-color-border, #eaeaea)); - border-radius: var(--ran-markdown-table-radius, var(--ran-radius-md, 12px)); + border-radius: var(--ran-markdown-table-radius, 0); overflow-x: auto; } table { @@ -313,11 +317,41 @@ td { vertical-align: top; } th { - background: var(--ran-markdown-table-header-bg, var(--ran-color-bg-muted, #fafafa)); + background: var(--ran-markdown-table-header-bg, transparent); color: var(--ran-markdown-table-header-color, var(--ran-color-text-secondary, #666)); font-weight: var(--ran-text-label-weight, 500); white-space: nowrap; } +th[align='right'], +td[align='right'] { + text-align: right; + font-variant-numeric: tabular-nums; +} +th[align='center'], +td[align='center'] { + text-align: center; +} +.ran-md-pre:focus-visible, +.ran-md-table-wrap:focus-visible { + outline: 2px solid var(--ran-color-link); + outline-offset: -2px; +} +.ran-md-copy-status { + min-width: 0; + color: var(--ran-color-danger); + font-size: var(--ran-text-copy-2, 14px); + line-height: 1.4; +} +.ran-md-copy-status[hidden] { + display: none; +} +@media (pointer: coarse) { + .ran-md-btn { + min-width: var(--ran-markdown-button-touch-size, 44px); + min-height: var(--ran-markdown-button-touch-size, 44px); + } +} + tr:last-child td { border-bottom: 0; } diff --git a/packages/ranui/components/markdown/index.ts b/packages/ranui/components/markdown/index.ts index 7c911e805b..a33f3f6ef8 100644 --- a/packages/ranui/components/markdown/index.ts +++ b/packages/ranui/components/markdown/index.ts @@ -1,5 +1,5 @@ import { RanElement } from '@/utils/ssr'; -import { Div, View, createRef } from '@/utils/builder'; +import { Div, View, createRef, EventManager } from '@/utils/builder'; import { ensureShadowRoot, getStringAttribute, @@ -15,6 +15,7 @@ import markdownCss from './index.less?inline'; import { defineSSR } from '@/utils/ssr-registry'; type RenderModule = typeof import('./render'); +type CodeCopyControl = import('./render').CodeCopyControl; interface BlockState { /** The block's diff key (source + the document's link definitions). */ @@ -110,7 +111,8 @@ export class Markdown extends RanElement { _renderModule: RenderModule | null = null; _themeObserver?: MutationObserver; _lightDomObserver?: MutationObserver; - _copyResetTimer?: number; + private _readerEvents = new EventManager(); + private _copies = new Map(); _error?: HTMLElement; static get observedAttributes(): string[] { @@ -125,6 +127,7 @@ export class Markdown extends RanElement { 'highlight', 'inline-math', 'link-target', + 'label-copy-error', 'sheet', ]; } @@ -280,7 +283,9 @@ export class Markdown extends RanElement { if (token !== this._renderToken) return; this.renderWith(mod, source, token); }) - .catch((err: Error) => this.showError(err?.message || String(err))); + .catch((err: Error) => { + if (token === this._renderToken && this.isConnected) this.showError(err?.message || String(err)); + }); } private renderWith(mod: RenderModule, source: string, token: number): void { @@ -301,6 +306,7 @@ export class Markdown extends RanElement { theme: this.theme, labels: { copy: this.label('copy', 'Copy code'), download: this.label('download', 'Download') }, onCopy: this.copyCode, + resetCopy: this.resetCopy, onDownload: this.downloadCode, }; let changed = 0; @@ -309,8 +315,10 @@ export class Markdown extends RanElement { const prev = this._blocks[i]; if (prev && prev.key === block.key && prev.incomplete === incomplete) return; const el = prev ? prev.el : this.createBlockElement(); - el.replaceChildren(doc.render(block)); - const { themed } = mod.enhance(el, { ...ctxBase, incomplete }); + const { themed } = mod.renderBlock(el, doc.render(block), { + ...ctxBase, + incomplete, + }); // The caret hides while a fence is still open or the block is a table (it would // land inside the code/table box and look like content). el.toggleAttribute('data-no-caret', incomplete || block.hasTable); @@ -326,6 +334,7 @@ export class Markdown extends RanElement { }); while (this._blocks.length > blocks.length) { const dropped = this._blocks.pop(); + if (dropped) mod.releaseReaders(dropped.el); dropped?.el.remove(); changed += 1; } @@ -344,7 +353,10 @@ export class Markdown extends RanElement { } private showError(message: string): void { - this._blocks.forEach((b) => b.el.remove()); + this._blocks.forEach((b) => { + this._renderModule?.releaseReaders(b.el); + b.el.remove(); + }); this._blocks = []; if (!this._error) { this._error = View('pre').class('ran-markdown-error').part('error').build(); @@ -357,7 +369,10 @@ export class Markdown extends RanElement { /** Force a full re-render (e.g. after toggling controls). */ private rerender(): void { - this._blocks.forEach((b) => b.el.remove()); + this._blocks.forEach((b) => { + this._renderModule?.releaseReaders(b.el); + b.el.remove(); + }); this._blocks = []; this.render(); } @@ -365,15 +380,52 @@ export class Markdown extends RanElement { // ── Code-block toolbar ──────────────────────────────────────────────────── // Bound to each button when enhance() builds it, so the code and language arrive as // arguments rather than being read back out of the rendered DOM at click time. - private copyCode = (icon: HTMLElement | null, code: string, language: string): void => { - if (!navigator.clipboard) return; - navigator.clipboard.writeText(code).then(() => { - this.emit('copied', { kind: 'code', language, code }); - if (!icon) return; - icon.setAttribute('name', 'check'); - window.clearTimeout(this._copyResetTimer); - this._copyResetTimer = window.setTimeout(() => icon.setAttribute('name', 'copy'), 1200); - }); + private resetCopy = (control: CodeCopyControl): void => { + const pending = this._copies.get(control.button); + if (pending?.timer) window.clearTimeout(pending.timer); + this._copies.delete(control.button); + control.button.removeAttribute('aria-busy'); + control.button.removeAttribute('aria-disabled'); + control.icon?.setAttribute('name', 'copy'); + control.status.textContent = ''; + control.status.hidden = true; + }; + + private copyCode = (control: CodeCopyControl, code: string, language: string, sourceCurrent: () => boolean): void => { + if (this._copies.get(control.button)?.busy) return; + this.resetCopy(control); + const pending = { control, busy: true, timer: undefined as number | undefined }; + this._copies.set(control.button, pending); + control.button.setAttribute('aria-busy', 'true'); + control.button.setAttribute('aria-disabled', 'true'); + const current = () => this.isConnected && sourceCurrent() && this._copies.get(control.button) === pending; + void (async () => { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable'); + await navigator.clipboard.writeText(code); + if (!current()) return; + this.emit('copied', { kind: 'code', language, code }); + if (!current()) return; + control.icon?.setAttribute('name', 'check'); + pending.timer = window.setTimeout(() => { + if (current()) this.resetCopy(control); + }, 1200); + } catch { + if (!current()) return; + control.status.textContent = this.label('copy-error', 'Could not copy. Try again.'); + control.status.hidden = false; + } finally { + if (current()) { + pending.busy = false; + control.button.removeAttribute('aria-busy'); + control.button.removeAttribute('aria-disabled'); + } + } + })(); + }; + + private settleReader = (): void => { + for (const block of this._blocks) this._renderModule?.settleHighlights(block.el); }; private downloadCode = (code: string, language: string): void => { @@ -392,6 +444,9 @@ export class Markdown extends RanElement { connectedCallback(): void { this.handlerExternalCss(); this.observeTheme(); + this._readerEvents.on(document, 'selectionchange', this.settleReader); + this._readerEvents.on(document, 'pointerup', this.settleReader); + this._readerEvents.on(document, 'keyup', this.settleReader); // Hand-authored / streamed light-DOM text: re-render when it changes, unless the // property or attribute is the source of truth. if (typeof MutationObserver !== 'undefined' && !this._lightDomObserver) { @@ -401,19 +456,27 @@ export class Markdown extends RanElement { this._lightDomObserver.observe(this, { childList: true, characterData: true, subtree: true }); } this.render(); + for (const block of this._blocks) this._renderModule?.resumeReaders(block.el); } disconnectedCallback(): void { + ++this._renderToken; + this._readerEvents.abort(); + for (const block of this._blocks) this._renderModule?.releaseReaders(block.el); + for (const pending of this._copies.values()) this.resetCopy(pending.control); this._themeObserver?.disconnect(); this._themeObserver = undefined; this._lightDomObserver?.disconnect(); this._lightDomObserver = undefined; - window.clearTimeout(this._copyResetTimer); } attributeChangedCallback(k: string, o: string | null, n: string | null): void { if (o === n) return; if (k === 'sheet') this.handlerExternalCss(); else if (k === 'content') this.render(); - else if (k === 'theme') { + else if (k === 'label-copy-error') { + for (const { control } of this._copies.values()) { + if (!control.status.hidden) control.status.textContent = this.label('copy-error', 'Could not copy. Try again.'); + } + } else if (k === 'theme') { this.observeTheme(); this.syncTheme(); } else if (k === 'caret') { diff --git a/packages/ranui/components/markdown/reader.ts b/packages/ranui/components/markdown/reader.ts new file mode 100644 index 0000000000..a492d5a3a7 --- /dev/null +++ b/packages/ranui/components/markdown/reader.ts @@ -0,0 +1,97 @@ +/** Reconcile already-sanitized renderer output without replacing its reading viewport. */ +export const hasReaderSelection = (element: Node): boolean => { + const root = element.getRootNode() as ShadowRoot & { getSelection?(): Selection | null }; + const selection = root.getSelection?.() ?? element.ownerDocument?.getSelection(); + // WebKit can retarget a closed-root selection to collapsed document endpoints. + // Ask for scoped endpoints before trusting the document's isCollapsed flag. + const composed = selection as + (Selection & { getComposedRanges?(options: { shadowRoots: ShadowRoot[] }): StaticRange[] }) | null | undefined; + try { + for (const selected of composed?.getComposedRanges?.({ shadowRoots: root instanceof ShadowRoot ? [root] : [] }) ?? + []) { + if (selected.startContainer === selected.endContainer && selected.startOffset === selected.endOffset) continue; + const range = element.ownerDocument!.createRange(); + range.setStart(selected.startContainer, selected.startOffset); + range.setEnd(selected.endContainer, selected.endOffset); + if (range.intersectsNode(element)) return true; + } + } catch { + // Older engines may expose only the ordinary range/anchor APIs below. + } + if (selection && !selection.isCollapsed && selection.rangeCount) { + try { + if (selection.getRangeAt(0).intersectsNode(element)) return true; + } catch { + // The document and a closed root may expose different range trees. + } + } + return ( + !!selection && + !selection.isCollapsed && + ((!!selection.anchorNode && element.contains(selection.anchorNode)) || + (!!selection.focusNode && element.contains(selection.focusNode))) + ); +}; + +const patchText = (current: Text, next: string): void => { + const old = current.data; + if (old === next) return; + let prefix = 0; + while (prefix < old.length && prefix < next.length && old[prefix] === next[prefix]) prefix++; + let suffix = 0; + while ( + suffix < old.length - prefix && + suffix < next.length - prefix && + old[old.length - suffix - 1] === next[next.length - suffix - 1] + ) + suffix++; + // Editing only the changed suffix preserves native Range offsets in the unchanged prefix. + current.replaceData(prefix, old.length - prefix - suffix, next.slice(prefix, next.length - suffix)); +}; + +export const syncReaderAttributes = (current: Element, next: Element): void => { + for (const attribute of [...current.attributes]) + if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name); + for (const attribute of [...next.attributes]) + if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value); +}; + +/** Both trees came from DOMPurify, our builder, or the existing trusted highlighter. */ +export const reconcileReader = (current: Node, next: Node, selecting = false): void => { + if (current.nodeType === Node.TEXT_NODE && next.nodeType === Node.TEXT_NODE) { + patchText(current as Text, next.textContent ?? ''); + return; + } + if (current instanceof Element && next instanceof Element) { + // An unchanged highlighted line need not lose its token nodes during a selected stream. + if (selecting && current.classList.contains('line') && next.classList.contains('line')) { + const old = current.textContent ?? '', + value = next.textContent ?? ''; + if (old === value) return; + if (value.startsWith(old)) { + current.append(document.createTextNode(value.slice(old.length))); + return; + } + } + syncReaderAttributes(current, next); + } + let old = current.firstChild; + for (const incoming of [...next.childNodes]) { + if ( + old && + old.nodeType === incoming.nodeType && + (!(old instanceof Element) || (incoming instanceof Element && old.tagName === incoming.tagName)) + ) { + const following = old.nextSibling; + reconcileReader(old, incoming, selecting); + old = following; + } else { + current.insertBefore(incoming, old); + } + } + while (old) { + const following = old.nextSibling; + current.removeChild(old); + old = following; + } +}; diff --git a/packages/ranui/components/markdown/render.ts b/packages/ranui/components/markdown/render.ts index bc078a9c80..30ea265076 100644 --- a/packages/ranui/components/markdown/render.ts +++ b/packages/ranui/components/markdown/render.ts @@ -18,7 +18,7 @@ * rather than from matching the text again, and cross-block state the lexer resolved — * link reference definitions, for one — survives into the rendered block. */ -import { Marked } from 'marked'; +import { Marked, Renderer } from 'marked'; import type { Token, TokenizerAndRendererExtension, Tokens } from 'marked'; import DOMPurify from 'dompurify'; import type { Config as PurifyConfig } from 'dompurify'; @@ -28,6 +28,7 @@ import { ButtonBuilder, Div, Span, View, createRef } from '@/utils/builder'; import { splitTokens, hasIncompleteCodeFence } from './blocks'; import { highlight, parseThemes } from './highlight'; import type { HighlightThemes } from './highlight'; +import { hasReaderSelection, reconcileReader, syncReaderAttributes } from './reader'; export { remend }; @@ -134,13 +135,28 @@ const inlineMathExtension = (dollar: boolean): TokenizerAndRendererExtension => // ── Parser instances ───────────────────────────────────────────────────────── const parsers = new Map(); +const codeRenderer = new Renderer(); +const fenceBodies = new WeakMap(); +// Only the synchronous marked → DOMPurify pass can create trusted fence metadata. +let renderingFences: Map | undefined; const getParser = (inlineDollarMath: boolean): Marked => { const key = inlineDollarMath ? 'dollar' : 'default'; let p = parsers.get(key); if (!p) { p = new Marked({ gfm: true, breaks: false, async: false }); - p.use({ extensions: [blockMath, inlineMathExtension(inlineDollarMath)] }); + p.use({ + extensions: [blockMath, inlineMathExtension(inlineDollarMath)], + renderer: { + code(token) { + // Keep marked's renderer, with inert source metadata for its exact fence body. + // This also distinguishes parser fences from a user-authored HTML
.
+          const key = [...crypto.getRandomValues(new Uint32Array(4))].join('-');
+          renderingFences?.set(key, codeSource(token));
+          return codeRenderer.code(token).replace('
', `
`);
+        },
+      },
+    });
     parsers.set(key, p);
   }
   return p;
@@ -186,6 +202,20 @@ export interface MarkdownBlock {
   incompleteCode: boolean;
 }
 
+const codeSource = (token: Tokens.Code): string => {
+  const opening = /^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/.exec(token.raw);
+  if (!opening) return token.text;
+  const body = token.raw.slice(opening[0].length);
+  let offset = 0;
+  for (const line of body.split('\n')) {
+    const closing = /^ {0,3}(`+|~+)[ \t]*$/.exec(line);
+    if (closing && closing[1][0] === opening[2][0] && closing[1].length >= opening[2].length)
+      return body.slice(0, offset);
+    offset += line.length + 1;
+  }
+  return body;
+};
+
 export interface MarkdownDocument {
   blocks: MarkdownBlock[];
   /** Render one of this document's blocks to a sanitized DOM fragment. */
@@ -215,8 +245,25 @@ const toBlock = (tokens: Token[], defs: string): MarkdownBlock => {
 /** Lex `markdown` once and group its tokens into independently renderable blocks. */
 export const parseDocument = (markdown: string, options: RenderOptions = {}): MarkdownDocument => {
   const parser = getParser(!!options.inlineMath);
-  const render = (block: MarkdownBlock): DocumentFragment =>
-    DOMPurify.sanitize(parser.parser(block.tokens), PURIFY_CONFIG);
+  const render = (block: MarkdownBlock): DocumentFragment => {
+    const sources = new Map();
+    const previous = renderingFences;
+    renderingFences = sources;
+    let fragment: DocumentFragment;
+    try {
+      fragment = DOMPurify.sanitize(parser.parser(block.tokens), PURIFY_CONFIG);
+    } finally {
+      renderingFences = previous;
+    }
+    const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT);
+    for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+      const element = node as Element;
+      const key = element.getAttribute('data-ran-code-source');
+      if (key && sources.has(key)) fenceBodies.set(element, sources.get(key)!);
+      element.removeAttribute('data-ran-code-source');
+    }
+    return fragment;
+  };
   if (!markdown.trim()) return { blocks: [], render };
   const tokens = parser.lexer(markdown);
   let defs = '';
@@ -239,11 +286,18 @@ export interface EnhanceContext {
   theme: string;
   labels: { copy: string; download: string };
   /** Copy pressed on a code block. `icon` is the button's , for the tick state. */
-  onCopy(icon: HTMLElement | null, code: string, lang: string): void;
+  onCopy(control: CodeCopyControl, code: string, lang: string, current: () => boolean): void;
+  resetCopy(control: CodeCopyControl): void;
   /** Download pressed on a code block. */
   onDownload(code: string, lang: string): void;
 }
 
+export interface CodeCopyControl {
+  button: HTMLButtonElement;
+  icon: HTMLElement | null;
+  status: HTMLElement;
+}
+
 export interface EnhanceResult {
   /**
    * Embedded renderers that bake the theme into their output, handed back so the
@@ -263,9 +317,10 @@ const LANG_CLASS_PREFIX = 'language-';
 const readCodeBlock = (codeEl: Element): { lang: string; code: string } => {
   const langClass = Array.from(codeEl.classList).find((c) => c.startsWith(LANG_CLASS_PREFIX));
   const text = codeEl.textContent || '';
+  const source = codeEl.parentElement ? fenceBodies.get(codeEl.parentElement) : undefined;
   return {
     lang: langClass ? langClass.slice(LANG_CLASS_PREFIX.length).toLowerCase() : '',
-    code: text.endsWith('\n') ? text.slice(0, -1) : text,
+    code: source ?? (text.endsWith('\n') ? text.slice(0, -1) : text),
   };
 };
 
@@ -297,43 +352,87 @@ const iconButton = (
 const codeLines = (code: string): HTMLElement[] => {
   const lines = code.split('\n');
   if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
-  return lines.flatMap((l, i) => {
+  const nodes = lines.flatMap((l, i) => {
     const line = Span().class('line').text(l).build();
     return i === 0 ? [line] : [document.createTextNode('\n') as unknown as HTMLElement, line];
   });
+  if (code.endsWith('\n')) nodes.push(document.createTextNode('\n') as unknown as HTMLElement);
+  return nodes;
 };
 
 /** A `
` holding the plain (un-highlighted) code. */
 const plainPre = (code: string): HTMLPreElement =>
   View('pre')
     .class('ran-md-pre')
+    .attr('tabindex', '0')
     .children(View('code').children(...codeLines(code)))
     .build();
 
-const applyHighlight = (
-  container: HTMLElement,
-  slot: { pre: HTMLElement },
-  code: string,
-  lang: string,
-  themes: HighlightThemes,
-  token: number,
-): void => {
-  const result = highlight(code, lang, themes);
+interface CodeView {
+  container: HTMLElement;
+  pre: HTMLElement;
+  code: HTMLElement;
+  language: string;
+  source: string;
+  revision: number;
+  ctx: EnhanceContext;
+  copy?: CodeCopyControl;
+  pendingHighlight?: string;
+}
+const codeViews = new WeakMap();
+const blockReaders = new WeakMap();
+
+const applyHighlight = (view: CodeView, html: string): void => {
+  if (hasReaderSelection(view.code)) {
+    view.pendingHighlight = html;
+    return;
+  }
+  view.pendingHighlight = undefined;
+  const template = document.createElement('template');
+  template.innerHTML = html;
+  const pre = template.content.firstElementChild;
+  const code = pre?.firstElementChild;
+  if (
+    !(pre instanceof HTMLElement) ||
+    pre.tagName !== 'PRE' ||
+    !(code instanceof HTMLElement) ||
+    code.tagName !== 'CODE'
+  )
+    return;
+  pre.classList.add('ran-md-pre');
+  pre.removeAttribute('style');
+  pre.setAttribute('tabindex', '0');
+  syncReaderAttributes(view.pre, pre);
+  reconcileReader(view.code, code);
+};
+
+const requestHighlight = (view: CodeView): void => {
+  if (!view.ctx.highlight) return;
+  const revision = view.revision;
   const apply = (html: string | null): void => {
-    // The block may have been re-rendered (streaming) or removed while shiki loaded.
-    if (!html || !container.isConnected || Number(container.dataset.renderToken) !== token) return;
-    const tpl = document.createElement('template');
-    tpl.innerHTML = html;
-    const shikiPre = tpl.content.firstElementChild as HTMLElement | null;
-    if (!shikiPre || shikiPre.tagName !== 'PRE') return;
-    shikiPre.classList.add('ran-md-pre');
-    shikiPre.removeAttribute('style');
-    shikiPre.removeAttribute('tabindex');
-    slot.pre.replaceWith(shikiPre);
-    slot.pre = shikiPre;
+    if (!html || !view.container.isConnected || view.revision !== revision) return;
+    applyHighlight(view, html);
   };
+  const result = highlight(view.source, view.language, view.ctx.highlight);
   if (typeof result === 'string') apply(result);
-  else result.then(apply);
+  else void result.then(apply, () => {});
+};
+
+export const settleHighlights = (root: HTMLElement): void => {
+  for (const view of blockReaders.get(root) ?? [])
+    if (view.container.isConnected && view.pendingHighlight) applyHighlight(view, view.pendingHighlight);
+};
+
+export const releaseReaders = (root: HTMLElement): void => {
+  for (const view of blockReaders.get(root) ?? []) {
+    ++view.revision;
+    view.pendingHighlight = undefined;
+    if (view.copy) view.ctx.resetCopy(view.copy);
+  }
+};
+
+export const resumeReaders = (root: HTMLElement): void => {
+  for (const view of blockReaders.get(root) ?? []) requestHighlight(view);
 };
 
 let renderSeq = 0;
@@ -352,10 +451,12 @@ const mathElement = (latex: string, display: 'block' | 'inline'): HTMLElement =>
   View('r-math').attr('latex', encodeURIComponent(latex)).attr('display', display).build();
 
 /** Rewrites one `
`; returns an embedded renderer to retheme, if it made one. */
-const enhanceCodeBlock = (pre: HTMLPreElement, ctx: EnhanceContext): HTMLElement | null => {
+const enhanceCodeBlock = (pre: HTMLPreElement, ctx: EnhanceContext): { themed?: HTMLElement; reader?: CodeView } => {
   const codeEl = pre.firstElementChild;
-  if (!codeEl || codeEl.tagName !== 'CODE') return null;
-  const { lang, code } = readCodeBlock(codeEl);
+  if (!codeEl || codeEl.tagName !== 'CODE') return {};
+  const parsed = readCodeBlock(codeEl);
+  const lang = parsed.lang,
+    code = parsed.code;
 
   // ```mermaid →  once the fence is closed (a half-streamed diagram is
   // never valid; keep it as a plain code block until then).
@@ -363,22 +464,35 @@ const enhanceCodeBlock = (pre: HTMLPreElement, ctx: EnhanceContext): HTMLElement
     void import('@/components/mermaid');
     const diagram = mermaidElement(code, ctx);
     pre.replaceWith(diagram);
-    return diagram;
+    return { themed: diagram };
   }
   // ```math / ```latex → block  (GitHub convention).
   if ((lang === 'math' || lang === 'latex') && !ctx.incomplete) {
     void import('@/components/math');
     pre.replaceWith(mathElement(code.trim(), 'block'));
-    return null;
+    return {};
   }
 
   const token = ++renderSeq;
   const actions: HTMLElement[] = [];
+  let view!: CodeView;
+  let copy: CodeCopyControl | undefined;
   if (ctx.copy) {
-    actions.push(iconButton('copy', 'copy', ctx.labels.copy, (icon) => ctx.onCopy(icon, code, lang)));
+    const status = Span().class('ran-md-copy-status').part('copy-status').attr('role', 'status').build();
+    status.hidden = true;
+    const button = iconButton('copy', 'copy', ctx.labels.copy, (icon) => {
+      if (!copy) return;
+      copy.icon = icon;
+      const revision = view.revision;
+      view.ctx.onCopy(copy, view.source, view.language, () => view.container.isConnected && view.revision === revision);
+    });
+    copy = { button, icon: null, status };
+    actions.push(status, button);
   }
   if (ctx.download) {
-    actions.push(iconButton('download', 'download', ctx.labels.download, () => ctx.onDownload(code, lang)));
+    actions.push(
+      iconButton('download', 'download', ctx.labels.download, () => view.ctx.onDownload(view.source, view.language)),
+    );
   }
   const header =
     lang || actions.length
@@ -406,8 +520,19 @@ const enhanceCodeBlock = (pre: HTMLPreElement, ctx: EnhanceContext): HTMLElement
   if (ctx.lineNumbers) container.dataset.lineNumbers = '';
   pre.replaceWith(container);
 
-  if (ctx.highlight) applyHighlight(container, slot, code, lang, ctx.highlight, token);
-  return null;
+  view = {
+    container,
+    pre: slot.pre,
+    code: slot.pre.firstElementChild as HTMLElement,
+    language: lang,
+    source: code,
+    revision: token,
+    ctx,
+    copy,
+  };
+  codeViews.set(container, view);
+  requestHighlight(view);
+  return { reader: view };
 };
 
 /**
@@ -505,10 +630,13 @@ export const enhance = (root: HTMLElement, ctx: EnhanceContext): EnhanceResult =
   }
 
   const themed: HTMLElement[] = [];
+  const readers: CodeView[] = [];
   for (const pre of pres) {
-    const el = enhanceCodeBlock(pre, ctx);
-    if (el) themed.push(el);
+    const result = enhanceCodeBlock(pre, ctx);
+    if (result.reader) readers.push(result.reader);
+    if (result.themed) themed.push(result.themed);
   }
+  blockReaders.set(root, readers);
 
   if (maths.length > 0) {
     void import('@/components/math');
@@ -534,7 +662,7 @@ export const enhance = (root: HTMLElement, ctx: EnhanceContext): EnhanceResult =
   }
 
   for (const table of tables) {
-    const wrap = Div().class('ran-md-table-wrap').part('table').build();
+    const wrap = Div().class('ran-md-table-wrap').part('table').attr('tabindex', '0').build();
     table.replaceWith(wrap);
     wrap.appendChild(table);
   }
@@ -546,5 +674,52 @@ export const enhance = (root: HTMLElement, ctx: EnhanceContext): EnhanceResult =
   return { themed };
 };
 
+/** Reuse the code/table reading object when the corresponding source block grows. */
+export const renderBlock = (root: HTMLElement, fragment: DocumentFragment, ctx: EnhanceContext): EnhanceResult => {
+  const old = root.childElementCount === 1 ? root.firstElementChild : null;
+  const next = fragment.childElementCount === 1 ? fragment.firstElementChild : null;
+  if (old instanceof HTMLElement && next instanceof HTMLPreElement) {
+    const view = codeViews.get(old);
+    const code = next.firstElementChild;
+    if (view && code?.tagName === 'CODE') {
+      const parsed = readCodeBlock(code);
+      if (parsed.lang === view.language && !(['mermaid', 'math', 'latex'].includes(parsed.lang) && !ctx.incomplete)) {
+        const source = parsed.code;
+        if (source !== view.source) {
+          ++view.revision;
+          view.pendingHighlight = undefined;
+          if (view.copy) view.ctx.resetCopy(view.copy);
+        }
+        view.source = source;
+        view.ctx = ctx;
+        view.container.toggleAttribute('data-incomplete', ctx.incomplete);
+        const plain = plainPre(source).firstElementChild!;
+        const selecting = hasReaderSelection(view.code);
+        reconcileReader(view.code, plain, selecting);
+        if (!selecting) view.pre.classList.remove('shiki');
+        requestHighlight(view);
+        return { themed: [] };
+      }
+    }
+  }
+  if (
+    old instanceof HTMLElement &&
+    old.getAttribute('part') === 'table' &&
+    old.firstElementChild instanceof HTMLTableElement &&
+    next instanceof HTMLTableElement
+  ) {
+    // Reconcile the same enhanced tree used on first render, so math, safe links
+    // and lazy images retain their contracts when table rows stream in.
+    const incoming = Div().build();
+    incoming.appendChild(next);
+    const result = enhance(incoming, ctx);
+    reconcileReader(old.firstElementChild, next);
+    return result;
+  }
+  releaseReaders(root);
+  root.replaceChildren(fragment);
+  return enhance(root, ctx);
+};
+
 export { parseThemes };
 export type { HighlightThemes };
diff --git a/packages/ranui/components/math/index.less b/packages/ranui/components/math/index.less
index 587f0f3ef0..621245bab1 100644
--- a/packages/ranui/components/math/index.less
+++ b/packages/ranui/components/math/index.less
@@ -4,6 +4,8 @@
   display: var(--ran-math-display, block);
   color: var(--ran-math-color, var(--ran-color-text, inherit));
   position: var(--ran-math-position, relative);
+  min-width: 0;
+  max-width: 100%;
 }
 
 /* `hidden` is how a caller hides an element, and the UA rule that implements it is a
@@ -18,6 +20,7 @@
 }
 
 .ran-math {
+  min-width: 0;
   position: relative;
   text-align: var(--ran-math-align, center);
 }
@@ -40,35 +43,37 @@
   text-align: left;
 }
 
-/* ── Toolbar (opt-in via `copy`) — hover-revealed, top-right ─────────────── */
+.ran-math-render {
+  overflow-x: auto;
+  max-width: 100%;
+}
+
+/* Opt-in actions remain visible in normal flow; absent controls take no space. */
 .ran-math-toolbar {
-  position: absolute;
-  top: var(--ran-math-toolbar-top, 6px);
-  right: var(--ran-math-toolbar-right, 6px);
+  position: relative;
+  top: var(--ran-math-toolbar-top, 0);
+  right: var(--ran-math-toolbar-right, 0);
   display: none;
-  gap: var(--ran-math-toolbar-gap, 4px);
-  padding: var(--ran-math-toolbar-padding, var(--ran-space-1, 4px));
-  border-radius: var(--ran-radius-md, 8px);
-  background: var(--ran-math-toolbar-background, var(--ran-color-bg-elevated, #fff));
-  box-shadow: var(--ran-math-toolbar-shadow, var(--ran-shadow-menu, 0 2px 8px rgba(0, 0, 0, 0.12)));
-  opacity: 0;
-  transition: opacity 0.15s ease;
-  pointer-events: none;
+  align-items: center;
+  justify-content: flex-end;
+  gap: var(--ran-math-toolbar-gap, var(--ran-space-1, 4px));
+  padding: var(--ran-math-toolbar-padding, 0);
+  margin-bottom: var(--ran-space-2, 8px);
+  background: var(--ran-math-toolbar-background, transparent);
+  box-shadow: var(--ran-math-toolbar-shadow, none);
 }
 .ran-math.has-controls .ran-math-toolbar {
   display: flex;
 }
-.ran-math:hover .ran-math-toolbar,
-.ran-math-toolbar:focus-within {
-  opacity: 1;
-  pointer-events: auto;
-}
 .ran-math-btn {
   display: inline-flex;
   align-items: center;
   justify-content: center;
-  width: var(--ran-math-button-size, var(--ran-size-5, 28px));
-  height: var(--ran-math-button-size, var(--ran-size-5, 28px));
+  width: var(--ran-math-button-size, 32px);
+  height: var(--ran-math-button-size, 32px);
+  min-width: 32px;
+  min-height: 32px;
+  flex-shrink: 0;
   padding: 0;
   border: none;
   border-radius: var(--ran-radius-sm, 6px);
@@ -88,7 +93,7 @@
 /* Download format menu (shown when `download` lists >1 format). */
 .ran-math-menu {
   position: absolute;
-  top: var(--ran-math-menu-top, 40px);
+  top: var(--ran-math-menu-top, calc(100% + var(--ran-space-2, 8px)));
   right: var(--ran-math-toolbar-right, 6px);
   display: flex;
   flex-direction: column;
@@ -115,8 +120,22 @@
 .ran-math-menu-item:hover {
   background: var(--ran-math-button-hover-background, var(--ran-color-bg-muted, rgba(0, 0, 0, 0.06)));
 }
-@media (prefers-reduced-motion: reduce) {
-  .ran-math-toolbar {
-    transition: none;
+
+.ran-math-copy-status {
+  flex: 1;
+  min-width: 0;
+  color: var(--ran-color-danger);
+  font-size: var(--ran-text-label-2, 13px);
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+  text-align: left;
+}
+@media (hover: none), (pointer: coarse) {
+  .ran-math-btn {
+    min-width: 44px;
+    min-height: 44px;
+  }
+  .ran-math-menu-item {
+    min-height: 44px;
   }
 }
diff --git a/packages/ranui/components/math/index.ts b/packages/ranui/components/math/index.ts
index 0f70747b0f..c8c127d48b 100644
--- a/packages/ranui/components/math/index.ts
+++ b/packages/ranui/components/math/index.ts
@@ -76,6 +76,9 @@ export class Math extends RanElement {
   contain: HTMLElement;
   _toolbar: HTMLElement;
   _copyResetTimer?: number;
+  private _copyStatus?: HTMLElement;
+  private _copyRevision = 0;
+  private _copyPending?: { button: HTMLButtonElement };
   _downloadMenu?: HTMLElement;
   // The lazy temml + font-face imports inside render() are deliberately
   // fire-and-forget for callers — but tests need a way to know when a render
@@ -95,8 +98,8 @@ export class Math extends RanElement {
       .class('ran-math')
       .part('math')
       .children(
-        Div().class('ran-math-render').ref(containRef).part('render'),
         Div().class('ran-math-toolbar').ref(toolbarRef).part('toolbar'),
+        Div().class('ran-math-render').ref(containRef).part('render'),
       )
       .build();
     this._shadowDom.appendChild(this._wrap);
@@ -197,6 +200,7 @@ export class Math extends RanElement {
   }
   // ── Rendering ─────────────────────────────────────────────────────────────
   render(): void {
+    this.invalidateCopy();
     const latex = this.latex;
     if (!latex) {
       this.contain.innerHTML = '';
@@ -228,7 +232,7 @@ export class Math extends RanElement {
           const wrap = this.wrap;
           if (wrap) options.wrap = wrap;
           this.contain.innerHTML = temml.renderToString(latex, options);
-          this.buildToolbar();
+          if (!this._toolbar.querySelector('button')) this.buildToolbar();
           this.emit('render', { ok: true });
         } catch (err) {
           this.showError(String((err as Error)?.message || err));
@@ -250,7 +254,17 @@ export class Math extends RanElement {
     this._events.on(btn, 'click', onClick);
     return btn;
   }
+  private invalidateCopy(): void {
+    this._copyRevision++;
+    this._copyPending?.button.removeAttribute('aria-busy');
+    this._copyPending = undefined;
+    window.clearTimeout(this._copyResetTimer);
+    this._copyStatus?.remove();
+    this._copyStatus = undefined;
+    if (this.copyable) this._toolbar.querySelector('button r-icon')?.setAttribute('name', 'copy');
+  }
   private buildToolbar(): void {
+    this.invalidateCopy();
     this.closeDownloadMenu();
     this._toolbar.innerHTML = '';
     const buttons: HTMLElement[] = [];
@@ -268,19 +282,53 @@ export class Math extends RanElement {
     return math ? math.outerHTML : '';
   }
   // Copy the source (default) or the rendered MathML, per the `copy` attribute value.
-  private copyContent = (): void => {
+  private copyContent = async (): Promise => {
+    const button = this._toolbar.querySelector('button');
+    if (!button || this._copyPending?.button === button) return;
     const target = this.copyTarget;
-    const text = target === 'mathml' ? this.currentMathML() : this.latex;
-    if (!text || !navigator.clipboard) return;
-    navigator.clipboard.writeText(text).then(() => {
+    const source = this.latex;
+    if (!source) return;
+    const revision = this._copyRevision;
+    const current = (): boolean =>
+      this.isConnected &&
+      revision === this._copyRevision &&
+      this.latex === source &&
+      this._toolbar.querySelector('button') === button;
+    const operation = { button };
+    this._copyPending = operation;
+    button.setAttribute('aria-busy', 'true');
+    window.clearTimeout(this._copyResetTimer);
+    this._copyStatus?.remove();
+    this._copyStatus = undefined;
+    const icon = button.querySelector('r-icon');
+    icon?.setAttribute('name', 'copy');
+    try {
+      // MathML belongs to the rendered source, not the previous frame. Wait for
+      // this render without replacing the focused control or copying stale DOM.
+      if (target === 'mathml') await this._pending;
+      if (!current()) return;
+      const text = target === 'mathml' ? this.currentMathML() : source;
+      if (!text) return;
+      if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
+      await navigator.clipboard.writeText(text);
+      if (!current()) return;
       this.emit('copied', { kind: target });
-      const icon = this._toolbar.querySelector('r-icon[name="copy"]');
-      if (icon) {
-        icon.setAttribute('name', 'check');
-        window.clearTimeout(this._copyResetTimer);
-        this._copyResetTimer = window.setTimeout(() => icon.setAttribute('name', 'copy'), 1200);
+      icon?.setAttribute('name', 'check');
+      this._copyResetTimer = window.setTimeout(() => {
+        if (current()) icon?.setAttribute('name', 'copy');
+      }, 1200);
+    } catch {
+      if (!current()) return;
+      const status = Div().class('ran-math-copy-status').part('copy-status').attr('role', 'status').build();
+      status.textContent = this.label('copy-error', 'Could not copy. Try again.');
+      this._copyStatus = status;
+      this._toolbar.prepend(status);
+    } finally {
+      if (this._copyPending === operation) {
+        button.removeAttribute('aria-busy');
+        this._copyPending = undefined;
       }
-    });
+    }
   };
   // ── Download (opt-in) — source (.tex) / MathML (.mml) ─────────────────────
   // `download` (bare) offers both via a menu; restrict with `download="mathml"` or
@@ -341,7 +389,7 @@ export class Math extends RanElement {
       });
       menu.appendChild(item);
     });
-    this._wrap.appendChild(menu);
+    this._toolbar.appendChild(menu);
     this._downloadMenu = menu;
   }
   private closeDownloadMenu = (): void => {
@@ -351,11 +399,14 @@ export class Math extends RanElement {
   // ── Lifecycle ─────────────────────────────────────────────────────────────
   connectedCallback(): void {
     this.handlerExternalCss();
+    // Rebind toolbar listeners after reconnect; source updates preserve its focus.
+    this.buildToolbar();
     this.render();
     // Close the download menu when the pointer leaves (the toolbar hides too).
     this._events.on(this._wrap, 'mouseleave', this.closeDownloadMenu);
   }
   disconnectedCallback(): void {
+    this.invalidateCopy();
     this._events.abort();
     window.clearTimeout(this._copyResetTimer);
     this.closeDownloadMenu();
diff --git a/packages/ranui/components/mermaid/index.less b/packages/ranui/components/mermaid/index.less
index 26b62ee505..ca32107476 100644
--- a/packages/ranui/components/mermaid/index.less
+++ b/packages/ranui/components/mermaid/index.less
@@ -23,35 +23,32 @@
   height: auto;
 }
 
-/* Hover-revealed control bar (opt-in via copy/download/fullscreen attributes). */
+/* Opt-in actions remain visible in normal flow; absent controls take no space. */
 .ran-mermaid-toolbar {
-  position: absolute;
-  top: var(--ran-mermaid-toolbar-top, 8px);
-  right: var(--ran-mermaid-toolbar-right, 8px);
+  position: relative;
+  top: var(--ran-mermaid-toolbar-top, 0);
+  right: var(--ran-mermaid-toolbar-right, 0);
   display: none;
-  gap: var(--ran-mermaid-toolbar-gap, 4px);
-  padding: var(--ran-mermaid-toolbar-padding, var(--ran-space-1, 4px));
-  border-radius: var(--ran-radius-md, 8px);
-  background: var(--ran-mermaid-toolbar-background, var(--ran-color-bg-elevated, #fff));
-  box-shadow: var(--ran-mermaid-toolbar-shadow, var(--ran-shadow-menu, 0 2px 8px rgba(0, 0, 0, 0.12)));
-  opacity: 0;
-  transition: opacity 0.15s ease;
-  pointer-events: none;
+  align-items: center;
+  justify-content: flex-end;
+  gap: var(--ran-mermaid-toolbar-gap, var(--ran-space-1, 4px));
+  padding: var(--ran-mermaid-toolbar-padding, 0);
+  margin-bottom: var(--ran-space-2, 8px);
+  background: var(--ran-mermaid-toolbar-background, transparent);
+  box-shadow: var(--ran-mermaid-toolbar-shadow, none);
 }
 .ran-mermaid.has-controls .ran-mermaid-toolbar {
   display: flex;
 }
-.ran-mermaid:hover .ran-mermaid-toolbar,
-.ran-mermaid-toolbar:focus-within {
-  opacity: 1;
-  pointer-events: auto;
-}
 .ran-mermaid-btn {
   display: inline-flex;
   align-items: center;
   justify-content: center;
-  width: var(--ran-mermaid-button-size, var(--ran-size-5, 28px));
-  height: var(--ran-mermaid-button-size, var(--ran-size-5, 28px));
+  width: var(--ran-mermaid-button-size, 32px);
+  height: var(--ran-mermaid-button-size, 32px);
+  min-width: 32px;
+  min-height: 32px;
+  flex-shrink: 0;
   padding: 0;
   border: none;
   border-radius: var(--ran-radius-sm, 6px);
@@ -70,7 +67,7 @@
 /* Download format menu (shown when the download attribute lists >1 format). */
 .ran-mermaid-menu {
   position: absolute;
-  top: var(--ran-mermaid-menu-top, 44px);
+  top: var(--ran-mermaid-menu-top, calc(100% + var(--ran-space-2, 8px)));
   right: var(--ran-mermaid-toolbar-right, 8px);
   display: flex;
   flex-direction: column;
@@ -104,3 +101,22 @@
   font-size: var(--ran-mermaid-error-font-size, var(--ran-text-label-2, 13px));
   font-weight: var(--ran-mermaid-error-font-weight, var(--ran-text-label-weight, 500));
 }
+
+.ran-mermaid-copy-status {
+  flex: 1;
+  min-width: 0;
+  color: var(--ran-color-danger);
+  font-size: var(--ran-text-label-2, 13px);
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+  text-align: left;
+}
+@media (hover: none), (pointer: coarse) {
+  .ran-mermaid-btn {
+    min-width: 44px;
+    min-height: 44px;
+  }
+  .ran-mermaid-menu-item {
+    min-height: 44px;
+  }
+}
diff --git a/packages/ranui/components/mermaid/index.ts b/packages/ranui/components/mermaid/index.ts
index a707efdaae..c9b6b6f7d9 100644
--- a/packages/ranui/components/mermaid/index.ts
+++ b/packages/ranui/components/mermaid/index.ts
@@ -33,6 +33,9 @@ export class Mermaid extends RanElement {
   _toolbar: HTMLElement;
   _themeObserver?: MutationObserver;
   _copyResetTimer?: number;
+  private _copyStatus?: HTMLElement;
+  private _copyRevision = 0;
+  private _copyPending?: { button: HTMLButtonElement };
   _fullscreenClose?: () => void;
   _downloadMenu?: HTMLElement;
   static get observedAttributes(): string[] {
@@ -47,8 +50,8 @@ export class Mermaid extends RanElement {
       .class('ran-mermaid')
       .part('mermaid')
       .children(
-        Div().class('ran-mermaid-diagram').ref(diagramRef).part('diagram'),
         Div().class('ran-mermaid-toolbar').ref(toolbarRef).part('toolbar'),
+        Div().class('ran-mermaid-diagram').ref(diagramRef).part('diagram'),
       )
       .build();
     this._shadowDom.appendChild(this._wrap);
@@ -113,6 +116,7 @@ export class Mermaid extends RanElement {
   };
   // ── Rendering ─────────────────────────────────────────────────────────────
   render(): void {
+    this.invalidateCopy();
     const graph = this.code;
     if (!graph) return;
     const id = `ran-mermaid-${seq++}`;
@@ -124,16 +128,27 @@ export class Mermaid extends RanElement {
           theme: this.resolveTheme(),
           fontFamily: 'inherit',
         });
+        // Mermaid measures an SVG in its temporary document-level #d{id} div.
+        // A host's universal reduced-motion duration can otherwise create tiny
+        // transform transitions during that measurement and inflate the viewBox.
+        // Keep this throwaway tree out of paint/accessibility while preserving
+        // layout and getBBox measurement. The displayed SVG remains visible and
+        // keeps the component's normal/reduced-motion treatment.
+        const measurementStyle = View('style').build();
+        measurementStyle.textContent = `#d${id}, #d${id} * { visibility: hidden !important; transition: none !important; }`;
+        document.head.appendChild(measurementStyle);
         try {
           const { svg } = await mermaid.render(id, graph);
           this._diagram.innerHTML = svg;
-          this.buildToolbar();
+          if (!this._toolbar.querySelector('button')) this.buildToolbar();
           this.emit('render', { ok: true });
         } catch (err) {
           // On failure mermaid leaves a "bomb" error graphic appended to  — drop it.
           document.getElementById(`d${id}`)?.remove();
           document.getElementById(id)?.remove();
           this.showError(String((err as Error)?.message || err));
+        } finally {
+          measurementStyle.remove();
         }
       })
       .catch((err: Error) => this.showError(err.message));
@@ -160,7 +175,18 @@ export class Mermaid extends RanElement {
     this._events.on(btn, 'click', onClick);
     return btn;
   }
+  private invalidateCopy(): void {
+    this._copyRevision++;
+    this._copyPending?.button.removeAttribute('aria-busy');
+    this._copyPending = undefined;
+    window.clearTimeout(this._copyResetTimer);
+    this._copyStatus?.remove();
+    this._copyStatus = undefined;
+    if (this.copyable) this._toolbar.querySelector('button r-icon')?.setAttribute('name', 'copy');
+  }
   private buildToolbar(): void {
+    this.invalidateCopy();
+    this.closeDownloadMenu();
     this._toolbar.innerHTML = '';
     const buttons: HTMLElement[] = [];
     if (this.copyable) buttons.push(this.iconButton('copy', this.label('copy', 'Copy source'), this.copySource));
@@ -171,18 +197,48 @@ export class Mermaid extends RanElement {
     buttons.forEach((b) => this._toolbar.appendChild(b));
     this._wrap.classList.toggle('has-controls', buttons.length > 0);
   }
-  private copySource = (): void => {
+  private copySource = async (): Promise => {
+    const button = this._toolbar.querySelector('button');
+    if (!button || this._copyPending?.button === button) return;
+    const target = 'source';
+    const text = this.code;
+    if (!text) return;
     const source = this.code;
-    if (!source || !navigator.clipboard) return;
-    navigator.clipboard.writeText(source).then(() => {
-      this.emit('copied', { kind: 'source' });
-      const icon = this._toolbar.querySelector('r-icon[name="copy"]');
-      if (icon) {
-        icon.setAttribute('name', 'check');
-        window.clearTimeout(this._copyResetTimer);
-        this._copyResetTimer = window.setTimeout(() => icon.setAttribute('name', 'copy'), 1200);
+    const revision = this._copyRevision;
+    const current = (): boolean =>
+      this.isConnected &&
+      revision === this._copyRevision &&
+      this.code === source &&
+      this._toolbar.querySelector('button') === button;
+    const operation = { button };
+    this._copyPending = operation;
+    button.setAttribute('aria-busy', 'true');
+    window.clearTimeout(this._copyResetTimer);
+    this._copyStatus?.remove();
+    this._copyStatus = undefined;
+    const icon = button.querySelector('r-icon');
+    icon?.setAttribute('name', 'copy');
+    try {
+      if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
+      await navigator.clipboard.writeText(text);
+      if (!current()) return;
+      this.emit('copied', { kind: target });
+      icon?.setAttribute('name', 'check');
+      this._copyResetTimer = window.setTimeout(() => {
+        if (current()) icon?.setAttribute('name', 'copy');
+      }, 1200);
+    } catch {
+      if (!current()) return;
+      const status = Div().class('ran-mermaid-copy-status').part('copy-status').attr('role', 'status').build();
+      status.textContent = this.label('copy-error', 'Could not copy. Try again.');
+      this._copyStatus = status;
+      this._toolbar.prepend(status);
+    } finally {
+      if (this._copyPending === operation) {
+        button.removeAttribute('aria-busy');
+        this._copyPending = undefined;
       }
-    });
+    }
   };
   private currentSvg(): SVGSVGElement | null {
     return this._diagram.querySelector('svg');
@@ -289,7 +345,7 @@ export class Mermaid extends RanElement {
       });
       menu.appendChild(item);
     });
-    this._wrap.appendChild(menu);
+    this._toolbar.appendChild(menu);
     this._downloadMenu = menu;
   }
   private closeDownloadMenu = (): void => {
@@ -420,6 +476,8 @@ export class Mermaid extends RanElement {
   // ── Lifecycle ─────────────────────────────────────────────────────────────
   connectedCallback(): void {
     this.handlerExternalCss();
+    // Rebind toolbar listeners after reconnect; source updates preserve its focus.
+    this.buildToolbar();
     this.render();
     // Close the download menu when the pointer leaves the diagram (the toolbar hides too).
     this._events.on(this._wrap, 'mouseleave', this.closeDownloadMenu);
@@ -432,6 +490,7 @@ export class Mermaid extends RanElement {
     }
   }
   disconnectedCallback(): void {
+    this.invalidateCopy();
     this._events.abort();
     this._themeObserver?.disconnect();
     this._themeObserver = undefined;
diff --git a/packages/ranui/components/message/index.less b/packages/ranui/components/message/index.less
index 819d01653e..4c55ed5845 100644
--- a/packages/ranui/components/message/index.less
+++ b/packages/ranui/components/message/index.less
@@ -1,3 +1,7 @@
+:host([hidden]) {
+  display: none;
+}
+
 :host {
   // A bare custom element defaults to `display: inline`, which silently drops
   // `transform` entirely (it only applies to block/inline-block/etc. boxes) —
@@ -11,7 +15,8 @@
         text-align: var(--ran-message-notice-toast-text-align, center);
         max-width: var(--ran-message-notice-toast-max-width, 90%);
         margin: var(--ran-message-notice-toast-margin, 0 auto);
-        word-break: var(--ran-message-notice-toast-word-break, break-all);
+        word-break: var(--ran-message-notice-toast-word-break, normal);
+        overflow-wrap: anywhere;
         &-content {
           display: var(--ran-message-content-toast-display, inline-block);
           padding: var(--ran-message-content-toast-padding, var(--ran-space-3, 12px) var(--ran-space-4, 16px));
@@ -50,7 +55,8 @@
     text-align: var(--ran-message-notice-text-align, center);
     max-width: var(--ran-message-notice-max-width, 90%);
     margin: var(--ran-message-notice-margin, 0 auto);
-    word-break: var(--ran-message-notice-word-break, break-all);
+    word-break: var(--ran-message-notice-word-break, normal);
+    overflow-wrap: anywhere;
     &-content {
       display: var(--ran-message-content-display, inline-block);
       padding: var(--ran-message-content-padding, var(--ran-space-3, 12px) var(--ran-space-4, 16px));
diff --git a/packages/ranui/components/message/index.ts b/packages/ranui/components/message/index.ts
index 7aae401777..4bc61d2ffc 100644
--- a/packages/ranui/components/message/index.ts
+++ b/packages/ranui/components/message/index.ts
@@ -191,7 +191,7 @@ function initMessageApi() {
 
       message.timeId = setTimeout(() => {
         message.classList.remove('message-leave');
-        div.removeChild(message);
+        if (message.parentNode === div) div.removeChild(message);
         if (close) close();
       }, duration);
 
diff --git a/packages/ranui/components/modal/index.ts b/packages/ranui/components/modal/index.ts
index b37cdaffe5..297d0e67bb 100644
--- a/packages/ranui/components/modal/index.ts
+++ b/packages/ranui/components/modal/index.ts
@@ -1,4 +1,11 @@
 import modalCss from './index.less?inline';
+import {
+  FLOATING_CHANGE,
+  floatingPanelsWithin,
+  dismissFloatingPanelWithin,
+  isFloatingDescendant,
+  closeFloatingPanelsWithin,
+} from '@/utils/floating';
 import { RanElement } from '@/utils/ssr';
 import { falseList } from '@/utils/dom';
 import { ButtonBuilder, Div, EventManager, Slot, View } from '@/utils/builder';
@@ -279,7 +286,10 @@ export class Modal extends RanElement {
   getFocusableElements = (): HTMLElement[] => {
     const dialogFocusable = Array.from(this._dialog.querySelectorAll(FOCUSABLE_SELECTOR)) as HTMLElement[];
     const slottedFocusable = Array.from(this.querySelectorAll(FOCUSABLE_SELECTOR)) as HTMLElement[];
-    return [...dialogFocusable, ...slottedFocusable].filter((item) => {
+    const portalFocusable = floatingPanelsWithin(this).flatMap((panel) =>
+      Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)),
+    );
+    return [...dialogFocusable, ...slottedFocusable, ...portalFocusable].filter((item) => {
       if (item.hasAttribute('disabled')) return false;
       const element = item as HTMLElement;
       return element.offsetParent !== null || element.getClientRects().length > 0;
@@ -319,12 +329,15 @@ export class Modal extends RanElement {
 
     if (Modal._openStack.length === 0) return;
     const openModalHosts = Modal._openStack.map((item) => item as HTMLElement);
+    const topModal = Modal.getTopModal();
+    const activePanels = topModal ? floatingPanelsWithin(topModal) : [];
     const bodyChildren = Array.from(body.children) as HTMLElement[];
 
     bodyChildren.forEach((child) => {
       // Keep the full ancestor branch interactive for every open modal host.
       const containsOpenModal = openModalHosts.some((host) => child === host || child.contains(host));
-      if (containsOpenModal) return;
+      const containsOwnedPanel = activePanels.some((panel) => child === panel || child.contains(panel));
+      if (containsOpenModal || containsOwnedPanel) return;
       Modal._inertSnapshot.set(child, {
         inert: child.inert,
         ariaHidden: child.getAttribute('aria-hidden'),
@@ -445,6 +458,8 @@ export class Modal extends RanElement {
       return;
     }
     this._previousActiveElement = document.activeElement;
+    const previousModal = Modal.getTopModal();
+    if (previousModal) closeFloatingPanelsWithin(previousModal, this);
     Modal.pushOpenModal(this);
     Modal.syncBackgroundInert();
     if (this.lockScroll) {
@@ -462,7 +477,9 @@ export class Modal extends RanElement {
     this.clearAfterTimers();
     this.emitAfterOpen();
     requestAnimationFrame(() => {
-      if (!this.autoFocus) return;
+      if (!this.autoFocus || !this.open || Modal.getTopModal() !== this) return;
+      // An opener or user may already have focused a field before this frame.
+      if (document.activeElement && isFloatingDescendant(document.activeElement, this)) return;
       const focusables = this.getFocusableElements();
       (focusables[0] || this._dialog).focus();
     });
@@ -474,6 +491,7 @@ export class Modal extends RanElement {
       this._dialog.setAttribute('aria-hidden', 'true');
       return;
     }
+    closeFloatingPanelsWithin(this);
     this._isDialogOpen = false;
     Modal.removeOpenModal(this);
     Modal.syncBackgroundInert();
@@ -514,6 +532,11 @@ export class Modal extends RanElement {
   onKeydown = (event: KeyboardEvent): void => {
     if (!this.open) return;
     if (Modal.getTopModal() !== this) return;
+    if (event.defaultPrevented) return;
+    if (event.key === 'Escape' && floatingPanelsWithin(this).length) {
+      dismissFloatingPanelWithin(this, event);
+      return;
+    }
     if (event.key === 'Escape' && this.closeOnEsc) {
       event.preventDefault();
       this.close('escape');
@@ -526,7 +549,7 @@ export class Modal extends RanElement {
     if (!this.open) return;
     if (Modal.getTopModal() !== this) return;
     const path = event.composedPath();
-    if (path.includes(this) || path.includes(this._dialog)) return;
+    if (path.some((target) => target instanceof Node && isFloatingDescendant(target, this))) return;
     const focusables = this.getFocusableElements();
     (focusables[0] || this._dialog).focus();
   };
@@ -536,8 +559,12 @@ export class Modal extends RanElement {
     this._events
       .on(this._mask, 'click', this.onMaskClick)
       .on(this._closeBtn, 'click', this.onCloseButtonClick)
+      // A target editor gets first refusal; the modal host then handles Escape
+      // before an ancestor portal, with document fallback for owned body portals.
+      .on(this, 'keydown', this.onKeydown as EventListener)
       .on(document, 'keydown', this.onKeydown as EventListener)
       .on(document, 'focusin', this.onDocumentFocusIn as EventListener)
+      .on(document, FLOATING_CHANGE, Modal.syncBackgroundInert)
       .on(this._footerSlot, 'slotchange', this.syncFooter);
     this.syncTitle();
     this.syncClosable();
diff --git a/packages/ranui/components/player/core/adapters/webrtc.ts b/packages/ranui/components/player/core/adapters/webrtc.ts
index e067d1d4e8..c76b299f2d 100644
--- a/packages/ranui/components/player/core/adapters/webrtc.ts
+++ b/packages/ranui/components/player/core/adapters/webrtc.ts
@@ -1,4 +1,3 @@
-import { withTimeoutFallback } from 'ranuts/utils';
 import { createAdapterEmitter, type EngineAdapter } from './types';
 
 /**
@@ -15,8 +14,7 @@ import { createAdapterEmitter, type EngineAdapter } from './types';
  * `format="webrtc"` attribute (`core/adapters/detect.ts`).
  *
  * Scope, deliberately kept modest: non-trickle ICE (waits for gathering to
- * finish, capped at `ICE_GATHERING_TIMEOUT_MS` via `ranuts/utils`'s
- * `withTimeoutFallback` — proceed with whatever candidates it has rather than
+ * finish, capped at `ICE_GATHERING_TIMEOUT_MS` — proceed with whatever candidates it has rather than
  * fail) rather than the WHEP PATCH-based trickle mechanism, and no
  * `Link: rel="ice-server"` header parsing for server-supplied STUN/TURN
  * hints — both are real WHEP spec features, left out because they add
@@ -28,80 +26,107 @@ import { createAdapterEmitter, type EngineAdapter } from './types';
  */
 const ICE_GATHERING_TIMEOUT_MS = 3000;
 
-function waitForIceGatheringComplete(pc: RTCPeerConnection, timeoutMs = ICE_GATHERING_TIMEOUT_MS): Promise {
-  if (pc.iceGatheringState === 'complete') return Promise.resolve();
-  let onChange: (() => void) | undefined;
-  const cleanup = (): void => {
-    if (onChange) pc.removeEventListener('icegatheringstatechange', onChange);
-  };
-  const gathering = new Promise((resolve) => {
-    onChange = (): void => {
-      if (pc.iceGatheringState !== 'complete') return;
-      cleanup();
+function waitForIceGatheringComplete(pc: RTCPeerConnection, signal: AbortSignal): Promise {
+  if (pc.iceGatheringState === 'complete' || signal.aborted) return Promise.resolve();
+  return new Promise((resolve) => {
+    const finish = (): void => {
+      clearTimeout(timer);
+      pc.removeEventListener('icegatheringstatechange', onChange);
+      signal.removeEventListener('abort', finish);
       resolve();
     };
+    const onChange = (): void => {
+      if (pc.iceGatheringState === 'complete') finish();
+    };
+    const timer = setTimeout(finish, ICE_GATHERING_TIMEOUT_MS);
     pc.addEventListener('icegatheringstatechange', onChange);
+    signal.addEventListener('abort', finish, { once: true });
   });
-  return withTimeoutFallback(gathering, timeoutMs, undefined, cleanup);
 }
 
 export function createWebrtcAdapter(): EngineAdapter {
   const emitter = createAdapterEmitter();
-  let peerConnection: RTCPeerConnection | undefined;
-  let resourceUrl: string | undefined;
+  type Session = {
+    pc: RTCPeerConnection;
+    abort: AbortController;
+    video: HTMLVideoElement;
+    stream?: MediaStream;
+    resourceUrl?: string;
+  };
+  let session: Session | undefined;
+  const deleteResource = (url: string): void => {
+    void fetch(url, { method: 'DELETE' }).catch(() => undefined);
+  };
+  const destroy = (): void => {
+    const previous = session;
+    session = undefined;
+    if (!previous) return;
+    previous.abort.abort();
+    previous.pc.ontrack = null;
+    previous.pc.onconnectionstatechange = null;
+    previous.pc.close();
+    if (previous.stream && previous.video.srcObject === previous.stream) previous.video.srcObject = null;
+    if (previous.resourceUrl) deleteResource(previous.resourceUrl);
+  };
 
   return {
     reloadsOnQualityChange: false,
     load: async (video, src) => {
+      destroy();
       if (!src) return;
       const pc = new RTCPeerConnection();
-      peerConnection = pc;
-      // `recvonly` — this is a playback-only adapter, never publishing.
+      const current: Session = { pc, video, abort: new AbortController() };
+      session = current;
+      const isCurrent = (): boolean => session === current;
       pc.addTransceiver('video', { direction: 'recvonly' });
       pc.addTransceiver('audio', { direction: 'recvonly' });
       pc.ontrack = (event) => {
+        if (!isCurrent()) return;
         const [stream] = event.streams;
-        if (stream) video.srcObject = stream;
+        if (stream) {
+          current.stream = stream;
+          video.srcObject = stream;
+        }
       };
       pc.onconnectionstatechange = () => {
-        if (pc.connectionState === 'failed') {
+        if (isCurrent() && pc.connectionState === 'failed') {
           emitter.emit('error', { fatal: true, detail: { connectionState: pc.connectionState } });
         }
       };
       try {
+        const endpoint = new URL(src, video.ownerDocument.baseURI).href;
         const offer = await pc.createOffer();
+        if (!isCurrent()) return;
         await pc.setLocalDescription(offer);
-        await waitForIceGatheringComplete(pc);
-        const response = await fetch(src, {
+        if (!isCurrent()) return;
+        await waitForIceGatheringComplete(pc, current.abort.signal);
+        if (!isCurrent()) return;
+        const response = await fetch(endpoint, {
           method: 'POST',
           headers: { 'Content-Type': 'application/sdp' },
           body: pc.localDescription?.sdp,
+          signal: current.abort.signal,
         });
         if (!response.ok) {
-          emitter.emit('error', { fatal: true, detail: { status: response.status } });
+          if (isCurrent()) emitter.emit('error', { fatal: true, detail: { status: response.status } });
           return;
         }
-        // The WHEP session's resource URL — DELETE this on destroy() to tell the
-        // server playback has ended. Resolved against `src` since servers may
-        // return a relative path.
+        // Redirected endpoints and relative session locations use the actual response URL.
         const location = response.headers.get('Location');
-        resourceUrl = location ? new URL(location, src).href : undefined;
+        const resourceUrl = location ? new URL(location, response.url || endpoint).href : undefined;
+        if (!isCurrent()) {
+          if (resourceUrl) deleteResource(resourceUrl);
+          return;
+        }
+        current.resourceUrl = resourceUrl;
         const answerSdp = await response.text();
+        if (!isCurrent()) return;
         await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
       } catch (error) {
-        emitter.emit('error', { fatal: true, detail: error });
-      }
-    },
-    destroy: () => {
-      if (resourceUrl) {
-        // Best-effort session teardown — the server should also time out the
-        // session on its own once the PeerConnection actually closes.
-        fetch(resourceUrl, { method: 'DELETE' }).catch(() => undefined);
+        if (isCurrent()) emitter.emit('error', { fatal: true, detail: error });
       }
-      peerConnection?.close();
-      peerConnection = undefined;
-      resourceUrl = undefined;
     },
+    destroy,
     getQualityLevels: () => [],
     setQuality: () => undefined,
     on: emitter.on,
diff --git a/packages/ranui/components/player/core/effects.ts b/packages/ranui/components/player/core/effects.ts
index f0178e4722..404e043241 100644
--- a/packages/ranui/components/player/core/effects.ts
+++ b/packages/ranui/components/player/core/effects.ts
@@ -13,6 +13,7 @@ export interface PlayerVisualEffectRefs {
   progressBuffer: HTMLElement;
   timeCurrent: HTMLElement;
   timeDuration: HTMLElement;
+  timeDivide: HTMLElement;
   volumeIconGlyph: HTMLElement;
   volumeProgress: HTMLElement;
 }
@@ -44,9 +45,12 @@ export function createPlaybackVisualEffects(
       refs.progressDot.style.setProperty('transform', `translateX(${percentage * refs.progress.offsetWidth}px)`);
       refs.progressBuffer.style.setProperty('transform', `scaleX(${normalizeProgress(buffered)})`);
       refs.progress.setAttribute('aria-valuenow', String(Math.round(percentage * 100)));
-      refs.progress.setAttribute('aria-valuetext', formatDuration(percentage * duration));
+      refs.progress.setAttribute('aria-valuetext', formatDuration(currentTime));
       refs.timeCurrent.innerText = formatDuration(currentTime);
-      refs.timeDuration.innerText = formatDuration(duration);
+      refs.timeDuration.innerText = hasDuration ? formatDuration(duration) : '';
+      refs.timeDuration.hidden = !hasDuration;
+      refs.timeDivide.innerText = hasDuration ? '/' : '';
+      refs.timeDivide.hidden = !hasDuration;
     }),
     // Volume icon mute/unmute + slider percent — was duplicated between
     // changePlayerVolume (mute toggle) and changeVolumeProgress (drag).
diff --git a/packages/ranui/components/player/core/media-dispatch.ts b/packages/ranui/components/player/core/media-dispatch.ts
index 0f913836d6..e463e96918 100644
--- a/packages/ranui/components/player/core/media-dispatch.ts
+++ b/packages/ranui/components/player/core/media-dispatch.ts
@@ -170,7 +170,6 @@ export function createMediaEventHandlers(deps: PlayerMediaDispatchDeps): PlayerM
         visualSignals.duration.setter(duration);
         visualSignals.currentTime.setter(currentTimeWhenSwitching);
       });
-      refs.timeDivide.innerText = '/';
       deps.change('loadeddata', e);
     },
     onTimeupdate: (e: Event): void => {
diff --git a/packages/ranui/components/player/index.less b/packages/ranui/components/player/index.less
index 6ee483b30c..4835d42d1a 100644
--- a/packages/ranui/components/player/index.less
+++ b/packages/ranui/components/player/index.less
@@ -219,6 +219,8 @@
 }
 
 .ran-player {
+  // The media canvas stays dark beneath the fixed white controls in either page theme.
+  background: var(--ran-player-background, var(--ran-media-background));
   display: var(--ran-player-display, block);
   height: var(--ran-player-height, 100%);
   width: var(--ran-player-width, 100%);
diff --git a/packages/ranui/components/player/index.ts b/packages/ranui/components/player/index.ts
index fe8be2060d..086e8ae445 100644
--- a/packages/ranui/components/player/index.ts
+++ b/packages/ranui/components/player/index.ts
@@ -238,6 +238,7 @@ export class RanPlayer extends RanElement {
       progressBuffer: this._progressWrapBuffer,
       timeCurrent: this._playerControllerBottomTimeCurrent,
       timeDuration: this._playerControllerBottomTimeDuration,
+      timeDivide: this._playerControllerBottomTimeDivide,
       volumeIconGlyph: this._playControllerBottomVolumeIconGlyph,
       volumeProgress: this._playControllerBottomVolumeProgress,
     };
diff --git a/packages/ranui/components/popover/index.ts b/packages/ranui/components/popover/index.ts
index faa55ac968..8923a2097c 100644
--- a/packages/ranui/components/popover/index.ts
+++ b/packages/ranui/components/popover/index.ts
@@ -19,7 +19,7 @@ import {
 } from '@/utils/component';
 import popoverCss from './index.less?inline';
 import { defineSSR } from '@/utils/ssr-registry';
-import { FloatingController } from '@/utils/floating';
+import { FloatingController, floatingPanelsWithin, dismissFloatingPanelWithin } from '@/utils/floating';
 import type { Placement, PlacementSide } from '@/utils/placement';
 import { isActivationKey } from '@/utils/a11y';
 
@@ -69,6 +69,7 @@ export class Popover extends RanElement {
   _shadowDom: ShadowRoot;
   removeTimeId?: ReturnType;
   _repositionBound = false;
+  private _returnFocus?: HTMLElement;
   /**
    * Positioning, portalling, scroll-following, the enter/exit animation and the
    * show/hide events are the shared controller's — r-select drives the same one.
@@ -76,6 +77,7 @@ export class Popover extends RanElement {
    * and where its arrow has to point afterwards.
    */
   _floating = new FloatingController({
+    onEscape: (event) => this._dismissFromKeyboard(event),
     host: this,
     panel: () => this.popoverContent,
     // The *trigger*, not the host. The host is `display: block` and stretches to
@@ -265,6 +267,7 @@ export class Popover extends RanElement {
         .cssVar('ran-dropdown-padding', contentPadding)
         .build() as HTMLElement;
       this.popoverContent?.addEventListener('click', this.stopPropagation);
+      this._events.on(this.popoverContent, 'keydown', this._dismissFromKeyboard);
 
       const div = Div().children(this.popoverContent).build() as HTMLDivElement;
       this.popoverInner = div;
@@ -287,7 +290,7 @@ export class Popover extends RanElement {
     if (this.popoverContent && content.length > 0) {
       this.popoverContent.innerHTML = '';
       const Fragment = document.createDocumentFragment();
-      for (const child of content) {
+      for (const child of Array.from(content)) {
         Fragment.appendChild(child);
       }
       this.popoverContent.appendChild(Fragment);
@@ -374,17 +377,51 @@ export class Popover extends RanElement {
     this.blur.cancel();
     clearTimeout(this.removeTimeId);
     this.removeTimeId = undefined;
+    const opening = !this.open;
     this.toggle();
+    if (opening) {
+      // WebKit can focus the host when a native child button is clicked. The
+      // activation path identifies the opener independently of that browser policy.
+      this._returnFocus =
+        (e
+          .composedPath()
+          .find(
+            (target) =>
+              target instanceof HTMLElement && target !== this && this.contains(target) && target.tabIndex >= 0,
+          ) as HTMLElement | undefined) ??
+        (Array.from(this.children).find((child) => child.tagName !== 'R-CONTENT') as HTMLElement) ??
+        this;
+    }
   };
   keydownPopover = (e: KeyboardEvent): void => {
+    if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
+    if (e.key === 'Escape') {
+      this.setDropdownDisplayBlock.cancel();
+      this._dismissFromKeyboard(e);
+      return;
+    }
     if (isActivationKey(e)) {
       e.preventDefault();
       this.setDropdownDisplayBlock();
     }
-    if (e.key === 'Escape') {
-      e.preventDefault();
-      this.setDropdownDisplayNone();
+  };
+  private _dismissFromKeyboard = (event: KeyboardEvent): void => {
+    if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing || event.keyCode === 229 || !this.open)
+      return;
+    if (floatingPanelsWithin(this).some((panel) => panel !== this.popoverContent)) {
+      dismissFloatingPanelWithin(this, event);
+      return;
     }
+    event.preventDefault();
+    event.stopPropagation();
+    this.setDropdownDisplayBlock.cancel();
+    this.setDropdownDisplayNone.cancel();
+    this.blur.cancel();
+    clearTimeout(this.removeTimeId);
+    this.removeTimeId = undefined;
+    this.hide();
+    // Keyboard dismissal must reveal the restored target even after a pointer opening.
+    if (this._returnFocus?.isConnected) this._returnFocus.focus({ preventScroll: true, focusVisible: true });
   };
   clickRemovePopover = (e: Event): void => {
     this.hoverRemovePopover(e);
@@ -401,6 +438,15 @@ export class Popover extends RanElement {
       this._events.on(this, 'mouseleave', this.blur);
     }
     this._events.on(this, 'click', this.clickPopover).on(this, 'keydown', this.keydownPopover);
+    this._events.on(this, 'show', () => {
+      const root = this.getRootNode() as Document | ShadowRoot;
+      const active = root.activeElement;
+      this._returnFocus =
+        active instanceof HTMLElement && (active === this || this.contains(active))
+          ? active
+          : ((Array.from(this.children).find((child) => child.tagName !== 'R-CONTENT') as HTMLElement) ?? this);
+    });
+    if (this.popoverContent) this._events.on(this.popoverContent, 'keydown', this._dismissFromKeyboard);
     this._floating.bindOutside(this._events, this.clickRemovePopover);
   };
   hoverRemovePopover = (e: Event): void => {
@@ -446,6 +492,10 @@ export class Popover extends RanElement {
     this._floating.destroy();
     clearTimeout(this.removeTimeId);
     this.removeTimeId = undefined;
+    // The portal borrows authored nodes. Return them before removing its wrapper
+    // so reconnecting the same element preserves control identity and local state.
+    const content = Array.from(this.children).find((child) => child.tagName === 'R-CONTENT');
+    if (content && this.popoverContent) content.append(...Array.from(this.popoverContent.childNodes));
     this.popoverInner?.remove();
     this.popoverInner = undefined;
     this.popoverContent = undefined;
diff --git a/packages/ranui/components/progress/index.ts b/packages/ranui/components/progress/index.ts
index 6ff900ace4..2097f6a989 100644
--- a/packages/ranui/components/progress/index.ts
+++ b/packages/ranui/components/progress/index.ts
@@ -80,24 +80,19 @@ export class Progress extends RanElement {
   }
 
   get percent(): string {
-    const percentAttr = this.getAttribute('percent') || '0';
-    const num = parsePercentLike(percentAttr);
-    const totalNum = Number(this.total);
-    if (num > totalNum) {
-      // ⚠️ Keep it silent or cap it, console.error is too loud for dev
-      return String(totalNum);
-    }
-    return String(num);
+    const value = parsePercentLike(this.getAttribute('percent'));
+    return String(range(Number.isFinite(value) ? value : 0, 0, Number(this.total)));
   }
   set percent(value: string) {
     this.setAttribute('percent', value || '0');
-    this.setAttribute('aria-valuenow', value || '0');
+    this.setAttribute('aria-valuenow', this.percent);
   }
 
   get total(): string {
     const total = this.getAttribute('total');
     if (!total) return '100'; // 💡 Better default for "percent" context
-    return `${parsePercentLike(total)}`;
+    const value = parsePercentLike(total);
+    return String(Number.isFinite(value) && value >= 0 ? value : 100);
   }
   set total(value: string) {
     this.setAttribute('total', value || '100');
@@ -243,14 +238,11 @@ export class Progress extends RanElement {
   };
 
   updateUI = (percentage: number): void => {
-    this.style.setProperty('--progress-percent', String(percentage));
+    this.style.setProperty('--progress-percent', String(range(Number.isFinite(percentage) ? percentage : 0, 0, 1)));
   };
 
   _preSerialize(): void {
-    const percent = parsePercentLike(this.getAttribute('percent') || '0');
-    const total = parsePercentLike(this.getAttribute('total') || '100');
-    const fraction = range(percent / total, 0, 1);
-    this.style.setProperty('--progress-percent', String(fraction));
+    this.updateCurrentProgress();
   }
 
   change = (): void => {
@@ -282,7 +274,7 @@ export class Progress extends RanElement {
   updateCurrentProgress = (): void => {
     if (!this._progress) return;
     const total = Number(this.total);
-    const percent = Number(this.percent) / total;
+    const percent = total > 0 ? Number(this.percent) / total : 0;
     this.updateUI(percent);
   };
 
diff --git a/packages/ranui/components/radar/index.ts b/packages/ranui/components/radar/index.ts
index 37c1327e4a..177396ae95 100644
--- a/packages/ranui/components/radar/index.ts
+++ b/packages/ranui/components/radar/index.ts
@@ -1,4 +1,4 @@
-import { getPixelRatio, roundRectByArc } from 'ranuts/utils';
+import { getPixelRatio, roundRectByArc, watchMediaQuery } from 'ranuts/utils';
 import radarCss from './index.less?inline';
 import { createRef, Div, RanElement, View } from '@/utils/index';
 import {
@@ -14,7 +14,7 @@ interface AbilityTags {
   abilityName: string;
   scoreRate: number;
   backgroundColor: string;
-  fontSize: string;
+  fontSize: string | number;
   fontFamily: string;
   fontColor: string;
 }
@@ -23,7 +23,7 @@ const BACKGROUND_COLOR = 'rgba(0,0,0,0)';
 const FONT_COLOR = 'rgba(0,0,0,1)';
 const POLYGON_COLOR = '#e6e6e6';
 const LINE_COLOR = '#e6e6e6';
-const FONT_FAMILY = '黑体';
+const FONT_FAMILY = 'sans-serif';
 const FILL_COLOR = 'rgba(255,121,35,0.60)';
 const STROKE_COLOR = 'rgba(255,121,35,0.60)';
 
@@ -42,6 +42,8 @@ export class RadarChart extends RanElement {
   _iconElement?: HTMLElement;
   _shadowDom: ShadowRoot;
   resizeObserver: ResizeObserver;
+  private _themeObserver?: MutationObserver;
+  private _detachThemeMedia?: () => void;
   constructor() {
     super();
     this._shadowDom = ensureShadowRoot(this, radarCss);
@@ -128,11 +130,14 @@ export class RadarChart extends RanElement {
     const height = this.abilityRadarChartContainer.clientHeight * radio;
     this.abilityRadarChart.width = width;
     this.abilityRadarChart.height = height;
-    this.mW = width;
+    // Fit the circular plot in the shorter dimension, with a centered origin.
+    const size = Math.min(width, height);
+    ctx.translate((width - size) / 2, (height - size) / 2);
+    this.mW = size;
     this.mData = this.abilitys;
     this.mCount = this.mData?.length || 1;
     this.mCenter = this.mW / 2;
-    this.mRadius = this.mCenter - 50 * radio;
+    this.mRadius = Math.max(0, this.mCenter - 50 * radio);
     this.mAngle = (Math.PI * 2) / this.mCount;
     this.drawPolygon(ctx);
     this.drawSide(ctx);
@@ -193,14 +198,17 @@ export class RadarChart extends RanElement {
     if (!this.mRadius || !this.mCount || !this.mCenter || !this.mAngle || !this.mData) return;
     ctx.save();
     const radio = getPixelRatio(ctx);
-    const defaultFontSize = this.mCenter / 12;
+    const labelSize = Number.parseFloat(this._cssVar('--ran-text-label-3'));
+    const defaultFontSize = (Number.isFinite(labelSize) && labelSize > 0 ? labelSize : 12) * radio;
+    const defaultFontFamily = this._cssVar('--ran-font-family') || FONT_FAMILY;
     for (let i = 0; i < this.mCount; i++) {
       const x = this.mCenter + this.mRadius * Math.sin(this.mAngle * i);
       const y = this.mCenter + this.mRadius * Math.cos(this.mAngle * i);
       const backgroundColor = this.mData[i]?.backgroundColor || BACKGROUND_COLOR;
       const fontColor = this.mData[i]?.fontColor || this.fontColor;
-      const fontFamily = this.mData[i]?.fontFamily || FONT_FAMILY;
-      const fontSize = this.mData[i]?.fontSize || defaultFontSize;
+      const fontFamily = this.mData[i]?.fontFamily || defaultFontFamily;
+      const itemFontSize = Number.parseFloat(String(this.mData[i]?.fontSize ?? ''));
+      const fontSize = Number.isFinite(itemFontSize) && itemFontSize > 0 ? itemFontSize * radio : defaultFontSize;
       ctx.font = `${fontSize}px ${fontFamily}`;
       if (this.mAngle * i >= 0 && this.mAngle * i < Math.PI / 2) {
         this.drawButton(
@@ -322,10 +330,19 @@ export class RadarChart extends RanElement {
   connectedCallback() {
     this.resizeObserver.observe(this.abilityRadarChartContainer);
     this.handlerExternalCss();
-    this.refreshData();
+    this._themeObserver = new MutationObserver(() => this.refreshData());
+    this._themeObserver.observe(document.documentElement, {
+      attributes: true,
+      attributeFilter: ['data-ran-theme', 'theme', 'class', 'style'],
+    });
+    this._detachThemeMedia = watchMediaQuery('(prefers-color-scheme: dark)', () => this.refreshData());
   }
   disconnectedCallback() {
     this.resizeObserver.disconnect();
+    this._themeObserver?.disconnect();
+    this._themeObserver = undefined;
+    this._detachThemeMedia?.();
+    this._detachThemeMedia = undefined;
   }
   attributeChangedCallback(name: string, oldValue: string, newValue: string) {
     if (oldValue === newValue) return;
diff --git a/packages/ranui/components/route/index.ts b/packages/ranui/components/route/index.ts
index 8d7a0ba9a2..635d7cdbd0 100644
--- a/packages/ranui/components/route/index.ts
+++ b/packages/ranui/components/route/index.ts
@@ -99,9 +99,10 @@ export class Route extends RanElement {
       this.appendChild(this._pageHost);
     }
     try {
-      this._module ??= (await import(/* @vite-ignore */ this.src)) as { default?: PageRender };
+      const module = this._module ?? ((await import(/* @vite-ignore */ this.src)) as { default?: PageRender });
       if (token !== this._mountToken) return; // superseded by a later unmount/mount while importing
-      const render = this._module.default;
+      this._module = module;
+      const render = module.default;
       if (typeof render !== 'function') {
         console.error(`[r-route] "${this.src}" has no default export (host) => void`);
         return;
@@ -113,6 +114,7 @@ export class Route extends RanElement {
         return dispose;
       });
     } catch (error) {
+      if (token !== this._mountToken) return;
       this._mounted = false;
       console.error(`[r-route] failed to load "${this.src}":`, error);
     }
diff --git a/packages/ranui/components/scratch/index.less b/packages/ranui/components/scratch/index.less
index 70495b4cfe..22fae22af4 100644
--- a/packages/ranui/components/scratch/index.less
+++ b/packages/ranui/components/scratch/index.less
@@ -1,7 +1,7 @@
 :host {
   position: relative;
   display: block;
-  --ran-scratch-cover-background: var(--ran-color-text-secondary, #6b6b6b);
+  --ran-scratch-cover-background: var(--ran-color-text-secondary, var(--ran-gray-900));
 }
 
 /* `hidden` is how a caller hides an element, and the UA rule that implements it is a
@@ -11,7 +11,7 @@
   display: none;
 }
 
-:host([disabled]) {
+:host([disabled]:not([disabled='false'])) {
   cursor: var(--ran-scratch-disabled-cursor, not-allowed);
   opacity: var(--ran-scratch-disabled-opacity, 0.6);
   .ran-scratch-ticket-canvas {
@@ -21,11 +21,19 @@
   }
 }
 
+.ran-scratch {
+  display: flex;
+  flex-direction: column;
+  gap: var(--ran-space-2, 8px);
+  height: 100%;
+}
+
 .ran-scratch-ticket {
   position: relative;
   display: block;
   width: 100%;
-  height: 100%;
+  flex: 1;
+  min-height: 0;
 }
 
 .ran-scratch-ticket-award {
@@ -35,7 +43,7 @@
   display: block;
   width: 100%;
   height: 100%;
-  background: var(--ran-scratch-award-background, var(--ran-color-bg-elevated, #fff));
+  background: var(--ran-scratch-award-background, var(--ran-color-bg-elevated, var(--ran-background-100)));
   z-index: 1;
 }
 
@@ -52,3 +60,43 @@
   touch-action: none;
   -webkit-tap-highlight-color: transparent;
 }
+
+.ran-scratch-reveal {
+  align-self: flex-end;
+  flex-shrink: 0;
+  min-height: var(--ran-scratch-reveal-min-height, var(--ran-size-7, 32px));
+  padding: 0 var(--ran-space-3, 12px);
+  border: 1px solid var(--ran-color-border, var(--ran-gray-400));
+  border-radius: var(--ran-radius-sm, 6px);
+  background: var(--ran-color-bg, var(--ran-background-100));
+  color: var(--ran-color-text, var(--ran-gray-1000));
+  font: inherit;
+  font-size: var(--ran-text-button-size, 14px);
+  cursor: pointer;
+  &:focus-visible {
+    outline: var(--ran-scratch-focus-outline, 2px solid var(--ran-color-link, #0070f3));
+    outline-offset: 2px;
+  }
+  &:hover:not([aria-disabled='true']) {
+    background: var(--ran-color-bg-hover, var(--ran-gray-200));
+  }
+  &:disabled {
+    cursor: not-allowed;
+  }
+  &[aria-disabled='true']:not(:disabled) {
+    cursor: default;
+  }
+}
+.ran-scratch-status {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+@media (pointer: coarse) {
+  .ran-scratch-reveal {
+    min-height: 44px;
+  }
+}
diff --git a/packages/ranui/components/scratch/index.ts b/packages/ranui/components/scratch/index.ts
index ff18a2c5b5..23c4ed26bd 100644
--- a/packages/ranui/components/scratch/index.ts
+++ b/packages/ranui/components/scratch/index.ts
@@ -21,6 +21,10 @@ class ScratchTicket extends RanElement {
     activePointerId?: number;
   };
   scratchAward: HTMLDivElement;
+  private revealButton: HTMLButtonElement;
+  private revealStatus: HTMLDivElement;
+  private revealed = false;
+  private coverInitialized = false;
   _shadowDom: ShadowRoot;
   _events = new EventManager();
   // `icon`/`effect`/`iconSize` used to be observed here with nothing reading them —
@@ -28,13 +32,14 @@ class ScratchTicket extends RanElement {
   // elements), not a single named icon, so it belongs in the default slot below
   // rather than a narrow name+size attribute pair. See docs/src/ranui/scratch.
   static get observedAttributes(): string[] {
-    return ['disabled', 'sheet'];
+    return ['disabled', 'sheet', 'reveal-label', 'revealed-label'];
   }
   constructor() {
     super();
     this._shadowDom = ensureShadowRoot(this, scratchCss);
     const scratchTicket = View('canvas')
       .class('ran-scratch-ticket-canvas')
+      .attr('aria-hidden', 'true')
       .style('width', '100%')
       .style('height', '100%')
       .build() as HTMLCanvasElement;
@@ -49,7 +54,16 @@ class ScratchTicket extends RanElement {
       .class('ran-scratch-ticket')
       .children(scratchTicket, scratchAward)
       .build() as HTMLDivElement;
-    this._shadowDom.appendChild(scratchTicketContainer);
+    const revealButton = View('button')
+      .class('ran-scratch-reveal')
+      .part('reveal-button')
+      .attr('type', 'button')
+      .build() as HTMLButtonElement;
+    const revealStatus = Div().class('ran-scratch-status').attr('role', 'status').build() as HTMLDivElement;
+    const scratchRoot = Div().class('ran-scratch').children(revealButton, scratchTicketContainer, revealStatus).build();
+    this.revealButton = revealButton;
+    this.revealStatus = revealStatus;
+    this._shadowDom.appendChild(scratchRoot);
 
     this.scratchTicketContainer = scratchTicketContainer;
     this.scratchAward = scratchAward;
@@ -76,11 +90,50 @@ class ScratchTicket extends RanElement {
   set sheet(value: string) {
     setStringAttribute(this, 'sheet', value);
   }
+  get revealLabel(): string {
+    return getStringAttribute(this, 'reveal-label') || 'Reveal';
+  }
+  set revealLabel(value: string) {
+    setStringAttribute(this, 'reveal-label', value);
+  }
+  get revealedLabel(): string {
+    return getStringAttribute(this, 'revealed-label') || 'Revealed';
+  }
+  set revealedLabel(value: string) {
+    setStringAttribute(this, 'revealed-label', value);
+  }
+  private syncReveal = (): void => {
+    this.revealButton.textContent = this.revealed ? this.revealedLabel : this.revealLabel;
+    this.revealButton.disabled = this.disabled;
+    this.revealButton.setAttribute('aria-disabled', String(this.disabled || this.revealed));
+    this.revealButton.tabIndex = this.revealed ? -1 : 0;
+    this.scratchAward.inert = !this.revealed;
+    this.scratchTicket.style.pointerEvents = this.revealed ? 'none' : '';
+  };
+  private cancelScratch = (): void => {
+    const id = this.state.activePointerId;
+    this.state.isScratching = false;
+    this.state.activePointerId = undefined;
+    if (id !== undefined && this.scratchTicket.hasPointerCapture?.(id)) this.scratchTicket.releasePointerCapture(id);
+  };
+  private reveal = (): void => {
+    if (this.disabled || this.revealed) return;
+    const ctx = this.scratchTicket.getContext('2d');
+    if (!ctx) return;
+    this.cancelScratch();
+    ctx.clearRect(0, 0, this.scratchTicket.width, this.scratchTicket.height);
+    this.state.scratchedArea = 0;
+    this.revealed = true;
+    this.syncReveal();
+    this.revealStatus.textContent = this.revealedLabel;
+  };
   handlerExternalCss = (): void => {
     syncSheetAttribute(this, this._shadowDom, 'sheet', null, this.sheet);
   };
   syncDisabled = (): void => {
     this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
+    if (this.disabled) this.cancelScratch();
+    this.syncReveal();
   };
   /**
    * Radius (canvas-space px) of the "coin" doing the scratching — scaled to the
@@ -137,7 +190,7 @@ class ScratchTicket extends RanElement {
    *   ends (`pointerup`/`pointercancel`/`lostpointercapture`).
    */
   onScratchPointerDown = (e: PointerEvent): void => {
-    if (this.disabled) return;
+    if (this.disabled || this.revealed) return;
     if (this.state.isScratching) return; // a second touch/pointer mid-stroke — ignore it
     if (e.pointerType === 'mouse' && e.button !== 0) return;
     // Stops the page from scrolling under a touch drag on the scratch surface, and
@@ -170,10 +223,12 @@ class ScratchTicket extends RanElement {
     const ctx = this.scratchTicket.getContext('2d');
     if (!ctx) return;
     if (this.state.scratchedArea > width * height * AUTO_REVEAL_THRESHOLD) {
-      this.state.scratchedArea = 0;
-      ctx.clearRect(0, 0, width, height);
+      this.reveal();
     }
   };
+  onScratchPointerCancel = (e: PointerEvent): void => {
+    if (e.pointerId === this.state.activePointerId) this.cancelScratch();
+  };
   drawScratchTicket = (): void => {
     const ctx = this.scratchTicket.getContext('2d');
     if (!this.scratchTicketContainer || !ctx) return;
@@ -182,6 +237,7 @@ class ScratchTicket extends RanElement {
     ctx.globalCompositeOperation = 'source-over';
     ctx.fillStyle = coverColor || '#6b6b6b';
     ctx.fillRect(0, 0, width, height);
+    this.coverInitialized = true;
   };
   /**
    * Matches the canvas's internal drawing-buffer resolution to its actual rendered
@@ -198,10 +254,22 @@ class ScratchTicket extends RanElement {
     const dpr = window.devicePixelRatio || 1;
     const width = Math.max(1, Math.round(rect.width * dpr));
     const height = Math.max(1, Math.round(rect.height * dpr));
-    if (this.scratchTicket.width === width && this.scratchTicket.height === height) return;
+    if (this.scratchTicket.width === width && this.scratchTicket.height === height) {
+      if (!this.coverInitialized) this.drawScratchTicket();
+      return;
+    }
+    // A real resize resets the cover. If its reward currently owns focus,
+    // return focus to the now-relevant action when making the slot inert.
+    const active = (this.getRootNode() as Document | ShadowRoot).activeElement;
+    const restoreFocus = this.revealed && !!active && this.contains(active) && !this.disabled;
     this.scratchTicket.width = width;
     this.scratchTicket.height = height;
+    this.cancelScratch();
     this.state.scratchedArea = 0;
+    this.revealed = false;
+    this.revealStatus.textContent = '';
+    this.syncReveal();
+    if (restoreFocus) this.revealButton.focus({ preventScroll: true });
     this.drawScratchTicket();
   };
   private onWindowResize = (): void => {
@@ -211,26 +279,29 @@ class ScratchTicket extends RanElement {
     this.handlerExternalCss();
     this.syncDisabled();
     this._events
+      .on(this.revealButton, 'click', this.reveal)
+
       .on(this.scratchTicket, 'pointerdown', this.onScratchPointerDown as EventListener, { passive: false })
       .on(this.scratchTicket, 'pointermove', this.onScratchPointerMove as EventListener)
       .on(this.scratchTicket, 'pointerup', this.onScratchPointerUp as EventListener)
-      .on(this.scratchTicket, 'pointercancel', this.onScratchPointerUp as EventListener)
+      .on(this.scratchTicket, 'pointercancel', this.onScratchPointerCancel as EventListener)
       // A device/OS can reclaim pointer capture mid-gesture without ever firing
       // `pointerup` (seen on some Android WebViews when a system gesture — e.g. the
       // back-swipe edge — interrupts it). Without this, `isScratching` sticks `true`
       // forever and the *next* unrelated pointer motion silently keeps scratching.
-      .on(this.scratchTicket, 'lostpointercapture', this.onScratchPointerUp as EventListener)
+      .on(this.scratchTicket, 'lostpointercapture', this.onScratchPointerCancel as EventListener)
       .on(window, 'resize', this.onWindowResize);
     this.syncCanvasResolution();
   }
   disconnectedCallback(): void {
+    this.cancelScratch();
     this._events.abort();
   }
   attributeChangedCallback(name: string, old: string, next: string): void {
     if (old === next) return;
     if (name === 'disabled') this.syncDisabled();
     if (name === 'sheet') this.handlerExternalCss();
-    this.drawScratchTicket();
+    if (name === 'reveal-label' || name === 'revealed-label') this.syncReveal();
   }
 }
 
diff --git a/packages/ranui/components/select/dropdown-item/index.less b/packages/ranui/components/select/dropdown-item/index.less
index 5f0d832bfc..8c8f68d731 100644
--- a/packages/ranui/components/select/dropdown-item/index.less
+++ b/packages/ranui/components/select/dropdown-item/index.less
@@ -11,7 +11,7 @@
     transition: var(--ran-dropdown-option-item-transition, none);
     border-radius: var(--ran-dropdown-option-item-border-radius, var(--ran-radius-sm, 6px));
     cursor: var(--ran-dropdown-option-item-cursor, pointer);
-    &:hover {
+    :host(:not([disabled])) &:hover {
       background-color: var(
         --ran-dropdown-option-item-hover-background-color,
         var(--ran-gray-alpha-100, rgba(0, 0, 0, 0.04))
@@ -34,8 +34,18 @@
   &-option-active {
     background-color: var(--ran-dropdown-option-active-background-color, var(--ran-blue-100, #e6f7ff));
     font-weight: var(--ran-dropdown-option-active-font-weight, bold);
-    &:hover {
+    :host(:not([disabled])) &:hover {
       background-color: var(--ran-dropdown-option-active-hover-background-color, var(--ran-blue-100, #e6f7ff));
     }
   }
 }
+
+:host([disabled]) {
+  .ranui-dropdown-option-item {
+    cursor: not-allowed;
+    color: var(--ran-color-text-disabled);
+  }
+  .ranui-dropdown-option-item-content {
+    color: var(--ran-color-text-disabled);
+  }
+}
diff --git a/packages/ranui/components/select/index.less b/packages/ranui/components/select/index.less
index 1ec8b60bf2..98b7de16bb 100644
--- a/packages/ranui/components/select/index.less
+++ b/packages/ranui/components/select/index.less
@@ -41,7 +41,7 @@
       }
       &-item {
         cursor: var(--ran-select-item-hover-cursor, pointer);
-        color: var(--ran-select-item-hover-color, var(--ran-color-text-disabled, #bfbfbf));
+        color: var(--ran-select-item-hover-color, var(--ran-color-text, #171717));
       }
     }
   }
@@ -57,19 +57,23 @@
   /* :focus-visible (not :focus) so the ring shows for keyboard navigation only —
      a mouse click that opens the dropdown shouldn't also draw a focus halo,
      matching r-button's convention. */
-  &(:not([disabled],[type="text"]):focus-visible) {
+  &(:not([disabled]):focus-visible) {
     .selection {
-      border: var(--ran-select-focus-border, 1px solid @theme-color);
       box-shadow: var(--ran-select-focus-box-shadow, var(--ran-focus-ring));
       &-search {
         cursor: var(--ran-select-search-focus-cursor, pointer);
       }
       &-item {
         cursor: var(--ran-select-item-focus-cursor, pointer);
-        color: var(--ran-select-item-focus-color, var(--ran-color-text-disabled, #bfbfbf));
+        color: var(--ran-select-item-focus-color, var(--ran-color-text, #171717));
       }
     }
   }
+  &(:not([disabled],[type="text"]):focus-visible) {
+    .selection {
+      border: var(--ran-select-focus-border, 1px solid @theme-color);
+    }
+  }
   .selection-search {
     display: var(--ran-select-search-display, none);
   }
@@ -250,3 +254,9 @@
     }
   }
 }
+
+// An empty hint must remain distinct from a selected value, including on focus.
+:host(:not([value])) .ran-select .selection .selection-item,
+:host([value='']) .ran-select .selection .selection-item {
+  color: var(--ran-color-text-secondary);
+}
diff --git a/packages/ranui/components/select/index.ts b/packages/ranui/components/select/index.ts
index dab554836e..ff6b3f49aa 100644
--- a/packages/ranui/components/select/index.ts
+++ b/packages/ranui/components/select/index.ts
@@ -4,6 +4,7 @@ import chevronDownIcon from '@/assets/icons/chevron-down.svg?raw';
 import { RanElement, isDisabled } from '@/utils/index';
 import '@/components/select/option';
 import '@/components/dropdown';
+import type { Dropdown } from '@/components/dropdown';
 import '@/components/select/dropdown-item';
 import { registerIcon } from '@/components/icon';
 import { defineSSR } from '@/utils/ssr-registry';
@@ -72,6 +73,10 @@ export class Select extends RanElement {
    * reposition listeners had begun to.
    */
   _floating = new FloatingController({
+    onEscape: (event) => {
+      this.keydownSelect(event);
+      if (event.defaultPrevented) this.focus({ preventScroll: true });
+    },
     host: this,
     panel: () => this._selectionDropdown,
     placement: () => this.placement,
@@ -117,6 +122,7 @@ export class Select extends RanElement {
       'type',
       'value',
       'label',
+      'placeholder',
       // Attribute names are lowercased by the DOM, so these MUST be lowercase to
       // be observed — the previous camelCase entries never fired (which is why
       // defaultValue/showSearch used to apply only on first connect).
@@ -306,9 +312,8 @@ export class Select extends RanElement {
 
   private _clearSelection = (): void => {
     this._pendingDefaultValue = false;
-    this._text.textContent = '';
+    this._syncPlaceholder();
     this._text.removeAttribute('title');
-    this._search.removeAttribute('placeholder');
     if (this._activeOption) {
       this._activeOption.removeAttribute('active');
       this._activeOption.setAttribute('aria-selected', 'false');
@@ -319,6 +324,19 @@ export class Select extends RanElement {
     this.syncActiveState();
     this.syncFormValue();
   };
+  /** Empty-field hint; the field's accessible name remains its label. */
+  get placeholder(): string {
+    return this.getAttribute('placeholder') || '';
+  }
+  set placeholder(value: string) {
+    setStringAttribute(this, 'placeholder', value);
+  }
+  private _syncPlaceholder = (): void => {
+    if (this.value || !this._text) return;
+    this._text.textContent = this.placeholder;
+    if (this.placeholder) this._search.placeholder = this.placeholder;
+    else this._search.removeAttribute('placeholder');
+  };
   get defaultValue(): string {
     return this.getAttribute('defaultValue') || '';
   }
@@ -434,6 +452,10 @@ export class Select extends RanElement {
 
   /** Drive the panel from `open`. Everything below it is the controller's. */
   _applyOpen = (): void => {
+    if (this.open && this.disabled) {
+      this.open = false;
+      return;
+    }
     this._floating.apply(this.open);
   };
 
@@ -490,7 +512,17 @@ export class Select extends RanElement {
     const activeValue = next.getAttribute('value') || '';
     next.setAttribute('active', activeValue);
     next.setAttribute('aria-selected', 'true');
-    next.scrollIntoView({ block: 'nearest' });
+    // A portaled option may still be in its pre-positioned opening layout.
+    // scrollIntoView also scrolls the page, losing the field's reading position.
+    // Move only the owned list surface when the consumer constrains its height.
+    const surface = (this._selectionDropdown as Dropdown | undefined)?.dropdown;
+    if (surface && surface.clientHeight > 0 && surface.scrollHeight > surface.clientHeight) {
+      const itemRect = next.getBoundingClientRect();
+      const listRect = surface.getBoundingClientRect();
+      const scale = listRect.height / surface.offsetHeight || 1;
+      if (itemRect.top < listRect.top) surface.scrollTop += (itemRect.top - listRect.top) / scale;
+      else if (itemRect.bottom > listRect.bottom) surface.scrollTop += (itemRect.bottom - listRect.bottom) / scale;
+    }
     this.syncActiveState();
   };
 
@@ -544,8 +576,8 @@ export class Select extends RanElement {
   };
 
   isDropdownOpen = (): boolean => {
-    if (!this._selectionDropdown) return false;
-    return this._selectionDropdown.style.display === 'block';
+    // The exit animation may still paint a panel whose open intent is false.
+    return this.open;
   };
 
   /**
@@ -614,6 +646,7 @@ export class Select extends RanElement {
       return;
     }
     if (e.key === 'Escape') {
+      if (!this.isDropdownOpen()) return;
       e.preventDefault();
       this.setSelectDropdownDisplayNone();
       return;
@@ -718,6 +751,7 @@ export class Select extends RanElement {
    */
   selectBlur = (e: Event): void => {
     e.stopPropagation();
+    if (this.disabled) return;
     if (this.removeTimeId) {
       this.removeDropDownTimeId(e);
     }
@@ -733,6 +767,12 @@ export class Select extends RanElement {
    */
   clickOption = (e: MouseEvent): void => {
     e.stopPropagation();
+    // A portaled option can still receive the pointerup/click of a press
+    // that disabled the control while the exit animation is painted.
+    if (this.disabled) {
+      e.preventDefault();
+      return;
+    }
     const element = (e.target as Element).closest('r-dropdown-item') as HTMLElement | null;
     // Disabled options are non-selectable, matching native 
', + ); + await expect(page.locator('body')).toHaveCSS( + 'background-color', + theme === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)', + ); + await page.locator('#picker').evaluate((picker) => { + (window as unknown as { pickerChanges: unknown[] }).pickerChanges = []; + picker.addEventListener('change', (event) => + (window as unknown as { pickerChanges: unknown[] }).pickerChanges.push((event as CustomEvent).detail), + ); + }); +} + +async function open(page: Page) { + await insideShadow(page, '#picker', (root) => { + root + .querySelector('r-popover')! + .addEventListener('after-show', () => root.host.setAttribute('data-painted', ''), { once: true }); + root.host.removeAttribute('data-painted'); + }); + await page.locator('#picker').click(); + await expect(page.locator('#picker')).toHaveAttribute('data-painted'); + await expect(page.locator('.ran-color-picker-inner')).toBeVisible(); +} + +const read = (page: Page) => + page.locator('#picker').evaluate((node) => ({ + value: (node as ColorPicker).value, + changes: (window as unknown as { pickerChanges: unknown[] }).pickerChanges.length, + })); + +for (const [motion, theme] of [ + ['no-preference', 'light'], + ['reduce', 'dark'], +] as const) { + test.describe(`colorpicker disabled interaction: ${motion}`, () => { + test.use({ contextOptions: { reducedMotion: motion } }); + + test('disabling an open picker closes it without another keyboard change and supports re-enabling', async ({ + page, + }) => { + await setup(page, theme); + await open(page); + const hue = page.getByRole('slider', { name: 'Hue', exact: true }); + await hue.focus(); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = true)); + await page.keyboard.press('ArrowRight'); + expect(await read(page)).toEqual({ value: '#ff0000', changes: 0 }); + expect(await insideShadow(page, '#picker', (root) => root.querySelector('r-popover')!.hasAttribute('open'))).toBe( + false, + ); + expect( + await page.locator('.ran-color-picker-slider-hue').evaluate((node) => node === document.activeElement), + ).toBe(false); + await expect(page.locator('.ran-popover-dropdown')).toBeHidden(); + await page.locator('#picker').click(); + await expect(page.locator('.ran-popover-dropdown')).toBeHidden(); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).value = '#00ff00')); + expect(await read(page)).toEqual({ value: '#00ff00', changes: 0 }); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = false)); + await open(page); + await hue.focus(); + await page.keyboard.press('ArrowRight'); + expect((await read(page)).value).not.toBe('#00ff00'); + expect((await read(page)).changes).toBe(1); + await page.locator('#outside').focus(); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = true)); + await expect(page.locator('#outside')).toBeFocused(); + }); + + for (const kind of ['palette', 'hue', 'alpha'] as const) { + test(`disabling cancels an active ${kind} drag even when enabled again before pointerup`, async ({ page }) => { + await setup(page, theme); + await open(page); + const target = page.locator( + kind === 'palette' ? '.ran-color-picker-palette' : `.ran-color-picker-slider-${kind}`, + ); + const box = (await target.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.4); + await page.mouse.down(); + const before = await read(page); + expect(before.changes).toBeGreaterThan(0); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = true)); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.8); + expect(await read(page)).toEqual(before); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = false)); + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.2); + expect(await read(page)).toEqual(before); + await page.mouse.up(); + }); + } + + test('disabling closes the owned format Select and releases only picker focus', async ({ page }) => { + await setup(page, theme); + await open(page); + const format = page.locator('.ran-color-picker-format'); + await format.click(); + await expect(format).toHaveAttribute('open'); + const popup = page.locator(`#${await format.getAttribute('aria-controls')}`); + await expect(popup).toBeVisible(); + const rgb = (await popup.locator('r-dropdown-item').filter({ hasText: 'RGB' }).boundingBox())!; + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = true)); + if (motion === 'no-preference') await page.mouse.click(rgb.x + rgb.width / 2, rgb.y + rgb.height / 2); + await expect(format).toHaveAttribute('value', 'HEX'); + await expect(format).not.toHaveAttribute('open'); + await expect(format).toHaveAttribute('disabled'); + await expect(popup).toBeHidden(); + expect(await format.evaluate((node) => node === document.activeElement)).toBe(false); + expect(await read(page)).toEqual({ value: '#ff0000', changes: 0 }); + await page.locator('#picker').evaluate((node) => ((node as ColorPicker).disabled = false)); + await open(page); + await format.click(); + await popup.locator('r-dropdown-item').filter({ hasText: 'RGB' }).click(); + await expect(format).toHaveAttribute('value', 'RGB'); + expect( + await page + .locator('.ran-color-picker-value') + .evaluate((node) => (node as HTMLElement & { value: string }).value), + ).toBe('rgb(255, 0, 0)'); + expect(await read(page)).toEqual({ value: '#ff0000', changes: 0 }); + }); + }); +} diff --git a/packages/ranui/test/e2e/conversation-follow.spec.ts b/packages/ranui/test/e2e/conversation-follow.spec.ts new file mode 100644 index 0000000000..eb2a62eadf --- /dev/null +++ b/packages/ranui/test/e2e/conversation-follow.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; +import { insideShadow, isolatedSetup } from './helpers'; + +for (const reducedMotion of ['no-preference', 'reduce'] as const) { + test.describe(`conversation explicit follow intent: ${reducedMotion}`, () => { + test.use({ contextOptions: { reducedMotion } }); + + test('preserves reading position across growth and reconnect until an explicit jump', async ({ page }) => { + await isolatedSetup(page, DEV_SERVER, 'r-conversation'); + await page.evaluate(() => { + const chat = document.createElement('r-conversation') as HTMLElement & { + register: (view: unknown) => void; + push: (event: unknown) => void; + }; + chat.id = 'chat'; + chat.setAttribute('follow', 'false'); + chat.style.cssText = 'height:280px;width:min(100%,600px)'; + document.body.append(chat); + chat.register({ + kind: 'message', + match: (event: { id: string }) => ({ id: event.id, role: 'start' }), + start: (event: { text: string }) => event.text, + update: (state: string) => state, + mount: () => { + const row = document.createElement('p'); + // Fixed host-owned row geometry isolates follow intent from lazy-row estimates. + row.style.cssText = 'block-size:48px;margin:0'; + return row; + }, + patch: (row: HTMLElement, node: { state: string }) => (row.textContent = node.state), + }); + for (let i = 0; i < 30; i++) chat.push({ id: `${i}`, text: `Release note ${i}: review the migration.` }); + }); + const position = () => + insideShadow(page, '#chat', (root) => { + const scroll = root.querySelector('.ran-conversation')!; + return { top: scroll.scrollTop, floor: scroll.scrollHeight - scroll.clientHeight }; + }); + await expect.poll(async () => (await position()).floor).toBeGreaterThan(500); + await page.waitForTimeout(120); // Allow the actual ResizeObserver growth path to settle. + expect((await position()).top).toBe(0); + expect(await page.locator('#chat').evaluate((chat) => (chat as HTMLElement & { pinned: boolean }).pinned)).toBe( + false, + ); + await page.locator('#chat').evaluate((chat) => { + chat.remove(); + document.body.append(chat); + }); + await insideShadow(page, '#chat', (root) => { + root.querySelector('.ran-conversation-list')!.style.paddingBottom = '240px'; + return null; + }); + await page.waitForTimeout(120); + expect((await position()).top).toBe(0); + await page + .locator('#chat') + .evaluate((chat) => (chat as HTMLElement & { scrollToBottom(): void }).scrollToBottom()); + await expect + .poll(async () => { + const p = await position(); + return Math.abs(p.floor - p.top); + }) + .toBeLessThan(2); + await page.locator('#chat').evaluate((chat) => ((chat as HTMLElement & { follow: boolean }).follow = false)); + const stopped = (await position()).top; + await insideShadow(page, '#chat', (root) => { + root.querySelector('.ran-conversation-list')!.style.paddingBottom = '480px'; + return null; + }); + await page.waitForTimeout(120); + expect((await position()).top).toBe(stopped); + expect((await position()).floor).toBeGreaterThan(stopped + 200); + }); + }); +} diff --git a/packages/ranui/test/e2e/demo-composition.spec.ts b/packages/ranui/test/e2e/demo-composition.spec.ts new file mode 100644 index 0000000000..0fa8cc9aa7 --- /dev/null +++ b/packages/ranui/test/e2e/demo-composition.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; + +for (const width of [1280, 900, 360, 320]) { + test(`demo shell and stages retain coherent proportions at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(`${DEV_SERVER}components`); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('#component-select')).toBeVisible(); + const geometry = await page.evaluate(() => { + const brand = document.querySelector('.brand span')!; + const line = getComputedStyle(brand); + const range = document.createRange(); + range.selectNodeContents(brand); + const rows = Array.from(range.getClientRects()).filter((rect) => rect.width > 0); + const titles = ['input', 'select', 'checkbox'].map((name) => { + const card = document.querySelector(`#component-${name}`)!; + return card.querySelector('h3')!.getBoundingClientRect().top - card.getBoundingClientRect().top; + }); + return { + titleOffsets: titles, + taglineRows: line.display === 'none' ? 0 : rows.length, + overflow: document.documentElement.scrollWidth > innerWidth, + controls: Array.from(document.querySelectorAll('.route-link, .topbar-actions > *, .brand strong')).map((el) => { + const rect = el.getBoundingClientRect(); + return { left: rect.left, right: rect.right }; + }), + }; + }); + expect(geometry.taglineRows).toBeLessThanOrEqual(1); + expect(Math.max(...geometry.titleOffsets) - Math.min(...geometry.titleOffsets)).toBeLessThanOrEqual(1); + expect(geometry.overflow).toBe(false); + for (const control of geometry.controls) { + expect(control.left).toBeGreaterThanOrEqual(0); + expect(control.right).toBeLessThanOrEqual(width); + } + }); +} diff --git a/packages/ranui/test/e2e/demo-media-intent.spec.ts b/packages/ranui/test/e2e/demo-media-intent.spec.ts new file mode 100644 index 0000000000..1dce5535f6 --- /dev/null +++ b/packages/ranui/test/e2e/demo-media-intent.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; + +test('browsing the component gallery never loads external video until explicitly requested', async ({ page }) => { + const requests: string[] = []; + await page.route('https://test-streams.mux.dev/**', async (route) => { + requests.push(route.request().url()); + await route.abort(); + }); + await page.goto(`${DEV_SERVER}components`); + await expect(page.locator('#theme-toggle')).toBeVisible(); + await page.waitForTimeout(500); + expect(requests).toEqual([]); + await page.locator('#theme-toggle').click(); + await expect(page.locator('r-modal[heading="Playback failed"]')).toHaveCount(0); + await page.locator('#load-player-sample').click(); + await expect.poll(() => requests.length).toBeGreaterThan(0); + await expect(page.locator('#component-player r-player')).toHaveAttribute('src', /x36xhzz\.m3u8$/); +}); diff --git a/packages/ranui/test/e2e/demo-reduced-motion.spec.ts b/packages/ranui/test/e2e/demo-reduced-motion.spec.ts index dc29095596..b1c325ad95 100644 --- a/packages/ranui/test/e2e/demo-reduced-motion.spec.ts +++ b/packages/ranui/test/e2e/demo-reduced-motion.spec.ts @@ -17,8 +17,39 @@ for (const reducedMotion of ['reduce', 'no-preference'] as const) { for (const name of ['home-card', 'demo-card', 'card', 'cta', 'swatch']) { const target = page.locator(`#${name}`); await target.hover(); - if (reducedMotion === 'reduce') await expect(target).toHaveCSS('transform', 'none'); + if (reducedMotion === 'reduce' || name === 'demo-card' || name === 'card') + await expect(target).toHaveCSS('transform', 'none'); else await expect(target).not.toHaveCSS('transform', 'none'); } }); } + +for (const touch of [false, true]) { + test.describe(`inert component stage (${touch ? 'touch' : 'mouse'})`, () => { + test.use({ hasTouch: touch }); + test('child interaction does not promote or move its container', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await demoSetup(page, 'r-card'); + await mount( + page, + '
', + ); + for (const id of ['frame', 'card']) { + const target = page.locator(`#${id}`); + const appearance = (el: HTMLElement) => { + const frame = (el as HTMLElement & { _shadowDom?: ShadowRoot })._shadowDom?.querySelector('.ran-card') ?? el; + return { + border: getComputedStyle(frame).borderColor, + shadow: getComputedStyle(frame).boxShadow, + y: el.getBoundingClientRect().y, + }; + }; + const before = await target.evaluate(appearance); + if (touch) await target.locator('button').tap(); + else await target.locator('button').hover(); + await expect(target).toHaveCSS('transform', 'none'); + expect(await target.evaluate(appearance)).toEqual(before); + } + }); + }); +} diff --git a/packages/ranui/test/e2e/demo-theme.spec.ts b/packages/ranui/test/e2e/demo-theme.spec.ts new file mode 100644 index 0000000000..db056519f2 --- /dev/null +++ b/packages/ranui/test/e2e/demo-theme.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; + +test('demo theme action follows system preference and explicit theme changes', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + await page.goto(`${DEV_SERVER}components`); + const toggle = page.locator('#theme-toggle'); + await expect(toggle).toHaveText('☀ Light'); + await toggle.click(); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', 'light'); + await expect(toggle).toHaveText('☾ Dark'); + await toggle.click(); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', 'dark'); + await expect(toggle).toHaveText('☀ Light'); + // Public theme changes originating outside this button must update its action too. + await page.evaluate(async () => { + const control = document.createElement('r-theme-switch') as HTMLElement & { value: string }; + document.body.append(control); + control.value = 'system'; + control.remove(); + }); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', 'light'); + await expect(toggle).toHaveText('☾ Dark'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(toggle).toHaveText('☀ Light'); + await page.evaluate(async () => { + const control = document.createElement('r-theme-switch') as HTMLElement & { value: string }; + document.body.append(control); + control.value = 'light'; + control.remove(); + }); + await expect(toggle).toHaveText('☾ Dark'); + await page.emulateMedia({ colorScheme: 'light' }); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(toggle).toHaveText('☾ Dark'); +}); + +test('theme switching keeps narrow header geometry and applies palette immediately', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'no-preference' }); + await page.goto(`${DEV_SERVER}components`); + const header = page.locator('.topbar'); + const toggle = page.locator('#theme-toggle'); + const before = await header.boundingBox(); + const buttonBefore = await toggle.boundingBox(); + await toggle.click(); + await expect(toggle).toHaveText('☀ Light'); + expect((await header.boundingBox())?.height).toBe(before?.height); + expect((await toggle.boundingBox())?.width).toBe(buttonBefore?.width); + const paletteDurations = await page.evaluate(() => + ['body', '.route-link', '.nav-link', '.lang-switch', '#theme-toggle'].map( + (selector) => getComputedStyle(document.querySelector(selector)!).transitionDuration, + ), + ); + expect(paletteDurations.every((duration) => duration.split(',').every((value) => parseFloat(value) === 0))).toBe( + true, + ); + await page.emulateMedia({ reducedMotion: 'reduce' }); + expect(await page.locator('body').evaluate((el) => getComputedStyle(el).transitionDuration)).toBe('0s'); +}); diff --git a/packages/ranui/test/e2e/disclosure-row.spec.ts b/packages/ranui/test/e2e/disclosure-row.spec.ts index 16f28eab8b..5ee1ea3a98 100644 --- a/packages/ranui/test/e2e/disclosure-row.spec.ts +++ b/packages/ranui/test/e2e/disclosure-row.spec.ts @@ -131,6 +131,50 @@ test.describe('r-disclosure-row', () => { expect(insideTitle).toBe(true); }); + test('swaps the leading icon for a chevron during keyboard focus', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await mount( + page, + '●

Body

', + ); + await page.mouse.move(500, 500); + await page.locator('#start').focus(); + await page.keyboard.press('Tab'); + const paint = () => + insideShadow(page, 'r-disclosure-row', (root) => ({ + focused: root.querySelector('[part="row"]')!.matches(':focus-visible'), + icon: getComputedStyle(root.querySelector('.ran-disclosure-icon')!).opacity, + chevron: getComputedStyle(root.querySelector('.ran-disclosure-chevron')!).opacity, + })); + await expect.poll(paint).toEqual({ focused: true, icon: '0', chevron: '1' }); + await page.locator('#start').focus(); + await expect.poll(paint).toEqual({ focused: false, icon: '1', chevron: '0' }); + }); + + for (const reducedMotion of ['no-preference', 'reduce'] as const) { + test(`returns body focus before programmatic or grouped collapse (${reducedMotion})`, async ({ page }) => { + await page.emulateMedia({ reducedMotion }); + await mount( + page, + '

Other

', + ); + const draft = page.locator('#draft'); + const focusedToggle = () => + insideShadow(page, '#a', (root) => root.activeElement === root.querySelector('[part="row"]')); + await draft.focus(); + await page.locator('#a').evaluate((node: HTMLElement & { open: boolean }) => (node.open = false)); + await expect.poll(focusedToggle).toBe(true); + await expect(draft).toHaveValue('Retained'); + await page.locator('#a').evaluate((node: HTMLElement & { open: boolean }) => (node.open = true)); + await draft.focus(); + await page.locator('#b').evaluate((node: HTMLElement & { open: boolean }) => (node.open = true)); + await expect.poll(focusedToggle).toBe(true); + await page.locator('#outside').focus(); + await page.locator('#a').evaluate((node: HTMLElement & { open: boolean }) => (node.open = false)); + await expect(page.locator('#outside')).toBeFocused(); + }); + } + test('renders', { tag: '@visual' }, async ({ page }) => { await mount( page, diff --git a/packages/ranui/test/e2e/dropdown-layer.spec.ts b/packages/ranui/test/e2e/dropdown-layer.spec.ts new file mode 100644 index 0000000000..5e832bbe2a --- /dev/null +++ b/packages/ranui/test/e2e/dropdown-layer.spec.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; +import { isolatedSetup, mount } from './helpers'; + +test.beforeEach(async ({ page }) => { + await isolatedSetup(page, DEV_SERVER, 'r-modal'); + await mount( + page, + `
AlphaBeta
`, + ); +}); + +test('an inline dropdown grid item does not claim the floating layer above a modal', async ({ page }) => { + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + // Grid items form stacking contexts for a non-auto z-index even without positioning. + // The page-owned inline panel must not claim the popup layer above the modal. + expect(await page.locator('#inline').evaluate((element) => getComputedStyle(element).zIndex)).toBe('auto'); + await expect(page.locator('#modal')).toHaveAttribute('open'); +}); + +test('Select and Popover managed portals remain above their modal and pointer operable', async ({ page }) => { + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + await page.locator('#choice').click(); + const selectPanel = page.locator('r-dropdown:not(#inline)').filter({ has: page.locator('r-dropdown-item') }); + await expect(selectPanel).toBeVisible(); + expect(await selectPanel.evaluate((element) => getComputedStyle(element).zIndex)).toBe('1100'); + await selectPanel.locator('r-dropdown-item').filter({ hasText: 'Beta' }).click(); + await expect(page.locator('#choice')).toHaveAttribute('value', 'b'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await page.locator('#help-trigger').click(); + await expect(page.locator('#inside')).toBeVisible(); + await page.locator('#inside').click(); + await expect(page.locator('#modal')).toHaveAttribute('open'); +}); + +test('the public host layer override applies to inline and managed dropdowns', async ({ page }) => { + await page + .locator('#inline') + .evaluate((element) => (element as HTMLElement).style.setProperty('--ran-dropdown-host-z-index', '1400')); + expect(await page.locator('#inline').evaluate((element) => getComputedStyle(element).zIndex)).toBe('1400'); + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + await page.locator('#help-trigger').click(); + const portal = page.locator('r-dropdown:not(#inline)').filter({ has: page.locator('#inside') }); + await portal.evaluate((element) => (element as HTMLElement).style.setProperty('--ran-dropdown-host-z-index', '1400')); + expect(await portal.evaluate((element) => getComputedStyle(element).zIndex)).toBe('1400'); + await page.locator('#inside').click(); +}); + +test('modal keeps owned portal focus, dismisses one layer, and restores background inert on close', async ({ + page, +}) => { + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + await page.locator('#help-trigger').click(); + await page.locator('#inside').focus(); + await expect(page.locator('#inside')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#help')).not.toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await expect(page.locator('#help-trigger')).toBeFocused(); + await page.locator('#choice').click(); + await page.keyboard.press('Escape'); + await expect(page.locator('#choice')).not.toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await page.locator('#help-trigger').click(); + await page.locator('#modal').evaluate((modal) => modal.removeAttribute('open')); + await expect(page.locator('#help')).not.toHaveAttribute('open'); + await expect(page.locator('#inside')).toBeHidden(); + expect(await page.evaluate(() => [...document.body.children].some((node) => (node as HTMLElement).inert))).toBe( + false, + ); +}); + +test('opening a second modal dismisses the first modal floating controls', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + await page.locator('#help-trigger').click(); + await expect(page.locator('#inside')).toBeVisible(); + const exitDisplay = await page.evaluate(() => { + const next = document.createElement('r-modal'); + next.id = 'next'; + next.setAttribute('heading', 'Confirm changes'); + next.innerHTML = ''; + document.body.append(next); + next.setAttribute('open', ''); + const panel = (document.querySelector('#help') as HTMLElement & { popoverContent: HTMLElement }).popoverContent; + return getComputedStyle(panel).display; + }); + expect(exitDisplay).toBe('none'); + await expect(page.locator('#help')).not.toHaveAttribute('open'); + await expect(page.locator('#inside')).toBeHidden(); + await page.locator('#next-action').click(); + await expect(page.locator('#next')).toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); +}); + +test('a modal opened from inside a portal keeps its ancestor mounted and remains operable', async ({ page }) => { + await page.locator('#modal').evaluate((modal) => modal.setAttribute('open', '')); + await page.locator('#help-trigger').click(); + await page.locator('#inside').evaluate((button) => { + const next = document.createElement('r-modal'); + next.id = 'nested'; + next.setAttribute('heading', 'Confirm changes'); + next.innerHTML = ''; + button.parentElement!.append(next); + next.setAttribute('open', ''); + }); + await expect(page.locator('#help')).toHaveAttribute('open'); + await page.locator('#nested-action').click(); + await expect(page.locator('#nested')).toHaveAttribute('open'); + await page.locator('#nested-action').focus(); + await expect(page.locator('#nested-action')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#nested')).not.toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); +}); + +test('a child editor consumes Escape before the modal dismisses on the next unhandled Escape', async ({ page }) => { + await page.locator('#modal').evaluate((modal) => { + const input = document.createElement('input'); + input.id = 'editor'; + input.value = 'Unsaved draft'; + input.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + } + }, + { once: true }, + ); + modal.append(input); + modal.setAttribute('open', ''); + }); + await page.locator('#editor').focus(); + await page.keyboard.press('Escape'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await expect(page.locator('#editor')).toBeFocused(); + await expect(page.locator('#editor')).toHaveValue('Unsaved draft'); + await page.keyboard.press('Escape'); + await expect(page.locator('#modal')).not.toHaveAttribute('open'); +}); + +for (const kind of ['popover', 'select']) { + test(`Escape from a sibling field dismisses ${kind} before its modal`, async ({ page }) => { + await page.locator('#modal').evaluate((modal, kind) => { + const input = document.createElement('input'); + input.id = 'sibling'; + // Put the field immediately after the tested trigger, so native Tab + // does not visit the unrelated Popover host and its native trigger first. + if (kind === 'select') modal.querySelector('#choice')!.after(input); + else modal.append(input); + modal.setAttribute('open', ''); + }, kind); + const trigger = page.locator(kind === 'popover' ? '#help-trigger' : '#choice'); + const owner = page.locator(kind === 'popover' ? '#help' : '#choice'); + await trigger.click(); + await expect(owner).toHaveAttribute('open'); + await trigger.focus(); + await page.keyboard.press('Tab'); + await expect(page.locator('#sibling')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(owner).not.toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await expect(trigger).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#modal')).not.toHaveAttribute('open'); + }); +} + +test('Escape from an outer popup sibling dismisses the nested popup first', async ({ page }) => { + await page.locator('#modal').evaluate((modal) => { + const outer = document.createElement('r-popover'); + outer.id = 'outer'; + outer.setAttribute('trigger', 'click'); + outer.innerHTML = + ''; + modal.append(outer); + modal.setAttribute('open', ''); + }); + await page.locator('#outer-trigger').click(); + await page.locator('#inner-trigger').click(); + await page.locator('#inner-trigger').focus(); + await page.keyboard.press('Tab'); + await expect(page.locator('#outer-sibling')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#inner')).not.toHaveAttribute('open'); + await expect(page.locator('#outer')).toHaveAttribute('open'); + await expect(page.locator('#modal')).toHaveAttribute('open'); + await expect(page.locator('#inner-trigger')).toBeFocused(); + await expect(page.locator('#outer-sibling')).toHaveValue('Retained'); + await page.keyboard.press('Escape'); + await expect(page.locator('#outer')).not.toHaveAttribute('open'); + await expect(page.locator('#outer-trigger')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#modal')).not.toHaveAttribute('open'); +}); diff --git a/packages/ranui/test/e2e/harness.spec.ts b/packages/ranui/test/e2e/harness.spec.ts index ce9ee6c47c..b1ef591816 100644 --- a/packages/ranui/test/e2e/harness.spec.ts +++ b/packages/ranui/test/e2e/harness.spec.ts @@ -31,3 +31,27 @@ test('isolated fixtures cannot inherit an external media error overlay', async ( expect(requests).toEqual([]); await expect(page.locator('r-modal[heading="Playback failed"]')).toHaveCount(0); }); + +test('frozen fixtures apply geometry, palette and layers without a transitional frame', async ({ page }) => { + await isolatedSetup(page, DEV_SERVER, 'r-button'); + await mount(page, '
Probe
'); + const state = await page.locator('#probe').evaluate(async (node) => { + const element = node as HTMLElement; + // Establish the initial computed values before changing all three roles together. + const before = getComputedStyle(element); + const initial = [before.left, before.color, before.zIndex]; + element.style.left = '120px'; + element.style.color = 'rgb(255, 255, 255)'; + element.style.zIndex = '1400'; + const read = () => { + const style = getComputedStyle(element); + return [style.left, style.color, style.zIndex]; + }; + const immediate = read(); + await new Promise(requestAnimationFrame); + return { initial, immediate, painted: read() }; + }); + expect(state.initial).toEqual(['0px', 'rgb(0, 0, 0)', '1']); + expect(state.immediate).toEqual(['120px', 'rgb(255, 255, 255)', '1400']); + expect(state.painted).toEqual(state.immediate); +}); diff --git a/packages/ranui/test/e2e/helpers.ts b/packages/ranui/test/e2e/helpers.ts index 71b3edc2a2..647eb70f0f 100644 --- a/packages/ranui/test/e2e/helpers.ts +++ b/packages/ranui/test/e2e/helpers.ts @@ -4,6 +4,9 @@ import { DEV_SERVER } from '../../build/config'; /** * Freezes animations in the **light DOM** only — page chrome, demo scaffolding, anything the * specs mount directly into ``. + * Zero transition duration matters: a tiny positive duration creates transitions on + * otherwise static elements (the initial transition-property is all), so geometry, + * theme colors and layer overrides can still be sampled before their next frame. * * It cannot reach inside a component: this is injected as a document-level `${Array.from({ length: 30 }, (_, index) => `Teammate ${index}`).join('')}`, + ); + await page.locator('#long-list').focus(); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('r-dropdown')).toBeVisible(); + await page.keyboard.press('End'); + const visible = await insideShadow(page, 'r-dropdown', (root) => { + const surface = root.querySelector('[part="dropdown"]')!; + const last = root.host.querySelector('r-dropdown-item:last-child')!; + const box = surface.getBoundingClientRect(); + const item = last.getBoundingClientRect(); + return { scroll: surface.scrollTop, contained: item.top >= box.top - 1 && item.bottom <= box.bottom + 1 }; + }); + expect(visible.scroll).toBeGreaterThan(0); + expect(visible.contained).toBe(true); + expect(await page.evaluate(() => scrollY)).toBe(0); +}); diff --git a/packages/ranui/test/e2e/select-disabled.spec.ts b/packages/ranui/test/e2e/select-disabled.spec.ts new file mode 100644 index 0000000000..470e955397 --- /dev/null +++ b/packages/ranui/test/e2e/select-disabled.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; +import { insideShadow, isolatedSetup, mount } from './helpers'; + +test.beforeEach(async ({ page }) => { + await isolatedSetup(page, DEV_SERVER, 'r-select'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await mount( + page, + 'AlphaBeta', + ); +}); + +test('disabling during a real option press cancels selection and closes the portal', async ({ page }) => { + await page.locator('#select').click(); + await expect(page.locator('#select')).toHaveAttribute('open'); + const option = page.locator('r-dropdown-item[value="b"]'); + await option.evaluate((node) => { + const host = document.querySelector('#select')!; + host.setAttribute('data-changes', '0'); + host.addEventListener('change', () => + host.setAttribute('data-changes', String(Number(host.getAttribute('data-changes')) + 1)), + ); + node.addEventListener('pointerdown', () => host.setAttribute('disabled', ''), { once: true }); + }); + await option.click(); + await expect(page.locator('#select')).toHaveAttribute('disabled', ''); + await expect(page.locator('#select')).toHaveAttribute('value', 'a'); + await expect(page.locator('#select')).toHaveAttribute('data-changes', '0'); + await expect(page.locator('#select')).not.toHaveAttribute('open'); + await expect(page.locator('#select')).not.toBeFocused(); + await expect(page.locator('r-dropdown')).toBeHidden(); + await page.locator('#select').evaluate((host) => host.removeAttribute('disabled')); + await page.locator('#select').click(); + await page.locator('r-dropdown-item[value="b"]').click(); + await expect(page.locator('#select')).toHaveAttribute('value', 'b'); + await expect(page.locator('#select')).toHaveAttribute('data-changes', '1'); +}); + +test('disabled search preserves its draft without accepting queued input or reopening', async ({ page }) => { + await page.locator('#select').evaluate((host) => host.setAttribute('showsearch', '')); + await page.locator('#select').click(); + await page.keyboard.type('Al'); + await page.locator('#select').evaluate((host) => { + host.setAttribute('data-searches', '0'); + host.addEventListener('search', () => + host.setAttribute('data-searches', String(Number(host.getAttribute('data-searches')) + 1)), + ); + host.setAttribute('disabled', ''); + const input = (host as HTMLElement & { _shadowDom: ShadowRoot })._shadowDom.querySelector('input')!; + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'late' })); + }); + expect( + await insideShadow(page, '#select', (root) => { + const input = root.querySelector('input')!; + return { value: input.value, disabled: input.disabled }; + }), + ).toEqual({ value: 'Al', disabled: true }); + await expect(page.locator('#select')).toHaveAttribute('data-searches', '0'); + await expect(page.locator('#select')).not.toHaveAttribute('open'); + await page.locator('#select').evaluate((host) => host.setAttribute('open', '')); + await expect(page.locator('#select')).not.toHaveAttribute('open'); + await page.locator('#select').evaluate((host) => host.setAttribute('value', 'b')); + await expect(page.locator('#select')).toHaveAttribute('value', 'b'); +}); diff --git a/packages/ranui/test/e2e/select-interaction.spec.ts b/packages/ranui/test/e2e/select-interaction.spec.ts index aba0aefb8b..a6834e4f60 100644 --- a/packages/ranui/test/e2e/select-interaction.spec.ts +++ b/packages/ranui/test/e2e/select-interaction.spec.ts @@ -69,6 +69,60 @@ test('host typeahead selects once with a nonbubbling change; composing Enter doe ).toEqual({ changes: [{ value: 'b', label: 'Beta' }], delegated: 0 }); }); +test.describe('normal-motion exit interruption', () => { + // Set the preference before sheets are adopted; changing emulation after mount + // can leave WebKit media rules at the preceding reduced-motion value. + test.use({ contextOptions: { reducedMotion: 'no-preference' } }); + + for (const key of ['ArrowDown', 'Home', 'Enter', 'g']) { + test(`normal-motion selection immediately reopens with ${key} during its exit`, async ({ page }) => { + const select = page.locator('#select'); + await select.evaluate((host) => { + host.removeAttribute('showsearch'); + (window as unknown as { selectionChanges: unknown[] }).selectionChanges = []; + host.addEventListener('change', (event) => + (window as unknown as { selectionChanges: unknown[] }).selectionChanges.push((event as CustomEvent).detail), + ); + host.addEventListener( + 'keydown', + () => { + if ((window as unknown as { selectionChanges: unknown[] }).selectionChanges.length !== 1) return; + // Observe the actual reopening key before the component handles it, without + // spending the short exit animation on intervening automation round trips. + (window as unknown as { beforeReopen: unknown }).beforeReopen = { + open: host.hasAttribute('open'), + expanded: host.getAttribute('aria-expanded'), + display: (document.querySelector('r-dropdown') as HTMLElement).style.display, + }; + }, + { capture: true }, + ); + }); + await select.focus(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await page.keyboard.press(key); + // Prove this key arrived during exit; never wait out the defect. + expect(await page.evaluate(() => (window as unknown as { beforeReopen: unknown }).beforeReopen)).toEqual({ + open: false, + expanded: 'false', + display: 'block', + }); + const panel = page.locator('r-dropdown'); + await expect(select).toHaveAttribute('open'); + await expect(select).toHaveAttribute('aria-expanded', 'true'); + await expect(select).toHaveAttribute('value', 'bb'); + expect( + await page.evaluate(() => (window as unknown as { selectionChanges: unknown[] }).selectionChanges), + ).toEqual([{ value: 'bb', label: 'Beta two' }]); + // The old exit completion cannot hide the newly opened panel. + await expect(panel).not.toHaveAttribute('transit'); + await expect(panel).toBeVisible(); + await expect(select).toHaveAttribute('open'); + }); + } +}); + for (const attribute of ['value', 'defaultvalue']) { test(`matching search preserves preselected ${attribute} and accepts replacement`, async ({ page }) => { await page.locator('#select').evaluate((host, name) => host.setAttribute(name, 'a'), attribute); diff --git a/packages/ranui/test/e2e/select-presentation.spec.ts b/packages/ranui/test/e2e/select-presentation.spec.ts new file mode 100644 index 0000000000..8a0744ca9e --- /dev/null +++ b/packages/ranui/test/e2e/select-presentation.spec.ts @@ -0,0 +1,101 @@ +import { expect, test, type Page } from '@playwright/test'; +import { DEV_SERVER } from '../../build/config'; +import { insideShadow, isolatedSetup, mount } from './helpers'; + +const triggerStyle = (page: Page) => + insideShadow(page, '#select', (root) => { + const surface = getComputedStyle(root.querySelector('[part="selection"]')!); + return { + color: getComputedStyle(root.querySelector('[part="selection-item"]')!).color, + shadow: surface.boxShadow, + borderWidth: surface.borderLeftWidth, + }; + }); + +for (const theme of ['light', 'dark'] as const) { + test.describe(`r-select presentation in ${theme}`, () => { + test.beforeEach(async ({ page }) => { + await isolatedSetup(page, DEV_SERVER, 'r-select'); + await page.emulateMedia({ colorScheme: theme }); + await page.evaluate((value) => { + document.documentElement.setAttribute('data-ran-theme', value); + document.body.style.background = 'var(--ran-color-bg)'; + }, theme); + expect( + await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--ran-gray-1000').trim(), + ), + ).toBe(theme === 'dark' ? '#ededed' : '#171717'); + }); + + test('text variant paints keyboard focus while retaining its chromeless surface', async ({ page }) => { + await mount( + page, + 'Private workspaceTeam workspace', + ); + expect((await triggerStyle(page)).shadow).toBe('none'); + await page.keyboard.press('Tab'); + await expect(page.locator('#select')).toBeFocused(); + expect(await page.locator('#select').evaluate((el) => el.matches(':focus-visible'))).toBe(true); + expect((await triggerStyle(page)).shadow).not.toBe('none'); + expect((await triggerStyle(page)).borderWidth).toBe('0px'); + await page.keyboard.press('Enter'); + await expect(page.locator('#select')).toHaveAttribute('open'); + await page.keyboard.press('Escape'); + await expect(page.locator('#select')).not.toHaveAttribute('open'); + expect((await triggerStyle(page)).shadow).not.toBe('none'); + }); + + test('keeps a valid selected value readable on keyboard focus and hover', async ({ page }) => { + await mount( + page, + 'Private workspaceTeam workspace', + ); + await page.mouse.move(500, 500); + const resting = await triggerStyle(page); + await page.keyboard.press('Tab'); + await expect(page.locator('#select')).toBeFocused(); + expect((await triggerStyle(page)).shadow).not.toBe('none'); + expect((await triggerStyle(page)).color).toBe(resting.color); + await page.keyboard.press('Tab'); + await page.locator('#select').hover(); + await expect.poll(async () => (await triggerStyle(page)).color).toBe(resting.color); + }); + + test('distinguishes a disabled option without hover feedback and preserves selection guards', async ({ page }) => { + await mount( + page, + 'Private workspacePublic workspaceTeam workspace', + ); + await page.locator('#select').click(); + const disabled = page.locator('r-dropdown-item[value="b"]'); + await expect(disabled).toBeVisible(); + const style = (selector: string) => + insideShadow(page, selector, (root) => { + const item = getComputedStyle(root.querySelector('[part="item"]')!); + return { + color: getComputedStyle(root.querySelector('[part="content"]')!).color, + cursor: item.cursor, + background: item.backgroundColor, + }; + }); + const resting = await style('r-dropdown-item[value="b"]'); + expect(resting.color).not.toBe((await style('r-dropdown-item[value="c"]')).color); + expect(resting.cursor).toBe('not-allowed'); + await disabled.hover(); + expect((await style('r-dropdown-item[value="b"]')).background).toBe(resting.background); + const target = await disabled.boundingBox(); + expect(target?.width).toBeGreaterThan(0); + // Playwright's locator click refuses aria-disabled targets. Send the actual pointer + // at its measured box so the component's own selection guard is exercised. + await page.mouse.click(target!.x + target!.width / 2, target!.y + target!.height / 2); + await expect(page.locator('#select')).toHaveAttribute('value', 'a'); + await page.locator('#select').focus(); + await page.keyboard.press('ArrowDown'); + await expect(disabled).toHaveAttribute('aria-selected', 'false'); + await page.keyboard.press('Enter'); + await expect(page.locator('#select')).toHaveAttribute('value', 'c'); + await expect(page.locator('#select')).not.toHaveAttribute('open'); + }); + }); +} diff --git a/packages/ranui/test/e2e/visual/components.spec.ts b/packages/ranui/test/e2e/visual/components.spec.ts index c5cc87ad34..620a45a2c9 100644 --- a/packages/ranui/test/e2e/visual/components.spec.ts +++ b/packages/ranui/test/e2e/visual/components.spec.ts @@ -32,14 +32,18 @@ test('select closed state', { tag: '@visual' }, async ({ page }) => { test('select open dropdown', { tag: '@visual' }, async ({ page }) => { const section = page.locator('#component-select'); await expect(section).toBeVisible(); - // Click the first select to open its dropdown - await section.locator('r-select').first().click(); - const popup = `#${await section.locator('r-select').first().getAttribute('aria-controls')}`; + await section.evaluate((node) => node.scrollIntoView({ block: 'center', behavior: 'instant' })); + const select = section.locator('r-select').first(); + await select.evaluate((node) => { + node.addEventListener('after-show', () => node.setAttribute('data-test-after-show', ''), { once: true }); + }); + await select.click(); + const popup = `#${await select.getAttribute('aria-controls')}`; await expect(page.locator(popup)).toBeVisible(); await expect(page.locator(popup).locator('r-dropdown-item')).toHaveCount(3); await expect(page.locator(popup).locator('r-dropdown-item').last()).toBeVisible(); + await expect(select).toHaveAttribute('data-test-after-show', ''); await expect(page).toHaveScreenshot('select-open.png', { - fullPage: true, clip: await popupCaptureBounds(page, '#component-select', popup), }); }); @@ -95,6 +99,7 @@ test('popover closed', { tag: '@visual' }, async ({ page }) => { test('popover opens by hover or touch', { tag: '@visual' }, async ({ page, hasTouch }) => { const section = page.locator('#component-popover'); await expect(section).toBeVisible(); + await section.evaluate((node) => node.scrollIntoView({ block: 'center', behavior: 'instant' })); const popover = section.locator('r-popover'); await popover.evaluate((node) => { node.addEventListener('after-show', () => node.setAttribute('data-test-after-show', ''), { once: true }); @@ -108,10 +113,19 @@ test('popover opens by hover or touch', { tag: '@visual' }, async ({ page, hasTo // Visibility precedes the positioning frame and entrance transform. Measure // the capture only after the component reports its final painted geometry. await expect(popover).toHaveAttribute('data-test-after-show', ''); + await page.evaluate(() => { + // A screenshot must not resize the viewport and invalidate its own portal crop. + document.documentElement.dataset.captureResizes = '0'; + window.addEventListener('resize', () => { + document.documentElement.dataset.captureResizes = String( + Number(document.documentElement.dataset.captureResizes) + 1, + ); + }); + }); await expect(page).toHaveScreenshot('popover-open.png', { - fullPage: true, clip: await popupCaptureBounds(page, '#component-popover', popup), }); + await expect(page.locator('html')).toHaveAttribute('data-capture-resizes', '0'); }); test('math rendering', { tag: '@visual' }, async ({ page }) => { diff --git a/packages/ranui/test/e2e/visual/player.spec.ts b/packages/ranui/test/e2e/visual/player.spec.ts index e52e094cf3..a7afcce9b9 100644 --- a/packages/ranui/test/e2e/visual/player.spec.ts +++ b/packages/ranui/test/e2e/visual/player.spec.ts @@ -44,7 +44,7 @@ test('player control bar layout', { tag: '@visual' }, async ({ page }) => { await expect(page.locator('r-player')).toHaveScreenshot('player-control-bar.png'); }); -test('player with live stream src mounts in the demo', async ({ page }) => { +test('player loads its live sample after explicit intent in the demo', async ({ page }) => { await demoSetup(page, 'r-player', { liveMedia: true }); // Functional only — deliberately no screenshot. This section holds the demo's live HLS // stream, and a decoding