Skip to content

Commit 2c8609e

Browse files
committed
Merge branch 'feature/ui-component-library-phase-a-bootstrap-button' into develop
2 parents 688181b + ddf71de commit 2c8609e

16 files changed

Lines changed: 660 additions & 130 deletions

File tree

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@xterm/addon-fit": "^0.11.0",
1717
"@xterm/addon-webgl": "^0.19.0",
1818
"@xterm/xterm": "^6.0.0",
19+
"clsx": "^2.1.1",
1920
"jotai": "^2.19.1",
2021
"jotai-family": "^1.0.1",
2122
"lucide-react": "^1.14.0",

packages/web/src/app.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe("App shell selection", () => {
6969
expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument();
7070
});
7171

72-
it("renders DesktopShell on wide coarse-pointer devices", () => {
72+
it("renders MobileShell on wide coarse-pointer devices", () => {
7373
setMatchMediaMock((query) => query.includes("pointer: coarse"));
7474
const store = createStore();
7575
store.set(connectionStatusAtom, "connected");
@@ -82,7 +82,7 @@ describe("App shell selection", () => {
8282
</Provider>
8383
);
8484

85-
expect(screen.getByTestId("desktop-shell")).toBeInTheDocument();
86-
expect(screen.queryByTestId("mobile-shell")).not.toBeInTheDocument();
85+
expect(screen.getByTestId("mobile-shell")).toBeInTheDocument();
86+
expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument();
8787
});
8888
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# UI Component Migration Inventory
2+
3+
| Component | Status | Legacy classes | Callers left | Last update |
4+
|---|---|---|---:|---|
5+
| Button | 🟡 in-flight | `.btn .btn-*` | 30 | 2026-05-06 |
6+
| IconButton | ⚫ not-started | `.btn` icon-only |||
7+
| Input | ⚫ not-started | `.input` |||
8+
| Textarea | ⚫ not-started | `.input.textarea` |||
9+
| Tag | ⚫ not-started | `.badge .badge-*` |||
10+
| Badge | ⚫ not-started | `.badge` |||
11+
| Pill | ⚫ not-started | `.settings-pill*` |||
12+
| StatusDot | ⚫ not-started | token-backed dot patterns |||
13+
| Kbd | ⚫ not-started | `kbd` |||
14+
| Spinner | ⚫ not-started | `.animate-spin` |||
15+
| Switch | ⚫ not-started | new |||
16+
| Modal | ⚫ not-started | `.modal-overlay .modal-card .modal-*` |||
17+
| ConfirmDialog | ⚫ not-started | modal convenience wrapper |||
18+
| Toast | ⚫ not-started | `.toast*` |||
19+
| Tooltip | ⚫ not-started | new |||
20+
| ProgressBar | ⚫ not-started | `--progress-height` patterns |||
21+
| Notice | ⚫ not-started | `.settings-page__notice*` |||
22+
| EmptyState | ⚫ not-started | feature-specific empty state blocks |||
23+
| Tabs | ⚫ not-started | tab / pill patterns across features |||
24+
| SegmentedControl | ⚫ not-started | `.settings-pill*` |||
25+
| Select | ⚫ not-started | `.input`, `.mobile-select-*` |||
26+
| Popover | ⚫ not-started | new |||
27+
| ActionMenu | ⚫ not-started | new |||
28+
| Sheet | ⚫ not-started | mobile sheet shells |||
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Coder Studio UI
2+
3+
## 总则
4+
- 已落地的基础组件统一从 public barrel(当前文件路径为 `src/components/ui/index.ts`)引入,不允许深链到组件子路径。
5+
- 所有颜色、间距、字号、圆角、阴影、动效必须来自 `src/styles/tokens.css`
6+
- 业务代码禁止新增 `btn btn-*``input` 这类旧式全局 className;未迁移的遗留调用点只允许原样保留,不允许扩散。
7+
- PC / 移动差异默认由 token 或共享内部逻辑解决,业务代码不直接写 `matchMedia`
8+
9+
## 已实现组件
10+
| Component | Tier | Public API | Notes |
11+
|---|---|---|---|
12+
| Button | 0 | `src/components/ui/index.ts` named export only | `primary / secondary / ghost / danger` × `sm / md / lg` |
13+
14+
## 迁移状态
15+
`./MIGRATION.md`
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { useViewport } from "./use-viewport";
4+
5+
type MediaListener = () => void;
6+
7+
const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)";
8+
9+
const createMatchMediaHarness = (initialMatches: boolean) => {
10+
let matches = initialMatches;
11+
const listeners = new Set<MediaListener>();
12+
13+
const matchMedia = vi.fn((query: string) => ({
14+
matches,
15+
media: query,
16+
addEventListener: (_event: string, listener: MediaListener) => {
17+
listeners.add(listener);
18+
},
19+
removeEventListener: (_event: string, listener: MediaListener) => {
20+
listeners.delete(listener);
21+
},
22+
addListener: (listener: MediaListener) => {
23+
listeners.add(listener);
24+
},
25+
removeListener: (listener: MediaListener) => {
26+
listeners.delete(listener);
27+
},
28+
dispatchEvent: () => true,
29+
}));
30+
31+
return {
32+
matchMedia,
33+
setMatches(nextMatches: boolean) {
34+
matches = nextMatches;
35+
for (const listener of listeners) {
36+
listener();
37+
}
38+
},
39+
};
40+
};
41+
42+
const Probe = () => {
43+
return <div data-testid="viewport">{useViewport()}</div>;
44+
};
45+
46+
describe("useViewport", () => {
47+
let originalMatchMedia: typeof window.matchMedia;
48+
49+
beforeEach(() => {
50+
originalMatchMedia = window.matchMedia;
51+
});
52+
53+
afterEach(() => {
54+
window.matchMedia = originalMatchMedia;
55+
});
56+
57+
it("returns desktop when the combined viewport query does not match", () => {
58+
const harness = createMatchMediaHarness(false);
59+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
60+
61+
render(<Probe />);
62+
63+
expect(window.matchMedia).toHaveBeenCalledWith(VIEWPORT_QUERY);
64+
expect(screen.getByTestId("viewport")).toHaveTextContent("desktop");
65+
});
66+
67+
it("returns mobile when the combined viewport query matches", () => {
68+
const harness = createMatchMediaHarness(true);
69+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
70+
71+
render(<Probe />);
72+
73+
expect(screen.getByTestId("viewport")).toHaveTextContent("mobile");
74+
});
75+
76+
it("updates when the media query match changes", () => {
77+
const harness = createMatchMediaHarness(false);
78+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
79+
80+
render(<Probe />);
81+
expect(screen.getByTestId("viewport")).toHaveTextContent("desktop");
82+
83+
act(() => {
84+
harness.setMatches(true);
85+
});
86+
87+
expect(screen.getByTestId("viewport")).toHaveTextContent("mobile");
88+
});
89+
90+
it("cleans up listeners on unmount", () => {
91+
const removeEventListener = vi.fn();
92+
const listeners = new Set<MediaListener>();
93+
94+
window.matchMedia = vi.fn((query: string) => ({
95+
matches: false,
96+
media: query,
97+
addEventListener: (_event: string, listener: MediaListener) => {
98+
listeners.add(listener);
99+
},
100+
removeEventListener,
101+
addListener: (listener: MediaListener) => {
102+
listeners.add(listener);
103+
},
104+
removeListener: vi.fn(),
105+
dispatchEvent: () => true,
106+
})) as unknown as typeof window.matchMedia;
107+
108+
const view = render(<Probe />);
109+
view.unmount();
110+
111+
expect(removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
112+
});
113+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useSyncExternalStore } from "react";
2+
3+
export type Viewport = "mobile" | "desktop";
4+
5+
const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)";
6+
7+
const subscribe = (onStoreChange: () => void) => {
8+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
9+
return () => undefined;
10+
}
11+
12+
const mediaQueryList = window.matchMedia(VIEWPORT_QUERY);
13+
const supportsEventListener = typeof mediaQueryList.addEventListener === "function";
14+
15+
if (supportsEventListener) {
16+
mediaQueryList.addEventListener("change", onStoreChange);
17+
return () => {
18+
mediaQueryList.removeEventListener("change", onStoreChange);
19+
};
20+
}
21+
22+
mediaQueryList.addListener(onStoreChange);
23+
return () => {
24+
mediaQueryList.removeListener(onStoreChange);
25+
};
26+
};
27+
28+
const getSnapshot = (): Viewport => {
29+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
30+
return "desktop";
31+
}
32+
33+
return window.matchMedia(VIEWPORT_QUERY).matches ? "mobile" : "desktop";
34+
};
35+
36+
const getServerSnapshot = (): Viewport => "desktop";
37+
38+
export function useViewport(): Viewport {
39+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
40+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Button
2+
3+
## 使用
4+
`src/components/ui/index.ts` 的 public barrel 导入后使用:
5+
6+
```tsx
7+
<Button variant="primary" size="md">保存</Button>
8+
```
9+
10+
## Props
11+
| Prop | Type | Default | 说明 |
12+
|---|---|---|---|
13+
| `variant` | `"primary" \| "secondary" \| "ghost" \| "danger"` | `"secondary"` | 视觉变体 |
14+
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 尺寸 |
15+
| `loading` | `boolean` | `false` | 显示 spinner,并禁用 button 点击 |
16+
| `leadingIcon` | `ReactNode` | `undefined` | 文本前图标 |
17+
| `trailingIcon` | `ReactNode` | `undefined` | 文本后图标 |
18+
| `as` | `"button" \| "a"` | `"button"` | 渲染元素 |
19+
20+
## 注意
21+
- `danger` 只用于破坏性操作。
22+
- `loading` 只会对原生 `<button>` 强制 `disabled`;对于 `<a>`,只会加 `aria-busy`
23+
- 新代码不要再写 `btn btn-*`
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
.btn,
2+
:global(.btn) {
3+
display: inline-flex;
4+
align-items: center;
5+
justify-content: center;
6+
gap: var(--sp-2);
7+
height: var(--btn-height-md);
8+
padding: 0 var(--sp-4);
9+
border: 1px solid transparent;
10+
border-radius: var(--radius-md);
11+
font-family: var(--font-sans);
12+
font-size: var(--text-base);
13+
font-weight: var(--font-medium);
14+
line-height: 1;
15+
white-space: nowrap;
16+
transition:
17+
background var(--duration-normal) var(--ease-out),
18+
border-color var(--duration-normal) var(--ease-out),
19+
color var(--duration-normal) var(--ease-out),
20+
box-shadow var(--duration-normal) var(--ease-out),
21+
transform var(--duration-fast) var(--ease-out);
22+
}
23+
24+
.btn:focus-visible,
25+
:global(.btn):focus-visible {
26+
box-shadow:
27+
0 0 0 2px var(--bg-page),
28+
0 0 0 4px rgba(108, 182, 255, 0.35);
29+
}
30+
31+
.btn:hover:not(:disabled),
32+
:global(.btn):hover:not(:disabled) {
33+
transform: translateY(-1px);
34+
}
35+
36+
.primary,
37+
:global(.btn-primary) {
38+
background: var(--accent-blue);
39+
color: var(--text-inverse);
40+
}
41+
42+
.primary:hover:not(:disabled):not([aria-disabled="true"]),
43+
:global(.btn-primary):hover:not(:disabled):not([aria-disabled="true"]) {
44+
background: color-mix(in srgb, var(--accent-blue) 84%, white 16%);
45+
}
46+
47+
.secondary,
48+
:global(.btn-default),
49+
:global(.btn-secondary) {
50+
background: color-mix(in srgb, var(--bg-surface) 84%, var(--accent-blue) 16%);
51+
border-color: color-mix(in srgb, var(--border) 70%, var(--accent-blue) 30%);
52+
color: var(--text-primary);
53+
}
54+
55+
.secondary:hover:not(:disabled):not([aria-disabled="true"]),
56+
:global(.btn-default):hover:not(:disabled):not([aria-disabled="true"]),
57+
:global(.btn-secondary):hover:not(:disabled):not([aria-disabled="true"]) {
58+
background: color-mix(in srgb, var(--bg-hover) 72%, var(--accent-blue) 28%);
59+
border-color: color-mix(in srgb, var(--border-light) 70%, var(--accent-blue) 30%);
60+
}
61+
62+
.ghost,
63+
:global(.btn-ghost) {
64+
background: transparent;
65+
border-color: transparent;
66+
color: var(--text-secondary);
67+
}
68+
69+
.ghost:hover:not(:disabled):not([aria-disabled="true"]),
70+
:global(.btn-ghost):hover:not(:disabled):not([aria-disabled="true"]) {
71+
background: var(--bg-hover);
72+
color: var(--text-primary);
73+
}
74+
75+
.danger,
76+
:global(.btn-danger) {
77+
background: var(--accent-pink);
78+
color: var(--text-inverse);
79+
}
80+
81+
.sm,
82+
:global(.btn-sm) {
83+
height: 24px;
84+
padding: 0 var(--sp-2);
85+
border-radius: var(--radius-sm);
86+
font-size: var(--text-xs);
87+
}
88+
89+
.lg,
90+
:global(.btn-lg) {
91+
height: 40px;
92+
padding: 0 var(--sp-6);
93+
border-radius: var(--radius-lg);
94+
font-size: var(--text-lg);
95+
}
96+
97+
.label {
98+
display: inline-flex;
99+
align-items: center;
100+
}
101+
102+
.icon {
103+
display: inline-flex;
104+
align-items: center;
105+
}
106+
107+
.loading {
108+
pointer-events: none;
109+
}
110+
111+
.spinner {
112+
width: var(--sp-3);
113+
height: var(--sp-3);
114+
border: calc(var(--sp-1) / 2) solid color-mix(in srgb, currentColor 30%, var(--bg-page) 70%);
115+
border-top-color: currentColor;
116+
border-radius: var(--radius-full);
117+
}

0 commit comments

Comments
 (0)