@vibe-x/dsh-html-workbench 0.1.1 → 0.2.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/lib/client.js CHANGED
@@ -6,11 +6,15 @@ window.__ModuleLoader__.load({
6
6
 
7
7
  // Closure symbols — the same names the dynamic runner injects.
8
8
  const React = require('react');
9
+ // Replace-on-insert (never bail when the id already exists): a stale
10
+ // stylesheet left over from a previous plugin generation would otherwise
11
+ // silently win and break the layout while the JS behaves correctly.
9
12
  const styles = {
10
13
  insert(css) {
11
14
  if (typeof document === 'undefined') return () => {};
12
15
  const id = 'html-workbench-dsh-plugin-styles';
13
- if (document.getElementById(id)) return () => {};
16
+ const prev = document.getElementById(id);
17
+ if (prev && prev.parentNode) prev.parentNode.removeChild(prev);
14
18
  const el = document.createElement('style');
15
19
  el.id = id;
16
20
  el.textContent = css;
@@ -21,8 +25,11 @@ window.__ModuleLoader__.load({
21
25
  const host = {
22
26
  call(method, args) {
23
27
  const a = args || {};
24
- let url = '/html-workbench/' + method;
25
- if (method === 'open' && a.file) url += '?file=' + encodeURIComponent(a.file);
28
+ const params = Object.keys(a)
29
+ .filter((k) => a[k] !== undefined && a[k] !== null)
30
+ .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(String(a[k])))
31
+ .join('&');
32
+ const url = '/html-workbench/' + method + (params ? '?' + params : '');
26
33
  return fetch(url).then((r) => r.json());
27
34
  },
28
35
  };
@@ -38,10 +45,17 @@ window.__ModuleLoader__.load({
38
45
  *
39
46
  * Responsibilities (runs in the browser):
40
47
  * - Register a right-side panel + corner trigger in `shell.overlay`.
41
- * - Preview takes the full panel height; the HTML-asset picker is a compact,
42
- * collapsible dock at the bottom (a <select> switcher + expandable list).
48
+ * - Layout is browser-like: a two-row chrome at the TOP (identity row +
49
+ * address/toolbar row) and the preview filling everything below. Nothing
50
+ * sits at the bottom, so the panel never visually competes with the chat
51
+ * composer on the left.
43
52
  * - Open a file by calling Host `open`, then embed the workbench URL in an
44
- * iframe. Left edge is draggable to resize the panel width.
53
+ * iframe. Left edge is draggable to resize the panel width (persisted).
54
+ *
55
+ * Styling contract: every colour/shadow comes from the host's `--dsw-alias-*`
56
+ * design tokens (see the DSH first-party plugins) so light/dark themes and
57
+ * future re-skins are inherited for free. No hard-coded palette, no magic
58
+ * offsets — all overlays are laid out with flex/grid.
45
59
  */
46
60
 
47
61
  return {
@@ -50,63 +64,213 @@ return {
50
64
  const slots = ctx.get('slots')
51
65
  if (slots === undefined) return
52
66
 
53
- styles.insert(`
54
- html #root { margin-right: var(--hwb-panel-width, 0px); transition: margin-right var(--ds-transition-duration-slow, 200ms) ease; }
67
+ // ── Styles ───────────────────────────────────────────────────────────────
68
+ // NOTE: both style shims (dynamic runner + static bundle) de-duplicate by a
69
+ // fixed element id and BAIL OUT when it already exists. During iterative
70
+ // development that silently keeps a STALE stylesheet alive — the symptom is
71
+ // "functionality works but the layout is broken" (e.g. an absolutely
72
+ // positioned chevron collapsing into a block below the input). So manage the
73
+ // <style> element here: drop every previous generation, then insert fresh.
74
+ const STYLE_MARK = 'data-hwb-styles'
75
+ const STYLE_VERSION = '2'
76
+
77
+ const CSS = `
78
+ html #root {
79
+ margin-right: calc(var(--dsh-sidebar-width, 0px) + var(--hwb-panel-width, 0px));
80
+ transition: margin-right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease);
81
+ }
55
82
  body[data-hwb-dragging] #root { transition: none; }
56
- body[data-hwb-dragging] { user-select: none; }
83
+ body[data-hwb-dragging] { user-select: none; cursor: col-resize; }
84
+
85
+ /* Reserve right-side clearance in the session header so the corner trigger
86
+ never overlaps its right-aligned utilities (e.g. "Session log"). The
87
+ clearance relaxes as the panel opens, because the trigger then hides. */
88
+ header:has([data-slot="conversation.session.header.utilities"]) {
89
+ padding-right: max(28px, calc(60px - var(--hwb-panel-width, 0px)));
90
+ transition: padding-right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease);
91
+ }
92
+
57
93
  .hwb-panel, .hwb-panel * { box-sizing: border-box; }
58
- .hwb-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 820px; max-width: calc(100vw - 24px); display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-left: 1px solid var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv2); z-index: 9999; font-family: var(--dsw-font-family, system-ui, sans-serif); font-size: 13px; line-height: 1.5; pointer-events: auto; }
59
- .hwb-resize { position: absolute; left: -4px; top: 0; bottom: 0; width: 9px; cursor: col-resize; z-index: 3; touch-action: none; }
60
- .hwb-resize::after { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: transparent; transition: background .15s; }
61
- .hwb-resize:hover::after, .hwb-resize:active::after { background: var(--dsw-alias-interactive-bg-hover-accent); }
62
- .hwb-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); flex: none; }
63
- .hwb-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); flex: none; }
64
- .hwb-dot.on { background: var(--dsw-alias-state-success-primary); }
65
- .hwb-titlewrap { min-width: 0; display: flex; flex-direction: column; }
66
- .hwb-title { font-weight: 600; font-size: 13px; line-height: 1.2; white-space: nowrap; }
67
- .hwb-sub { font-size: 11px; color: var(--dsw-alias-label-tertiary); line-height: 1.2; }
68
- .hwb-spacer { flex: 1; }
69
- .hwb-close { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
70
- .hwb-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
71
- .hwb-preview { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: #fff; }
72
- .hwb-frame { flex: 1; border: 0; width: 100%; }
73
- .hwb-hint { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); text-align: center; padding: 16px; }
74
- .hwb-error { padding: 10px 14px; color: var(--dsw-alias-state-error-primary); font-size: 12px; }
75
- .hwb-dock { flex: none; border-top: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; }
76
- .hwb-dockbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; }
77
- .hwb-select { flex: 1; min-width: 0; height: 28px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 0 8px; cursor: pointer; }
78
- .hwb-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); flex: none; white-space: nowrap; }
79
- .hwb-docktoggle { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
80
- .hwb-docktoggle:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
81
- .hwb-docklist { flex: none; max-height: 38vh; overflow-y: auto; border-top: 1px solid var(--dsw-alias-border-l2); }
82
- .hwb-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); cursor: pointer; }
83
- .hwb-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
84
- .hwb-item.active { background: var(--dsw-alias-interactive-bg-active); }
85
- .hwb-item-main { flex: 1; min-width: 0; }
86
- .hwb-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
87
- .hwb-item-path { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
88
- .hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
89
- .hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
90
- .hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
91
- .hwb-trigger { position: fixed; top: 10px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
92
- .hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
93
- `)
94
+ .hwb-panel {
95
+ position: fixed; top: 0; right: var(--dsh-sidebar-width, 0px); bottom: 0;
96
+ display: flex; flex-direction: column; min-width: 0;
97
+ background: var(--dsw-alias-bg-base);
98
+ color: var(--dsw-alias-label-primary);
99
+ border-left: 1px solid var(--dsw-alias-border-l1);
100
+ box-shadow: var(--dsw-shadow-lv2);
101
+ z-index: 9999; pointer-events: auto;
102
+ font-family: var(--dsw-font-family, ui-sans-serif, system-ui, sans-serif);
103
+ font-size: 13px; line-height: 1.5;
104
+ --hwb-mono: var(--dsh-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
105
+ }
106
+
107
+ /* Resize handle — a wide invisible hit area with a thin visible rail. */
108
+ .hwb-resize { position: absolute; left: -4px; top: 0; bottom: 0; width: 9px; z-index: 5; cursor: col-resize; touch-action: none; border: none; padding: 0; background: transparent; }
109
+ .hwb-resize::after { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: transparent; transition: background 150ms ease; }
110
+ .hwb-resize:hover::after, .hwb-resize:focus-visible::after, .hwb-resize[data-active]::after { background: var(--dsw-alias-interactive-bg-hover-accent); }
111
+ .hwb-resize:focus-visible { outline: none; }
112
+
113
+ /* ── Chrome: identity row + toolbar row ───────────────────────────────────── */
114
+ .hwb-chrome { flex: none; display: flex; flex-direction: column; background: var(--dsw-alias-bg-layer-1); border-bottom: 1px solid var(--dsw-alias-border-l2); }
115
+ .hwb-idrow { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 8px 0 14px; }
116
+ .hwb-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
117
+ .hwb-dot { position: relative; width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary)); box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 0%, transparent); }
118
+ .hwb-dot[data-on] { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent); }
119
+ .hwb-dot[data-off] { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); }
120
+ .hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
121
+ .hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
122
+ .hwb-filechip { display: flex; align-items: center; gap: 6px; min-width: 0; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-interactive-bg-hover)); color: var(--dsw-alias-label-secondary); font-size: 11.5px; }
123
+ .hwb-filechip > svg { flex: none; opacity: .7; }
124
+ .hwb-filechip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
125
+ .hwb-spacer { flex: 1 1 auto; min-width: 8px; }
126
+ .hwb-actions { display: flex; align-items: center; gap: 2px; flex: none; }
127
+
128
+ .hwb-icon { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; flex: none; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; transition: background 120ms ease, color 120ms ease; }
129
+ .hwb-icon:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
130
+ .hwb-icon:disabled { opacity: .4; cursor: default; }
131
+ .hwb-icon:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
132
+ .hwb-icon[data-spin] > svg { animation: hwb-spin 800ms linear infinite; }
133
+
134
+ .hwb-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 10px 10px; }
135
+
136
+ /* Combobox field: input + status + chevron composed with flex — no absolute
137
+ positioning, so nothing can escape the field box. */
138
+ .hwb-fieldwrap { position: relative; flex: 1 1 auto; min-width: 0; }
139
+ .hwb-field { display: flex; align-items: center; gap: 2px; height: 32px; padding: 0 2px 0 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; background: var(--dsw-alias-bg-base); transition: border-color 150ms ease, box-shadow 150ms ease; }
140
+ .hwb-field:hover { border-color: var(--dsw-alias-border-l1); }
141
+ .hwb-field[data-focus] { border-color: var(--dsw-alias-state-business-primary, #4d6bfe); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 16%, transparent); }
142
+ .hwb-input { flex: 1 1 auto; min-width: 0; height: 100%; border: none; outline: none; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; font-family: var(--hwb-mono); }
143
+ .hwb-input::placeholder { color: var(--dsw-alias-label-tertiary); font-family: var(--dsw-font-family, ui-sans-serif, system-ui, sans-serif); }
144
+ .hwb-state { display: grid; place-items: center; width: 20px; height: 20px; flex: none; }
145
+ .hwb-state[data-state="exists"] { color: var(--dsw-alias-state-success-primary); }
146
+ .hwb-state[data-state="missing"] { color: var(--dsw-alias-state-error-primary); }
147
+ .hwb-state[data-state="invalid"] { color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-warn-label)); }
148
+ .hwb-state[data-state="checking"] { color: var(--dsw-alias-label-tertiary); }
149
+ .hwb-state[data-state="checking"] > svg { animation: hwb-spin 800ms linear infinite; }
150
+ .hwb-caret { display: grid; place-items: center; width: 26px; height: 26px; flex: none; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; transition: background 120ms ease, color 120ms ease; }
151
+ .hwb-caret:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
152
+ .hwb-caret > svg { transition: transform 180ms ease; }
153
+ .hwb-caret[aria-expanded="true"] > svg { transform: rotate(180deg); }
154
+
155
+ .hwb-btn { flex: none; height: 32px; padding: 0 14px; border-radius: 9px; border: 1px solid transparent; font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; transition: background 120ms ease, opacity 120ms ease; }
156
+ .hwb-btn-primary { background: var(--dsw-alias-button-primary-fill, #4d6bfe); color: var(--dsw-alias-button-primary-label, #fff); }
157
+ .hwb-btn-primary:hover:not(:disabled) { opacity: .88; }
158
+ .hwb-btn:disabled { opacity: .45; cursor: default; }
159
+ .hwb-btn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 2px; }
160
+
161
+ /* Dropdown opens DOWNWARD (the field lives at the top of the panel). */
162
+ .hwb-menu { position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 30; display: flex; flex-direction: column; max-height: min(340px, 50vh); overflow: hidden; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); box-shadow: var(--dsw-shadow-lv2); animation: hwb-pop 140ms var(--ds-ease-in-out, ease); }
163
+ .hwb-menu-head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; }
164
+ .hwb-menu-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px; }
165
+ .hwb-menu-empty { padding: 22px 16px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; line-height: 1.7; }
166
+
167
+ .hwb-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 8px; border: none; border-radius: 8px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
168
+ .hwb-item:hover, .hwb-item:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
169
+ .hwb-item[data-active] { background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover)); }
170
+ .hwb-item > svg { flex: none; color: var(--dsw-alias-label-tertiary); }
171
+ .hwb-item-main { flex: 1 1 auto; min-width: 0; display: block; }
172
+ .hwb-item-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
173
+ .hwb-item-path { display: block; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 11px; font-family: var(--hwb-mono); }
174
+ .hwb-tag { flex: none; padding: 1px 6px; border-radius: 5px; font-size: 10px; font-weight: 600; }
175
+ .hwb-tag[data-kind="create"] { background: var(--dsw-alias-state-success-tertiary, color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)); color: var(--dsw-alias-state-success-primary); }
176
+ .hwb-tag[data-kind="edit"] { background: var(--dsw-alias-state-warn-tertiary, color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b7791f) 14%, transparent)); color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warning-primary, #b7791f)); }
177
+
178
+ /* ── Banner ───────────────────────────────────────────────────────────────── */
179
+ .hwb-banner { flex: none; display: flex; align-items: flex-start; gap: 8px; padding: 9px 10px 9px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); color: var(--dsw-alias-state-error-primary); font-size: 12px; }
180
+ .hwb-banner > svg { flex: none; margin-top: 1px; }
181
+ .hwb-banner-text { flex: 1 1 auto; min-width: 0; word-break: break-word; }
182
+
183
+ /* ── Body ─────────────────────────────────────────────────────────────────── */
184
+ .hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
185
+ .hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
186
+
187
+ .hwb-center { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 24px; overflow-y: auto; text-align: center; }
188
+ .hwb-blank { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-tertiary); }
189
+ .hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
190
+ .hwb-blank-desc { max-width: 320px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.7; }
191
+ .hwb-recent { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 4px; margin-top: 2px; text-align: left; }
192
+ .hwb-recent-label { padding: 0 8px 2px; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
193
+ .hwb-spinner { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l1); border-top-color: var(--dsw-alias-state-business-primary, #4d6bfe); animation: hwb-spin 700ms linear infinite; }
94
194
 
195
+ /* ── Corner trigger ───────────────────────────────────────────────────────── */
196
+ .hwb-trigger {
197
+ position: fixed; top: 8px;
198
+ right: calc(var(--dsh-sidebar-width, 0px) + var(--hwb-panel-width, 0px) + 12px);
199
+ z-index: 10000; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
200
+ border: none; border-radius: 8px; background: transparent;
201
+ color: var(--dsw-alias-label-secondary); cursor: pointer; pointer-events: auto;
202
+ transition: right var(--ds-transition-duration-slow, 200ms) var(--ds-ease-in-out, ease), background 120ms ease, color 120ms ease;
203
+ }
204
+ .hwb-trigger:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
205
+ .hwb-trigger:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
206
+
207
+ @keyframes hwb-spin { to { transform: rotate(360deg); } }
208
+ @keyframes hwb-pop { from { opacity: 0; transform: translateY(-4px); } }
209
+ @media (prefers-reduced-motion: reduce) {
210
+ html #root, .hwb-trigger, header:has([data-slot="conversation.session.header.utilities"]) { transition: none; }
211
+ .hwb-menu { animation: none; }
212
+ }
213
+ `
214
+
215
+ const applyStyles = () => {
216
+ if (typeof document === 'undefined') { try { styles.insert(CSS) } catch (e) {} return () => {} }
217
+ const stale = document.querySelectorAll('style[' + STYLE_MARK + '], style#html-workbench-dsh-plugin-styles')
218
+ for (let i = 0; i < stale.length; i += 1) {
219
+ const node = stale[i]
220
+ if (node.parentNode) node.parentNode.removeChild(node)
221
+ }
222
+ const el = document.createElement('style')
223
+ el.setAttribute(STYLE_MARK, STYLE_VERSION)
224
+ el.textContent = CSS
225
+ document.head.appendChild(el)
226
+ return () => { if (el.parentNode) el.parentNode.removeChild(el) }
227
+ }
228
+
229
+ if (typeof ctx.effect === 'function') ctx.effect(applyStyles, 'html-workbench: styles')
230
+ else applyStyles()
231
+
232
+ // ── Store ────────────────────────────────────────────────────────────────
95
233
  const DEFAULT_WIDTH = 820
96
- const MIN_WIDTH = 480
234
+ const MIN_WIDTH = 460
235
+ const WIDTH_KEY = 'hwb:panel-width'
236
+
237
+ const readWidth = () => {
238
+ try {
239
+ const raw = window.localStorage.getItem(WIDTH_KEY)
240
+ const n = raw == null ? NaN : parseInt(raw, 10)
241
+ return Number.isFinite(n) && n >= MIN_WIDTH ? n : DEFAULT_WIDTH
242
+ } catch (e) { return DEFAULT_WIDTH }
243
+ }
244
+ const writeWidth = (w) => { try { window.localStorage.setItem(WIDTH_KEY, String(w)) } catch (e) {} }
245
+
246
+ let resolveTimer = null
97
247
 
98
248
  const store = {
99
249
  open: false,
100
- panelWidth: DEFAULT_WIDTH,
101
- expanded: false,
250
+ panelWidth: readWidth(),
102
251
  assets: [],
103
252
  running: false,
104
253
  current: null,
254
+ nonce: 0,
105
255
  loading: false,
256
+ refreshing: false,
106
257
  error: null,
258
+ pathInput: '',
259
+ resolveState: 'idle',
107
260
  listeners: [],
108
261
  subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
109
- set(p) { Object.assign(this, p); this.listeners.forEach((fn) => { try { fn() } catch (e) {} }) },
262
+ set(patch) {
263
+ // Only notify when something actually changed: the panel polls `list`
264
+ // every few seconds, and blindly re-rendering on every tick makes the
265
+ // input feel laggy and needlessly churns the preview subtree.
266
+ let dirty = false
267
+ const keys = Object.keys(patch)
268
+ for (let i = 0; i < keys.length; i += 1) {
269
+ const k = keys[i]
270
+ if (this[k] !== patch[k]) { this[k] = patch[k]; dirty = true }
271
+ }
272
+ if (dirty) this.listeners.forEach((fn) => { try { fn() } catch (e) {} })
273
+ },
110
274
  }
111
275
 
112
276
  const useStore = () => {
@@ -116,117 +280,364 @@ body[data-hwb-dragging] { user-select: none; }
116
280
  }
117
281
 
118
282
  const basename = (p) => {
119
- const parts = String(p).split('/')
120
- return parts[parts.length - 1] || p
283
+ const parts = String(p || '').split('/')
284
+ return parts[parts.length - 1] || String(p || '')
285
+ }
286
+
287
+ const dirname = (p) => {
288
+ const s = String(p || '')
289
+ const i = s.lastIndexOf('/')
290
+ return i <= 0 ? '/' : s.slice(0, i)
291
+ }
292
+
293
+ // Paths are long and their INFORMATIVE half is the tail, so clip the head
294
+ // (CSS ellipsis would eat the tail). The CSS ellipsis stays as a backstop
295
+ // for narrow panels.
296
+ const clipHead = (s, max) => {
297
+ const str = String(s || '')
298
+ return str.length <= max ? str : '…' + str.slice(str.length - max + 1)
299
+ }
300
+
301
+ const sameAssets = (a, b) => {
302
+ if (a === b) return true
303
+ if (!a || !b || a.length !== b.length) return false
304
+ for (let i = 0; i < a.length; i += 1) {
305
+ if (a[i].path !== b[i].path || a[i].kind !== b[i].kind || a[i].seq !== b[i].seq) return false
306
+ }
307
+ return true
121
308
  }
122
309
 
123
- const refresh = () => {
124
- host.call('list').then((res) => {
125
- if (res && res.ok) store.set({ assets: res.assets || [], running: !!res.running })
126
- else store.set({ error: (res && res.error) || 'list failed' })
310
+ const refresh = (explicit) => {
311
+ if (explicit) store.set({ refreshing: true })
312
+ return host.call('list').then((res) => {
313
+ if (res && res.ok) {
314
+ const next = res.assets || []
315
+ store.set({
316
+ assets: sameAssets(store.assets, next) ? store.assets : next,
317
+ running: !!res.running,
318
+ })
319
+ } else {
320
+ store.set({ error: (res && res.error) || 'list failed' })
321
+ }
127
322
  }).catch((e) => store.set({ error: String(e && e.message ? e.message : e) }))
323
+ .then(() => { if (explicit) store.set({ refreshing: false }) })
128
324
  }
129
325
 
130
326
  const openFile = (path) => {
131
- store.set({ loading: true, current: { path: path, url: null }, error: null })
132
- host.call('open', { file: path }).then((res) => {
133
- if (res && res.ok) store.set({ loading: false, current: { path: path, url: res.url }, running: true })
134
- else store.set({ loading: false, error: (res && res.error) || 'open failed' })
327
+ const file = String(path || '').trim()
328
+ if (!file) return
329
+ store.set({ loading: true, error: null, pathInput: file })
330
+ host.call('open', { file: file }).then((res) => {
331
+ if (res && res.ok) {
332
+ // Bump the nonce so re-opening the SAME file remounts the iframe:
333
+ // with an unchanged `src` the browser would otherwise keep the old
334
+ // document and "打开" would look like a no-op.
335
+ store.set({
336
+ loading: false,
337
+ running: true,
338
+ resolveState: 'exists',
339
+ current: { path: file, url: res.url },
340
+ nonce: store.nonce + 1,
341
+ })
342
+ } else {
343
+ store.set({ loading: false, error: (res && res.error) || 'open failed' })
344
+ }
135
345
  }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
136
346
  }
137
347
 
348
+ const STATE_TITLE = {
349
+ exists: '文件存在',
350
+ missing: '未找到该文件',
351
+ invalid: '仅支持 .html / .htm 文件',
352
+ checking: '检测中…',
353
+ }
354
+
355
+ const checkPath = (value) => {
356
+ const trimmed = (value || '').trim()
357
+ if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
358
+ if (!trimmed) { store.set({ resolveState: 'idle' }); return }
359
+ store.set({ resolveState: 'checking' })
360
+ resolveTimer = setTimeout(() => {
361
+ resolveTimer = null
362
+ host.call('resolve', { file: trimmed }).then((res) => {
363
+ if (!res || res.ok === false) { store.set({ resolveState: 'idle' }); return }
364
+ if (!res.isHtml) store.set({ resolveState: 'invalid' })
365
+ else if (res.exists === true) store.set({ resolveState: 'exists' })
366
+ else if (res.exists === false) store.set({ resolveState: 'missing' })
367
+ else store.set({ resolveState: 'idle' })
368
+ }).catch(() => store.set({ resolveState: 'idle' }))
369
+ }, 300)
370
+ }
371
+
372
+ // ── Icons ────────────────────────────────────────────────────────────────
373
+ const icon = (size, children, extra) => {
374
+ const props = {
375
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
376
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round',
377
+ strokeLinejoin: 'round', 'aria-hidden': true,
378
+ }
379
+ if (extra) Object.assign(props, extra)
380
+ return React.createElement('svg', props, children)
381
+ }
382
+ const path = (d, key) => React.createElement('path', { d: d, key: key })
383
+
384
+ const I_CLOSE = icon(15, [path('M18 6 6 18', 'a'), path('m6 6 12 12', 'b')])
385
+ const I_REFRESH = icon(15, [path('M21 12a9 9 0 1 1-3.2-6.9', 'a'), path('M21 4v5h-5', 'b')])
386
+ const I_EXTERNAL = icon(15, [path('M15 3h6v6', 'a'), path('M10 14 21 3', 'b'), path('M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5', 'c')])
387
+ const I_CARET = icon(14, [path('m6 9 6 6 6-6', 'a')])
388
+ const I_CHECK = icon(14, [path('M20 6 9 17l-5-5', 'a')], { strokeWidth: 2.2 })
389
+ const I_MISSING = icon(14, [path('M18 6 6 18', 'a'), path('m6 6 12 12', 'b')], { strokeWidth: 2.2 })
390
+ const I_WARN = icon(14, [path('M12 9v4', 'a'), path('M12 17h.01', 'b'), path('M10.3 3.9 2.4 17.5A1.8 1.8 0 0 0 4 20.2h16a1.8 1.8 0 0 0 1.6-2.7L13.7 3.9a1.8 1.8 0 0 0-3.4 0Z', 'c')])
391
+ const I_LOADING = icon(14, [React.createElement('circle', { cx: 12, cy: 12, r: 9, strokeOpacity: .25, key: 'a' }), path('M21 12a9 9 0 0 0-9-9', 'b')], { strokeWidth: 2.2 })
392
+ const I_FILE = icon(14, [path('M14 3v5h5', 'a'), path('M15 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z', 'b')])
393
+ const I_ALERT = icon(14, [React.createElement('circle', { cx: 12, cy: 12, r: 9, key: 'a' }), path('M12 8v4', 'b'), path('M12 16h.01', 'c')])
394
+ const I_BLANK = icon(22, [path('M14 3v5h5', 'a'), path('M15 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z', 'b'), path('m9 15 1.8-2.2L12.4 15l1.4-1.8', 'c')])
395
+ const I_TRIGGER = icon(17, [
396
+ React.createElement('rect', { x: 2.5, y: 4, width: 19, height: 14, rx: 2.4, key: 'a' }),
397
+ path('M2.5 8.4h19', 'b'),
398
+ path('M8 21h8', 'c'),
399
+ path('M12 18v3', 'd'),
400
+ ])
401
+
402
+ const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
403
+
404
+ // ── Resize ───────────────────────────────────────────────────────────────
138
405
  const startResize = (e) => {
406
+ if (e.button !== undefined && e.button !== 0) return
139
407
  e.preventDefault()
408
+ const handle = e.currentTarget
409
+ handle.setAttribute('data-active', '')
140
410
  document.body.setAttribute('data-hwb-dragging', '')
141
- const maxW = Math.max(MIN_WIDTH, window.innerWidth - 60)
411
+ const maxW = () => Math.max(MIN_WIDTH, window.innerWidth - 80)
142
412
  const onMove = (ev) => {
143
- const w = window.innerWidth - ev.clientX
144
- store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW)) })
413
+ const w = Math.round(window.innerWidth - ev.clientX)
414
+ store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW())) })
145
415
  }
146
416
  const onUp = () => {
417
+ handle.removeAttribute('data-active')
147
418
  document.body.removeAttribute('data-hwb-dragging')
148
419
  document.removeEventListener('mousemove', onMove)
149
420
  document.removeEventListener('mouseup', onUp)
421
+ writeWidth(store.panelWidth)
150
422
  }
151
423
  document.addEventListener('mousemove', onMove)
152
424
  document.addEventListener('mouseup', onUp)
153
425
  }
