@dsh-workbench/shared 0.2.13 → 0.2.15

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 (47) hide show
  1. package/dist/api/dsh.d.ts +1 -0
  2. package/dist/components/FormActions.css +13 -0
  3. package/dist/components/FormActions.d.ts +15 -0
  4. package/dist/components/FormActions.js +38 -0
  5. package/dist/components/FormRows.css +21 -5
  6. package/dist/components/SaveBar.d.ts +0 -1
  7. package/dist/components/SaveBar.js +13 -5
  8. package/dist/components/SelectField.css +20 -0
  9. package/dist/components/SelectField.d.ts +3 -1
  10. package/dist/components/SelectField.js +43 -25
  11. package/dist/components/SelectFieldNodes.d.ts +21 -0
  12. package/dist/components/SelectFieldNodes.js +17 -0
  13. package/dist/config/constants.d.ts +1 -0
  14. package/dist/config/constants.js +1 -0
  15. package/dist/host/tool-kit.d.ts +1 -0
  16. package/dist/host/tool-kit.js +4 -0
  17. package/dist/host.d.ts +1 -0
  18. package/dist/host.js +1 -0
  19. package/dist/i18n/en.js +17 -0
  20. package/dist/i18n/index.d.ts +18 -1
  21. package/dist/i18n/zh.d.ts +17 -0
  22. package/dist/i18n/zh.js +17 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/lib/diy-debug.d.ts +38 -0
  26. package/dist/lib/diy-debug.js +77 -0
  27. package/package.json +1 -1
  28. package/src/api/dsh.ts +6 -1
  29. package/src/components/FormActions.css +13 -0
  30. package/src/components/FormActions.test.tsx +157 -0
  31. package/src/components/FormActions.tsx +75 -0
  32. package/src/components/FormRows.css +21 -5
  33. package/src/components/FormRows.test.tsx +56 -0
  34. package/src/components/SaveBar.tsx +14 -18
  35. package/src/components/SelectField.css +20 -0
  36. package/src/components/SelectField.test.tsx +63 -0
  37. package/src/components/SelectField.tsx +56 -81
  38. package/src/components/SelectFieldNodes.tsx +77 -0
  39. package/src/config/constants.ts +2 -0
  40. package/src/host/tool-kit.ts +5 -0
  41. package/src/host.ts +1 -0
  42. package/src/i18n/en.ts +18 -0
  43. package/src/i18n/zh.ts +18 -0
  44. package/src/index.ts +2 -0
  45. package/src/lib/diy-debug.test.ts +103 -0
  46. package/src/lib/diy-debug.ts +122 -0
  47. package/src/components/SaveBar.css +0 -7
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export * from './components/CardBoundary.js';
20
20
  export * from './components/CardHead.js';
21
21
  export * from './components/ChoiceGroup.js';
22
22
  export * from './components/Field.js';
23
+ export * from './components/FormActions.js';
23
24
  export * from './components/FormRows.js';
24
25
  export * from './components/Link.js';
25
26
  export * from './components/LongTaskRow.js';
@@ -44,6 +45,7 @@ export * from './i18n/zh.js';
44
45
  export * from './lib/blocker-action.js';
45
46
  export * from './lib/busy.js';
46
47
  export * from './lib/form-problem.js';
48
+ export * from './lib/diy-debug.js';
47
49
  export * from './lib/panel-state.js';
48
50
  export * from './lib/react.js';
49
51
  export * from './lib/text.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export * from './components/CardBoundary.js';
20
20
  export * from './components/CardHead.js';
21
21
  export * from './components/ChoiceGroup.js';
22
22
  export * from './components/Field.js';
23
+ export * from './components/FormActions.js';
23
24
  export * from './components/FormRows.js';
24
25
  export * from './components/Link.js';
25
26
  export * from './components/LongTaskRow.js';
@@ -44,6 +45,7 @@ export * from './i18n/zh.js';
44
45
  export * from './lib/blocker-action.js';
45
46
  export * from './lib/busy.js';
46
47
  export * from './lib/form-problem.js';
48
+ export * from './lib/diy-debug.js';
47
49
  export * from './lib/panel-state.js';
48
50
  export * from './lib/react.js';
49
51
  export * from './lib/text.js';
