dsh-select-quote 0.3.0 → 0.3.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 (3) hide show
  1. package/README.md +67 -55
  2. package/lib/client.js +489 -486
  3. package/package.json +6 -2
package/lib/client.js CHANGED
@@ -51,60 +51,122 @@ function lastEditable() {
51
51
  }
52
52
  //#endregion
53
53
  //#region src/client/styles.ts
54
+ /**
55
+ * Every style the plugin ships, in four layers.
56
+ *
57
+ * 1. tokens — product design tokens, aliased once with their fallbacks
58
+ * 2. atoms — shared shapes (`.dsq_a_*`), no numbers, no colors
59
+ * 3. components — one block per UI piece; everything measurable lives here
60
+ * 4. host — rules that reach into product DOM, theme flips, keyframes
61
+ *
62
+ * Injection order is the cascade order, so a component rule always wins over an
63
+ * atom on the same element — which is what lets atoms stay shape-only and the
64
+ * component blocks stay the single source of numbers.
65
+ */
66
+ /** Layer 2 — atoms. Shape only: adding a number here is a layering violation. */
67
+ const ATOMS = {
68
+ row: "display: flex; align-items: center;",
69
+ inlineRow: "display: inline-flex; align-items: center;",
70
+ col: "display: flex; flex-direction: column;",
71
+ colEnd: "display: flex; flex-direction: column; align-items: flex-end;",
72
+ gridMid: "display: grid; place-items: center;",
73
+ ell: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
74
+ plain: "appearance: none; border: none; background: transparent; cursor: pointer;",
75
+ keep: "flex: none;",
76
+ grow: "flex: 1; min-width: 0;",
77
+ box: "box-sizing: border-box;"
78
+ };
79
+ const toKebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
80
+ const atomRules = Object.entries(ATOMS).map(([name, declarations]) => `.dsq_a_${toKebab(name)} { ${declarations} }`).join("\n");
81
+ /** Atom class names, for `cx(styles.card, A.row)`. */
82
+ const A = Object.fromEntries(Object.keys(ATOMS).map((name) => [name, `dsq_a_${toKebab(name)}`]));
83
+ /** Join class parts, dropping the falsy ones so a variant can be `cond && A.x`. */
84
+ function cx(...parts) {
85
+ return parts.filter(Boolean).join(" ");
86
+ }
54
87
  const CSS = `
88
+ /* Layer 1 — tokens. Every product token this plugin reads is aliased here once,
89
+ with the fallback it used to repeat inline at each use site. Scoped to \`body\`,
90
+ not \`:root\`: the product flips its palette on body[data-ds-dark-theme], so an
91
+ alias computed on the root element would freeze the light values and inherit
92
+ them into the dark page. */
93
+ body {
94
+ --dsq-chat-width: var(--dsh-chat-content-width, 748px);
95
+ --dsq-font-size: var(--dsh-content-font-size, 14px);
96
+ --dsq-font-delta: var(--dsh-content-font-delta, 0px);
97
+ --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
98
+ --dsq-bg-panel: var(--dsw-alias-bg-layer-2, #fff);
99
+ --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
100
+ --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
101
+ --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
102
+ --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
103
+ --dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
104
+ --dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
105
+ --dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
106
+ --dsq-label-2: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
107
+ --dsq-label-3: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
108
+ --dsq-link: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
109
+ --dsq-shadow-pop: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
110
+ --dsq-shadow-soft: var(--dsw-elevation-soft, 0 1px 2px rgba(0, 0, 0, 0.03), 0 6px 20px rgba(0, 0, 0, 0.06));
111
+ --dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
112
+ --dsq-menu: var(--dsw-specific-menu, #fff);
113
+ --dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
114
+ --dsq-accent: var(--dsw-static-blue-500, #3b82f6);
115
+ --dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
116
+ --dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
117
+ --dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
118
+ --dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
119
+ --dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
120
+ --dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
121
+ --dsq-neutral-850: var(--dsw-static-neutral-850, #212123);
122
+ }
123
+
124
+ /* Layer 2 — atoms (generated from ATOMS above; the class names and the rules
125
+ cannot drift apart because both come from the same table). */
126
+ ${atomRules}
127
+
128
+ /* Layer 3 — components. Floating selection toolbar. */
55
129
  .dsq_toolbar {
56
130
  position: fixed;
57
131
  z-index: 10060;
58
132
  transform: translate(-50%, -100%);
59
- display: flex;
60
- align-items: center;
61
133
  gap: 2px;
62
134
  padding: 4px;
63
- /* 无边框:--dsw-elevation-soft 自带 0 0 0 .5px 的描边环,由它勾勒轮廓,
135
+ /* 无边框:--dsq-shadow-soft 自带 0 0 0 .5px 的描边环,由它勾勒轮廓,
64
136
  所以这里不要再画 border,否则描边会和环叠成两条线。 */
65
137
  border: 0;
66
- border-radius: 999px;
67
- background: var(--dsw-alias-bg-layer-2, #fff);
68
- box-shadow: var(--dsw-elevation-soft, 0 1px 2px rgba(0, 0, 0, 0.03), 0 6px 20px rgba(0, 0, 0, 0.06));
138
+ border-radius: 12px;
139
+ background: var(--dsq-bg-panel);
140
+ box-shadow: var(--dsq-shadow-soft);
69
141
  pointer-events: auto;
70
142
  /* Mounts once per selection, after the drag ends: soft rise + fade. */
71
143
  animation: dsq-toolbar-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
72
144
  }
73
145
 
74
- @keyframes dsq-toolbar-in {
75
- from {
76
- opacity: 0;
77
- transform: translate(-50%, -100%) translateY(4px);
78
- }
79
- to {
80
- opacity: 1;
81
- transform: translate(-50%, -100%);
82
- }
83
- }
84
-
85
146
  .dsq_button {
86
- appearance: none;
87
- border: none;
88
- background: transparent;
89
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
147
+ color: var(--dsq-label);
90
148
  font: inherit;
91
- font-size: 13px;
149
+ font-size: 12px;
92
150
  line-height: 20px;
93
- cursor: pointer;
94
- border-radius: 999px;
151
+ border-radius: 8px;
95
152
  padding: 4px 10px;
96
- display: inline-flex;
97
- align-items: center;
98
153
  gap: 6px;
99
154
  white-space: nowrap;
100
155
  }
101
-
102
156
  .dsq_button:hover {
103
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
157
+ background: var(--dsq-fill-hover);
158
+ }
159
+
160
+ .dsq_buttonAddComment {
161
+ background: var(--dsq-accent);
162
+ color:#fff;
163
+ }
164
+ .dsq_buttonAddComment:hover {
165
+ background: var(--dsq-accent-400);
104
166
  }
105
167
 
106
168
  .dsq_button:focus-visible {
107
- outline: 2px solid var(--dsw-alias-button-primary-fill, #1677ff);
169
+ outline: 2px solid var(--dsq-focus-ring);
108
170
  outline-offset: 1px;
109
171
  }
110
172
 
@@ -115,7 +177,7 @@ const CSS = `
115
177
  .dsq_divider {
116
178
  width: 1px;
117
179
  height: 14px;
118
- background: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
180
+ background: var(--dsq-line-divider);
119
181
  margin: 0 2px;
120
182
  }
121
183
 
@@ -125,8 +187,8 @@ const CSS = `
125
187
  transform: translate(-50%, 8px);
126
188
  padding: 4px 10px;
127
189
  border-radius: 999px;
128
- background: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
129
- color: var(--dsw-alias-label-primary-inverted, #fff);
190
+ background: var(--dsq-bg-toast);
191
+ color: var(--dsq-label-inverted);
130
192
  font-size: 12px;
131
193
  line-height: 18px;
132
194
  pointer-events: none;
@@ -142,23 +204,20 @@ const CSS = `
142
204
  width: 20%;
143
205
  min-width: 148px;
144
206
  max-width: 220px;
145
- display: flex;
146
- flex-direction: column;
147
207
  align-items: stretch;
148
208
  gap: 6px;
149
209
  pointer-events: auto;
150
210
  }
151
211
 
