@lokvis/ui-react 0.2.2 → 0.4.2

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 (133) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +10 -54
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts.map +1 -1
  5. package/dist/components/Canvas.js +4 -4
  6. package/dist/components/Canvas.js.map +1 -1
  7. package/dist/components/CompareSlider.d.ts +4 -3
  8. package/dist/components/CompareSlider.d.ts.map +1 -1
  9. package/dist/components/CompareSlider.js +5 -4
  10. package/dist/components/CompareSlider.js.map +1 -1
  11. package/dist/components/DownloadPanel.d.ts.map +1 -1
  12. package/dist/components/DownloadPanel.js +1 -8
  13. package/dist/components/DownloadPanel.js.map +1 -1
  14. package/dist/components/ErrorBoundary.d.ts +40 -0
  15. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  16. package/dist/components/ErrorBoundary.js +52 -0
  17. package/dist/components/ErrorBoundary.js.map +1 -0
  18. package/dist/components/ExifPanel.js +1 -1
  19. package/dist/components/ExifPanel.js.map +1 -1
  20. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  21. package/dist/components/GlobalDropzone.js +10 -3
  22. package/dist/components/GlobalDropzone.js.map +1 -1
  23. package/dist/components/Inspector.d.ts.map +1 -1
  24. package/dist/components/Inspector.js +10 -7
  25. package/dist/components/Inspector.js.map +1 -1
  26. package/dist/components/ParamForm.d.ts.map +1 -1
  27. package/dist/components/ParamForm.js +8 -5
  28. package/dist/components/ParamForm.js.map +1 -1
  29. package/dist/components/PipelineBar.d.ts.map +1 -1
  30. package/dist/components/PipelineBar.js +17 -26
  31. package/dist/components/PipelineBar.js.map +1 -1
  32. package/dist/components/StatusBar.d.ts.map +1 -1
  33. package/dist/components/StatusBar.js +3 -12
  34. package/dist/components/StatusBar.js.map +1 -1
  35. package/dist/components/StatusDot.d.ts +12 -0
  36. package/dist/components/StatusDot.d.ts.map +1 -0
  37. package/dist/components/StatusDot.js +15 -0
  38. package/dist/components/StatusDot.js.map +1 -0
  39. package/dist/components/ThemeToggle.js +1 -1
  40. package/dist/components/ThemeToggle.js.map +1 -1
  41. package/dist/components/ToolRunner.d.ts +53 -0
  42. package/dist/components/ToolRunner.d.ts.map +1 -0
  43. package/dist/components/ToolRunner.js +142 -0
  44. package/dist/components/ToolRunner.js.map +1 -0
  45. package/dist/components/Toolbar.js +2 -2
  46. package/dist/components/Toolbar.js.map +1 -1
  47. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  48. package/dist/components/WorkflowEditor.js +31 -40
  49. package/dist/components/WorkflowEditor.js.map +1 -1
  50. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  51. package/dist/components/WorkflowTemplates.js +15 -5
  52. package/dist/components/WorkflowTemplates.js.map +1 -1
  53. package/dist/components/Workspace.d.ts +23 -1
  54. package/dist/components/Workspace.d.ts.map +1 -1
  55. package/dist/components/Workspace.js +60 -13
  56. package/dist/components/Workspace.js.map +1 -1
  57. package/dist/hooks/useCustomPresets.d.ts +98 -0
  58. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  59. package/dist/hooks/useCustomPresets.js +210 -0
  60. package/dist/hooks/useCustomPresets.js.map +1 -0
  61. package/dist/hooks/useLokvis.d.ts +19 -1
  62. package/dist/hooks/useLokvis.d.ts.map +1 -1
  63. package/dist/hooks/useLokvis.js +30 -9
  64. package/dist/hooks/useLokvis.js.map +1 -1
  65. package/dist/index.d.ts +3 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +3 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/store/assets-slice.d.ts +8 -1
  70. package/dist/store/assets-slice.d.ts.map +1 -1
  71. package/dist/store/assets-slice.js +101 -54
  72. package/dist/store/assets-slice.js.map +1 -1
  73. package/dist/store/capabilities-slice.d.ts +1 -1
  74. package/dist/store/capabilities-slice.d.ts.map +1 -1
  75. package/dist/store/capabilities-slice.js +8 -1
  76. package/dist/store/capabilities-slice.js.map +1 -1
  77. package/dist/store/index.d.ts +1 -1
  78. package/dist/store/index.d.ts.map +1 -1
  79. package/dist/store/index.js +1 -1
  80. package/dist/store/index.js.map +1 -1
  81. package/dist/store/runtime-slice.d.ts.map +1 -1
  82. package/dist/store/runtime-slice.js +22 -16
  83. package/dist/store/runtime-slice.js.map +1 -1
  84. package/dist/store/subscribe-utils.d.ts +45 -0
  85. package/dist/store/subscribe-utils.d.ts.map +1 -0
  86. package/dist/store/subscribe-utils.js +69 -0
  87. package/dist/store/subscribe-utils.js.map +1 -0
  88. package/dist/store/types.d.ts +19 -11
  89. package/dist/store/types.d.ts.map +1 -1
  90. package/dist/store/types.js +0 -10
  91. package/dist/store/types.js.map +1 -1
  92. package/dist/store/workflow-slice.d.ts +1 -1
  93. package/dist/store/workflow-slice.d.ts.map +1 -1
  94. package/dist/store/workflow-slice.js +19 -51
  95. package/dist/store/workflow-slice.js.map +1 -1
  96. package/dist/utils.d.ts +24 -0
  97. package/dist/utils.d.ts.map +1 -0
  98. package/dist/utils.js +31 -0
  99. package/dist/utils.js.map +1 -0
  100. package/package.json +7 -6
  101. package/src/components/AssetPanel.tsx +18 -58
  102. package/src/components/Canvas.tsx +11 -7
  103. package/src/components/CompareSlider.tsx +13 -10
  104. package/src/components/DownloadPanel.tsx +1 -7
  105. package/src/components/ErrorBoundary.tsx +101 -0
  106. package/src/components/ExifPanel.tsx +1 -1
  107. package/src/components/GlobalDropzone.tsx +8 -3
  108. package/src/components/Inspector.tsx +24 -16
  109. package/src/components/ParamForm.tsx +12 -9
  110. package/src/components/PipelineBar.tsx +20 -27
  111. package/src/components/StatusBar.tsx +3 -10
  112. package/src/components/StatusDot.tsx +27 -0
  113. package/src/components/ThemeToggle.tsx +1 -1
  114. package/src/components/ToolRunner.tsx +352 -0
  115. package/src/components/Toolbar.tsx +2 -2
  116. package/src/components/WorkflowEditor.tsx +50 -43
  117. package/src/components/WorkflowTemplates.tsx +27 -4
  118. package/src/components/Workspace.tsx +97 -10
  119. package/src/hooks/useCustomPresets.ts +284 -0
  120. package/src/hooks/useLokvis.ts +43 -10
  121. package/src/index.ts +3 -0
  122. package/src/store/assets-slice.ts +106 -52
  123. package/src/store/capabilities-slice.ts +10 -2
  124. package/src/store/index.ts +1 -1
  125. package/src/store/runtime-slice.ts +20 -15
  126. package/src/store/subscribe-utils.ts +82 -0
  127. package/src/store/types.ts +19 -12
  128. package/src/store/workflow-slice.ts +19 -53
  129. package/src/utils.ts +41 -0
  130. package/dist/store.d.ts +0 -76
  131. package/dist/store.d.ts.map +0 -1
  132. package/dist/store.js +0 -217
  133. package/dist/store.js.map +0 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ErrorBoundary - React 错误边界(D6)