@@ -0,0 +1,38 @@
1
+ export type DebugPhase = 'debugging' | 'paused';
2
+ export declare const DEBUG_STALE_MS: number;
3
+ export interface DebugState {
4
+ readonly phase: DebugPhase;
5
+ readonly sessionId: string;
6
+ readonly workspace: string;
7
+ readonly board: string;
8
+ readonly unit: string;
9
+ readonly name: string;
10
+ readonly startedAt: number;
11
+ readonly at: number;
12
+ }
13
+ export interface DebugPact {
14
+ readonly sessionId: string;
15
+ readonly workspace: string;
16
+ readonly board: string;
17
+ readonly unit: string;
18
+ readonly name: string;
19
+ readonly now: number;
20
+ }
21
+ export type DebugRefusal = 'already-held' | 'held-by-other' | 'not-owner' | 'none' | 'already-paused' | 'already-debugging';
22
+ export type DebugMove = {
23
+ readonly ok: true;
24
+ readonly state: DebugState;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly code: DebugRefusal;
28
+ };
29
+ export interface DebugStore {
30
+ live(now: number): DebugState | null;
31
+ begin(pact: DebugPact): DebugMove;
32
+ stall(sessionId: string, now: number): DebugMove;
33
+ pause(sessionId: string, now: number): DebugMove;
34
+ resume(sessionId: string, now: number): DebugMove;
35
+ stop(sessionId: string, now: number): DebugMove;
36
+ }
37
+ export declare function createDebugStore(): DebugStore;
38
+ export declare function debugOwnerText(state: DebugState): string;
@@ -0,0 +1,77 @@
1
+ export const DEBUG_STALE_MS = 4 * 60 * 60 * 1000;
2
+ function alive(state, now) {
3
+ if (state === null)
4
+ return null;
5
+ return now - state.at <= DEBUG_STALE_MS ? state : null;
6
+ }
7
+ function ownerOf(state, sessionId, now) {
8
+ const held = alive(state, now);
9
+ if (held === null)
10
+ return 'none';
11
+ return held.sessionId === sessionId ? held : 'not-owner';
12
+ }
13
+ export function createDebugStore() {
14
+ let held = null;
15
+ const live = (now) => alive(held, now);
16
+ return {
17
+ live,
18
+ begin(pact) {
19
+ const current = alive(held, pact.now);
20
+ if (current !== null) {
21
+ return { ok: false, code: current.sessionId === pact.sessionId ? 'already-held' : 'held-by-other' };
22
+ }
23
+ const state = {
24
+ phase: 'debugging',
25
+ sessionId: pact.sessionId,
26
+ workspace: pact.workspace,
27
+ board: pact.board,
28
+ unit: pact.unit,
29
+ name: pact.name,
30
+ startedAt: pact.now,
31
+ at: pact.now,
32
+ };
33
+ held = state;
34
+ return { ok: true, state };
35
+ },
36
+ stall(sessionId, now) {
37
+ const found = ownerOf(held, sessionId, now);
38
+ if (typeof found === 'string')
39
+ return { ok: false, code: found };
40
+ if (found.phase === 'paused')
41
+ return { ok: false, code: 'already-paused' };
42
+ const state = { ...found, phase: 'paused', at: now };
43
+ held = state;
44
+ return { ok: true, state };
45
+ },
46
+ pause(sessionId, now) {
47
+ const found = ownerOf(held, sessionId, now);
48
+ if (typeof found === 'string')
49
+ return { ok: false, code: found };
50
+ if (found.phase === 'paused')
51
+ return { ok: false, code: 'already-paused' };
52
+ const state = { ...found, phase: 'paused', at: now };
53
+ held = state;
54
+ return { ok: true, state };
55
+ },
56
+ resume(sessionId, now) {
57
+ const found = ownerOf(held, sessionId, now);
58
+ if (typeof found === 'string')
59
+ return { ok: false, code: found };
60
+ if (found.phase === 'debugging')
61
+ return { ok: false, code: 'already-debugging' };
62
+ const state = { ...found, phase: 'debugging', at: now };
63
+ held = state;
64
+ return { ok: true, state };
65
+ },
66
+ stop(sessionId, now) {
67
+ const found = ownerOf(held, sessionId, now);
68
+ if (typeof found === 'string')
69
+ return { ok: false, code: found };
70
+ held = null;
71
+ return { ok: true, state: found };
72
+ },
73
+ };
74
+ }
75
+ export function debugOwnerText(state) {
76
+ return `会话 ${state.sessionId}(自 ${new Date(state.startedAt).toISOString().slice(11, 19)}Z 起在调试 ${state.unit})`;
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/shared",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "工作台与模块共享的纯代码与契约类型:类型、纯逻辑、哑组件、平台解析。宿主侧的东西从 /host 进(不影响浏览器产物)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/api/dsh.ts CHANGED
@@ -151,7 +151,12 @@ export interface DshToolDefinition {
151
151
  export interface DshToolRunContext {
152
152
  readonly agent?:
153
153
  | {
154
- readonly session?: { readonly header?: { readonly cwd?: string } | undefined } | undefined;
154
+ readonly session?:
155
+ | {
156
+ readonly id?: string;
157
+ readonly header?: { readonly cwd?: string } | undefined;
158
+ }
159
+ | undefined;
155
160
  }
156
161
  | undefined;
157
162
  }
@@ -0,0 +1,13 @@
1
+ .wb-formActions {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ align-items: center;
5
+ justify-content: flex-end;
6
+ gap: var(--wb-len-8);
7
+ }
8
+
9
+ .wb-formActionDanger {
10
+ color: var(--dsw-alias-state-error-primary);
11
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
12
+ --dsw-alias-interactive-bg-hover: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
13
+ }
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { FormActions, type FormAction, type FormActionKind } from './FormActions.js';
4
+ import { SaveBar } from './SaveBar.js';
5
+
6
+ function action(key: string, kind?: FormActionKind): FormAction {
7
+ return { key, label: key, ...(kind === undefined ? {} : { kind }), onClick: () => {} };
8
+ }
9
+
10
+ function buttonsOf(node: unknown): { props: Record<string, unknown> }[] {
11
+ return (node as { props: { children: unknown[] } }).props.children as { props: Record<string, unknown> }[];
12
+ }
13
+
14
+ function labelsOf(node: unknown): unknown[] {
15
+ return buttonsOf(node).map((one) => one.props['children']);
16
+ }
17
+
18
+ describe('FormActions', () => {
19
+ it('★ 顺序由组件决定:给乱序数组,primary 仍在末尾、danger 紧挨它左侧、其余保持调用点顺序', () => {
20
+ const node = FormActions({
21
+ actions: [action('save', 'primary'), action('retry'), action('remove', 'danger'), action('edit')],
22
+ });
23
+
24
+ expect(labelsOf(node)).toEqual(['retry', 'edit', 'remove', 'save']);
25
+ expect(buttonsOf(node).map((one) => one.props['variant'])).toEqual([
26
+ 'ghost',
27
+ 'ghost',
28
+ 'outline',
29
+ 'primary',
30
+ ]);
31
+ });
32
+
33
+ it('没有 primary 时 danger 仍是最后一个(固定槽位,不随调用点顺序漂)', () => {
34
+ const node = FormActions({ actions: [action('remove', 'danger'), action('reset')] });
35
+
36
+ expect(labelsOf(node)).toEqual(['reset', 'remove']);
37
+ });
38
+
39
+ it('★ 两颗 primary 给可诊断信号(点名 key),但不吞、不挪、不丢', () => {
40
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
41
+ try {
42
+ const node = FormActions({ actions: [action('keep', 'primary'), action('also', 'primary')] });
43
+
44
+ expect(spy).toHaveBeenCalledTimes(1);
45
+ const message = String(spy.mock.calls[0]?.[0]);
46
+ expect(message).toContain('[dsh-workbench]');
47
+ expect(message).toContain('keep');
48
+ expect(message).toContain('also');
49
+ expect(labelsOf(node)).toEqual(['keep', 'also']);
50
+ } finally {
51
+ spy.mockRestore();
52
+ }
53
+ });
54
+
55
+ it('一颗 primary 不报', () => {
56
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ try {
58
+ FormActions({ actions: [action('save', 'primary'), action('cancel')] });
59
+ expect(spy).not.toHaveBeenCalled();
60
+ } finally {
61
+ spy.mockRestore();
62
+ }
63
+ });
64
+
65
+ it('★ 空数组不渲空壳', () => {
66
+ expect(FormActions({ actions: [] })).toBeNull();
67
+ });
68
+
69
+ it('danger 是 outline + 危险类,绝不是 primary 填充', () => {
70
+ const node = FormActions({ actions: [action('remove', 'danger')] });
71
+ const only = buttonsOf(node)[0];
72
+
73
+ expect(only?.props['variant']).toBe('outline');
74
+ expect(only?.props['className']).toBe('wb-formActionDanger');
75
+ });
76
+
77
+ it('busy 那颗禁用并带 Spinner;disabled 单独算', () => {
78
+ const node = FormActions({
79
+ actions: [
80
+ { key: 'save', label: '保存', kind: 'primary', onClick: () => {}, busy: true },
81
+ { key: 'cancel', label: '取消', onClick: () => {}, disabled: true },
82
+ ],
83
+ });
84
+ const [cancel, save] = buttonsOf(node);
85
+
86
+ expect(save?.props['disabled']).toBe(true);
87
+ expect(save?.props['icon']).toBeTruthy();
88
+ expect(cancel?.props['disabled']).toBe(true);
89
+ expect(cancel?.props['icon']).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ const CSS = readFileSync(new URL('./FormActions.css', import.meta.url), 'utf8');
94
+
95
+ function declarations(selector: string): string[] {
96
+ for (const block of CSS.split('}')) {
97
+ const [head, ...rest] = block.split('{');
98
+ if (rest.length === 0 || head === undefined) continue;
99
+ if (head.trim() !== selector) continue;
100
+ return rest
101
+ .join('{')
102
+ .split(';')
103
+ .map((one) => one.replace(/\s+/g, ' ').trim())
104
+ .filter((one) => one !== '');
105
+ }
106
+ throw new Error(`FormActions.css 里没有这条规则:${selector}`);
107
+ }
108
+
109
+ describe('操作区的版式(ADR-0054 §2 规则四)', () => {
110
+ it('★ 窄容器换行 + 整组右对齐 + 唯一一档间距', () => {
111
+ const rule = declarations('.wb-formActions');
112
+
113
+ expect(rule).toContain('flex-wrap: wrap');
114
+ expect(rule).toContain('justify-content: flex-end');
115
+ expect(rule).toContain('gap: var(--wb-len-8)');
116
+ });
117
+
118
+ it('★ 危险色走现成令牌(文字 / 描边 / 悬停)', () => {
119
+ const rule = declarations('.wb-formActionDanger');
120
+
121
+ expect(rule).toContain('color: var(--dsw-alias-state-error-primary)');
122
+ expect(rule.some((one) => one.startsWith('border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary)'))).toBe(
123
+ true,
124
+ );
125
+ expect(rule.some((one) => one.startsWith('--dsw-alias-interactive-bg-hover:'))).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe('SaveBar 自吃狗粮(ADR-0054 §10)', () => {
130
+ it('两个动作喂给 FormActions:取消是 default、保存是 primary(顺序不再由它摆)', () => {
131
+ const node = SaveBar({
132
+ busy: false,
133
+ labels: { cancel: '取消', save: '保存', saving: '保存中…' },
134
+ onCancel: () => {},
135
+ onSave: () => {},
136
+ }) as unknown as { props: { actions: readonly FormAction[] } };
137
+
138
+ expect(node.props.actions.map((one) => [one.key, one.kind])).toEqual([
139
+ ['cancel', 'default'],
140
+ ['save', 'primary'],
141
+ ]);
142
+ });
143
+
144
+ it('busy 时保存那颗显示 saving 文案、禁用且带忙碌标记', () => {
145
+ const node = SaveBar({
146
+ busy: true,
147
+ labels: { cancel: '取消', save: '保存', saving: '保存中…' },
148
+ onCancel: () => {},
149
+ onSave: () => {},
150
+ }) as unknown as { props: { actions: readonly FormAction[] } };
151
+ const save = node.props.actions[1];
152
+
153
+ expect(save?.label).toBe('保存中…');
154
+ expect(save?.disabled).toBe(true);
155
+ expect(save?.busy).toBe(true);
156
+ });
157
+ });
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from '../lib/react.js';
2
+ import { h } from '../lib/react.js';
3
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { Spinner } from './Spinner.js';
5
+ import './FormActions.css';
6
+
7
+ export type FormActionKind = 'default' | 'danger' | 'primary';
8
+
9
+ export interface FormAction {
10
+ readonly key: string;
11
+ readonly label: ReactNode;
12
+ readonly kind?: FormActionKind | undefined;
13
+ readonly onClick: () => void;
14
+ readonly disabled?: boolean | undefined;
15
+ readonly busy?: boolean | undefined;
16
+ }
17
+
18
+ export interface FormActionsProps {
19
+ readonly actions: readonly FormAction[];
20
+ }
21
+
22
+ const RANK: Record<FormActionKind, number> = { default: 0, danger: 1, primary: 2 };
23
+
24
+ function kindOf(action: FormAction): FormActionKind {
25
+ return action.kind ?? 'default';
26
+ }
27
+
28
+ function ordered(actions: readonly FormAction[]): readonly FormAction[] {
29
+ return actions
30
+ .map((action, index) => ({ action, index, rank: RANK[kindOf(action)] }))
31
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
32
+ .map((one) => one.action);
33
+ }
34
+
35
+ function reportExtraPrimaries(actions: readonly FormAction[]): void {
36
+ const keys = actions.filter((one) => kindOf(one) === 'primary').map((one) => one.key);
37
+ if (keys.length < 2) return;
38
+ console.error(
39
+ `[dsh-workbench] FormActions 一个区里出现了 ${keys.length} 颗 primary(${keys.join('、')})—— ` +
40
+ '一个区只许一颗主操作(ADR-0054 §2 规则二):多余的这颗该改成 default',
41
+ );
42
+ }
43
+
44
+ function variantOf(kind: FormActionKind): 'ghost' | 'outline' | 'primary' {
45
+ if (kind === 'primary') return 'primary';
46
+ if (kind === 'danger') return 'outline';
47
+ return 'ghost';
48
+ }
49
+
50
+ export function FormActions({ actions }: FormActionsProps) {
51
+ if (actions.length === 0) return null;
52
+ reportExtraPrimaries(actions);
53
+ return (
54
+ <div className="wb-formActions">
55
+ {ordered(actions).map((action) => {
56
+ const kind = kindOf(action);
57
+ const busy = action.busy === true;
58
+ return (
59
+ <Button
60
+ key={action.key}
61
+ type="button"
62
+ size="sm"
63
+ variant={variantOf(kind)}
64
+ className={kind === 'danger' ? 'wb-formActionDanger' : undefined}
65
+ disabled={busy || action.disabled === true}
66
+ icon={busy ? <Spinner size={12} /> : undefined}
67
+ onClick={action.onClick}
68
+ >
69
+ {action.label}
70
+ </Button>
71
+ );
72
+ })}
73
+ </div>
74
+ );
75
+ }
@@ -1,14 +1,25 @@
1
1
  .wb-formRows {
2
- display: flex;
3
- flex-direction: column;
4
- gap: var(--wb-len-12);
2
+ display: grid;
3
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
4
+ column-gap: var(--wb-len-8);
5
+ row-gap: var(--wb-len-12);
6
+ }
7
+
8
+ .wb-formRows > :not(.wb-formRow) {
9
+ grid-column: 1 / -1;
5
10
  }
6
11
 
7
12
  .wb-formRow {
8
13
  display: grid;
9
- grid-template-columns: 72px minmax(0, 1fr);
14
+ grid-column: 1 / -1;
15
+ grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
10
16
  align-items: center;
11
- gap: var(--wb-len-8);
17
+ column-gap: var(--wb-len-8);
18
+ row-gap: var(--wb-len-8);
19
+ }
20
+
21
+ .wb-formRows > .wb-formRow {
22
+ grid-template-columns: subgrid;
12
23
  }
13
24
 
14
25
  .wb-formRow[data-layout='stacked'] {
@@ -22,6 +33,11 @@
22
33
  font-size: var(--dsw-font-xs-13-font-size);
23
34
  color: var(--dsw-alias-label-secondary);
24
35
  overflow-wrap: anywhere;
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .wb-formRow[data-layout='stacked'] .wb-formLabel {
40
+ white-space: normal;
25
41
  }
26
42
 
27
43
  .wb-formControl {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { FormRow, FormRows } from './FormRows.js';
3
4
 
@@ -80,3 +81,58 @@ describe('FormRow', () => {
80
81
  expect(texts(node)).toEqual(['两行']);
81
82
  });
82
83
  });
84
+
85
+ const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
86
+
87
+ function declarations(selector: string): string[] {
88
+ for (const block of FORM_CSS.split('}')) {
89
+ const [head, ...rest] = block.split('{');
90
+ if (rest.length === 0 || head === undefined) continue;
91
+ if (head.trim() !== selector) continue;
92
+ return rest
93
+ .join('{')
94
+ .split(';')
95
+ .map((one) => one.replace(/\s+/g, ' ').trim())
96
+ .filter((one) => one !== '');
97
+ }
98
+ throw new Error(`FormRows.css 里没有这条规则:${selector}`);
99
+ }
100
+
101
+ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () => {
102
+ it('★ FormRows 自己是一个 grid:标签列 minmax(72px, max-content),行距还是那一档', () => {
103
+ const rows = declarations('.wb-formRows');
104
+
105
+ expect(rows).toContain('display: grid');
106
+ expect(rows).toContain('grid-template-columns: minmax(72px, max-content) minmax(0, 1fr)');
107
+ expect(rows).toContain('row-gap: var(--wb-len-12)');
108
+ });
109
+
110
+ it('★ 行先留回退两列,再由 subgrid 继承父网格那两列(老浏览器丢掉 subgrid 仍有回退)', () => {
111
+ expect(declarations('.wb-formRow')).toContain(
112
+ 'grid-template-columns: minmax(72px, max-content) minmax(0, 1fr)',
113
+ );
114
+ expect(declarations('.wb-formRows > .wb-formRow')).toContain('grid-template-columns: subgrid');
115
+ });
116
+
117
+ it('★ 行自己的列距与 FormRows 那一档同值(不同值就是控件起点参差)', () => {
118
+ const rowGap = declarations('.wb-formRow').find((one) => one.startsWith('column-gap:'));
119
+ const rowsGap = declarations('.wb-formRows').find((one) => one.startsWith('column-gap:'));
120
+
121
+ expect(rowGap).toBe('column-gap: var(--wb-len-8)');
122
+ expect(rowGap).toBe(rowsGap);
123
+ });
124
+
125
+ it('★ 标签不折行;stacked 还是单列、标签照常换行', () => {
126
+ expect(declarations('.wb-formLabel')).toContain('white-space: nowrap');
127
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formLabel")).toContain(
128
+ 'white-space: normal',
129
+ );
130
+ expect(declarations(".wb-formRow[data-layout='stacked']")).toContain(
131
+ 'grid-template-columns: minmax(0, 1fr)',
132
+ );
133
+ });
134
+
135
+ it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
136
+ expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
137
+ });
138
+ });
@@ -1,6 +1,6 @@
1
- import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
2
1
  import { h } from '../lib/react.js';
3
- import './SaveBar.css';
2
+ import type { FormAction } from './FormActions.js';
3
+ import { FormActions } from './FormActions.js';
4
4
 
5
5
  export interface SaveBarProps {
6
6
  readonly busy: boolean;
@@ -15,20 +15,16 @@ export interface SaveBarProps {
15
15
  }
16
16
 
17
17
  export function SaveBar({ busy, disabled, labels, onCancel, onSave }: SaveBarProps) {
18
- return (
19
- <div className="wb-formActions">
20
- <Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={busy}>
21
- {labels.cancel}
22
- </Button>
23
- <Button
24
- type="button"
25
- size="sm"
26
- variant="primary"
27
- onClick={onSave}
28
- disabled={busy || disabled === true}
29
- >
30
- {busy ? labels.saving : labels.save}
31
- </Button>
32
- </div>
33
- );
18
+ const actions: readonly FormAction[] = [
19
+ { key: 'cancel', label: labels.cancel, kind: 'default', onClick: onCancel, disabled: busy },
20
+ {
21
+ key: 'save',
22
+ label: busy ? labels.saving : labels.save,
23
+ kind: 'primary',
24
+ onClick: onSave,
25
+ disabled: busy || disabled === true,
26
+ busy,
27
+ },
28
+ ];
29
+ return <FormActions actions={actions} />;
34
30
  }
@@ -61,6 +61,26 @@
61
61
  box-shadow: none;
62
62
  }
63
63
 
64
+ .wb-selectIconOnly {
65
+ width: var(--wb-len-36);
66
+ height: var(--wb-len-36);
67
+ min-height: 0;
68
+ gap: 0;
69
+ padding: 0;
70
+ justify-content: center;
71
+ }
72
+
73
+ .wb-selectReveal {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ }
78
+
79
+ .wb-selectIconOnly .wb-selectIcon {
80
+ width: 100%;
81
+ height: 100%;
82
+ }
83
+
64
84
  .wb-selectAnchor {
65
85
  display: block;
66
86
  width: 100%;