152
- /* One quote card. Geometry mirrors the product's own file card
153
- (.nyYjTG_file in ui-deliverables): hairline border, neutral fill,
154
- 18px radius, fixed 72px row, centered content, background transition. */
212
+ /* One annotation card, shared by the composer and the transcript: the base
213
+ carries the shape, and \`.dsq_tCard\` / \`.dsq_tCardIcon\` override only the
214
+ numbers where the transcript really differs.
215
+ Geometry mirrors the product's own file card (\`.nyYjTG_file\` in
216
+ ui-deliverables): hairline border, neutral fill, 18px radius, fixed 72px row. */
155
217
  .dsq_card {
156
- --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
157
- --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
158
- box-sizing: border-box;
218
+ --dsq-card-fill: var(--dsq-neutral-50);
219
+ --dsq-card-hover: var(--dsq-neutral-100);
159
220
  flex: 0 0 auto;
160
- display: flex;
161
- align-items: center;
162
221
  gap: 10px;
163
222
  width: fit-content;
164
223
  max-width: calc(25% - 5px);
@@ -166,10 +225,10 @@ const CSS = `
166
225
  height: 64px;
167
226
  margin: 0;
168
227
  padding: 10px;
169
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
228
+ border: 0.5px solid var(--dsq-line);
170
229
  border-radius: 18px;
171
230
  background: var(--dsq-card-fill);
172
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
231
+ color: var(--dsq-label);
173
232
  transition: background-color 0.12s;
174
233
  position: relative;
175
234
  overflow: hidden;
@@ -185,15 +244,12 @@ const CSS = `
185
244
  quarter of the input, so 48px would leave no room for the quote text. */
186
245
  .dsq_cardIcon {
187
246
  z-index: 2;
188
- flex: none;
189
247
  width: 36px;
190
248
  height: 36px;
191
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
249
+ border: 0.5px solid var(--dsq-line);
192
250
  border-radius: 12px;
193
- display: grid;
194
- place-items: center;
195
251
  background: var(--dsq-card-fill);
196
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
252
+ color: var(--dsq-link);
197
253
  font-size: 12px;
198
254
  font-weight: 600;
199
255
  letter-spacing: -0.02em;
@@ -201,13 +257,13 @@ const CSS = `
201
257
 
202
258
  .dsq_cardBody {
203
259
  z-index: 2;
204
- flex: 1;
205
- min-width: 0;
206
- display: flex;
207
- flex-direction: column;
208
260
  justify-content: center;
209
261
  gap: 2px;
210
- /* Keeps the ellipsised title clear of the corner remove button. */
262
+ }
263
+
264
+ /* Keeps the ellipsised title clear of the corner remove button, which only the
265
+ composer stack renders. */
266
+ .dsq_cardStack .dsq_cardBody {
211
267
  padding-right: 14px;
212
268
  }
213
269
 
@@ -215,19 +271,13 @@ const CSS = `
215
271
  font-size: 14px;
216
272
  font-weight: 500;
217
273
  line-height: 22px;
218
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
219
- overflow: hidden;
220
- text-overflow: ellipsis;
221
- white-space: nowrap;
274
+ color: var(--dsq-label);
222
275
  }
223
276
 
224
277
  .dsq_cardSubtitle {
225
278
  font-size: 12px;
226
279
  line-height: 18px;
227
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
228
- overflow: hidden;
229
- text-overflow: ellipsis;
230
- white-space: nowrap;
280
+ color: var(--dsq-label-3);
231
281
  }
232
282
 
233
283
  /* Corner remove button: hidden until the card is hovered or focused. */
@@ -236,16 +286,10 @@ const CSS = `
236
286
  top: 4px;
237
287
  right: 4px;
238
288
  z-index: 3;
239
- appearance: none;
240
- border: none;
241
- background: transparent;
242
289
  width: 20px;
243
290
  height: 20px;
244
291
  border-radius: 6px;
245
- cursor: pointer;
246
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
247
- display: grid;
248
- place-items: center;
292
+ color: var(--dsq-label-3);
249
293
  font-size: 14px;
250
294
  line-height: 1;
251
295
  opacity: 0;
@@ -262,8 +306,8 @@ const CSS = `
262
306
  }
263
307
 
264
308
  .dsq_cardClose:hover {
265
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
266
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
309
+ background: var(--dsq-fill-hover);
310
+ color: var(--dsq-label);
267
311
  }
268
312
 
269
313
  /* Secondary corner action (edit comment). */
@@ -272,16 +316,10 @@ const CSS = `
272
316
  top: 4px;
273
317
  right: 28px;
274
318
  z-index: 3;
275
- appearance: none;
276
- border: none;
277
- background: transparent;
278
319
  width: 20px;
279
320
  height: 20px;
280
321
  border-radius: 6px;
281
- cursor: pointer;
282
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
283
- display: grid;
284
- place-items: center;
322
+ color: var(--dsq-label-3);
285
323
  font-size: 12px;
286
324
  line-height: 1;
287
325
  opacity: 0;
@@ -296,42 +334,35 @@ const CSS = `
296
334
  }
297
335
 
298
336
  .dsq_cardAction:hover {
299
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
337
+ background: var(--dsq-fill-hover);
300
338
  }
301
339
 
302
340
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
303
341
  .dsq_summary {
304
342
  position: relative;
305
343
  z-index: 10040;
306
- display: flex;
307
- align-items: center;
308
344
  gap: 10px;
309
345
  width: 100%;
310
346
  max-width: 100%;
311
347
  padding: 8px 10px;
312
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
348
+ border: 0.5px solid var(--dsq-line);
313
349
  border-radius: 18px;
314
- background: var(--dsw-static-neutral-50, #fafafa);
350
+ background: var(--dsq-neutral-50);
315
351
  pointer-events: auto;
316
352
  user-select: none;
317
353
  }
318
354
 
319
355
  .dsq_summaryIcon {
320
- flex: none;
321
356
  width: 32px;
322
357
  height: 32px;
323
358
  border-radius: 10px;
324
- display: grid;
325
- place-items: center;
326
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
327
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
359
+ background: var(--dsq-fill-hover);
360
+ color: var(--dsq-link);
328
361
  font-size: 13px;
329
362
  font-weight: 600;
330
363
  }
331
364
 
332
365
  .dsq_summaryBody {
333
- flex: 1;
334
- min-width: 0;
335
366
  position: relative;
336
367
  }
337
368
 
@@ -339,16 +370,18 @@ const CSS = `
339
370
  font-size: 14px;
340
371
  font-weight: 500;
341
372
  line-height: 20px;
342
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
373
+ color: var(--dsq-label);
343
374
  }
344
375
 
345
376
  .dsq_summaryHint {
346
377
  font-size: 12px;
347
378
  line-height: 18px;
348
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
379
+ color: var(--dsq-label-3);
349
380
  }
350
381
 
351
382
  .dsq_summaryHover {
383
+ /* \`display: none\` here is the closed state, so this rule must not take the
384
+ flex atoms — the open class below turns display back on. */
352
385
  display: none;
353
386
  position: fixed;
354
387
  z-index: 10050;
@@ -378,63 +411,47 @@ const CSS = `
378
411
  transform-origin: bottom center;
379
412
  }
380
413
 
