@linhey/rhine-design 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +149 -2
- package/components/Actions.tsx +40 -0
- package/components/COMMON_COMPONENTS.md +70 -0
- package/components/Data.tsx +42 -0
- package/components/Forms.tsx +64 -0
- package/components/Layout.tsx +13 -0
- package/components/Logo.tsx +34 -0
- package/components/MOTION.md +72 -0
- package/components/Overlays.tsx +53 -0
- package/components/README.md +137 -0
- package/components/STARTUP.md +34 -0
- package/components/Select.tsx +22 -0
- package/components/TYPOGRAPHY.md +60 -0
- package/components/Toast.tsx +25 -0
- package/components/Toolbar.tsx +14 -0
- package/components/Typography.tsx +13 -0
- package/components/assets/logo.svg +5 -0
- package/components/common.css +69 -0
- package/components/components.tsx +151 -0
- package/components/index.ts +24 -0
- package/components/styles.css +109 -0
- package/components/tokens.css +58 -0
- package/components/typography.css +31 -0
- package/design/README.md +14 -0
- package/docs/DESIGN_SYSTEM.md +84 -0
- package/docs/DESIGN_SYSTEM_AUDIT.md +63 -0
- package/docs/RELEASING.md +27 -0
- package/index.html +3 -0
- package/package.json +76 -4
- package/preview/CommonGallery.tsx +35 -0
- package/preview/MotionGallery.tsx +89 -0
- package/preview/StartupGallery.tsx +73 -0
- package/preview/brand-assembly.css +18 -0
- package/preview/main.tsx +100 -0
- package/preview/motion.css +25 -0
- package/preview/preview.css +27 -0
- package/preview/startup.css +33 -0
- package/scripts/check-build.mjs +15 -0
- package/skills/rhine-design/SKILL.md +37 -0
- package/skills/rhine-design/agents/openai.yaml +4 -0
- package/skills/rhine-design/references/API_MAP.md +34 -0
- package/skills/rhine-design/references/TASK_MAP.md +20 -0
- package/skills/rhine-design/references/integration.md +40 -0
- package/tests/CommonComponents.test.tsx +71 -0
- package/tests/DesignSystem.test.tsx +100 -0
- package/tests/MotionGallery.test.tsx +32 -0
- package/tests/StartupGallery.test.tsx +55 -0
- package/tests/setup.ts +4 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +3 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
body { margin:0; }
|
|
2
|
+
.ds-type-row { display:grid; grid-template-columns:180px minmax(0,1fr); align-items:baseline; gap:12px 24px; padding:16px 0; border-bottom:1px solid var(--rhine-line); }
|
|
3
|
+
.ds-type-row:last-child { border:0; }
|
|
4
|
+
.ds-type-copy { display:grid; gap:12px; margin-top:16px; max-width:60ch; }
|
|
5
|
+
@media(max-width:600px) { .ds-type-row { grid-template-columns:1fr; } }
|
|
6
|
+
.ds-header { display:flex; justify-content:space-between; align-items:center; gap:16px; border-bottom:1px solid var(--rhine-line); padding:20px max(24px,calc((100vw - 1080px)/2)); }
|
|
7
|
+
.ds-brand { display:inline-flex; align-items:center; color:var(--rhine-ink); text-decoration:none; }
|
|
8
|
+
.ds-brand>span:not(.rhine-logo) { font:12px var(--rhine-mono); letter-spacing:1px; margin-left:16px; }
|
|
9
|
+
.ds-logo-pair { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; }
|
|
10
|
+
.ds-logo-stage { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:24px; padding:32px 12px; background:var(--rhine-surface); border:1px solid var(--rhine-line); }
|
|
11
|
+
.ds-logo-stage>span:last-child { font:12px var(--rhine-mono); letter-spacing:2px; }
|
|
12
|
+
.ds-logo-stage--dark { background:var(--rhine-ink); color:var(--rhine-bg); border-color:var(--rhine-ink); }
|
|
13
|
+
@media(max-width:600px) { .ds-logo-pair { grid-template-columns:1fr; } }
|
|
14
|
+
.ds-gallery main { max-width:1128px; padding:0 24px; margin:auto; }
|
|
15
|
+
.ds-hero { display:grid; grid-template-columns:1.5fr 1fr; gap:48px; padding:56px 0 40px; align-items:end; }
|
|
16
|
+
.ds-kicker { font:12px var(--rhine-mono); letter-spacing:1px; color:var(--rhine-muted); }
|
|
17
|
+
.ds-hero h1 { font-size:44px; line-height:1.35; letter-spacing:-2px; margin:24px 0; font-weight:600; }.ds-hero h1>span { color:var(--rhine-green); }
|
|
18
|
+
.ds-hero p { color:var(--rhine-muted); }
|
|
19
|
+
.ds-motion-control { border-left:2px solid var(--rhine-signal); padding-left:24px; }.ds-motion-control>.ds-caption { display:block; margin-bottom:12px; }.ds-motion-control p { font-size:12px; }
|
|
20
|
+
.ds-nav { display:flex; flex-wrap:wrap; gap:16px 28px; padding:16px 0; border-block:1px solid var(--rhine-ink); }.ds-nav a { color:var(--rhine-green); text-decoration:none; min-height:28px; display:flex; align-items:center; }
|
|
21
|
+
.ds-gallery section { padding:32px 0; scroll-margin-top:24px; }.ds-section-title { display:flex; align-items:baseline; gap:16px; margin-bottom:24px; }.ds-section-title>span { font:12px var(--rhine-mono); color:var(--rhine-warning); }.ds-section-title h2 { font-size:22px; font-weight:600; margin:0; }
|
|
22
|
+
.ds-palette { display:grid; grid-template-columns:repeat(5,1fr); gap:16px; }.ds-palette>div>div { height:82px; border:1px solid var(--rhine-line); }.ds-palette strong,.ds-palette code { display:block; font:12px/1.8 var(--rhine-mono); }.ds-palette strong { margin-top:10px; }.ds-palette code { color:var(--rhine-muted); }
|
|
23
|
+
.ds-notes { display:flex; flex-wrap:wrap; gap:12px 32px; margin-top:24px; padding-top:16px; border-top:1px solid var(--rhine-line); font-size:12px; color:var(--rhine-muted); }
|
|
24
|
+
.ds-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; align-items:start; }.ds-grid>* { min-width:0; }.ds-component-title { display:flex; align-items:baseline; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:20px; }.ds-component-title h3 { font:600 16px var(--rhine-mono); margin:0; }.ds-component-title>span { color:var(--rhine-muted); font-size:12px; }
|
|
25
|
+
.ds-row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }.ds-caption { font-size:12px; line-height:1.8; color:var(--rhine-muted); }.ds-gap-top { margin-top:20px; }.ds-badges { margin-bottom:24px; }.ds-loaded { min-height:72px; margin:0; }
|
|
26
|
+
.ds-footer { border-top:1px solid var(--rhine-ink); display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; padding:24px 0; color:var(--rhine-muted); font-size:12px; }
|
|
27
|
+
@media(max-width:760px) { .ds-hero,.ds-grid { grid-template-columns:1fr; gap:24px; }.ds-hero { padding-top:32px; }.ds-hero h1 { font-size:32px; letter-spacing:-1px; }.ds-brand>span:not(.rhine-logo) { display:none; }.ds-palette { grid-template-columns:repeat(auto-fit,minmax(min(100%,90px),1fr)); }.ds-palette>div>div { height:64px; }.ds-gallery main { padding:0 20px; }.ds-header { padding:16px 20px; flex-wrap:wrap; }.ds-section-title h2 { font-size:19px; } }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.ds-startup-layout { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(240px,1fr); gap:40px; margin:24px 0; align-items:center; }
|
|
2
|
+
.ds-startup-window { border:1px solid var(--rhine-line); background:var(--rhine-bg); min-width:0; }
|
|
3
|
+
.ds-startup-chrome { display:flex; justify-content:space-between; gap:12px; padding:12px 20px; border-bottom:1px solid var(--rhine-line); font:12px/18px var(--rhine-font); color:var(--rhine-muted); }
|
|
4
|
+
.ds-startup-stage { min-height:540px; padding:40px 24px 24px; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; position:relative; isolation:isolate; }
|
|
5
|
+
.ds-startup-emblem { width:240px; height:220px; position:relative; box-sizing:border-box; flex:none; }
|
|
6
|
+
.ds-startup-logo-anchor { position:absolute; left:32px; top:32px; width:176px; height:176px; transform-origin:center; transition:transform calc(560ms * var(--startup-speed)) cubic-bezier(.22,1,.36,1); }
|
|
7
|
+
.ds-startup-construction { position:absolute; inset:0; width:240px; height:240px; pointer-events:none; transition:opacity calc(180ms * var(--startup-speed)) ease-out; }
|
|
8
|
+
.ds-startup-guide { stroke:var(--rhine-control); stroke-width:.75; opacity:0; }
|
|
9
|
+
.ds-startup-ticks { stroke:var(--rhine-line); stroke-width:1; }
|
|
10
|
+
.ds-startup-signal { fill:var(--rhine-signal); opacity:0; }
|
|
11
|
+
.ds-startup-wordmark { position:absolute; top:246px; width:100%; text-align:center; color:var(--rhine-ink); font:600 30px/38px var(--rhine-font); letter-spacing:.04em; transition:transform calc(560ms * var(--startup-speed)) cubic-bezier(.22,1,.36,1); }
|
|
12
|
+
.ds-startup-status { font:12px/18px var(--rhine-font); color:var(--rhine-muted); margin:100px 0 24px; }
|
|
13
|
+
.ds-startup-footnote { display:flex; justify-content:space-between; flex-wrap:wrap; gap:16px; width:100%; margin-top:auto; padding-top:32px; font:12px/18px var(--rhine-mono); letter-spacing:.06em; color:var(--rhine-muted); }
|
|
14
|
+
.ds-startup-score h3 { font:600 26px/1.4 var(--rhine-font); margin:20px 0 12px; }.ds-startup-score>p { color:var(--rhine-muted); }
|
|
15
|
+
.ds-startup-score ol { list-style:none; padding:0; margin:28px 0; }.ds-startup-score li { padding:16px 0; border-top:1px solid var(--rhine-line); }.ds-startup-score li>span { display:block; color:var(--rhine-muted); font:12px/18px var(--rhine-mono); margin-bottom:8px; }.ds-startup-score li strong { font-size:16px; font-weight:600; }.ds-startup-score li p { margin:4px 0 0; font-size:12px; color:var(--rhine-muted); }
|
|
16
|
+
.ds-startup-controls { display:flex; align-items:center; gap:24px; flex-wrap:wrap; margin-bottom:20px; }.ds-startup-controls>.rhine-field { flex:1; min-width:180px; }
|
|
17
|
+
.ds-startup-destination { display:flex; flex-direction:column; gap:16px; align-items:center; text-align:center; width:100%; max-width:320px; animation:startup-arrival calc(420ms * var(--startup-speed)) ease-out both; }
|
|
18
|
+
.ds-startup-destination>.rhine-button { margin-top:8px; width:100%; max-width:260px; }
|
|
19
|
+
.ds-startup-destination:focus-within { animation:none; }
|
|
20
|
+
.ds-startup-stage[data-state=ready] .ds-startup-logo-anchor { transform:translateY(-16px) scale(.5); }
|
|
21
|
+
.ds-startup-stage[data-state=ready] .ds-startup-wordmark { transform:translateY(-84px); }
|
|
22
|
+
.ds-startup-stage[data-state=ready] .ds-startup-construction { opacity:0; }
|
|
23
|
+
@keyframes startup-arrival { from { opacity:.12; transform:translateY(3px); } to { opacity:1; transform:translateY(0); } }
|
|
24
|
+
.ds-startup-stage[data-state=playing] .ds-startup-guide { animation:startup-guide calc(1340ms * var(--startup-speed)) var(--rhine-ease-standard) both; }
|
|
25
|
+
.ds-startup-stage[data-state=playing] .ds-startup-signal { animation:startup-signal calc(280ms * var(--startup-speed)) linear calc(1060ms * var(--startup-speed)) both; }
|
|
26
|
+
.ds-startup-stage[data-state=playing] .ds-startup-wordmark { animation:startup-destination calc(240ms * var(--startup-speed)) var(--rhine-ease-standard) calc(1080ms * var(--startup-speed)) both; }
|
|
27
|
+
@keyframes startup-guide { 0% { stroke-dasharray:1; stroke-dashoffset:1; opacity:0; } 24%,79% { stroke-dasharray:1; stroke-dashoffset:0; opacity:.65; } 100% { stroke-dasharray:1; stroke-dashoffset:0; opacity:0; } }
|
|
28
|
+
@keyframes startup-signal { 0%,100% { opacity:0; } 20%,65% { opacity:1; } }
|
|
29
|
+
@keyframes startup-destination { from { opacity:0; } to { opacity:1; } }
|
|
30
|
+
.ds-startup[data-rhine-motion=reduced] * { animation:none!important; transition:none!important; }
|
|
31
|
+
@media(prefers-reduced-motion:reduce) { .ds-startup * { animation:none!important; transition:none!important; } }
|
|
32
|
+
@media(max-width:760px) { .ds-startup-layout { grid-template-columns:minmax(0,1fr); gap:24px; }.ds-startup-stage { padding-inline:16px; }.ds-startup-score ol { margin:20px 0; } }
|
|
33
|
+
@media(max-width:360px) { .ds-startup-chrome { padding-inline:12px; }.ds-startup-footnote { flex-wrap:wrap; } }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const assets = new URL('../dist/assets/', import.meta.url);
|
|
5
|
+
const files = await readdir(assets);
|
|
6
|
+
const css = (await Promise.all(files.filter(file => file.endsWith('.css')).map(file => readFile(new URL(file, assets), 'utf8')))).join('\n');
|
|
7
|
+
for (const required of ['--rhine-bg:', '.rhine-theme', '.rhine-button', '.ds-startup']) {
|
|
8
|
+
if (!css.includes(required)) throw new Error(`Design kit build lost required stylesheet content: ${required}`);
|
|
9
|
+
}
|
|
10
|
+
if (!files.some(file => /^logo-.*\.svg$/.test(file))) throw new Error('Design kit build lost the SVG master asset');
|
|
11
|
+
const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8');
|
|
12
|
+
for (const match of html.matchAll(/(?:src|href)="(\.\/assets\/[^"#]+)"/g)) {
|
|
13
|
+
await readFile(new URL(join('../dist/', match[1]), import.meta.url));
|
|
14
|
+
}
|
|
15
|
+
console.log('Design kit build verified: theme, controls, startup styles and SVG asset retained');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rhine-design
|
|
3
|
+
description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
|
|
4
|
+
metadata:
|
|
5
|
+
version: "0.3.0"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Rhine Design · 莱茵设计
|
|
9
|
+
|
|
10
|
+
Use the installed version's components and contracts to build consistent interfaces. This is an independently versioned, general design system; retain the host product's identity and data ownership.
|
|
11
|
+
|
|
12
|
+
## Locate the matching source
|
|
13
|
+
|
|
14
|
+
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.3.0.
|
|
15
|
+
|
|
16
|
+
Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
|
|
17
|
+
|
|
18
|
+
## Route the task
|
|
19
|
+
|
|
20
|
+
- Screen design, interaction, typography, motion: start with [TASK_MAP.md](references/TASK_MAP.md), then read only the relevant contract.
|
|
21
|
+
- A named component or prop: use [API_MAP.md](references/API_MAP.md), then inspect its exported type and implementation.
|
|
22
|
+
- First integration, linking, theme or missing styles: read [integration.md](references/integration.md).
|
|
23
|
+
|
|
24
|
+
## Design before implementation
|
|
25
|
+
|
|
26
|
+
1. Inventory existing components and tokens for the requested behavior. Compose them before creating a new component or duplicating styles.
|
|
27
|
+
2. Update the host's page mockup or the library's real `preview/` showcase first. A new shared capability includes its contract and a usable example.
|
|
28
|
+
3. Verify the changed states and interaction in the preview; then integrate into the app when implementation is in scope. Continue both stages when already authorized; a design-only request does not authorize an app migration.
|
|
29
|
+
4. For library changes run `npm run check`; for visual changes inspect relevant widths (400/520/880), 200% zoom, keyboard/focus and reduced motion. For consumer changes run that project's checks. Documentation-only edits need link, example and version consistency checks, not an unrelated UI test campaign.
|
|
30
|
+
|
|
31
|
+
## Preserve these contracts
|
|
32
|
+
|
|
33
|
+
- Use public exports, the `.rhine-theme` root and existing tokens. Use the `rhine-` namespace for CSS variables, classes, animation names and motion data attributes; do not introduce host-product prefixes.
|
|
34
|
+
- Read the actual component signature before using it. Keep semantic headings separate from visual text variants.
|
|
35
|
+
- Host state determines data, selection and operation outcomes. Search and pagination remain host/server responsibilities; busy indicators never imply success.
|
|
36
|
+
- Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
|
|
37
|
+
- Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# API map
|
|
2
|
+
|
|
3
|
+
All paths are relative to the matching package root. `components/index.ts` is the public export authority. This index locates APIs; exact props come from the installed source, not from remembered examples.
|
|
4
|
+
|
|
5
|
+
| Exports | Source |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Logo | `components/Logo.tsx` |
|
|
8
|
+
| Text | `components/Typography.tsx` |
|
|
9
|
+
| Select | `components/Select.tsx` |
|
|
10
|
+
| Toolbar | `components/Toolbar.tsx` |
|
|
11
|
+
| Icon, IconButton, Link, CopyButton | `components/Actions.tsx` |
|
|
12
|
+
| Divider, Stack, Inline, Grid | `components/Layout.tsx` |
|
|
13
|
+
| Metric, KeyValue, DataTable, Pagination | `components/Data.tsx` |
|
|
14
|
+
| Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox | `components/Forms.tsx` |
|
|
15
|
+
| Dialog, ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
|
|
16
|
+
| Toast | `components/Toast.tsx` |
|
|
17
|
+
| Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx` |
|
|
18
|
+
|
|
19
|
+
Primary contracts: `components/README.md` and `components/COMMON_COMPONENTS.md`. Find behavioral evidence in `tests/` and composition examples in `preview/`.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
# From the resolved package root:
|
|
23
|
+
rg -n 'ConfirmDialog|DataTable|Combobox' components/index.ts components/*.tsx
|
|
24
|
+
rg -n 'ConfirmDialog|DataTable|Combobox' tests preview
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
API decisions that commonly matter:
|
|
28
|
+
|
|
29
|
+
- `Select` uses a native single-select; searchable candidates use `Combobox`.
|
|
30
|
+
- `SelectionCard` uses checkbox semantics; the host determines single-selection policy. Do not nest other interactive controls in it.
|
|
31
|
+
- `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
|
|
32
|
+
- `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
|
|
33
|
+
- `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
|
|
34
|
+
- Components in `preview/` are demonstrations unless re-exported through `components/index.ts`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Task map
|
|
2
|
+
|
|
3
|
+
Resolve the matching package root as described in SKILL.md. Open the first relevant source; expand only when the task crosses domains.
|
|
4
|
+
|
|
5
|
+
| Task | First reference (relative to package root) | Implementation / example |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Visual direction, colors, spacing, layout | `docs/DESIGN_SYSTEM.md` | `components/tokens.css`, `components/Layout.tsx` |
|
|
8
|
+
| Text hierarchy, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx` |
|
|
9
|
+
| Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
|
|
10
|
+
| Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
|
|
11
|
+
| Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
|
|
12
|
+
| Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
|
|
13
|
+
| Transition, replay, cancellation, reduced motion | `components/MOTION.md` | `preview/`, `components/styles.css` |
|
|
14
|
+
| Startup sequence, logo assembly, ready handoff | `components/STARTUP.md` | Search `startup` in `preview/`; not a public Startup component |
|
|
15
|
+
| Missing capability or system coverage | `docs/DESIGN_SYSTEM_AUDIT.md` | Verify status against `components/index.ts` |
|
|
16
|
+
| Version upgrade | `CHANGELOG.md` | Installed `package.json`, current types |
|
|
17
|
+
|
|
18
|
+
Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion` and `/#startup`. Do not assume another project's server or local filesystem layout.
|
|
19
|
+
|
|
20
|
+
For host pages, keep product-specific drafts in the host project. The library's `preview/` demonstrates reusable behavior, not business APIs. Labels such as “candidate” or “preview” in motion docs identify experiments; check whether the behavior is actually exported before integrating it.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Integration and diagnosis
|
|
2
|
+
|
|
3
|
+
## Supported baseline
|
|
4
|
+
|
|
5
|
+
Rhine Design 0.3.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { Button, Surface, Text } from '@linhey/rhine-design';
|
|
9
|
+
|
|
10
|
+
export function Example() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="rhine-theme">
|
|
13
|
+
<Surface>
|
|
14
|
+
<Text as="h2" variant="section">工作空间</Text>
|
|
15
|
+
<Button onClick={() => { /* host action */ }}>继续</Button>
|
|
16
|
+
</Surface>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Integration invariants
|
|
23
|
+
|
|
24
|
+
- The entry imports CSS automatically. Keep the package's entry and CSS `sideEffects` declarations so production tree shaking retains styles.
|
|
25
|
+
- Put `.rhine-theme` at the page root. Do not transform, clip or apply containment to it: overlays portal to the nearest theme root and need its variables and reduced-motion settings.
|
|
26
|
+
- For a locally linked checkout, use Vite `resolve.dedupe: ['react', 'react-dom']`. Duplicate React can break hooks; do not patch component state to mask this.
|
|
27
|
+
- Preserve SVG `?no-inline` handling. Logo uses a shared geometric master; missing symbols in production warrant checking the emitted SVG asset before replacing the logo.
|
|
28
|
+
- Reuse tokens for layout and state styling. Full dark theme is not exported in 0.3.0; the logo's dark variant alone does not establish dark-mode support.
|
|
29
|
+
|
|
30
|
+
## Host responsibilities
|
|
31
|
+
|
|
32
|
+
Pass controlled form values, data, sorting/pagination intentions and operation results through the documented APIs. The host owns network requests, debouncing, IME composition, cancellation and stale-response protection. Do not replace server filtering with an unrequested full-data download.
|
|
33
|
+
|
|
34
|
+
Await actual copy/confirm outcomes. Errors remain actionable; busy state preserves meaningful focus. Inspect overlay focus return, Escape and keyboard navigation in the browser when changing those interactions. Progress with unknown duration is indeterminate; startup animation ends when the host is ready rather than imposing a decorative delay.
|
|
35
|
+
|
|
36
|
+
## Upgrade and feedback
|
|
37
|
+
|
|
38
|
+
Check the installed version against this skill's metadata, then consult `CHANGELOG.md`. Refresh the copied skill together with the package; keep local customizations when comparing versions. If types and docs disagree, use source for the immediate API and repair the stale reference when in scope.
|
|
39
|
+
|
|
40
|
+
For reproducible feedback record package version, component/API, relevant source or reference path, expected and actual behavior, and the smallest reproduction. Public reports need no private host payloads or credentials. Filing an external issue remains a user-directed action.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Checkbox, Combobox, ConfirmDialog, CopyButton, DataTable, Metric, Pagination, RadioGroup, SearchField, Switch, Toast } from '../components';
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
7
|
+
describe('common design components', () => {
|
|
8
|
+
it('distinguishes zero from unknown metrics', () => {
|
|
9
|
+
render(<><Metric label="计数" value={0}/><Metric label="未知" value={null}/></>);
|
|
10
|
+
expect(screen.getByText('0')).toBeVisible(); expect(screen.getByText('—')).toBeVisible();
|
|
11
|
+
});
|
|
12
|
+
it('emits sort intent without sorting or dropping stale rows on failure', () => {
|
|
13
|
+
const sort=vi.fn();
|
|
14
|
+
render(<DataTable caption="请求" rows={[{id:'b'},{id:'a'}]} rowKey={row=>row.id} columns={[{key:'id',header:'标识',sortable:true,render:row=>row.id}]} onSortChange={sort} error="连接失败"/>);
|
|
15
|
+
fireEvent.click(screen.getByRole('button',{name:'标识'}));
|
|
16
|
+
expect(sort).toHaveBeenCalledWith({key:'id',direction:'asc'});
|
|
17
|
+
expect(screen.getAllByRole('cell').map(node=>node.textContent)).toEqual(['b','a']);
|
|
18
|
+
expect(screen.getByRole('alert')).toHaveTextContent('保留上次读取');
|
|
19
|
+
});
|
|
20
|
+
it('respects pagination boundaries, busy state and unknown totals', () => {
|
|
21
|
+
const change=vi.fn();const {rerender}=render(<Pagination page={1} pageSize={10} total={23} onPageChange={change}/>);
|
|
22
|
+
expect(screen.getByRole('button',{name:'上一页'})).toBeDisabled();fireEvent.click(screen.getByRole('button',{name:'下一页'}));expect(change).toHaveBeenCalledWith(2);
|
|
23
|
+
rerender(<Pagination page={3} pageSize={10} total={23} onPageChange={change}/>);expect(screen.getByRole('button',{name:'下一页'})).toBeDisabled();
|
|
24
|
+
rerender(<Pagination page={3} pageSize={10} hasNext loading onPageChange={change}/>);expect(screen.getAllByRole('button').every(b=>b.hasAttribute('disabled'))).toBe(true);
|
|
25
|
+
rerender(<Pagination page={3} pageSize={10} hasNext onPageChange={change}/>);expect(screen.getByText('第 3 页')).toBeVisible();expect(screen.getByRole('button',{name:'下一页'})).toBeEnabled();
|
|
26
|
+
});
|
|
27
|
+
it('reports copy success only after resolution and prevents duplicate writes', async () => {
|
|
28
|
+
let resolve!:()=>void;const copy=vi.fn(()=>new Promise<void>(done=>{resolve=done;}));
|
|
29
|
+
render(<CopyButton value="demo" copy={copy}/>);const button=screen.getByRole('button',{name:'复制'});button.focus();fireEvent.click(button);fireEvent.click(button);
|
|
30
|
+
expect(copy).toHaveBeenCalledTimes(1);expect(button).toHaveFocus();expect(screen.queryByText('已复制')).not.toBeInTheDocument();
|
|
31
|
+
await act(async()=>resolve());expect(screen.getByRole('status')).toHaveTextContent('已复制');
|
|
32
|
+
});
|
|
33
|
+
it('allows failed copies to retry and ignores stale completion after a value change', async () => {
|
|
34
|
+
const copy=vi.fn().mockRejectedValueOnce(new Error('test')).mockResolvedValue(undefined);
|
|
35
|
+
const {rerender}=render(<CopyButton value="a" copy={copy}/>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await screen.findByRole('alert');fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(screen.getByRole('status')).toHaveTextContent('已复制'));
|
|
36
|
+
let resolve!:()=>void;const delayed=()=>new Promise<void>(done=>{resolve=done;});
|
|
37
|
+
rerender(<CopyButton value="b" copy={delayed}/>);fireEvent.click(screen.getByRole('button',{name:'复制'}));rerender(<CopyButton value="c" copy={delayed}/>);await act(async()=>resolve());expect(screen.getByRole('status')).toBeEmptyDOMElement();
|
|
38
|
+
});
|
|
39
|
+
it('exposes mixed checkbox state and separates native radio groups', () => {
|
|
40
|
+
render(<><Checkbox label="部分选择" indeterminate readOnly/><RadioGroup label="组一" value="a" onValueChange={()=>{}} items={[{value:'a',label:'A'}]}/><RadioGroup label="组二" value="a" onValueChange={()=>{}} items={[{value:'a',label:'A'}]}/></>);
|
|
41
|
+
expect(screen.getByRole('checkbox')).toBePartiallyChecked();const radios=screen.getAllByRole('radio');expect(radios[0].getAttribute('name')).not.toBe(radios[1].getAttribute('name'));
|
|
42
|
+
});
|
|
43
|
+
it('does not change a controlled switch before the caller updates it', () => {
|
|
44
|
+
const change=vi.fn();render(<Switch label="偏好" checked={false} onCheckedChange={change}/>);fireEvent.click(screen.getByRole('switch'));expect(change).toHaveBeenCalledWith(true);expect(screen.getByRole('switch')).not.toBeChecked();
|
|
45
|
+
});
|
|
46
|
+
it('does not submit searches while IME composition is active', () => {
|
|
47
|
+
const search=vi.fn();render(<SearchField label="搜索" value="研究" onValueChange={()=>{}} onSearch={search}/>);const input=screen.getByRole('searchbox');fireEvent.compositionStart(input);fireEvent.keyDown(input,{key:'Enter'});expect(search).not.toHaveBeenCalled();fireEvent.compositionEnd(input);fireEvent.keyDown(input,{key:'Enter'});expect(search).toHaveBeenCalledWith('研究');
|
|
48
|
+
});
|
|
49
|
+
it('navigates supplied combobox results, skips disabled items and blocks IME commit', () => {
|
|
50
|
+
const choose=vi.fn(),query=vi.fn();render(<Combobox label="模型" query="" onQueryChange={query} value={null} onValueChange={choose} options={[{value:'a',label:'A'},{value:'b',label:'B',disabled:true},{value:'c',label:'C'}]}/>);
|
|
51
|
+
const input=screen.getByRole('combobox');fireEvent.focus(input);fireEvent.keyDown(input,{key:'ArrowDown'});fireEvent.keyDown(input,{key:'ArrowDown'});expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('C');
|
|
52
|
+
fireEvent.compositionStart(input);fireEvent.change(input,{target:{value:'中'}});fireEvent.keyDown(input,{key:'Enter'});expect(choose).not.toHaveBeenCalled();expect(query).not.toHaveBeenCalled();fireEvent.compositionEnd(input);expect(query).toHaveBeenCalledWith('中');fireEvent.keyDown(input,{key:'End'});fireEvent.keyDown(input,{key:'Enter'});expect(choose).toHaveBeenCalledWith({value:'c',label:'C'});expect(input).toHaveAttribute('aria-expanded','false');
|
|
53
|
+
});
|
|
54
|
+
it('does not allow stale combobox results to be picked during loading', () => {
|
|
55
|
+
const choose=vi.fn();render(<Combobox label="模型" query="" value={null} onQueryChange={()=>{}} onValueChange={choose} options={[{value:'a',label:'A'}]} loading/>);const input=screen.getByRole('combobox');fireEvent.focus(input);fireEvent.keyDown(input,{key:'ArrowDown'});fireEvent.keyDown(input,{key:'Enter'});expect(choose).not.toHaveBeenCalled();expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
it('pauses success notification countdown on hover and keeps errors persistent', () => {
|
|
58
|
+
vi.useFakeTimers();const close=vi.fn();const {rerender}=render(<Toast open onOpenChange={close} title="已完成" duration={5000}/>);
|
|
59
|
+
act(()=>vi.advanceTimersByTime(2000));fireEvent.mouseEnter(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();fireEvent.mouseLeave(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(2999));expect(close).not.toHaveBeenCalled();act(()=>vi.advanceTimersByTime(1));expect(close).toHaveBeenCalledWith(false);
|
|
60
|
+
close.mockClear();rerender(<Toast open onOpenChange={close} title="失败" tone="error" duration={1000}/>);act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
it('resets toast countdown when reopening', () => {
|
|
63
|
+
vi.useFakeTimers();function Demo(){const [open,setOpen]=useState(true);return <><button onClick={()=>setOpen(true)}>显示</button><Toast open={open} onOpenChange={setOpen} title="通知" duration={1000}/></>;}
|
|
64
|
+
render(<Demo/>);act(()=>vi.advanceTimersByTime(1000));expect(screen.queryByText('通知')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'显示'}));act(()=>vi.advanceTimersByTime(999));expect(screen.getByText('通知')).toBeVisible();act(()=>vi.advanceTimersByTime(1));expect(screen.queryByText('通知')).not.toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
it('keeps a failed confirmation open and closes only after successful retry', async () => {
|
|
67
|
+
const confirm=vi.fn().mockRejectedValueOnce(new Error('demo')).mockResolvedValue(undefined);
|
|
68
|
+
render(<div className="rhine-theme"><ConfirmDialog trigger={<button>打开</button>} title="确认任务" description="演示" onConfirm={confirm}/></div>);
|
|
69
|
+
fireEvent.click(screen.getByRole('button',{name:'打开'}));expect(await screen.findByRole('alertdialog')).toBeVisible();expect(screen.getByRole('button',{name:'取消'})).toHaveFocus();fireEvent.click(screen.getByRole('button',{name:'确认操作'}));expect(await screen.findByRole('alert')).toHaveTextContent('操作未完成');fireEvent.click(screen.getByRole('button',{name:'确认操作'}));await waitFor(()=>expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Button, Disclosure, Logo, Progress, SegmentedControl, Select, SelectionCard, Tabs, TextField } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('reusable design components', () => {
|
|
7
|
+
it('uses the independent library identity and accepts a host wordmark', () => {
|
|
8
|
+
const { rerender } = render(<Logo variant="lockup" />);
|
|
9
|
+
expect(screen.getByRole('img', { name: 'Rhine Design' })).toHaveTextContent('Rhine Design');
|
|
10
|
+
rerender(<Logo variant="lockup" wordmark="Studio" />);
|
|
11
|
+
expect(screen.getByRole('img', { name: 'Studio' })).toHaveTextContent('Studio');
|
|
12
|
+
rerender(<Logo variant="lockup" wordmark="Studio" label="Studio home" decorative />);
|
|
13
|
+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
it('associates native selects with unique labels, hints, errors and caller descriptions', () => {
|
|
16
|
+
render(<><span id="context">当前工作区</span><Select label="页面" hint="请选择" error="页面不可用" aria-describedby="context"><option>用量</option></Select><Select label="场景" disabled><option>正常</option></Select></>);
|
|
17
|
+
const page = screen.getByRole('combobox', {name:'页面'});
|
|
18
|
+
const scene = screen.getByRole('combobox', {name:'场景'});
|
|
19
|
+
expect(page.id).not.toBe(scene.id);
|
|
20
|
+
expect(page).toHaveAttribute('aria-invalid','true');
|
|
21
|
+
expect(page).toHaveAccessibleDescription('当前工作区 请选择 页面不可用');
|
|
22
|
+
expect(scene).toBeDisabled();
|
|
23
|
+
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
|
24
|
+
});
|
|
25
|
+
it('keeps the selected value controlled by the caller', () => {
|
|
26
|
+
function Demo() {
|
|
27
|
+
const [value,setValue] = useState('a');
|
|
28
|
+
return <Select label="页面" value={value} onChange={event=>setValue(event.target.value)}><option value="a">同步</option><option value="b">用量</option></Select>;
|
|
29
|
+
}
|
|
30
|
+
render(<Demo />);
|
|
31
|
+
const select=screen.getByRole('combobox');
|
|
32
|
+
fireEvent.change(select,{target:{value:'b'}});
|
|
33
|
+
expect(select).toHaveValue('b');
|
|
34
|
+
});
|
|
35
|
+
it('retains focus while busy and blocks repeat submission', () => {
|
|
36
|
+
const submit = vi.fn();
|
|
37
|
+
const { rerender } = render(<Button onClick={submit}>保存</Button>);
|
|
38
|
+
const button = screen.getByRole('button'); button.focus(); fireEvent.click(button);
|
|
39
|
+
rerender(<Button busy busyLabel="保存中" onClick={submit}>保存</Button>);
|
|
40
|
+
fireEvent.click(button);
|
|
41
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(button).toHaveFocus(); expect(button).toHaveAttribute('aria-busy', 'true');
|
|
43
|
+
expect(button).not.toBeDisabled();
|
|
44
|
+
});
|
|
45
|
+
it('keeps disabled actions unavailable', () => {
|
|
46
|
+
const action = vi.fn(); render(<Button disabled onClick={action}>删除</Button>);
|
|
47
|
+
fireEvent.click(screen.getByRole('button')); expect(action).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
it('uses non-submit buttons by default inside forms', () => {
|
|
50
|
+
render(<form><Button>动作</Button></form>); expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
|
|
51
|
+
});
|
|
52
|
+
it('tabs skip disabled entries, wrap, and preserve mounted input state', () => {
|
|
53
|
+
function Demo() {
|
|
54
|
+
const [value,setValue] = useState('a');
|
|
55
|
+
return <Tabs label="切换" value={value} onValueChange={setValue} items={[
|
|
56
|
+
{value:'a',label:'甲',content:<input aria-label="草稿" defaultValue=""/>},
|
|
57
|
+
{value:'b',label:'乙',disabled:true,content:null},
|
|
58
|
+
{value:'c',label:'丙',content:'第三页'},
|
|
59
|
+
]}/>;
|
|
60
|
+
}
|
|
61
|
+
render(<Demo/>); fireEvent.change(screen.getByLabelText('草稿'),{target:{value:'保留草稿'}});
|
|
62
|
+
const first=screen.getByRole('tab',{name:'甲'}); first.focus(); fireEvent.keyDown(first,{key:'ArrowRight'});
|
|
63
|
+
const last=screen.getByRole('tab',{name:'丙'}); expect(last).toHaveFocus(); expect(last).toHaveAttribute('aria-selected','true');
|
|
64
|
+
fireEvent.keyDown(last,{key:'ArrowRight'}); expect(first).toHaveFocus();
|
|
65
|
+
expect(screen.getByLabelText('草稿')).toHaveValue('保留草稿');
|
|
66
|
+
fireEvent.keyDown(first,{key:'End'}); expect(last).toHaveFocus();
|
|
67
|
+
fireEvent.keyDown(last,{key:'Home'}); expect(first).toHaveFocus();
|
|
68
|
+
});
|
|
69
|
+
it('assigns unique ARIA associations to multiple tab groups', () => {
|
|
70
|
+
render(<>{['one','two'].map(label=><Tabs key={label} label={label} value="a" onValueChange={()=>{}} items={[{value:'a',label,content:label}]}/>)}</>);
|
|
71
|
+
const tabs=screen.getAllByRole('tab'); expect(tabs[0].id).not.toBe(tabs[1].id);
|
|
72
|
+
for(const tab of tabs) expect(document.getElementById(tab.getAttribute('aria-controls')!)).toHaveAttribute('aria-labelledby',tab.id);
|
|
73
|
+
});
|
|
74
|
+
it('keeps separate segmented controls in independent native radio groups', () => {
|
|
75
|
+
render(<>{['a','b'].map(label=><SegmentedControl key={label} label={label} value="one" onValueChange={()=>{}} items={[{value:'one',label:'一'},{value:'two',label:'二'}]}/>)}</>);
|
|
76
|
+
const radios=screen.getAllByRole('radio'); expect(radios[0].getAttribute('name')).not.toBe(radios[2].getAttribute('name'));
|
|
77
|
+
});
|
|
78
|
+
it('allows controlled selection to be cancelled', () => {
|
|
79
|
+
function Demo(){const [value,setValue]=useState(false);return <SelectionCard label="选择项" selected={value} onSelectedChange={setValue}>详情</SelectionCard>}
|
|
80
|
+
render(<Demo/>); const input=screen.getByRole('checkbox'); fireEvent.click(input); expect(input).toBeChecked();fireEvent.click(input);expect(input).not.toBeChecked();
|
|
81
|
+
});
|
|
82
|
+
it('associates input hints and errors with the actual field', () => {
|
|
83
|
+
render(<TextField label="名称" hint="必填" error="请填写名称" aria-describedby="external" />);
|
|
84
|
+
const input=screen.getByLabelText('名称'); expect(input).toHaveAttribute('aria-invalid','true');
|
|
85
|
+
const ids=input.getAttribute('aria-describedby')!.split(' '); expect(ids).toContain('external');
|
|
86
|
+
expect(ids.slice(1).map(id=>document.getElementById(id)?.textContent)).toEqual(['必填','请填写名称']);
|
|
87
|
+
});
|
|
88
|
+
it('does not invent a percentage for unknown progress and clamps known values', () => {
|
|
89
|
+
const {rerender}=render(<Progress label="进度" value={null}/>);
|
|
90
|
+
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
|
|
91
|
+
rerender(<Progress label="进度" value={150}/>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','100');
|
|
92
|
+
rerender(<Progress label="进度" value={NaN}/>);expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
|
|
93
|
+
});
|
|
94
|
+
it('disclosure works without animation support and closes keyboard content', () => {
|
|
95
|
+
render(<Disclosure title="详细信息"><button>内部按钮</button></Disclosure>);
|
|
96
|
+
const summary=screen.getByText('详细信息');const details=summary.closest('details')!;
|
|
97
|
+
expect(details.open).toBe(false);fireEvent.click(summary);expect(details.open).toBe(true);
|
|
98
|
+
fireEvent.click(summary);expect(details.open).toBe(false);expect(screen.getByText('内部按钮')).not.toBeVisible();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { MotionGallery } from '../preview/MotionGallery';
|
|
4
|
+
|
|
5
|
+
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
|
6
|
+
function clock() {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(()=>callback(0),16));
|
|
9
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
|
10
|
+
}
|
|
11
|
+
it('replay cancels the old simulated completion and keeps the control focused', () => {
|
|
12
|
+
clock(); render(<MotionGallery reduced={false}/>);
|
|
13
|
+
const card=within(screen.getByRole('article',{name:'操作 → 结果'}));
|
|
14
|
+
fireEvent.click(card.getByRole('button',{name:'播放操作 → 结果'}));
|
|
15
|
+
act(()=>vi.advanceTimersByTime(400));
|
|
16
|
+
const replay=card.getByRole('button',{name:'重播操作 → 结果'}); replay.focus(); fireEvent.click(replay);
|
|
17
|
+
act(()=>vi.advanceTimersByTime(400));
|
|
18
|
+
expect(card.queryByText('已完成 · 演示')).not.toBeInTheDocument();
|
|
19
|
+
expect(replay).toHaveFocus();
|
|
20
|
+
act(()=>vi.advanceTimersByTime(400));
|
|
21
|
+
expect(card.getByRole('status')).toHaveTextContent('已完成 · 演示');
|
|
22
|
+
});
|
|
23
|
+
it('reset and unmount cancel pending demo timers without producing a result', () => {
|
|
24
|
+
clock(); const {unmount}=render(<MotionGallery reduced={false}/>);
|
|
25
|
+
const card=within(screen.getByRole('article',{name:'加载 → 内容'}));
|
|
26
|
+
fireEvent.click(card.getByRole('button',{name:'播放加载 → 内容'})); act(()=>vi.advanceTimersByTime(100));
|
|
27
|
+
fireEvent.click(card.getByRole('button',{name:'复位加载 → 内容'})); act(()=>vi.advanceTimersByTime(1000));
|
|
28
|
+
expect(card.queryByText('研究工作区')).not.toBeInTheDocument();
|
|
29
|
+
expect(card.getByRole('button',{name:'重播加载 → 内容'})).toBeDisabled();
|
|
30
|
+
fireEvent.click(card.getByRole('button',{name:'播放加载 → 内容'})); act(()=>vi.advanceTimersByTime(100)); unmount();
|
|
31
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
32
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { StartupGallery } from '../preview/StartupGallery';
|
|
4
|
+
|
|
5
|
+
afterEach(() => vi.useRealTimers());
|
|
6
|
+
|
|
7
|
+
it('replay cancels stale readiness and preserves control focus; reset clears the timer', () => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
const { unmount } = render(<StartupGallery reduced={false} />);
|
|
10
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
11
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
12
|
+
const replay = screen.getByRole('button', { name: '重播启动动画' });
|
|
13
|
+
replay.focus(); fireEvent.click(replay);
|
|
14
|
+
act(() => vi.advanceTimersByTime(700));
|
|
15
|
+
expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
|
|
16
|
+
expect(replay).toHaveFocus();
|
|
17
|
+
fireEvent.click(screen.getByRole('button', { name: '复位启动动画' }));
|
|
18
|
+
act(() => vi.advanceTimersByTime(2000));
|
|
19
|
+
expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
|
|
20
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
21
|
+
unmount(); expect(vi.getTimerCount()).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('reduced motion skips synthetic normal delay but does not turn waiting or failure into success', () => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
const { rerender } = render(<StartupGallery reduced={false} />);
|
|
27
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
28
|
+
rerender(<StartupGallery reduced />);
|
|
29
|
+
expect(screen.getByText('连接你的工作空间')).toBeInTheDocument();
|
|
30
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
31
|
+
fireEvent.change(screen.getByLabelText('启动场景'), { target: { value: 'waiting' } });
|
|
32
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
33
|
+
act(() => vi.advanceTimersByTime(10000));
|
|
34
|
+
expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
|
|
35
|
+
fireEvent.click(screen.getByRole('button', { name: '模拟就绪 · 立即进入' }));
|
|
36
|
+
expect(screen.getByText('连接你的工作空间')).toBeInTheDocument();
|
|
37
|
+
fireEvent.change(screen.getByLabelText('启动场景'), { target: { value: 'error' } });
|
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
39
|
+
expect(screen.getByRole('alert')).toHaveTextContent('暂时无法恢复会话');
|
|
40
|
+
expect(screen.queryByText('连接你的工作空间')).not.toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps the same brand node when readiness arrives and makes the login preview usable immediately', () => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
const { container } = render(<StartupGallery reduced={false} />);
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: '播放启动动画' }));
|
|
47
|
+
const logo = container.querySelector('.ds-startup-logo-anchor .rhine-logo');
|
|
48
|
+
act(() => vi.advanceTimersByTime(300));
|
|
49
|
+
fireEvent.click(screen.getByRole('button', { name: '模拟就绪 · 立即进入' }));
|
|
50
|
+
expect(container.querySelector('.ds-startup-logo-anchor .rhine-logo')).toBe(logo);
|
|
51
|
+
expect(container.querySelectorAll('.ds-startup-stage .rhine-logo')).toHaveLength(1);
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: '在浏览器中登录' }));
|
|
53
|
+
expect(screen.getByText('仅演示登录入口,不会打开授权页面。')).toBeInTheDocument();
|
|
54
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
55
|
+
});
|
package/tests/setup.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"ES2022",
|
|
6
|
+
"DOM",
|
|
7
|
+
"DOM.Iterable"
|
|
8
|
+
],
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"moduleResolution": "Bundler",
|
|
11
|
+
"jsx": "react-jsx",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"noUnusedLocals": true,
|
|
19
|
+
"noUnusedParameters": true,
|
|
20
|
+
"types": [
|
|
21
|
+
"vite/client",
|
|
22
|
+
"node"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"include": [
|
|
26
|
+
"components",
|
|
27
|
+
"preview",
|
|
28
|
+
"tests",
|
|
29
|
+
"vite.config.ts",
|
|
30
|
+
"vitest.config.ts"
|
|
31
|
+
]
|
|
32
|
+
}
|
package/vite.config.ts
ADDED
package/vitest.config.ts
ADDED