@linhey/rhine-design 0.10.0 → 0.11.1

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +17 -14
  4. package/components/AdvancedForms.tsx +13 -5
  5. package/components/COMMON_COMPONENTS.md +5 -3
  6. package/components/Forms.tsx +66 -6
  7. package/components/README.md +13 -5
  8. package/components/SPACING.md +9 -0
  9. package/components/Toast.tsx +10 -4
  10. package/components/common.css +4 -0
  11. package/components/components.tsx +13 -7
  12. package/components/styles.css +12 -0
  13. package/components/useFloatingScale.ts +7 -3
  14. package/docs/APPLICATION_COMPONENTS.md +2 -2
  15. package/docs/DESIGN_SYSTEM.md +10 -1
  16. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  17. package/docs/DISCLOSURE_VERIFICATION.md +49 -0
  18. package/docs/FLOATING_OVERLAYS.md +16 -0
  19. package/docs/INTERACTION_PATTERNS.md +9 -1
  20. package/docs/REVIEW_TOOL.md +128 -0
  21. package/docs/REVIEW_VERIFICATION.md +56 -0
  22. package/docs/SUMMARY_TABS.md +77 -0
  23. package/docs/THEMING.md +1 -1
  24. package/docs/assets/disclosure/arknights-400.png +0 -0
  25. package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
  26. package/docs/assets/disclosure/rhine-400.png +0 -0
  27. package/docs/assets/disclosure/rhine-native200.png +0 -0
  28. package/examples/review/http/App.tsx +46 -0
  29. package/examples/review/http/catalog.tsx +44 -0
  30. package/examples/review/http/details.ts +1 -0
  31. package/examples/review/http/vite.config.ts +8 -0
  32. package/examples/review/platform/Workspace.tsx +34 -0
  33. package/examples/review/platform/catalog.tsx +46 -0
  34. package/examples/review/platform/vite.config.ts +8 -0
  35. package/examples/review/product.css +3 -0
  36. package/package.json +17 -5
  37. package/preview/CommonGallery.tsx +1 -1
  38. package/preview/DenseFilterControlsGallery.tsx +5 -2
  39. package/preview/GalleryShell.tsx +3 -2
  40. package/preview/OrbitGallery.tsx +1 -1
  41. package/preview/SpacingGallery.tsx +9 -1
  42. package/preview/SummaryTabsGallery.tsx +37 -0
  43. package/preview/main.tsx +7 -3
  44. package/preview/packageInfo.ts +5 -0
  45. package/review/canvas.tsx +107 -0
  46. package/review/dev-client.mjs +17 -0
  47. package/review/feedback.ts +17 -0
  48. package/review/index.ts +2 -0
  49. package/review/isolation.ts +64 -0
  50. package/review/lifecycle.ts +59 -0
  51. package/review/protocol.ts +7 -0
  52. package/review/review.css +41 -0
  53. package/review/shell.tsx +165 -0
  54. package/review/state.ts +41 -0
  55. package/review/types.ts +59 -0
  56. package/review/vite.d.mts +10 -0
  57. package/review/vite.mjs +107 -0
  58. package/scripts/check-combobox-modal-focus.js +22 -0
  59. package/scripts/check-combobox-zoom.js +109 -0
  60. package/scripts/check-multiselect-completion.js +46 -0
  61. package/scripts/check-review-package.mjs +11 -0
  62. package/scripts/check-review-recovery.js +47 -0
  63. package/scripts/check-review.js +153 -0
  64. package/skills/rhine-design/references/API_MAP.md +4 -0
  65. package/skills/rhine-design/references/TASK_MAP.md +2 -0
  66. package/tests/CommonComponents.test.tsx +8 -0
  67. package/tests/DesignSystem.test.tsx +32 -0
  68. package/tests/FormParity.test.tsx +104 -2
  69. package/tests/Review.test.tsx +117 -0
  70. package/tests/ReviewBuild.test.tsx +59 -0
  71. package/tests/ReviewShell.test.tsx +63 -0
  72. package/tsconfig.json +5 -2
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { defineReview, parseReviewState, serializeReviewState } from '../review/state';
3
+ import { ReviewLifecycle } from '../review/lifecycle';
4
+ import { createMockFetch, MemoryStorage } from '../review/isolation';
5
+ import type { ReviewContext, ReviewScene, ReviewSession } from '../review/types';
6
+
7
+ const session = (): ReviewSession => ({ render: () => null, dispose: vi.fn() });
8
+ const scene: ReviewScene = { id: 'projects', title: '项目', setup: session, motionControls: ['full', 'paused'], dimensions: [
9
+ { id: 'status', label: '状态', options: [{ id: 'normal', label: '正常' }, { id: 'error', label: '错误' }] },
10
+ { id: 'role', label: '角色', options: [{ id: 'editor', label: '编辑' }, { id: 'reader', label: '只读' }] },
11
+ ] };
12
+ const catalog = defineReview({ title: 'Review', scenes: [scene, { id: 'other', title: '其他', setup: session }] });
13
+ describe('review URL contract', () => {
14
+ it('restores independent dimensions and all controls', () => {
15
+ const hash = '#scene=projects&d.status=error&d.role=reader&theme=arknights&width=520&zoom=2&motion=paused';
16
+ const state = parseReviewState(hash, catalog);
17
+ expect(state).toEqual({ scene: 'projects', dimensions: { status: 'error', role: 'reader' }, theme: 'arknights', width: 520, zoom: 2, motion: 'paused' });
18
+ expect(parseReviewState(serializeReviewState(state), catalog)).toEqual(state);
19
+ });
20
+ it('drops unknown values and arbitrary form drafts with deterministic fallback', () => {
21
+ const state = parseReviewState('#scene=gone&theme=unknown&width=999&zoom=5&motion=bad&d.role=admin&draft=secret', catalog);
22
+ expect(state).toEqual({ scene: 'projects', dimensions: { status: 'normal', role: 'editor' }, theme: 'rhine', width: 'fit', zoom: 1, motion: 'system' });
23
+ expect(serializeReviewState(state)).not.toContain('secret');
24
+ });
25
+ it('does not leak dimensions to a scene which does not support them', () => {
26
+ expect(parseReviewState('#scene=other&d.role=reader', catalog).dimensions).toEqual({});
27
+ });
28
+ it('rejects duplicate IDs, empty dimensions and invalid widths', () => {
29
+ expect(() => defineReview({ title: '', scenes: [] })).toThrow();
30
+ expect(() => defineReview({ title: '', scenes: [scene, scene] })).toThrow();
31
+ expect(() => defineReview({ title: '', scenes: [{ ...scene, dimensions: [{ id: 'x', label: '', options: [] }] }] })).toThrow();
32
+ expect(() => defineReview({ title: '', scenes: [scene], widths: [0] })).toThrow();
33
+ });
34
+ });
35
+ const deferred = <T,>() => { let resolve!: (value: T) => void; const promise = new Promise<T>(yes => { resolve = yes; }); return { promise, resolve }; };
36
+ function harness() {
37
+ const hooks = { clear: vi.fn(), ready: vi.fn(), error: vi.fn(), context: () => ({ mockHttp: vi.fn(), report: vi.fn() }) };
38
+ return { hooks, lifecycle: new ReviewLifecycle(hooks) };
39
+ }
40
+ describe('review lifecycle', () => {
41
+ it('aborts pending setup and cleans resources registered before it returns', async () => {
42
+ const { lifecycle, hooks } = harness();
43
+ const pending = deferred<ReviewSession>();
44
+ const earlyCleanup = vi.fn();
45
+ let context!: ReviewContext;
46
+ const first = lifecycle.select({ ...scene, setup: ctx => { context = ctx; ctx.onDispose(earlyCleanup); return pending.promise; } }, {});
47
+ await lifecycle.select(catalog.scenes[1], {});
48
+ expect(context.signal.aborted).toBe(true);
49
+ expect(earlyCleanup).toHaveBeenCalledOnce();
50
+ const stale = session(); pending.resolve(stale); await first;
51
+ expect(stale.dispose).toHaveBeenCalledOnce();
52
+ expect(hooks.ready).toHaveBeenCalledTimes(1);
53
+ await lifecycle.dispose();
54
+ });
55
+ it('waits for async disposal across rapid consecutive selections', async () => {
56
+ const { lifecycle, hooks } = harness(); const disposal = deferred<void>();
57
+ await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose: () => disposal.promise }) }, {});
58
+ const discarded = vi.fn(session), accepted = vi.fn(session);
59
+ const second = lifecycle.select({ ...scene, setup: discarded }, {});
60
+ const third = lifecycle.select({ ...scene, setup: accepted }, {});
61
+ await Promise.resolve(); expect(accepted).not.toHaveBeenCalled();
62
+ disposal.resolve(); await Promise.all([second, third]);
63
+ expect(discarded).not.toHaveBeenCalled(); expect(accepted).toHaveBeenCalledOnce();
64
+ expect(hooks.error).not.toHaveBeenCalled(); await lifecycle.dispose();
65
+ });
66
+ it('resets then disposes and creates a fresh session', async () => {
67
+ const { lifecycle } = harness(); const log: string[] = [];
68
+ const definition = { ...scene, setup: (context: ReviewContext) => { log.push(`setup:${context.reason}`); return { render: () => null, reset: () => { log.push('reset'); }, dispose: () => { log.push('dispose'); } }; } };
69
+ await lifecycle.select(definition, {}); await lifecycle.select(definition, {}, 'reset');
70
+ expect(log).toEqual(['setup:select', 'reset', 'dispose', 'setup:reset']); await lifecycle.dispose();
71
+ });
72
+ it('runs cleanup on setup rejection and surfaces failure', async () => {
73
+ const { lifecycle, hooks } = harness(); const cleanup = vi.fn();
74
+ await lifecycle.select({ ...scene, setup: async context => { context.onDispose(cleanup); throw new Error('setup failed'); } }, {});
75
+ expect(cleanup).toHaveBeenCalledOnce(); expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'setup failed' }), 'projects');
76
+ });
77
+ it('attributes a disposal failure to the old scene while accepting the next scene', async () => {
78
+ const { lifecycle, hooks } = harness();
79
+ await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose() { throw new Error('cleanup failed'); } }) }, {});
80
+ await lifecycle.select(catalog.scenes[1], {});
81
+ expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'cleanup failed' }), 'projects');
82
+ expect(hooks.ready).toHaveBeenLastCalledWith(expect.anything(), catalog.scenes[1]);
83
+ await lifecycle.dispose();
84
+ });
85
+ });
86
+ describe('synthetic request boundary', () => {
87
+ it('preserves Request method/body and omits credentials', async () => {
88
+ const handler = vi.fn(async (request: Request) => {
89
+ expect(request.method).toBe('POST'); expect(request.credentials).toBe('omit');
90
+ return Response.json({ body: await request.text() });
91
+ });
92
+ const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
93
+ const response = await fetch(new Request('https://synthetic.invalid/items', { method: 'POST', body: 'hello' }));
94
+ expect(await response.json()).toEqual({ body: 'hello' });
95
+ });
96
+ it('rejects unmatched requests with visible failure instead of fallback network', async () => {
97
+ const report = vi.fn(); const fetch = createMockFetch(() => undefined, () => new AbortController().signal, report);
98
+ await expect(fetch(new URL('https://synthetic.invalid/missing'))).rejects.toThrow('未实现模拟请求');
99
+ expect(report).toHaveBeenCalledWith('未实现模拟请求:GET /missing');
100
+ });
101
+ it('cancels a noncooperative handler when its scene is disposed', async () => {
102
+ const abort = new AbortController(); const fetch = createMockFetch(() => () => new Promise(() => {}), () => abort.signal, vi.fn());
103
+ const pending = fetch('https://synthetic.invalid/items'); abort.abort();
104
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
105
+ });
106
+ it('honors a Request abort signal and a pre-aborted request', async () => {
107
+ const abort = new AbortController(); const handler = vi.fn(() => new Promise<Response>(() => {}));
108
+ const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
109
+ abort.abort(); await expect(fetch(new Request('https://synthetic.invalid', { signal: abort.signal }))).rejects.toMatchObject({ name: 'AbortError' });
110
+ expect(handler).not.toHaveBeenCalled();
111
+ });
112
+ it('keeps storage in memory and resets all keys', () => {
113
+ const storage = new MemoryStorage(); storage.setItem('name', 'synthetic');
114
+ expect(storage.getItem('name')).toBe('synthetic'); expect(storage.length).toBe(1);
115
+ storage.clear(); expect(storage.getItem('name')).toBeNull(); expect(storage.key(0)).toBeNull();
116
+ });
117
+ });
@@ -0,0 +1,59 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { build, preview } from 'vite';
7
+ import react from '@vitejs/plugin-react';
8
+ import { reviewPlugin, reviewProductionGuard } from '../review/vite.mjs';
9
+
10
+ const directories: string[] = [];
11
+ async function fixture() { const path = await mkdtemp(join(tmpdir(), 'rhine-review-test-')); directories.push(path); return path; }
12
+ afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
13
+ describe('review build integration', () => {
14
+ it.each(['/nested/review/', './'])('emits final CSS and usable preview for base %s', async base => {
15
+ const root = await fixture();
16
+ await build({ configFile: false, root, base, logLevel: 'silent', plugins: [react(), reviewPlugin({ catalog: resolve('examples/review/http/catalog.tsx') })] });
17
+ for (const name of ['index.html', 'canvas.html']) {
18
+ const html = await readFile(join(root, 'dist', name), 'utf8');
19
+ const stylesheet = html.match(/rel="stylesheet" href="([^\"]+\.css)"/)?.[1];
20
+ expect(stylesheet?.startsWith(base)).toBe(true);
21
+ const css = stylesheet?.slice(base.length);
22
+ expect(css).toBeTruthy();
23
+ expect(await readFile(join(root, 'dist', css!), 'utf8')).toContain('.rhine-review-header');
24
+ expect(html).toContain("connect-src 'none'");
25
+ expect(html).toContain(`src="${base}assets/`);
26
+ }
27
+ const server = await preview({ configFile: false, root, base, logLevel: 'silent', plugins: [reviewPlugin({ catalog: resolve('examples/review/http/catalog.tsx') })], preview: { host: '127.0.0.1', port: 0 } });
28
+ try {
29
+ const address = server.httpServer.address();
30
+ if (!address || typeof address === 'string') throw new Error('Missing preview port');
31
+ const url = `http://127.0.0.1:${address.port}${base === './' ? '/' : base}`;
32
+ const response = await fetch(url);
33
+ expect(response.status).toBe(200);
34
+ expect(await response.text()).toContain('Rhine Review');
35
+ expect((await fetch(`${url}api/missing`)).status).toBe(404);
36
+ } finally { await new Promise<void>((resolve, reject) => server.httpServer.close(error => error ? reject(error) : resolve())); }
37
+ }, 20000);
38
+ it('allows production theme directories but rejects explicit fixture paths', async () => {
39
+ const root = await fixture();
40
+ await mkdir(join(root, 'design')); await mkdir(join(root, 'fixtures'));
41
+ await writeFile(join(root, 'design/theme.js'), 'export const theme = "synthetic-theme";');
42
+ await writeFile(join(root, 'fixtures/data.js'), 'export const data = "synthetic-fixture";');
43
+ const entry = join(root, 'entry.js');
44
+ const options = { configFile: false as const, root, logLevel: 'silent' as const, plugins: [reviewProductionGuard({ forbidden: ['./fixtures'] })], build: { lib: { entry, formats: ['es' as const] } } };
45
+ await writeFile(entry, 'export {theme} from "./design/theme.js";');
46
+ await expect(build(options)).resolves.toBeTruthy();
47
+ await writeFile(entry, 'export {data} from "./fixtures/data.js";');
48
+ await expect(build(options)).rejects.toThrow('forbidden fixtures: fixtures/data.js');
49
+ }, 20000);
50
+ it('rejects accidental official runtime inclusion without a product denylist', async () => {
51
+ const root = await fixture(); const entry = join(root, 'entry.js');
52
+ await writeFile(entry, `export { defineReview } from ${JSON.stringify(resolve('review/index.ts'))};`);
53
+ await expect(build({ configFile: false, root, logLevel: 'silent', plugins: [reviewProductionGuard()], build: { lib: { entry, formats: ['es'] } } })).rejects.toThrow('Production bundle imports review runtime');
54
+ }, 20000);
55
+ it('refuses backend proxy and project env configuration', async () => {
56
+ const root = await fixture();
57
+ await expect(build({ configFile: false, root, logLevel: 'silent', plugins: [reviewPlugin({ catalog: './catalog.tsx' })], server: { proxy: { '/api': 'http://synthetic.invalid' } } })).rejects.toThrow('no server/preview proxies');
58
+ });
59
+ });
@@ -0,0 +1,63 @@
1
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { ReviewShell, reviewWaitMs } from '../review/shell';
4
+ import { appendFeedback, emptyFeedback } from '../review/feedback';
5
+ import { protocol } from '../review/protocol';
6
+
7
+ const catalog = { title: '故障恢复', scenes: [{ id: 'a', title: '场景 A' }, { id: 'b', title: '场景 B' }] };
8
+ function iframe() { return screen.getByTitle('产品场景画布') as HTMLIFrameElement; }
9
+ function send(data: Record<string, unknown>, source = iframe().contentWindow!) {
10
+ fireEvent(window, new MessageEvent('message', { origin: location.origin, source, data: { protocol, ...data } }));
11
+ }
12
+ function connect() {
13
+ const post = vi.spyOn(iframe().contentWindow!, 'postMessage');
14
+ send({ type: 'ready', catalog });
15
+ return { post, request: () => post.mock.calls.filter(call => call[0].type === 'select').at(-1)![0].requestId as number };
16
+ }
17
+ beforeEach(() => { vi.useFakeTimers(); history.replaceState(null, '', '#scene=a'); });
18
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
19
+ describe('review shell recovery', () => {
20
+ it('offers recovery when the initial iframe never handshakes', () => {
21
+ render(<ReviewShell />); const oldFrame = iframe();
22
+ act(() => vi.advanceTimersByTime(reviewWaitMs));
23
+ expect(screen.getByRole('alert')).toHaveTextContent('连接画布超时');
24
+ fireEvent.click(screen.getByRole('button', { name: '重新载入画布' }));
25
+ expect(iframe()).not.toBe(oldFrame);
26
+ const { request } = connect(); send({ type: 'rendered', requestId: request() });
27
+ expect(screen.getByRole('status')).toHaveTextContent('已就绪');
28
+ expect(screen.getByRole('alert')).toHaveTextContent('连接画布超时');
29
+ });
30
+ it('replaces a stuck browsing context, keeps selection, ignores old acknowledgements', () => {
31
+ render(<ReviewShell />); const { request } = connect();
32
+ send({ type: 'rendered', requestId: request() });
33
+ fireEvent.click(screen.getByRole('link', { name: '场景 B' }));
34
+ const pending = request();
35
+ send({ type: 'rendered', requestId: pending - 1 });
36
+ act(() => vi.advanceTimersByTime(reviewWaitMs));
37
+ expect(screen.getByRole('alert')).toHaveTextContent('初始化或清理超过');
38
+ const oldFrame = iframe(), oldWindow = oldFrame.contentWindow!;
39
+ fireEvent.click(screen.getByRole('button', { name: '重新载入画布' }));
40
+ expect(oldFrame.isConnected).toBe(false); expect(location.hash).toContain('scene=b');
41
+ send({ type: 'feedback', kind: 'error', message: 'stale error' }, oldWindow);
42
+ expect(screen.queryByText(/stale error/)).not.toBeInTheDocument();
43
+ const next = connect(); send({ type: 'rendered', requestId: next.request() });
44
+ expect(screen.getByRole('status')).toHaveTextContent('已就绪');
45
+ });
46
+ it('retains scoped errors across ready and selection until explicit acknowledgement', () => {
47
+ render(<ReviewShell />); const { request } = connect();
48
+ send({ type: 'feedback', scene: 'a', kind: 'error', message: '清理失败 A' });
49
+ send({ type: 'rendered', requestId: request() });
50
+ fireEvent.click(screen.getByRole('link', { name: '场景 B' }));
51
+ send({ type: 'rendered', requestId: request() });
52
+ expect(screen.getByRole('alert')).toHaveTextContent('清理失败 A');
53
+ fireEvent.click(screen.getByText(/评审反馈 ·/));
54
+ expect(screen.getByText('场景 A', { selector: 'strong' })).toBeInTheDocument();
55
+ fireEvent.click(screen.getByRole('button', { name: '确认并清除反馈' }));
56
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
57
+ });
58
+ it('does not silently lose unacknowledged errors when bounded history overflows', () => {
59
+ let log = emptyFeedback();
60
+ for (let id = 0; id < 30; id++) log = appendFeedback(log, { id, kind: id < 4 ? 'error' : 'info', message: String(id), scene: 'a' });
61
+ expect(log.entries).toHaveLength(20); expect(log.errors).toBe(4); expect(log.omittedErrors).toBe(4);
62
+ });
63
+ });
package/tsconfig.json CHANGED
@@ -20,13 +20,16 @@
20
20
  "types": [
21
21
  "vite/client",
22
22
  "node"
23
- ]
23
+ ],
24
+ "allowJs": true
24
25
  },
25
26
  "include": [
26
27
  "components",
27
28
  "preview",
28
29
  "tests",
29
30
  "vite.config.ts",
30
- "vitest.config.ts"
31
+ "vitest.config.ts",
32
+ "review",
33
+ "examples/review"
31
34
  ]
32
35
  }