381
- @keyframes dsq-panel-in {
382
- from {
383
- transform: scale(0.96);
384
- }
385
- to {
386
- transform: scale(1);
387
- }
388
- }
389
-
390
414
  .dsq_summaryHoverInner {
391
- display: flex;
392
- flex-direction: column;
393
415
  gap: 4px;
394
416
  max-height: min(420px, calc(100vh - 120px));
395
417
  overflow-y: auto;
396
418
  overscroll-behavior: contain;
397
- padding: 6px;
398
- border-radius: 14px;
399
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
419
+ padding: 4px;
420
+ border-radius: 12px;
421
+ border: 0.5px solid var(--dsq-line);
400
422
  /* Frosted panel: the menu token at partial alpha over a blur. */
401
- background: color-mix(in srgb, var(--dsw-specific-menu, #fff) 82%, transparent);
423
+ background: color-mix(in srgb, var(--dsq-bg-raised) 50%, transparent);
402
424
  backdrop-filter: blur(14px) saturate(1.4);
403
425
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
404
- box-shadow: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
426
+ box-shadow: var(--dsq-shadow-pop);
405
427
  }
406
428
 
407
429
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
408
430
  .dsq_summaryHoverInner {
409
- background: var(--dsw-specific-menu, #fff);
431
+ background: var(--dsq-menu);
410
432
  }
411
433
  }
412
434
 
413
435
  .dsq_summaryItem {
414
436
  display: block;
415
437
  padding: 8px 10px;
416
- border-radius: 10px;
438
+ border-radius: 8px;
417
439
  transition: background-color 0.12s;
418
440
  }
419
441
 
420
442
  .dsq_summaryItem:hover {
421
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
443
+ background: color-mix(in srgb, var(--dsq-fill-hover) 50%, transparent) ;
422
444
  }
423
445
 
424
446
  .dsq_summaryItemHead {
425
- display: flex;
426
- align-items: center;
427
447
  gap: 6px;
428
448
  }
429
449
 
430
450
  .dsq_summaryItemBadge {
431
- flex: none;
432
- display: grid;
433
- place-items: center;
434
451
  width: 18px;
435
452
  height: 18px;
436
453
  border-radius: 999px;
437
- background: var(--dsw-static-blue-500, #3b82f6);
454
+ background: var(--dsq-accent);
438
455
  color: #fff;
439
456
  font-size: 11px;
440
457
  font-weight: 600;
@@ -442,8 +459,6 @@ const CSS = `
442
459
  }
443
460
 
444
461
  .dsq_summaryItemActions {
445
- display: flex;
446
- align-items: center;
447
462
  gap: 2px;
448
463
  margin-left: auto;
449
464
  opacity: 0.5;
@@ -456,21 +471,15 @@ const CSS = `
456
471
  }
457
472
 
458
473
  .dsq_summaryItemAction {
459
- appearance: none;
460
- border: none;
461
- background: transparent;
462
474
  width: 26px;
463
475
  height: 26px;
464
476
  border-radius: 7px;
465
- cursor: pointer;
466
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
467
- display: grid;
468
- place-items: center;
477
+ color: var(--dsq-label-3);
469
478
  }
470
479
 
471
480
  .dsq_summaryItemAction:hover {
472
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
473
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
481
+ background: var(--dsq-fill-hover);
482
+ color: var(--dsq-label);
474
483
  }
475
484
 
476
485
  .dsq_summaryItemMain {
@@ -478,14 +487,10 @@ const CSS = `
478
487
  }
479
488
 
480
489
  .dsq_summaryItemButton {
481
- appearance: none;
482
490
  display: block;
483
491
  width: 100%;
484
492
  text-align: left;
485
493
  font: inherit;
486
- border: none;
487
- background: transparent;
488
- cursor: pointer;
489
494
  border-radius: 8px;
490
495
  padding: 2px 0 0;
491
496
  }
@@ -501,13 +506,13 @@ const CSS = `
501
506
  .dsq_summaryItemLabel {
502
507
  font-size: 11px;
503
508
  line-height: 16px;
504
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
509
+ color: var(--dsq-label-3);
505
510
  }
506
511
 
507
512
  .dsq_summaryItemText {
508
513
  font-size: 13px;
509
514
  line-height: 18px;
510
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
515
+ color: var(--dsq-label);
511
516
  white-space: pre-wrap;
512
517
  overflow-wrap: anywhere;
513
518
  display: -webkit-box;
@@ -517,14 +522,14 @@ const CSS = `
517
522
  }
518
523
 
519
524
  .dsq_summaryItemTextEmpty {
520
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
525
+ color: var(--dsq-label-3);
521
526
  }
522
527
 
523
528
  /* Inline edit form rendered inside the hover panel item (no extra layer). */
524
529
  .dsq_annEditText {
525
530
  font-size: 13px;
526
531
  line-height: 20px;
527
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
532
+ color: var(--dsq-label);
528
533
  white-space: pre-wrap;
529
534
  overflow-wrap: anywhere;
530
535
  max-height: 132px;
@@ -533,7 +538,6 @@ const CSS = `
533
538
  }
534
539
 
535
540
  .dsq_annEditInput {
536
- box-sizing: border-box;
537
541
  width: 100%;
538
542
  min-height: 72px;
539
543
  resize: vertical;
@@ -543,54 +547,49 @@ const CSS = `
543
547
  font: inherit;
544
548
  font-size: 13px;
545
549
  line-height: 20px;
546
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
547
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
550
+ color: var(--dsq-label);
551
+ background: var(--dsq-fill-hover);
548
552
  outline: none;
549
553
  }
550
554
 
551
555
  .dsq_annEditInput::placeholder {
552
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
556
+ color: var(--dsq-label-3);
553
557
  }
554
558
 
555
559
  .dsq_annEditActions {
556
- display: flex;
557
560
  justify-content: flex-end;
558
- align-items: center;
559
561
  gap: 8px;
562
+ margin-top: 4px;
560
563
  }
561
564
 
562
565
  .dsq_annEditCancel {
563
- appearance: none;
564
- border: none;
565
- background: transparent;
566
- color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
566
+ color: var(--dsq-label-2);
567
567
  font: inherit;
568
568
  font-size: 13px;
569
- padding: 6px 10px;
570
- border-radius: 999px;
571
- cursor: pointer;
569
+ padding: 4px 12px;
570
+ border-radius: 10px;
572
571
  }
573
572
 
574
573
  .dsq_annEditCancel:hover {
575
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
574
+ background: var(--dsq-fill-hover);
576
575
  }
577
576
 
578
577
  .dsq_annEditSave {
579
578
  appearance: none;
580
579
  border: none;
581
- background: var(--dsw-static-blue-500, #3b82f6);
580
+ background: var(--dsq-accent);
582
581
  color: #fff;
583
582
  font: inherit;
584
583
  font-size: 13px;
585
584
  font-weight: 500;
586
- padding: 6px 16px;
587
- border-radius: 999px;
585
+ padding: 4px 12px;
586
+ border-radius: 10px;
588
587
  cursor: pointer;
589
588
  transition: filter 0.12s;
590
589
  }
591
590
 
592
591
  .dsq_annEditSave:hover {
593
- filter: brightness(1.06);
592
+ background: var(--dsq-accent-400);
594
593
  }
595
594
 
596
595
  .dsq_annEditSave:disabled {
@@ -604,36 +603,32 @@ const CSS = `
604
603
  z-index: 10050;
605
604
  transform: translate(-50%, 4px);
606
605
  width: min(320px, calc(100vw - 24px));
607
- display: flex;
608
- flex-direction: column;
609
606
  gap: 8px;
610
- padding: 10px;
611
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
612
- border-radius: 14px;
613
- background: color-mix(in srgb, var(--dsw-specific-menu, #fff) 88%, transparent);
614
- backdrop-filter: blur(14px) saturate(1.4);
607
+ padding: 8px;
608
+ border-radius: 16px;
609
+ background: color-mix(in srgb, var(--dsq-bg-raised) 75%, transparent);
610
+ backdrop-filter: blur(10px) saturate(1.4);
615
611
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
616
- box-shadow: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
612
+ box-shadow: var(--dsq-shadow-pop);
617
613
  }
618
614
 
619
615
  .dsq_commentInput {
620
- box-sizing: border-box;
621
616
  width: 100%;
622
617
  min-height: 64px;
623
618
  resize: none;
624
619
  border: none;
625
- border-radius: 10px;
620
+ border-radius: 8px;
626
621
  padding: 8px 10px;
627
622
  font: inherit;
628
623
  font-size: 13px;
629
624
  line-height: 20px;
630
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
631
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
625
+ color: var(--dsq-label);
626
+ background: var(--dsq-fill-hover);
632
627
  outline: none;
633
628
  }
634
629
 
635
630
  .dsq_commentInput::placeholder {
636
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
631
+ color: var(--dsq-label-3);
637
632
  }
638
633
 
639
634
  .dsq_commentActions {
@@ -659,104 +654,38 @@ const CSS = `
659
654
  margin-right: 0;
660
655
  }
661
656
 
657
+ /* Transcript-only differences from the shared card. Unlike the composer
658
+ summary (one collapsed row per message) each history card shows its own
659
+ comment, so the fixed height and single-line clamps are lifted on \`.dsq_tCardButton\`. */
662
660
  .dsq_tCard {
663
- --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
664
- --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
665
- box-sizing: border-box;
666
- display: flex;
667
- align-items: center;
668
- gap: 10px;
669
- width: fit-content;
670
- max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.72), 520px);
671
- min-width: 0;
672
- height: 64px;
673
- margin: 0 0 0 auto;
674
- padding: 10px;
675
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
676
- border-radius: 18px;
677
- background: var(--dsq-card-fill);
678
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
679
- transition: background-color 0.12s;
680
- position: relative;
681
- overflow: hidden;
682
- user-select: none;
683
- }
684
-
685
- .dsq_tCard:hover {
686
- background: var(--dsq-card-hover);
661
+ max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
662
+ margin-left: auto;
687
663
  }
688
664
 
689
665
  .dsq_tCardIcon {
690
- z-index: 2;
691
- flex: none;
692
666
  width: 40px;
693
667
  height: 40px;
694
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
695
- border-radius: 12px;
696
- display: grid;
697
- place-items: center;
698
- background: var(--dsq-card-fill);
699
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
700
668
  font-size: 14px;
701
- font-weight: 600;
702
- letter-spacing: -0.02em;
703
- }
704
-
705
- .dsq_tCardBody {
706
- z-index: 2;
707
- flex: 1;
708
- min-width: 0;
709
- display: flex;
710
- flex-direction: column;
711
- justify-content: center;
712
- gap: 2px;
713
669
  }
714
670
 
715
- .dsq_tCardTitle {
716
- font-size: 14px;
717
- font-weight: 500;
718
- line-height: 22px;
719
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
720
- overflow: hidden;
721
- text-overflow: ellipsis;
722
- white-space: nowrap;
723
- }
724
-
725
- .dsq_tCardSubtitle {
726
- font-size: 12px;
727
- line-height: 18px;
728
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
729
- overflow: hidden;
730
- text-overflow: ellipsis;
731
- white-space: nowrap;
732
- }
733
-
734
- /* Transcript card wrapper: the card itself is a click target that scrolls back
735
- to the marked text. Unlike the composer summary (one collapsed row per
736
- message) each history card shows its own comment, so the fixed 64px height
737
- and the single-line clamps are lifted here. */
738
671
  .dsq_tCardButton {
739
- appearance: none;
740
- display: block;
741
672
  /* Compact cards: several fit in one row before wrapping. */
673
+ display: block;
742
674
  flex: 0 1 auto;
743
675
  width: 230px;
744
676
  max-width: 100%;
745
677
  padding: 0;
746
- border: none;
747
- background: transparent;
748
678
  font: inherit;
749
679
  text-align: left;
750
- cursor: pointer;
751
680
  border-radius: 14px;
752
681
  }
753
682
 
754
683
  .dsq_tCardButton:focus-visible {
755
- outline: 2px solid var(--dsw-static-blue-500, #3b82f6);
684
+ outline: 2px solid var(--dsq-accent);
756
685
  outline-offset: 2px;
757
686
  }
758
687
 
759
- .dsq_tCardButton .dsq_tCard {
688
+ .dsq_tCardButton .dsq_card {
760
689
  height: auto;
761
690
  min-height: 0;
762
691
  align-items: flex-start;
@@ -766,14 +695,14 @@ const CSS = `
766
695
  border-radius: 14px;
767
696
  }
768
697
 
769
- .dsq_tCardButton .dsq_tCardIcon {
698
+ .dsq_tCardButton .dsq_cardIcon {
770
699
  width: 22px;
771
700
  height: 22px;
772
701
  border-radius: 8px;
773
702
  font-size: 11px;
774
703
  }
775
704
 
776
- .dsq_tCardButton .dsq_tCardTitle {
705
+ .dsq_tCardButton .dsq_cardTitle {
777
706
  display: -webkit-box;
778
707
  -webkit-line-clamp: 2;
779
708
  -webkit-box-orient: vertical;
@@ -784,7 +713,7 @@ const CSS = `
784
713
  line-height: 18px;
785
714
  }
786
715
 
787
- .dsq_tCardButton .dsq_tCardSubtitle {
716
+ .dsq_tCardButton .dsq_cardSubtitle {
788
717
  display: -webkit-box;
789
718
  -webkit-line-clamp: 3;
790
719
  -webkit-box-orient: vertical;
@@ -798,24 +727,18 @@ const CSS = `
798
727
 
799
728
  /* A comment is the user's own words — give it more presence than the neutral
800
729
  "no comment" placeholder. */
801
- .dsq_tCardButton .dsq_tCardSubtitle[data-dsq-has-comment='true'] {
802
- color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
730
+ .dsq_cardSubtitle[data-dsq-has-comment='true'] {
731
+ color: var(--dsq-label-2);
803
732
  }
804
733
 
805
734
  /* Replacement user bubble (quote block stripped from display). */
806
735
  .dsq_userRow {
807
- display: flex;
808
- flex-direction: column;
809
- align-items: flex-end;
810
736
  gap: 6px;
811
737
  }
812
738
 
813
739
  .dsq_userStack {
814
740
  min-width: 0;
815
- max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
816
- display: flex;
817
- flex-direction: column;
818
- align-items: flex-end;
741
+ max-width: min(calc(var(--dsq-chat-width) * 0.702), 82%);
819
742
  gap: 8px;
820
743
  }
821
744
 
@@ -823,48 +746,28 @@ const CSS = `
823
746
  max-width: 100%;
824
747
  padding: 10px 16px;
825
748
  border-radius: 22px;
826
- background: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
827
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
828
- font-size: var(--dsh-content-font-size, 14px);
829
- line-height: calc(22px + var(--dsh-content-font-delta, 0px));
749
+ background: var(--dsq-bubble);
750
+ color: var(--dsq-label);
751
+ font-size: var(--dsq-font-size);
752
+ line-height: calc(22px + var(--dsq-font-delta));
830
753
  white-space: pre-wrap;
831
754
  word-break: break-word;
832
755
  }
833
756
 
834
- /* Reserve room for the floating quote cards inside the composer card, so the
835
- attachment rail and the editor flow below them instead of being covered. */
836
- .dsq_cardPad {
837
- padding-top: var(--dsq-quote-pad, 88px) !important;
838
- }
839
-
840
757
  /* Durable message images of a replacement user bubble. */
841
758
  .dsq_userImages {
842
- display: flex;
843
- flex-direction: column;
844
- align-items: flex-end;
845
- gap: 8px;
846
759
  max-width: 100%;
847
760
  }
848
761
 
849
- /* Dark theme: the product flips its palette on body[data-ds-dark-theme], and
850
- every --dsw-alias-* token follows it. Only the card fill is ours. */
851
- body[data-ds-dark-theme] .dsq_card,
852
- body[data-ds-dark-theme] .dsq_tCard {
853
- --dsq-card-fill: var(--dsw-static-neutral-850, #212123);
854
- --dsq-card-hover: var(--dsw-static-neutral-800, #292929);
855
- }
856
-
857
762
  /* Model-written :dsh-annotation{index="N"} chips. */
858
763
  .dsq_annDirective {
859
764
  appearance: none;
860
- display: inline-flex;
861
- align-items: center;
862
765
  margin: 0 2px;
863
766
  padding: 0 6px;
864
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
767
+ border: 0.5px solid var(--dsq-line);
865
768
  border-radius: 6px;
866
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
867
- color: var(--dsw-alias-link, #1677ff);
769
+ background: var(--dsq-fill-hover);
770
+ color: var(--dsq-link);
868
771
  font: inherit;
869
772
  font-size: 12px;
870
773
  line-height: 18px;
@@ -872,14 +775,10 @@ body[data-ds-dark-theme] .dsq_tCard {
872
775
  vertical-align: baseline;
873
776
  }
874
777
 
875
- .dsq_annDirective:hover {
876
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08));
877
- }
878
-
879
778
  /* Inline annotation mark in message bodies: highlight underline + ordinal badge. */
880
779
  .dsq_annMark {
881
- --dsq-mark-color: var(--dsw-static-blue-500, #3b82f6);
882
- --dsq-mark-tint: var(--dsw-static-blue-50p, #eaf3ff);
780
+ --dsq-mark-color: var(--dsq-accent);
781
+ --dsq-mark-tint: var(--dsq-accent-tint);
883
782
  background: var(--dsq-mark-tint);
884
783
  text-decoration: underline;
885
784
  text-decoration-style: dashed;
@@ -912,13 +811,36 @@ body[data-ds-dark-theme] .dsq_tCard {
912
811
  }
913
812
 
914
813
  .dsq_annMark:hover {
915
- background: var(--dsw-static-blue-75, #e5f0ff);
814
+ background: var(--dsq-accent-tint-hover);
916
815
  }
917
816
 
918
817
  .dsq_annMarkFlash {
919
818
  animation: dsq-ann-flash 1.4s ease-out;
920
819
  }
921
820
 
821
+ /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
822
+ into product-owned DOM. */
823
+
824
+ @keyframes dsq-toolbar-in {
825
+ from {
826
+ opacity: 0;
827
+ transform: translate(-50%, -100%) translateY(4px);
828
+ }
829
+ to {
830
+ opacity: 1;
831
+ transform: translate(-50%, -100%);
832
+ }
833
+ }
834
+
835
+ @keyframes dsq-panel-in {
836
+ from {
837
+ transform: scale(0.96);
838
+ }
839
+ to {
840
+ transform: scale(1);
841
+ }
842
+ }
843
+
922
844
  @keyframes dsq-ann-flash {
923
845
  0% {
924
846
  background: var(--dsq-mark-color);
@@ -930,8 +852,22 @@ body[data-ds-dark-theme] .dsq_tCard {
930
852
  }
931
853
  }
932
854
 
855
+ /* The product flips its palette on body[data-ds-dark-theme], and every
856
+ --dsq-* alias follows it. Only values we own ourselves need restating — the
857
+ transcript card carries .dsq_card too, so one selector covers both. */
858
+ body[data-ds-dark-theme] .dsq_card {
859
+ --dsq-card-fill: var(--dsq-neutral-850);
860
+ --dsq-card-hover: var(--dsq-neutral-800);
861
+ }
862
+
933
863
  body[data-ds-dark-theme] .dsq_annMark {
934
- --dsq-mark-tint: var(--dsw-static-blue-950, #172554);
864
+ --dsq-mark-tint: var(--dsq-accent-tint-dark);
865
+ }
866
+
867
+ /* Reserve room for the floating quote cards inside the composer card, so the
868
+ attachment rail and the editor flow below them instead of being covered. */
869
+ .dsq_cardPad {
870
+ padding-top: var(--dsq-quote-pad, 88px) !important;
935
871
  }
936
872
  `;
937
873
  const TAG_ID = "dsh-select-quote/toolbar.css";
@@ -947,6 +883,7 @@ function ensureToolbarStyles() {
947
883
  const styles = {
948
884
  toolbar: "dsq_toolbar",
949
885
  button: "dsq_button",
886
+ buttonAddComment: "dsq_buttonAddComment",
950
887
  divider: "dsq_divider",
951
888
  status: "dsq_status",
952
889
  cardStack: "dsq_cardStack",
@@ -992,9 +929,6 @@ const styles = {
992
929
  tCardButton: "dsq_tCardButton",
993
930
  tCard: "dsq_tCard",
994
931
  tCardIcon: "dsq_tCardIcon",
995
- tCardBody: "dsq_tCardBody",
996
- tCardTitle: "dsq_tCardTitle",
997
- tCardSubtitle: "dsq_tCardSubtitle",
998
932
  userRow: "dsq_userRow",
999
933
  userStack: "dsq_userStack",
1000
934
  userImages: "dsq_userImages",
@@ -1765,195 +1699,224 @@ function LucideIcon({ name, size = 15 }) {
1765
1699
  }
1766
1700
  //#endregion
1767
1701
  //#region src/client/i18n.ts
1768
- const LOCALES = [
1769
- "ru",
1770
- "en",
1771
- "zh"
1772
- ];
1773
- const STORAGE_KEY = "dsq-locale";
1774
- const MESSAGES = {
1775
- ru: {
1776
- "common.selectedText": "Выделенный текст",
1777
- "common.commentPlaceholder": "Добавить необязательный комментарий…",
1778
- "common.cancel": "Отмена",
1779
- "common.save": "Сохранить",
1780
- "toolbar.aria": "Действия с выделенным текстом",
1781
- "toolbar.commentHint": "Добавить аннотацию…",
1782
- "toolbar.copy": "Копировать",
1783
- "toolbar.copyFailed": "Не удалось скопировать",
1784
- "toolbar.addToTask": "Добавить к задаче",
1785
- "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1786
- "toolbar.skipComment": "Без комментария",
1787
- "toolbar.addComment": "Добавить комментарий",
1788
- "card.commentSubtitle": "Комментарий · {comment}",
1789
- "card.ariaIndexed": "Аннотация {index}",
1790
- "card.ariaPlain": "Аннотация к выделенному тексту",
1791
- "card.editComment": "Изменить комментарий",
1792
- "card.remove": "Удалить аннотацию",
1793
- "summary.hint": "Наведите, чтобы увидеть аннотации",
1794
- "summary.dialogAria": "Детали аннотаций: {count}",
1795
- "summary.edit": "Изменить аннотацию {index}",
1796
- "summary.remove": "Удалить аннотацию {index}",
1797
- "summary.userComment": "Комментарий пользователя",
1798
- "summary.noComment": "Комментарий не добавлен",
1799
- "summary.aria": {
1800
- one: "{count} аннотация. Наведите, чтобы увидеть детали.",
1801
- few: "{count} аннотации. Наведите, чтобы увидеть детали.",
1802
- many: "{count} аннотаций. Наведите, чтобы увидеть детали."
1803
- },
1804
- "summary.title": {
1805
- one: "{count} аннотация",
1806
- few: "{count} аннотации",
1807
- many: "{count} аннотаций"
1808
- },
1809
- "summary.removeAll": "Удалить все аннотации",
1810
- "chip.annotation": "Аннотация {index}"
1811
- },
1812
- en: {
1813
- "common.selectedText": "Selected text",
1814
- "common.commentPlaceholder": "Add an optional comment…",
1815
- "common.cancel": "Cancel",
1816
- "common.save": "Save",
1817
- "toolbar.aria": "Selection actions",
1818
- "toolbar.commentHint": "Add annotation…",
1819
- "toolbar.copy": "Copy",
1820
- "toolbar.copyFailed": "Copy failed",
1821
- "toolbar.addToTask": "Add to task",
1822
- "toolbar.commentDialogAria": "Add an optional comment",
1823
- "toolbar.skipComment": "Skip comment",
1824
- "toolbar.addComment": "Add comment",
1825
- "card.commentSubtitle": "Comment · {comment}",
1826
- "card.ariaIndexed": "Annotation {index}",
1827
- "card.ariaPlain": "Selection annotation",
1828
- "card.editComment": "Edit comment",
1829
- "card.remove": "Remove annotation",
1830
- "summary.hint": "Hover to view annotations",
1831
- "summary.dialogAria": "Annotation details: {count}",
1832
- "summary.edit": "Edit annotation {index}",
1833
- "summary.remove": "Remove annotation {index}",
1834
- "summary.userComment": "User comment",
1835
- "summary.noComment": "No comment added",
1836
- "summary.aria": {
1837
- one: "{count} annotation. Hover to view details.",
1838
- other: "{count} annotations. Hover to view details."
1839
- },
1840
- "summary.title": {
1841
- one: "{count} annotation",
1842
- other: "{count} annotations"
1843
- },
1844
- "summary.removeAll": "Remove all annotations",
1845
- "chip.annotation": "Annotation {index}"
1846
- },
1847
- zh: {
1848
- "common.selectedText": "选中的文本",
1849
- "common.commentPlaceholder": "添加可选评论…",
1850
- "common.cancel": "取消",
1851
- "common.save": "保存",
1852
- "toolbar.aria": "划词操作",
1853
- "toolbar.commentHint": "添加批注…",
1854
- "toolbar.copy": "复制",
1855
- "toolbar.copyFailed": "复制失败",
1856
- "toolbar.addToTask": "添加到任务",
1857
- "toolbar.commentDialogAria": "添加可选评论",
1858
- "toolbar.skipComment": "暂不评论",
1859
- "toolbar.addComment": "添加评论",
1860
- "card.commentSubtitle": "评论 · {comment}",
1861
- "card.ariaIndexed": "批注 {index}",
1862
- "card.ariaPlain": "划词批注",
1863
- "card.editComment": "编辑评论",
1864
- "card.remove": "移除批注",
1865
- "summary.hint": "悬停查看批注",
1866
- "summary.dialogAria": "{count} 条划词批注详情",
1867
- "summary.edit": "编辑批注 {index}",
1868
- "summary.remove": "移除批注 {index}",
1869
- "summary.userComment": "用户评论",
1870
- "summary.noComment": "未添加评论",
1871
- "summary.aria": { other: "{count} 条划词批注。悬停查看详情。" },
1872
- "summary.title": { other: "{count} 条批注" },
1873
- "summary.removeAll": "移除全部划词批注",
1874
- "chip.annotation": "批注 {index}"
1875
- }
1702
+ /**
1703
+ * Copy for dsh-select-quote, served through the host locale service.
1704
+ *
1705
+ * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
1706
+ * General → Language, persisted as `locale.preference`). This module owns only
1707
+ * the dictionaries and the plural form selection that the host's plain-template
1708
+ * lookup cannot express; it never resolves a language on its own.
1709
+ *
1710
+ * NOT localized on purpose:
1711
+ * - wire markers (`> [选中文本]`, `<response-annotations>`,
1712
+ * `:dsh-annotation{index="N"}`) — they are the message format the model
1713
+ * and the transcript parser agree on;
1714
+ * - the `# Response annotations:` header — model-facing instruction prose,
1715
+ * not UI text.
1716
+ */
1717
+ const LOCALE_NS = "select-quote";
1718
+ const zh = {
1719
+ "common.selectedText": "选中的文本",
1720
+ "common.commentPlaceholder": "添加可选评论…",
1721
+ "common.cancel": "取消",
1722
+ "common.save": "保存",
1723
+ "toolbar.aria": "划词操作",
1724
+ "toolbar.commentHint": "添加批注…",
1725
+ "toolbar.copy": "复制",
1726
+ "toolbar.copyFailed": "复制失败",
1727
+ "toolbar.addToTask": "添加到任务",
1728
+ "toolbar.commentDialogAria": "添加可选评论",
1729
+ "toolbar.skipComment": "暂不评论",
1730
+ "toolbar.addComment": "添加评论",
1731
+ "card.commentSubtitle": "评论 · {comment}",
1732
+ "card.ariaIndexed": "批注 {index}",
1733
+ "card.ariaPlain": "划词批注",
1734
+ "card.editComment": "编辑评论",
1735
+ "card.remove": "移除批注",
1736
+ "summary.hint": "悬停查看批注",
1737
+ "summary.dialogAria": "{count} 条划词批注详情",
1738
+ "summary.edit": "编辑批注 {index}",
1739
+ "summary.remove": "移除批注 {index}",
1740
+ "summary.userComment": "用户评论",
1741
+ "summary.noComment": "未添加评论",
1742
+ "summary.aria.other": "{count} 条划词批注。悬停查看详情。",
1743
+ "summary.title.other": "{count} 条批注",
1744
+ "summary.removeAll": "移除全部划词批注",
1745
+ "chip.annotation": "批注 {index}"
1876
1746
  };
1877
- let cached = null;
1878
- function matchLocale(tag) {
1879
- const lower = tag.toLowerCase();
1880
- for (const locale of LOCALES) if (lower === locale || lower.startsWith(`${locale}-`) || lower.startsWith(`${locale}_`)) return locale;
1881
- return null;
1882
- }
1883
- function localStore() {
1884
- return globalThis.localStorage;
1885
- }
1886
- function readOverride() {
1887
- try {
1888
- const raw = localStore()?.getItem(STORAGE_KEY);
1889
- return raw ? matchLocale(raw) : null;
1890
- } catch {
1891
- return null;
1892
- }
1893
- }
1894
- function detectLocale() {
1895
- const override = readOverride();
1896
- if (override) return override;
1897
- const nav = globalThis.navigator;
1898
- const tags = nav ? [...nav.languages ?? [], nav.language].filter(Boolean) : [];
1899
- for (const tag of tags) {
1900
- const hit = matchLocale(tag);
1901
- if (hit) return hit;
1902
- }
1903
- return "en";
1747
+ const en = {
1748
+ "common.selectedText": "Selected text",
1749
+ "common.commentPlaceholder": "Add an optional comment…",
1750
+ "common.cancel": "Cancel",
1751
+ "common.save": "Save",
1752
+ "toolbar.aria": "Selection actions",
1753
+ "toolbar.commentHint": "Add annotation…",
1754
+ "toolbar.copy": "Copy",
1755
+ "toolbar.copyFailed": "Copy failed",
1756
+ "toolbar.addToTask": "Add to task",
1757
+ "toolbar.commentDialogAria": "Add an optional comment",
1758
+ "toolbar.skipComment": "Skip comment",
1759
+ "toolbar.addComment": "Add comment",
1760
+ "card.commentSubtitle": "Comment · {comment}",
1761
+ "card.ariaIndexed": "Annotation {index}",
1762
+ "card.ariaPlain": "Selection annotation",
1763
+ "card.editComment": "Edit comment",
1764
+ "card.remove": "Remove annotation",
1765
+ "summary.hint": "Hover to view annotations",
1766
+ "summary.dialogAria": "Annotation details: {count}",
1767
+ "summary.edit": "Edit annotation {index}",
1768
+ "summary.remove": "Remove annotation {index}",
1769
+ "summary.userComment": "User comment",
1770
+ "summary.noComment": "No comment added",
1771
+ "summary.aria.one": "{count} annotation. Hover to view details.",
1772
+ "summary.aria.other": "{count} annotations. Hover to view details.",
1773
+ "summary.title.one": "{count} annotation",
1774
+ "summary.title.other": "{count} annotations",
1775
+ "summary.removeAll": "Remove all annotations",
1776
+ "chip.annotation": "Annotation {index}"
1777
+ };
1778
+ /**
1779
+ * Russian is not a shipped host locale: it is added through
1780
+ * `ctx.locale.addLanguage()` and its dictionary through the three-argument
1781
+ * `register()`. Its fallback chain ends at `en`, as the host requires.
1782
+ */
1783
+ const ru = {
1784
+ "common.selectedText": "Выделенный текст",
1785
+ "common.commentPlaceholder": "Добавить необязательный комментарий…",
1786
+ "common.cancel": "Отмена",
1787
+ "common.save": "Сохранить",
1788
+ "toolbar.aria": "Действия с выделенным текстом",
1789
+ "toolbar.commentHint": "Добавить аннотацию…",
1790
+ "toolbar.copy": "Копировать",
1791
+ "toolbar.copyFailed": "Не удалось скопировать",
1792
+ "toolbar.addToTask": "Добавить к задаче",
1793
+ "toolbar.commentDialogAria": "Добавить необязательный комментарий",
1794
+ "toolbar.skipComment": "Без комментария",
1795
+ "toolbar.addComment": "Добавить комментарий",
1796
+ "card.commentSubtitle": "Комментарий · {comment}",
1797
+ "card.ariaIndexed": "Аннотация {index}",
1798
+ "card.ariaPlain": "Аннотация к выделенному тексту",
1799
+ "card.editComment": "Изменить комментарий",
1800
+ "card.remove": "Удалить аннотацию",
1801
+ "summary.hint": "Наведите, чтобы увидеть аннотации",
1802
+ "summary.dialogAria": "Детали аннотаций: {count}",
1803
+ "summary.edit": "Изменить аннотацию {index}",
1804
+ "summary.remove": "Удалить аннотацию {index}",
1805
+ "summary.userComment": "Комментарий пользователя",
1806
+ "summary.noComment": "Комментарий не добавлен",
1807
+ "summary.aria.one": "{count} аннотация. Наведите, чтобы увидеть детали.",
1808
+ "summary.aria.few": "{count} аннотации. Наведите, чтобы увидеть детали.",
1809
+ "summary.aria.many": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1810
+ "summary.aria.other": "{count} аннотаций. Наведите, чтобы увидеть детали.",
1811
+ "summary.title.one": "{count} аннотация",
1812
+ "summary.title.few": "{count} аннотации",
1813
+ "summary.title.many": "{count} аннотаций",
1814
+ "summary.title.other": "{count} аннотаций",
1815
+ "summary.removeAll": "Удалить все аннотации",
1816
+ "chip.annotation": "Аннотация {index}"
1817
+ };
1818
+ /** Both shipped host locales, required together by the typed `register()`. */
1819
+ const SHIPPED_DICTS = Object.freeze({
1820
+ zh,
1821
+ en
1822
+ });
1823
+ /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
1824
+ const DICTIONARIES = Object.freeze({
1825
+ zh,
1826
+ en,
1827
+ ru
1828
+ });
1829
+ const LANGUAGE_RU = Object.freeze({
1830
+ id: "ru",
1831
+ label: "Русский",
1832
+ fallback: "en"
1833
+ });
1834
+ let api;
1835
+ /**
1836
+ * Install the host-bound translation entry point. Called once from `apply()`
1837
+ * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
1838
+ */
1839
+ function bindLocale(next) {
1840
+ api = next;
1904
1841
  }
1905
- /** Active locale (resolved once, then cached). */
1906
- function getLocale() {
1907
- cached ??= detectLocale();
1908
- return cached;
1842
+ /** Active language id, or `en` before the host service is bound. */
1843
+ function activeLocale() {
1844
+ return api?.getActive() ?? "en";
1909
1845
  }
1910
- function pluralize(forms, locale, count) {
1846
+ function pluralForm(count) {
1847
+ const locale = activeLocale();
1911
1848
  if (locale === "ru") {
1912
1849
  const mod10 = count % 10;
1913
1850
  const mod100 = count % 100;
1914
- if (mod10 === 1 && mod100 !== 11) return forms.one ?? forms.other ?? "";
1915
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms.few ?? forms.other ?? "";
1916
- return forms.many ?? forms.other ?? "";
1851
+ if (mod10 === 1 && mod100 !== 11) return "one";
1852
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
1853
+ return "many";
1917
1854
  }
1918
- if (locale === "en") return (count === 1 ? forms.one : void 0) ?? forms.other ?? "";
1919
- return forms.other ?? forms.many ?? "";
1920
- }
1921
- function interpolate(template, params) {
1922
- return template.replace(/\{(\w+)\}/g, (whole, name) => name in params ? String(params[name]) : whole);
1855
+ if (locale === "en") return count === 1 ? "one" : "other";
1856
+ return "other";
1923
1857
  }
1924
1858
  /**
1925
- * Translate a key for the active locale.
1926
- * `{name}` placeholders are filled from `params`; a numeric `count` also
1927
- * selects the right plural form when the message is a `PluralForms` object.
1859
+ * Translate a `select-quote` key. A numeric `count` selects a plural form and
1860
+ * the matching `key.form` entry is used, falling back to `key.other`.
1928
1861
  */
1929
1862
  function t(key, params = {}) {
1930
- const locale = getLocale();
1931
- const value = MESSAGES[locale][key] ?? MESSAGES.zh[key] ?? key;
1932
- return interpolate(typeof value === "string" ? value : pluralize(value, locale, typeof params.count === "number" ? params.count : 0), params);
1863
+ if (!api) return key;
1864
+ if (typeof params.count === "number") {
1865
+ const form = pluralForm(params.count);
1866
+ const exact = api.translate(`${key}.${form}`, params);
1867
+ if (exact !== `${key}.${form}`) return exact;
1868
+ const other = api.translate(`${key}.other`, params);
1869
+ if (other !== `${key}.other`) return other;
1870
+ }
1871
+ return api.translate(key, params);
1872
+ }
1873
+ /**
1874
+ * Subscribe to language or dictionary changes. Components go through
1875
+ * `useLocale()` instead; this is the non-React entry point (DOM decorations).
1876
+ * Subscribing before the host service is bound is a no-op, so callers subscribe
1877
+ * from `apply()`, after `bindLocale()`.
1878
+ */
1879
+ function subscribeLocale(listener) {
1880
+ return api ? api.subscribe(listener) : () => {};
1881
+ }
1882
+ /** Monotonic host revision counter, bumped on locale and dictionary changes. */
1883
+ function localeRevision() {
1884
+ return api?.getRevision() ?? 0;
1885
+ }
1886
+ //#endregion
1887
+ //#region src/client/useLocale.ts
1888
+ /**
1889
+ * Re-render the calling component when the host language changes, so slot copy
1890
+ * switches without a page reload. Subscriptions happen after mount, by which
1891
+ * time `apply()` has bound the locale service.
1892
+ */
1893
+ function useLocale() {
1894
+ (0, react.useSyncExternalStore)(subscribeLocale, localeRevision, localeRevision);
1933
1895
  }
1934
1896
  //#endregion
1935
1897
  //#region src/client/ui/AnnotationCardView.tsx
1936
1898
  function AnnotationCardView({ title, text, comment, variant, index, onRemove, onEditComment }) {
1899
+ useLocale();
1937
1900
  const composer = variant === "composer";
1938
1901
  const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
1939
1902
  return (0, react_jsx_runtime.jsxs)("div", {
1940
- className: composer ? styles.card : styles.tCard,
1903
+ className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
1941
1904
  role: "group",
1942
1905
  "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
1943
1906
  children: [
1944
1907
  (0, react_jsx_runtime.jsx)("div", {
1945
- className: composer ? styles.cardIcon : styles.tCardIcon,
1908
+ className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
1946
1909
  "aria-hidden": true,
1947
1910
  children: index ? String(index) : "AI"
1948
1911
  }),
1949
1912
  (0, react_jsx_runtime.jsxs)("div", {
1950
- className: composer ? styles.cardBody : styles.tCardBody,
1913
+ className: cx(A.grow, A.col, styles.cardBody),
1951
1914
  children: [(0, react_jsx_runtime.jsx)("div", {
1952
- className: composer ? styles.cardTitle : styles.tCardTitle,
1915
+ className: cx(A.ell, styles.cardTitle),
1953
1916
  title: text,
1954
1917
  children: title
1955
1918
  }), (0, react_jsx_runtime.jsx)("div", {
1956
- className: composer ? styles.cardSubtitle : styles.tCardSubtitle,
1919
+ className: cx(A.ell, styles.cardSubtitle),
1957
1920
  ...comment ? {
1958
1921
  title: comment,
1959
1922
  "data-dsq-has-comment": "true"
@@ -1963,14 +1926,14 @@ function AnnotationCardView({ title, text, comment, variant, index, onRemove, on
1963
1926
  }),
1964
1927
  composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
1965
1928
  type: "button",
1966
- className: styles.cardAction,
1929
+ className: cx(A.plain, A.gridMid, styles.cardAction),
1967
1930
  "aria-label": t("card.editComment"),
1968
1931
  onClick: onEditComment,
1969
1932
  children: "✎"
1970
1933
  }) : null,
1971
1934
  composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
1972
1935
  type: "button",
1973
- className: styles.cardClose,
1936
+ className: cx(A.plain, A.gridMid, styles.cardClose),
1974
1937
  "aria-label": t("card.remove"),
1975
1938
  onClick: onRemove,
1976
1939
  children: "×"
@@ -1993,7 +1956,8 @@ const SETTLE_DELAY = 600;
1993
1956
  * other cancels. Comment editing happens inline in the list — the panel stays
1994
1957
  * put and auto-close is suppressed while an edit is open.
1995
1958
  */
1996
- function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit, hint = t("summary.hint") }) {
1959
+ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit }) {
1960
+ useLocale();
1997
1961
  const rootRef = (0, react.useRef)(null);
1998
1962
  const popRef = (0, react.useRef)(null);
1999
1963
  const closeTimerRef = (0, react.useRef)(null);
@@ -2153,16 +2117,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2153
2117
  onPointerEnter: openPop,
2154
2118
  onPointerLeave,
2155
2119
  children: (0, react_jsx_runtime.jsx)("div", {
2156
- className: styles.summaryHoverInner,
2120
+ className: cx(A.col, styles.summaryHoverInner),
2157
2121
  children: items.map((item, i) => {
2158
2122
  const isEditing = editing === i;
2159
2123
  return (0, react_jsx_runtime.jsxs)("div", {
2160
2124
  className: styles.summaryItem,
2161
2125
  children: [(0, react_jsx_runtime.jsxs)("div", {
2162
- className: styles.summaryItemHead,
2126
+ className: cx(A.row, styles.summaryItemHead),
2163
2127
  children: [
2164
2128
  (0, react_jsx_runtime.jsx)("div", {
2165
- className: styles.summaryItemBadge,
2129
+ className: cx(A.keep, A.gridMid, styles.summaryItemBadge),
2166
2130
  "aria-hidden": true,
2167
2131
  children: String(i + 1)
2168
2132
  }),
@@ -2171,16 +2135,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2171
2135
  children: t("common.selectedText")
2172
2136
  }),
2173
2137
  !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
2174
- className: styles.summaryItemActions,
2138
+ className: cx(A.row, styles.summaryItemActions),
2175
2139
  children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
2176
2140
  type: "button",
2177
- className: styles.summaryItemAction,
2141
+ className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2178
2142
  "aria-label": t("summary.edit", { index: i + 1 }),
2179
2143
  onClick: () => startEdit(i),
2180
2144
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2181
2145
  }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
2182
2146
  type: "button",
2183
- className: styles.summaryItemAction,
2147
+ className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2184
2148
  "aria-label": t("summary.remove", { index: i + 1 }),
2185
2149
  onClick: () => onItemRemove(i, item),
2186
2150
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
@@ -2200,7 +2164,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2200
2164
  }),
2201
2165
  (0, react_jsx_runtime.jsx)("textarea", {
2202
2166
  ref: editInputRef,
2203
- className: styles.annEditInput,
2167
+ className: cx(A.box, styles.annEditInput),
2204
2168
  placeholder: t("common.commentPlaceholder"),
2205
2169
  value: editDraft,
2206
2170
  onChange: (e) => setEditDraft(e.target.value),
@@ -2217,10 +2181,10 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2217
2181
  }
2218
2182
  }),
2219
2183
  (0, react_jsx_runtime.jsxs)("div", {
2220
- className: styles.annEditActions,
2184
+ className: cx(A.row, styles.annEditActions),
2221
2185
  children: [(0, react_jsx_runtime.jsx)("button", {
2222
2186
  type: "button",
2223
- className: styles.annEditCancel,
2187
+ className: cx(A.plain, styles.annEditCancel),
2224
2188
  onClick: () => setEditing(null),
2225
2189
  children: t("common.cancel")
2226
2190
  }), (0, react_jsx_runtime.jsx)("button", {
@@ -2235,7 +2199,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2235
2199
  className: styles.summaryItemMain,
2236
2200
  children: (0, react_jsx_runtime.jsxs)("button", {
2237
2201
  type: "button",
2238
- className: styles.summaryItemButton,
2202
+ className: cx(A.plain, styles.summaryItemButton),
2239
2203
  onClick: () => {
2240
2204
  onItemSelect?.(i, item);
2241
2205
  closePop();
@@ -2262,7 +2226,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2262
2226
  }) : null;
2263
2227
  return (0, react_jsx_runtime.jsxs)("div", {
2264
2228
  ref: rootRef,
2265
- className: styles.summary,
2229
+ className: cx(A.row, styles.summary),
2266
2230
  tabIndex: 0,
2267
2231
  "data-selection-annotation-summary": "true",
2268
2232
  "aria-label": t("summary.aria", { count }),
@@ -2274,23 +2238,23 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2274
2238
  onKeyDown,
2275
2239
  children: [
2276
2240
  (0, react_jsx_runtime.jsx)("div", {
2277
- className: styles.summaryIcon,
2241
+ className: cx(A.keep, A.gridMid, styles.summaryIcon),
2278
2242
  "aria-hidden": true,
2279
2243
  children: String(count)
2280
2244
  }),
2281
2245
  (0, react_jsx_runtime.jsxs)("div", {
2282
- className: styles.summaryBody,
2246
+ className: cx(A.grow, styles.summaryBody),
2283
2247
  children: [(0, react_jsx_runtime.jsx)("div", {
2284
2248
  className: styles.summaryTitle,
2285
2249
  children: t("summary.title", { count })
2286
2250
  }), (0, react_jsx_runtime.jsx)("div", {
2287
2251
  className: styles.summaryHint,
2288
- children: hint
2252
+ children: t("summary.hint")
2289
2253
  })]
2290
2254
  }),
2291
2255
  onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2292
2256
  type: "button",
2293
- className: styles.cardClose,
2257
+ className: cx(A.plain, A.gridMid, styles.cardClose),
2294
2258
  "aria-label": t("summary.removeAll"),
2295
2259
  onClick: onRemoveAll,
2296
2260
  children: (0, react_jsx_runtime.jsx)(LucideIcon, {
@@ -2471,7 +2435,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2471
2435
  if (!sessionId || items.length === 0) return null;
2472
2436
  return (0, react_jsx_runtime.jsx)("div", {
2473
2437
  ref: stackRef,
2474
- className: styles.cardStack,
2438
+ className: cx(A.col, styles.cardStack),
2475
2439
  children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2476
2440
  count: items.length,
2477
2441
  items: items.map((item) => ({
@@ -2586,6 +2550,7 @@ async function copyText(text) {
2586
2550
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
2587
2551
  const COMMENT_MAX_HEIGHT = 220;
2588
2552
  function SelectionToolbar({ sessionId }) {
2553
+ useLocale();
2589
2554
  const toolbarRef = (0, react.useRef)(null);
2590
2555
  const commentWrapRef = (0, react.useRef)(null);
2591
2556
  const commentRef = (0, react.useRef)(null);
@@ -2760,7 +2725,7 @@ function SelectionToolbar({ sessionId }) {
2760
2725
  return (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
2761
2726
  (0, react_jsx_runtime.jsx)("div", {
2762
2727
  ref: toolbarRef,
2763
- className: styles.toolbar,
2728
+ className: cx(A.row, styles.toolbar),
2764
2729
  style: toolbarStyle,
2765
2730
  role: "toolbar",
2766
2731
  "aria-label": t("toolbar.aria"),
@@ -2789,7 +2754,7 @@ function SelectionToolbar({ sessionId }) {
2789
2754
  children: [
2790
2755
  (0, react_jsx_runtime.jsx)("button", {
2791
2756
  type: "button",
2792
- className: styles.button,
2757
+ className: cx(A.plain, A.inlineRow, styles.button),
2793
2758
  onClick: () => void handleCopy(),
2794
2759
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2795
2760
  name: "copy",
@@ -2802,7 +2767,7 @@ function SelectionToolbar({ sessionId }) {
2802
2767
  }),
2803
2768
  (0, react_jsx_runtime.jsx)("button", {
2804
2769
  type: "button",
2805
- className: styles.button,
2770
+ className: cx(A.plain, A.inlineRow, styles.button),
2806
2771
  onClick: openComment,
2807
2772
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2808
2773
  name: "plus",
@@ -2814,14 +2779,14 @@ function SelectionToolbar({ sessionId }) {
2814
2779
  }),
2815
2780
  phase === "comment" ? (0, react_jsx_runtime.jsxs)("div", {
2816
2781
  ref: commentWrapRef,
2817
- className: styles.commentPop,
2782
+ className: cx(A.col, styles.commentPop),
2818
2783
  style: void 0,
2819
2784
  role: "dialog",
2820
2785
  "aria-label": t("toolbar.commentDialogAria"),
2821
2786
  onPointerDown: handleToolbarPointerDown,
2822
2787
  children: [(0, react_jsx_runtime.jsx)("textarea", {
2823
2788
  ref: commentRef,
2824
- className: styles.commentInput,
2789
+ className: cx(A.box, styles.commentInput),
2825
2790
  placeholder: t("common.commentPlaceholder"),
2826
2791
  value: comment,
2827
2792
  onChange: (e) => setComment(e.target.value),
@@ -2840,12 +2805,12 @@ function SelectionToolbar({ sessionId }) {
2840
2805
  className: styles.commentActions,
2841
2806
  children: [(0, react_jsx_runtime.jsx)("button", {
2842
2807
  type: "button",
2843
- className: styles.button,
2808
+ className: cx(A.plain, A.inlineRow, styles.button),
2844
2809
  onClick: () => commitAnnotation(void 0),
2845
2810
  children: t("toolbar.skipComment")
2846
2811
  }), (0, react_jsx_runtime.jsx)("button", {
2847
2812
  type: "button",
2848
- className: styles.button,
2813
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
2849
2814
  disabled: !comment.trim(),
2850
2815
  onClick: () => commitAnnotation(comment),
2851
2816
  children: t("toolbar.addComment")
@@ -2867,6 +2832,7 @@ function SelectionToolbar({ sessionId }) {
2867
2832
  * Display-only decoration — durable message text is never rewritten.
2868
2833
  */
2869
2834
  const DECO = "dsq-ann-directive";
2835
+ const DECO_KEY = "dsqAnnDirective";
2870
2836
  function decorateTextNode(node) {
2871
2837
  const value = node.nodeValue;
2872
2838
  if (!value || !value.includes(":dsh-annotation{")) return;
@@ -2885,9 +2851,9 @@ function decorateTextNode(node) {
2885
2851
  const index = Number(m[1]);
2886
2852
  const chip = document.createElement("button");
2887
2853
  chip.type = "button";
2888
- chip.className = "dsq_annDirective";
2889
- chip.dataset[DECO] = String(index);
2854
+ chip.className = cx(styles.annDirective, A.inlineRow);
2890
2855
  chip.setAttribute("data-dsq-deco", "true");
2856
+ chip.dataset[DECO_KEY] = String(index);
2891
2857
  chip.textContent = t("chip.annotation", { index });
2892
2858
  chip.addEventListener("click", () => {
2893
2859
  const mark = findAnnotationMarkBefore(index, chip);
@@ -2913,6 +2879,22 @@ function decorateAnnotationDirectives(root) {
2913
2879
  }
2914
2880
  for (const node of hits) decorateTextNode(node);
2915
2881
  }
2882
+ /**
2883
+ * Re-label chips that are already on screen after the language changed.
2884
+ *
2885
+ * Chip text is plain DOM, not React state, so a switch would otherwise leave
2886
+ * old-language chips next to new-language popovers. Writing `textContent` here
2887
+ * is safe for the observer: the new text node never contains the directive
2888
+ * literal, so `decorateTextNode()` returns before it can touch the DOM.
2889
+ */
2890
+ function refreshDirectiveChips(root) {
2891
+ const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
2892
+ if (!el) return;
2893
+ for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
2894
+ const index = Number(chip.dataset[DECO_KEY]);
2895
+ if (Number.isFinite(index)) chip.textContent = t("chip.annotation", { index });
2896
+ }
2897
+ }
2916
2898
  /** Observe chat DOM and decorate new directive text. Returns disposer. */
2917
2899
  function watchAnnotationDirectives(root) {
2918
2900
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
@@ -2970,7 +2952,7 @@ function TranscriptQuoteCard({ node }) {
2970
2952
  className: styles.tCardStack,
2971
2953
  "data-dsq-deco": "true",
2972
2954
  children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
2973
- className: styles.tCardButton,
2955
+ className: cx(A.plain, styles.tCardButton),
2974
2956
  role: "button",
2975
2957
  tabIndex: 0,
2976
2958
  "aria-label": quote.title,
@@ -3007,7 +2989,8 @@ function contentImages(content) {
3007
2989
  * Replacement for the built-in `user` Chat node view.
3008
2990
  *
3009
2991
  * Strips the `<response-annotations>` protocol block from the bubble **and**
3010
- * renders one card per annotation right under it.
2992
+ * renders one card per annotation right above it — the same order the composer
2993
+ * shows them in, so the sent message reads like the draft did.
3011
2994
  *
3012
2995
  * Why the cards live here instead of on a `select-quote` Chat node:
3013
2996
  * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
@@ -3027,12 +3010,12 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3027
3010
  const images = contentImages(node.data?.content);
3028
3011
  if (!text && images.length === 0 && quotes.length === 0) return null;
3029
3012
  return (0, react_jsx_runtime.jsx)("div", {
3030
- className: styles.userRow,
3013
+ className: cx(A.colEnd, styles.userRow),
3031
3014
  children: (0, react_jsx_runtime.jsxs)("div", {
3032
- className: styles.userStack,
3015
+ className: cx(A.colEnd, styles.userStack),
3033
3016
  children: [
3034
3017
  images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3035
- className: styles.userImages,
3018
+ className: cx(A.colEnd, styles.userImages),
3036
3019
  "data-message-attachments": true,
3037
3020
  children: renderMessageImages({
3038
3021
  images,
@@ -3040,11 +3023,11 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3040
3023
  compact: images.length > 1
3041
3024
  })
3042
3025
  }, "images") : null,
3026
+ quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null,
3043
3027
  text ? (0, react_jsx_runtime.jsx)("div", {
3044
3028
  className: styles.userBubble,
3045
3029
  children: text
3046
- }, "bubble") : null,
3047
- quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null
3030
+ }, "bubble") : null
3048
3031
  ]
3049
3032
  })
3050
3033
  });
@@ -3064,10 +3047,30 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3064
3047
  * kind is classified as a turn-process member and `hidden` when the completed
3065
3048
  * turn's process disclosure is folded — mounted, but invisible. `user` is on
3066
3049
  * that allowlist, so the bubble is the only placement that is reliably visible.
3050
+ *
3051
+ * Language is the host's decision, never this plugin's: dictionaries go to
3052
+ * `ctx.locale`, and `t()` resolves against whatever language the user picked in
3053
+ * Settings. Self-detecting from `navigator` would disagree with the host on the
3054
+ * desktop shell, where Electron pins the document language (`--lang`) to its own
3055
+ * value while the host preference comes through `__DSH_LOCALE__`.
3067
3056
  */
3068
- const inject = ["slots", "sessions"];
3057
+ const inject = [
3058
+ "slots",
3059
+ "sessions",
3060
+ "locale"
3061
+ ];
3069
3062
  function apply(ctx) {
3070
3063
  ensureToolbarStyles();
3064
+ ctx.effect(() => ctx.locale.addLanguage(LANGUAGE_RU), "dsh-select-quote: ru language");
3065
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, SHIPPED_DICTS), "dsh-select-quote: dictionaries (zh, en)");
3066
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, LANGUAGE_RU.id, DICTIONARIES.ru), "dsh-select-quote: dictionary (ru)");
3067
+ bindLocale({
3068
+ translate: ctx.locale.bind(LOCALE_NS),
3069
+ getActive: () => ctx.locale.getLocale().active,
3070
+ getRevision: () => ctx.locale.getSnapshot().revision,
3071
+ subscribe: (listener) => ctx.locale.subscribe(listener)
3072
+ });
3073
+ ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
3071
3074
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
3072
3075
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
3073
3076
  ctx.effect(() => {