3
+ *
4
+ * 捕获子组件树渲染期间的运行时异常,避免整个 Workspace 白屏。
5
+ * 必须是 class 组件(React 错误边界 API 仅支持 class)。
6
+ *
7
+ * 设计:
8
+ * - 默认 fallback 使用 lokvis token,与 Workspace 错误态视觉一致
9
+ * - 可选 fallback 渲染 prop(消费方自定义 UI)
10
+ * - 可选 onError 回调(上报等);默认 console.error,不引入 sentry 等
11
+ * app 层依赖,保持 ui-react 包架构纯净(不反向依赖消费方)
12
+ * - reset() 清空 error 状态,尝试重新渲染子树
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <ErrorBoundary>
17
+ * <Workspace plugins={[...]} />
18
+ * </ErrorBoundary>
19
+ * ```
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ export interface ErrorBoundaryProps {
25
+ children: React.ReactNode;
26
+ /** 自定义 fallback;不传则用默认 UI */
27
+ fallback?: (error: Error, reset: () => void) => React.ReactNode;
28
+ /** 错误回调(上报等),不传则 console.error */
29
+ onError?: (error: Error, info: React.ErrorInfo) => void;
30
+ }
31
+
32
+ interface State {
33
+ error: Error | null;
34
+ }
35
+
36
+ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, State> {
37
+ state: State = { error: null };
38
+
39
+ static getDerivedStateFromError(error: Error): State {
40
+ return { error };
41
+ }
42
+
43
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
44
+ if (this.props.onError) {
45
+ this.props.onError(error, info);
46
+ } else {
47
+ console.error('[lokvis ErrorBoundary]', error, info.componentStack);
48
+ }
49
+ }
50
+
51
+ reset = () => {
52
+ this.setState({ error: null });
53
+ };
54
+
55
+ render() {
56
+ if (this.state.error) {
57
+ if (this.props.fallback) {
58
+ return this.props.fallback(this.state.error, this.reset);
59
+ }
60
+ return <DefaultFallback error={this.state.error} onReset={this.reset} />;
61
+ }
62
+ return this.props.children;
63
+ }
64
+ }
65
+
66
+ function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
67
+ return (
68
+ <div className="flex h-full w-full items-center justify-center p-6" role="alert">
69
+ <div className="max-w-md rounded-xl border border-[var(--lokvis-danger)]/30 bg-[var(--lokvis-surface)] p-6 text-center shadow-[var(--lokvis-elevation-2)]">
70
+ <div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--lokvis-danger)]/15">
71
+ <svg
72
+ width="20"
73
+ height="20"
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ strokeWidth="2"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ className="text-[var(--lokvis-danger)]"
81
+ >
82
+ <circle cx="12" cy="12" r="10" />
83
+ <line x1="12" y1="8" x2="12" y2="12" />
84
+ <line x1="12" y1="16" x2="12.01" y2="16" />
85
+ </svg>
86
+ </div>
87
+ <p className="font-semibold text-[var(--lokvis-fg)]">Something went wrong</p>
88
+ <p className="mt-1.5 break-words text-sm text-[var(--lokvis-fg-muted)]">
89
+ {error.message || 'An unexpected error occurred while rendering this panel.'}
90
+ </p>
91
+ <button
92
+ type="button"
93
+ onClick={onReset}
94
+ className="mt-4 inline-flex items-center justify-center rounded-lg bg-[var(--lokvis-primary)] px-4 py-2 text-sm font-medium text-[var(--lokvis-primary-fg)] transition-colors hover:bg-[var(--lokvis-primary-hover)]"
95
+ >
96
+ Try again
97
+ </button>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -121,7 +121,7 @@ export function ExifPanel({ className = '' }: ExifPanelProps) {
121
121
  <button
122
122
  type="button"
123
123
  onClick={() => setOpen(!open)}
124
- className="flex w-full items-center justify-between px-3 h-10 text-left transition-colors hover:bg-[var(--lokvis-surface)]"
124
+ className="flex w-full items-center justify-between px-3 h-[var(--lokvis-panel-header-h)] text-left transition-colors hover:bg-[var(--lokvis-surface)]"
125
125
  aria-expanded={open}
126
126
  >
127
127
  <div className="flex items-center gap-2">
@@ -76,6 +76,8 @@ export function GlobalDropzone({
76
76
  // dragCounter:用计数器而非布尔,避免子元素 dragenter/dragleave 触发抖动
77
77
  const [dragCounter, setDragCounter] = React.useState(0);
78
78
  const [rejectedFiles, setRejectedFiles] = React.useState<string[]>([]);
79
+ // W21.6: 持有 rejectedFiles 自动清除定时器,unmount 时 cleanup
80
+ const rejectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
79
81
 
80
82
  React.useEffect(() => {
81
83
  // 阻止浏览器默认行为(打开文件 / 下载),否则 drop 不会触发
@@ -134,8 +136,9 @@ export function GlobalDropzone({
134
136
  }
135
137
  if (rejected.length > 0) {
136
138
  setRejectedFiles(rejected);
137
- // 5 秒后自动清空拒绝提示
138
- setTimeout(() => setRejectedFiles([]), 5000);
139
+ // W21.6: 5 秒后自动清空拒绝提示,clear 旧 timer 避免重叠
140
+ if (rejectTimerRef.current) clearTimeout(rejectTimerRef.current);
141
+ rejectTimerRef.current = setTimeout(() => setRejectedFiles([]), 5000);
139
142
  }
140
143
  if (accepted.length > 0) {
141
144
  void importFiles(accepted);
@@ -154,6 +157,8 @@ export function GlobalDropzone({
154
157
  window.removeEventListener('dragover', onDragOver);
155
158
  window.removeEventListener('dragleave', onDragLeave);
156
159
  window.removeEventListener('drop', onDrop);
160
+ // W21.6: 清理 rejectedFiles 自动清除定时器
161
+ if (rejectTimerRef.current) clearTimeout(rejectTimerRef.current);
157
162
  };
158
163
  }, [accept, importFiles, setStatus, setError]);
159
164
 
@@ -173,7 +178,7 @@ export function GlobalDropzone({
173
178
 
174
179
  {/* 内容 */}
175
180
  <div
176
- className={`relative w-full max-w-md rounded-2xl border-2 border-dashed p-8 text-center bg-[var(--lokvis-surface)] shadow-2xl ${
181
+ className={`relative w-full max-w-md rounded-2xl border-2 border-dashed p-8 text-center bg-[var(--lokvis-surface)] shadow-[var(--lokvis-elevation-overlay)] ${
177
182
  hasRejections ? 'border-[var(--lokvis-danger)]' : 'border-[var(--lokvis-primary)]'
178
183
  }`}
179
184
  >
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import * as React from 'react';
9
- import { Icon } from '@lokvis/ui-core';
9
+ import { Icon, Input } from '@lokvis/ui-core';
10
10
  import { useWorkspaceStore } from '../store/index.js';
11
+ import { filterCapabilities } from '../utils.js';
11
12
  import { ParamForm } from './ParamForm.js';
12
13
  import { ExifPanel } from './ExifPanel.js';
13
14
 
@@ -20,6 +21,7 @@ export function Inspector({ className = '' }: InspectorProps) {
20
21
  const nodes = useWorkspaceStore((s) => s.nodes);
21
22
  const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
22
23
  const capabilityMap = useWorkspaceStore((s) => s.capabilityMap);
24
+ const stubCapabilities = useWorkspaceStore((s) => s.stubCapabilities);
23
25
  const addNode = useWorkspaceStore((s) => s.addNode);
24
26
  const updateNodeParams = useWorkspaceStore((s) => s.updateNodeParams);
25
27
 
@@ -27,11 +29,7 @@ export function Inspector({ className = '' }: InspectorProps) {
27
29
  const [filter, setFilter] = React.useState('');
28
30
  const [configureOpen, setConfigureOpen] = React.useState(true);
29
31
 
30
- const filtered = capabilities.filter(
31
- (c) =>
32
- c.name.toLowerCase().includes(filter.toLowerCase()) ||
33
- c.description.toLowerCase().includes(filter.toLowerCase())
34
- );
32
+ const filtered = filterCapabilities(capabilities, filter);
35
33
 
36
34
  // 按域分组
37
35
  const grouped = React.useMemo(() => {
@@ -59,7 +57,9 @@ export function Inspector({ className = '' }: InspectorProps) {
59
57
  <button
60
58
  type="button"
61
59
  onClick={() => setConfigureOpen(!configureOpen)}
62
- className="flex w-full items-center justify-between px-3 h-10 text-left transition-colors hover:bg-[var(--lokvis-surface)]"
60
+ aria-expanded={configureOpen}
61
+ aria-controls={`configure-panel-${selectedNode.id}`}
62
+ className="flex w-full items-center justify-between px-3 h-[var(--lokvis-panel-header-h)] text-left transition-colors hover:bg-[var(--lokvis-surface)]"
63
63
  >
64
64
  <div className="flex items-center gap-2">
65
65
  <Icon size={12} className={`text-[var(--lokvis-fg-subtle)] transition-transform ${configureOpen ? 'rotate-90' : ''}`}><path d="m9 5 7 7-7 7" /></Icon>
@@ -70,7 +70,7 @@ export function Inspector({ className = '' }: InspectorProps) {
70
70
  </span>
71
71
  </button>
72
72
  {configureOpen && (
73
- <div className="px-3 pb-3 max-h-60 overflow-y-auto">
73
+ <div id={`configure-panel-${selectedNode.id}`} className="px-3 pb-3 max-h-60 overflow-y-auto">
74
74
  <ParamForm
75
75
  capability={capabilityMap[selectedNode.capability]!}
76
76
  values={selectedNode.params}
@@ -83,7 +83,7 @@ export function Inspector({ className = '' }: InspectorProps) {
83
83
 
84
84
  {/* Capabilities section */}
85
85
  <div className="flex flex-1 flex-col overflow-hidden">
86
- <div className="flex items-center justify-between px-3 h-10 shrink-0 border-b border-[var(--lokvis-border)]">
86
+ <div className="flex items-center justify-between px-3 h-[var(--lokvis-panel-header-h)] shrink-0 border-b border-[var(--lokvis-border)]">
87
87
  <span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
88
88
  Capabilities
89
89
  </span>
@@ -92,17 +92,16 @@ export function Inspector({ className = '' }: InspectorProps) {
92
92
 
93
93
  {/* Search */}
94
94
  <div className="px-2 py-2 border-b border-[var(--lokvis-border)]">
95
- <div className="relative">
96
- <Icon size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--lokvis-fg-subtle)]"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></Icon>
97
- <input
98
- type="text"
95
+ <Input
96
+ size="sm"
99
97
  placeholder="Search capabilities..."
100
98
  value={filter}
101
99
  onChange={(e) => setFilter(e.target.value)}
102
- className="w-full rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] py-1 pl-7 pr-2 text-[11px] placeholder-zinc-400 focus:border-[var(--lokvis-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--lokvis-primary)]"
100
+ leadingIcon={
101
+ <Icon size={12}><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></Icon>
102
+ }
103
103
  />
104
104
  </div>
105
- </div>
106
105
 
107
106
  {/* Capability list */}
108
107
  <div className="flex-1 overflow-y-auto p-2">
@@ -123,11 +122,14 @@ export function Inspector({ className = '' }: InspectorProps) {
123
122
  <ul className="space-y-0.5">
124
123
  {caps.map((cap) => {
125
124
  const isInPipeline = nodes.some((n) => n.capability === cap.name);
125
+ // A7: stub-only 能力显示 "Coming Soon" 标记(无可用引擎)
126
+ const isStubOnly = stubCapabilities.has(cap.name);
126
127
  return (
127
128
  <li key={cap.name}>
128
129
  <button
129
130
  type="button"
130
131
  onClick={() => addNode(cap.name)}
132
+ title={isStubOnly ? 'Coming soon — no engine installed yet' : undefined}
131
133
  className={`group w-full rounded-md px-2 py-1.5 text-left transition-all ${
132
134
  isInPipeline
133
135
  ? 'bg-[var(--lokvis-primary)]/5 ring-1 ring-[var(--lokvis-primary)]/40'
@@ -135,7 +137,12 @@ export function Inspector({ className = '' }: InspectorProps) {
135
137
  }`}
136
138
  >
137
139
  <div className="flex items-center justify-between gap-2">
138
- <span className="truncate font-mono text-[11px] font-medium">{cap.name}</span>
140
+ <span className={`truncate font-mono text-[11px] font-medium ${isStubOnly ? 'text-[var(--lokvis-fg-muted)]' : ''}`}>{cap.name}</span>
141
+ {isStubOnly ? (
142
+ <span className="shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]">
143
+ Soon
144
+ </span>
145
+ ) : (
139
146
  <span className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
140
147
  cap.performance === 'fast'
141
148
  ? 'bg-[var(--lokvis-success)]/15 text-[var(--lokvis-success)]'
@@ -145,6 +152,7 @@ export function Inspector({ className = '' }: InspectorProps) {
145
152
  }`}>
146
153
  {cap.performance}
147
154
  </span>
155
+ )}
148
156
  </div>
149
157
  <p className="mt-0.5 truncate text-[10px] text-[var(--lokvis-fg-muted)]">{cap.description}</p>
150
158
  </button>
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Capability, CapabilityParam } from '@lokvis/schema';
9
+ import { Input } from '@lokvis/ui-core';
9
10
 
10
11
  export interface ParamFormProps {
11
12
  capability: Capability;
@@ -40,8 +41,10 @@ export function ParamForm({ capability, values, onChange }: ParamFormProps) {
40
41
  );
41
42
  }
42
43
 
43
- const inputBase =
44
- 'w-full rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-1.5 text-[11px] transition-colors focus:border-[var(--lokvis-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--lokvis-primary)]';
44
+ // 仅用于 enum 的原生 <select>(Select 组件 API 不同,不在此迁移);
45
+ // 文本/数字输入已统一改用 <Input size="sm"> 组件,消除重复样式。
46
+ const selectBase =
47
+ 'w-full rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-1.5 text-[11px] transition-colors hover:border-[var(--lokvis-border-strong)] focus:border-[var(--lokvis-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--lokvis-primary)]/30 disabled:cursor-not-allowed disabled:opacity-50';
45
48
 
46
49
  function ParamField({
47
50
  param,
@@ -79,7 +82,7 @@ function ParamField({
79
82
  <select
80
83
  value={(value as string) ?? (param.default as string) ?? ''}
81
84
  onChange={(e) => onChange(e.target.value)}
82
- className={inputBase}
85
+ className={selectBase}
83
86
  >
84
87
  {param.values.map((v) => (
85
88
  <option key={v} value={v}>{v}</option>
@@ -102,11 +105,11 @@ function ParamField({
102
105
  onChange={(e) => onChange(e.target.value)}
103
106
  className="h-7 w-7 shrink-0 cursor-pointer rounded border border-[var(--lokvis-border)]"
104
107
  />
105
- <input
108
+ <Input
106
109
  type="text"
110
+ size="sm"
107
111
  value={(value as string) ?? (param.default as string) ?? '#ffffff'}
108
112
  onChange={(e) => onChange(e.target.value)}
109
- className={inputBase}
110
113
  />
111
114
  </div>
112
115
  </div>
@@ -129,13 +132,13 @@ function ParamField({
129
132
  </span>
130
133
  )}
131
134
  </div>
132
- <input
135
+ <Input
133
136
  type="number"
137
+ size="sm"
134
138
  value={value === undefined ? (param.default as number | undefined) ?? '' : (value as number)}
135
139
  min={param.min}
136
140
  max={param.max}
137
141
  onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
138
- className={inputBase}
139
142
  />
140
143
  </div>
141
144
  );
@@ -151,12 +154,12 @@ function ParamField({
151
154
  <span className="ml-1 font-normal text-[var(--lokvis-fg-subtle)]">{param.description}</span>
152
155
  )}
153
156
  </label>
154
- <input
157
+ <Input
155
158
  type="text"
159
+ size="sm"
156
160
  value={(value as string) ?? (param.default as string) ?? ''}
157
161
  onChange={(e) => onChange(e.target.value)}
158
162
  placeholder={param.type === 'file' ? 'data URL or path' : ''}
159
- className={inputBase}
160
163
  />
161
164
  </div>
162
165
  );
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import * as React from 'react';
9
- import { Icon } from '@lokvis/ui-core';
9
+ import { FOCUS_RING, Icon } from '@lokvis/ui-core';
10
10
  import { useWorkspaceStore } from '../store/index.js';
11
+ import { StatusDot } from './StatusDot.js';
11
12
 
12
13
  export interface PipelineBarProps {
13
14
  className?: string;
@@ -47,10 +48,21 @@ export function PipelineBar({ className = '' }: PipelineBarProps) {
47
48
  const selected = node.id === selectedNodeId;
48
49
  return (
49
50
  <React.Fragment key={node.id}>
51
+ {/* D7: remove button 作为 node button 的兄弟而非子元素(HTML 规范禁止 button 嵌套) */}
52
+ <div className="group flex shrink-0 items-center gap-0.5">
50
53
  <button
51
54
  type="button"
52
55
  onClick={() => selectNode(node.id)}
53
- className={`group flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-all ${
56
+ onKeyDown={(e) => {
57
+ // 键盘可达:Delete/Backspace 删除节点(与 WorkflowEditor 一致)
58
+ if (e.key === 'Delete' || e.key === 'Backspace') {
59
+ e.preventDefault();
60
+ removeNode(node.id);
61
+ }
62
+ }}
63
+ aria-label={`节点 ${node.capability},位置 ${i + 1},Delete 删除`}
64
+ aria-pressed={selected}
65
+ className={`flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-all ${FOCUS_RING} ${
54
66
  selected
55
67
  ? 'bg-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)] ring-1 ring-[var(--lokvis-primary)]/50'
56
68
  : node.status === 'running'
@@ -64,18 +76,16 @@ export function PipelineBar({ className = '' }: PipelineBarProps) {
64
76
  >
65
77
  <span className="font-mono">{node.capability}</span>
66
78
  <StatusDot status={node.status} />
67
- <span
68
- onClick={(e) => {
69
- e.stopPropagation();
70
- removeNode(node.id);
71
- }}
72
- className="ml-0.5 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-[var(--lokvis-danger)]/15 hover:text-[var(--lokvis-danger)]"
73
- role="button"
79
+ </button>
80
+ <button
81
+ type="button"
82
+ onClick={() => removeNode(node.id)}
83
+ className="rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--lokvis-danger)] hover:bg-[var(--lokvis-danger)]/15 hover:text-[var(--lokvis-danger)]"
74
84
  aria-label="Remove step"
75
85
  >
76
86
  <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
77
- </span>
78
87
  </button>
88
+ </div>
79
89
 
80
90
  {/* Arrow between nodes */}
81
91
  {i < nodes.length - 1 && (
@@ -107,20 +117,3 @@ export function PipelineBar({ className = '' }: PipelineBarProps) {
107
117
  </div>
108
118
  );
109
119
  }
110
-
111
- function StatusDot({ status }: { status: string }) {
112
- const color =
113
- status === 'running'
114
- ? 'bg-[var(--lokvis-warning)]'
115
- : status === 'success'
116
- ? 'bg-[var(--lokvis-success)]'
117
- : status === 'failed'
118
- ? 'bg-[var(--lokvis-danger)]'
119
- : status === 'pending'
120
- ? 'bg-[var(--lokvis-fg-subtle)]'
121
- : 'bg-[var(--lokvis-fg-subtle)]';
122
-
123
- const animate = status === 'running' ? 'animate-pulse' : '';
124
-
125
- return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
126
- }
@@ -12,19 +12,12 @@
12
12
  import * as React from 'react';
13
13
  import { useWorkspaceStore } from '../store/index.js';
14
14
  import { useWorkflowProgress } from '../hooks/useWorkflowProgress.js';
15
+ import { formatBytes } from '../utils.js';
15
16
 
16
17
  export interface StatusBarProps {
17
18
  className?: string;
18
19
  }
19
20
 
20
- /** 格式化字节数为人类可读(KB/MB/GB) */
21
- function formatBytes(bytes: number): string {
22
- if (bytes < 1024) return `${bytes} B`;
23
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
24
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
26
- }
27
-
28
21
  /** useOnlineStatus - 监听 navigator.onLine + online/offline 事件(W9.6) */
29
22
  function useOnlineStatus(): boolean {
30
23
  const [online, setOnline] = React.useState<boolean>(() =>
@@ -68,7 +61,7 @@ export function StatusBar({ className = '' }: StatusBarProps) {
68
61
 
69
62
  return (
70
63
  <footer
71
- className={`flex h-7 shrink-0 items-center justify-between border-t border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-3 ${className}`}
64
+ className={`flex h-[var(--lokvis-statusbar-h)] shrink-0 items-center justify-between border-t border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-3 ${className}`}
72
65
  >
73
66
  {/* Left: Status — m5 加 aria-live,屏幕阅读器播报状态变化 */}
74
67
  <div
@@ -116,7 +109,7 @@ export function StatusBar({ className = '' }: StatusBarProps) {
116
109
  <button
117
110
  type="button"
118
111
  onClick={() => setError(null)}
119
- className="shrink-0 text-[10px] text-[var(--lokvis-danger)] underline decoration-red-300 hover:text-[var(--lokvis-danger)]"
112
+ className="shrink-0 text-[10px] text-[var(--lokvis-danger)] underline decoration-[var(--lokvis-danger)]/40 hover:text-[var(--lokvis-danger)]"
120
113
  >
121
114
  dismiss
122
115
  </button>
@@ -0,0 +1,27 @@
1
+ /**
2
+ * StatusDot - 节点状态指示圆点
3
+ *
4
+ * 根据 status 显示不同颜色,running 时带 pulse 动画。
5
+ * PipelineBar 与 WorkflowEditor 共享此组件。
6
+ */
7
+ export interface StatusDotProps {
8
+ /** 节点状态(pending/running/success/failed/idle) */
9
+ status: string;
10
+ }
11
+
12
+ export function StatusDot({ status }: StatusDotProps) {
13
+ const color =
14
+ status === 'running'
15
+ ? 'bg-[var(--lokvis-warning)]'
16
+ : status === 'success'
17
+ ? 'bg-[var(--lokvis-success)]'
18
+ : status === 'failed'
19
+ ? 'bg-[var(--lokvis-danger)]'
20
+ : status === 'pending'
21
+ ? 'bg-[var(--lokvis-fg-subtle)]'
22
+ : 'bg-[var(--lokvis-fg-subtle)]';
23
+
24
+ const animate = status === 'running' ? 'animate-pulse' : '';
25
+
26
+ return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
27
+ }
@@ -85,7 +85,7 @@ export function ThemeToggle({ className = '', showLabel = false }: ThemeTogglePr
85
85
  {menuOpen && (
86
86
  <div
87
87
  role="menu"
88
- className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] py-1 shadow-lg"
88
+ className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] py-1 shadow-[var(--lokvis-elevation-2)]"
89
89
  >
90
90
  {(['light', 'dark', null] as Array<ThemeMode | null>).map((mode) => {
91
91
  const isActive = theme === mode;