154
426
 
155
- const chevronUp = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
156
- React.createElement('path', { d: 'M18 15l-6-6-6 6' }))
157
- const chevronDown = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
158
- React.createElement('path', { d: 'M6 9l6 6 6-6' }))
427
+ const nudgeWidth = (delta) => {
428
+ const maxW = Math.max(MIN_WIDTH, window.innerWidth - 80)
429
+ const w = Math.max(MIN_WIDTH, Math.min(store.panelWidth + delta, maxW))
430
+ store.set({ panelWidth: w })
431
+ writeWidth(w)
432
+ }
159
433
 
434
+ // ── Panel ────────────────────────────────────────────────────────────────
160
435
  const Panel = () => {
161
436
  const s = useStore()
437
+ const [menuOpen, setMenuOpen] = React.useState(false)
438
+ const [focused, setFocused] = React.useState(false)
439
+ const fieldRef = React.useRef(null)
440
+ const inputRef = React.useRef(null)
441
+
162
442
  React.useEffect(() => {
163
443
  const root = document.documentElement
164
- root.style.setProperty('--hwb-panel-width', s.open ? (s.panelWidth + 'px') : '0px')
444
+ root.style.setProperty('--hwb-panel-width', s.open ? s.panelWidth + 'px' : '0px')
165
445
  return () => { root.style.setProperty('--hwb-panel-width', '0px') }
166
446
  }, [s.open, s.panelWidth])
447
+
167
448
  React.useEffect(() => {
168
- if (!s.open) return
449
+ if (!s.open) return undefined
169
450
  refresh()
170
- const dispose = ctx.interval(refresh, 3000)
451
+ const dispose = ctx.interval(refresh, 4000)
171
452
  return () => { if (dispose) dispose() }
172
453
  }, [s.open])
454
+
455
+ // Drop any in-flight path check when the panel closes.
456
+ React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
457
+
458
+ React.useEffect(() => {
459
+ if (!menuOpen) return undefined
460
+ const onDown = (e) => { if (fieldRef.current && !fieldRef.current.contains(e.target)) setMenuOpen(false) }
461
+ const onKey = (e) => { if (e.key === 'Escape') { setMenuOpen(false); if (inputRef.current) inputRef.current.focus() } }
462
+ document.addEventListener('mousedown', onDown)
463
+ document.addEventListener('keydown', onKey)
464
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
465
+ }, [menuOpen])
466
+
173
467
  if (!s.open) return null
174
468
 
175
469
  const assets = s.assets || []
176
- const currentPath = s.current && s.current.path ? s.current.path : ''
177
- const opts = assets.map((a) => React.createElement('option', { key: a.id || a.path, value: a.path }, basename(a.path)))
178
- const items = assets.map((a) => {
179
- const active = currentPath === a.path
180
- return React.createElement('div', { key: a.id || a.path, className: 'hwb-item' + (active ? ' active' : '') },
181
- React.createElement('div', { className: 'hwb-item-main' },
182
- React.createElement('div', { className: 'hwb-item-name' }, basename(a.path)),
183
- React.createElement('div', { className: 'hwb-item-path', title: a.path }, a.path),
184
- ),
185
- React.createElement('button', { type: 'button', className: 'hwb-item-btn', onClick: () => openFile(a.path) }, '打开'),
470
+ const trimmed = (s.pathInput || '').trim()
471
+ const currentPath = s.current ? s.current.path : null
472
+ const submit = () => { if (trimmed) { setMenuOpen(false); openFile(trimmed) } }
473
+ const pick = (p) => { setMenuOpen(false); store.set({ pathInput: p }); openFile(p) }
474
+
475
+ const assetRow = (a, inMenu) => React.createElement('button', {
476
+ key: a.id || a.path,
477
+ type: 'button',
478
+ className: 'hwb-item',
479
+ 'data-active': a.path === currentPath ? '' : undefined,
480
+ title: a.path,
481
+ onClick: () => pick(a.path),
482
+ },
483
+ I_FILE,
484
+ React.createElement('span', { className: 'hwb-item-main' },
485
+ React.createElement('span', { className: 'hwb-item-name' }, basename(a.path)),
486
+ React.createElement('span', { className: 'hwb-item-path' }, clipHead(dirname(a.path), 52)),
487
+ ),
488
+ inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
489
+ )
490
+
491
+ const body = s.loading
492
+ ? React.createElement('div', { className: 'hwb-center' },
493
+ React.createElement('div', { className: 'hwb-spinner' }),
494
+ React.createElement('div', { className: 'hwb-blank-desc' }, '正在启动 Workbench 服务…'),
186
495
  )
187
- })
188
-
189
- return React.createElement('div', { className: 'hwb-panel', style: { width: s.panelWidth + 'px' }, role: 'dialog', 'aria-label': 'HTML Workbench' },
190
- React.createElement('div', { className: 'hwb-resize', title: '拖动调整宽度', onMouseDown: startResize }),
191
- React.createElement('div', { className: 'hwb-head' },
192
- React.createElement('span', { className: 'hwb-dot' + (s.running ? ' on' : '') }),
193
- React.createElement('div', { className: 'hwb-titlewrap' },
194
- React.createElement('div', { className: 'hwb-title' }, 'HTML Workbench'),
195
- React.createElement('div', { className: 'hwb-sub' }, s.running ? '服务运行中 · 拖动左边缘调整宽度' : '服务未就绪'),
196
- ),
197
- React.createElement('span', { className: 'hwb-spacer' }),
198
- React.createElement('button', { type: 'button', className: 'hwb-close', title: '关闭', onClick: () => store.set({ open: false }) },
199
- React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' },
200
- React.createElement('path', { d: 'M18 6L6 18' }),
201
- React.createElement('path', { d: 'M6 6l12 12' }),
496
+ : s.current && s.current.url
497
+ ? React.createElement('iframe', {
498
+ key: 'frame-' + s.nonce,
499
+ className: 'hwb-frame',
500
+ src: s.current.url,
501
+ title: 'HTML Workbench — ' + basename(s.current.path),
502
+ })
503
+ : React.createElement('div', { className: 'hwb-center' },
504
+ React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
505
+ React.createElement('div', null,
506
+ React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
507
+ React.createElement('div', { className: 'hwb-blank-desc' }, assets.length
508
+ ? '在上方地址栏填入 .html 的绝对路径,或从下面的产物里挑一个开始可视化编辑。'
509
+ : '在上方地址栏填入 .html 的绝对路径开始可视化编辑。代理 write / edit 过的 .html 之后也会自动出现在这里。'),
202
510
  ),
203
- ),
204
- ),
205
- React.createElement('div', { className: 'hwb-preview' },
206
- s.loading ? React.createElement('div', { className: 'hwb-hint' }, '正在启动 Workbench…')
207
- : s.current && s.current.url ? React.createElement('iframe', { className: 'hwb-frame', src: s.current.url, title: 'HTML Workbench' })
208
- : React.createElement('div', { className: 'hwb-hint' }, '从下方选择一个 HTML 文件开始编辑与预览'),
209
- ),
210
- React.createElement('div', { className: 'hwb-dock' },
211
- React.createElement('div', { className: 'hwb-dockbar' },
212
- React.createElement('select', {
213
- className: 'hwb-select',
214
- value: currentPath,
215
- onChange: (e) => { const v = e.target.value; if (v) openFile(v) },
216
- },
217
- React.createElement('option', { value: '', disabled: true }, assets.length ? '选择 HTML 文件…' : '暂无 HTML 产物'),
218
- opts,
511
+ assets.length
512
+ ? React.createElement('div', { className: 'hwb-recent' },
513
+ React.createElement('div', { className: 'hwb-recent-label' }, '最近的 HTML 产物'),
514
+ assets.slice(0, 6).map((a) => assetRow(a, false)),
515
+ )
516
+ : null,
517
+ )
518
+
519
+ return React.createElement('div', {
520
+ className: 'hwb-panel',
521
+ style: { width: s.panelWidth + 'px', maxWidth: 'calc(100vw - 24px)' },
522
+ role: 'complementary',
523
+ 'aria-label': 'HTML Workbench',
524
+ },
525
+ React.createElement('div', {
526
+ className: 'hwb-resize',
527
+ role: 'separator',
528
+ 'aria-orientation': 'vertical',
529
+ 'aria-label': '调整面板宽度',
530
+ tabIndex: 0,
531
+ title: '拖动调整宽度(双击复位)',
532
+ onMouseDown: startResize,
533
+ onDoubleClick: () => { store.set({ panelWidth: DEFAULT_WIDTH }); writeWidth(DEFAULT_WIDTH) },
534
+ onKeyDown: (e) => {
535
+ if (e.key === 'ArrowLeft') { e.preventDefault(); nudgeWidth(32) }
536
+ else if (e.key === 'ArrowRight') { e.preventDefault(); nudgeWidth(-32) }
537
+ },
538
+ }),
539
+
540
+ React.createElement('div', { className: 'hwb-chrome' },
541
+ React.createElement('div', { className: 'hwb-idrow' },
542
+ React.createElement('div', { className: 'hwb-brand' },
543
+ React.createElement('span', {
544
+ className: 'hwb-dot',
545
+ 'data-on': s.running ? '' : undefined,
546
+ 'data-off': !s.running ? '' : undefined,
547
+ title: s.running ? '本地服务运行中' : '本地服务未就绪',
548
+ }),
549
+ React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
219
550
  ),
220
- React.createElement('span', { className: 'hwb-count' }, assets.length + ' 个'),
221
- React.createElement('button', { type: 'button', className: 'hwb-docktoggle', title: s.expanded ? '收起列表' : '展开列表', onClick: () => store.set({ expanded: !s.expanded }) },
222
- s.expanded ? chevronDown : chevronUp,
551
+ currentPath ? React.createElement('span', { className: 'hwb-sep' }) : null,
552
+ currentPath
553
+ ? React.createElement('span', { className: 'hwb-filechip', title: currentPath }, I_FILE, React.createElement('span', null, basename(currentPath)))
554
+ : null,
555
+ React.createElement('span', { className: 'hwb-spacer' }),
556
+ React.createElement('div', { className: 'hwb-actions' },
557
+ React.createElement('button', {
558
+ type: 'button', className: 'hwb-icon', title: '刷新产物列表',
559
+ 'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
560
+ onClick: () => refresh(true),
561
+ }, I_REFRESH),
562
+ React.createElement('button', {
563
+ type: 'button', className: 'hwb-icon', title: '在浏览器标签页中打开',
564
+ 'aria-label': '在浏览器标签页中打开', disabled: !(s.current && s.current.url),
565
+ onClick: () => { if (s.current && s.current.url) window.open(s.current.url, '_blank', 'noopener') },
566
+ }, I_EXTERNAL),
567
+ React.createElement('button', {
568
+ type: 'button', className: 'hwb-icon', title: '关闭面板',
569
+ 'aria-label': '关闭面板', onClick: () => store.set({ open: false }),
570
+ }, I_CLOSE),
223
571
  ),
224
572
  ),
225
- s.expanded ? React.createElement('div', { className: 'hwb-docklist' },
226
- items.length ? items : React.createElement('div', { className: 'hwb-dockempty' }, '暂无 HTML 产物 — 代理 write/edit 的 .html 文件会出现在这里。'),
227
- s.error ? React.createElement('div', { className: 'hwb-error' }, s.error) : null,
228
- ) : null,
573
+
574
+ React.createElement('div', { className: 'hwb-toolbar' },
575
+ React.createElement('div', { className: 'hwb-fieldwrap', ref: fieldRef },
576
+ React.createElement('div', { className: 'hwb-field', 'data-focus': focused ? '' : undefined },
577
+ React.createElement('input', {
578
+ ref: inputRef,
579
+ className: 'hwb-input',
580
+ type: 'text',
581
+ placeholder: '/absolute/path/page.html',
582
+ value: s.pathInput,
583
+ spellCheck: false,
584
+ autoComplete: 'off',
585
+ 'aria-label': 'HTML 文件绝对路径',
586
+ onChange: (e) => { const v = e.target.value; store.set({ pathInput: v }); checkPath(v) },
587
+ onKeyDown: (e) => {
588
+ if (e.key === 'Enter') submit()
589
+ else if (e.key === 'ArrowDown' && assets.length) { e.preventDefault(); setMenuOpen(true) }
590
+ },
591
+ onFocus: () => { setFocused(true); if (assets.length && !trimmed) setMenuOpen(true) },
592
+ onBlur: () => setFocused(false),
593
+ }),
594
+ s.resolveState !== 'idle'
595
+ ? React.createElement('span', {
596
+ className: 'hwb-state',
597
+ 'data-state': s.resolveState,
598
+ title: STATE_TITLE[s.resolveState] || '',
599
+ }, STATE_ICON[s.resolveState] || null)
600
+ : null,
601
+ React.createElement('button', {
602
+ type: 'button',
603
+ className: 'hwb-caret',
604
+ 'aria-expanded': menuOpen,
605
+ 'aria-haspopup': 'listbox',
606
+ title: '历史 HTML 产物' + (assets.length ? '(' + assets.length + ')' : ''),
607
+ onClick: () => setMenuOpen((v) => !v),
608
+ }, I_CARET),
609
+ ),
610
+ menuOpen
611
+ ? React.createElement('div', { className: 'hwb-menu', role: 'listbox' },
612
+ React.createElement('div', { className: 'hwb-menu-head' },
613
+ React.createElement('span', null, 'HTML 产物'),
614
+ React.createElement('span', null, assets.length ? assets.length + ' 个' : '空'),
615
+ ),
616
+ assets.length
617
+ ? React.createElement('div', { className: 'hwb-menu-body' }, assets.map((a) => assetRow(a, true)))
618
+ : React.createElement('div', { className: 'hwb-menu-empty' }, '暂无产物', React.createElement('br'), '代理 write / edit 过的 .html 会出现在这里'),
619
+ )
620
+ : null,
621
+ ),
622
+ React.createElement('button', {
623
+ type: 'button', className: 'hwb-btn hwb-btn-primary',
624
+ disabled: !trimmed || s.loading, onClick: submit,
625
+ }, '打开'),
626
+ ),
229
627
  ),
628
+
629
+ s.error
630
+ ? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
631
+ I_ALERT,
632
+ React.createElement('span', { className: 'hwb-banner-text' }, s.error),
633
+ React.createElement('button', {
634
+ type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
635
+ onClick: () => store.set({ error: null }),
636
+ }, I_CLOSE),
637
+ )
638
+ : null,
639
+
640
+ React.createElement('div', { className: 'hwb-body' }, body),
230
641
  )
231
642
  }
232
643
 
@@ -237,13 +648,10 @@ body[data-hwb-dragging] { user-select: none; }
237
648
  type: 'button',
238
649
  className: 'hwb-trigger',
239
650
  title: 'HTML Workbench',
240
- 'aria-expanded': s.open,
651
+ 'aria-label': '打开 HTML Workbench',
652
+ 'aria-expanded': false,
241
653
  onClick: () => { store.set({ open: true }); refresh() },
242
- }, React.createElement('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
243
- React.createElement('rect', { x: 2, y: 4, width: 20, height: 14, rx: 2 }),
244
- React.createElement('path', { d: 'M8 21h8' }),
245
- React.createElement('path', { d: 'M12 18v3' }),
246
- ))
654
+ }, I_TRIGGER)
247
655
  }
248
656
 
249
657
  slots.inject('shell.overlay', () => slots.register(