@artooi/ag-ui-web-component 0.21.0 → 0.22.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/src/ui/styles.ts CHANGED
@@ -74,6 +74,36 @@ export const STYLES = `
74
74
  --_icon-size: var(--ag-ui-icon-size, 22px);
75
75
  --_icon-radius: var(--ag-ui-icon-radius, 4px);
76
76
 
77
+ /* Composer — one bordered surface holding the field and its tool row. */
78
+ --_composer-radius: var(--ag-ui-composer-radius, 14px);
79
+ --_composer-max-height: var(--ag-ui-composer-max-height, 40vh);
80
+ --_tool-btn-size: var(--ag-ui-tool-btn-size, 30px);
81
+ --_send-size: var(--ag-ui-send-size, 30px);
82
+ --_glyph-size: var(--ag-ui-glyph-size, 18px);
83
+ --_glyph-stroke: var(--ag-ui-glyph-stroke, 1.75);
84
+
85
+ /* Unread badge on the launcher. */
86
+ --_badge-bg: var(--ag-ui-badge-bg, var(--_danger));
87
+ --_badge-fg: var(--ag-ui-badge-fg, #ffffff);
88
+ --_badge-size: var(--ag-ui-badge-size, 18px);
89
+ --_badge-font-size: var(--ag-ui-badge-font-size, 11px);
90
+
91
+ /* The floating launcher a collapsed widget shrinks to. */
92
+ --_launcher-size: var(--ag-ui-launcher-size, 56px);
93
+ --_launcher-bg: var(--ag-ui-launcher-bg, var(--_header-bg));
94
+ --_launcher-fg: var(--ag-ui-launcher-fg, var(--_header-fg));
95
+ --_launcher-radius: var(--ag-ui-launcher-radius, 50%);
96
+ --_launcher-icon-size: var(--ag-ui-launcher-icon-size, 26px);
97
+ --_launcher-inset: var(--ag-ui-launcher-inset, auto 0 0 auto);
98
+
99
+ /* Motion. One duration and two curves drive every collapse, expand and
100
+ slide-over, so the whole widget accelerates and settles as one thing.
101
+ The default curve is a long-tailed ease-out (a fast start that decelerates
102
+ into place); the pop curve overshoots slightly, for something arriving. */
103
+ --_motion: var(--ag-ui-motion, 0.28s);
104
+ --_ease: var(--ag-ui-ease, cubic-bezier(0.32, 0.72, 0, 1));
105
+ --_ease-pop: var(--ag-ui-ease-pop, cubic-bezier(0.34, 1.36, 0.64, 1));
106
+
77
107
  /* Spacing — the density preset overrides these. */
78
108
  --_space: var(--ag-ui-space, 10px);
79
109
  --_pad: var(--ag-ui-pad, 16px);
@@ -238,56 +268,161 @@ export const STYLES = `
238
268
  --_height: var(--ag-ui-height, 100vh);
239
269
  --_max-height: var(--ag-ui-max-height, 100vh);
240
270
  --_radius: var(--ag-ui-radius, 0);
241
- transition: width 0.28s ease;
271
+ transition: width var(--_motion) var(--_ease);
242
272
  }
243
273
 
244
274
  :host([placement="sidebar"][data-side="left"]) {
245
275
  --_inset: var(--ag-ui-inset, 0 auto 0 0);
246
276
  }
247
277
 
278
+ /* The docked panel is pinned to the edge it docks against rather than filling
279
+ the host as a flex child. Collapsing shrinks the host to the rail width, and
280
+ a flex child would be squashed to 52px on the way out instead of sliding out
281
+ at full width. */
248
282
  :host([placement="sidebar"]) .chat {
249
- transition: transform 0.28s ease;
283
+ position: absolute;
284
+ inset: 0 0 0 auto;
285
+ width: var(--_width);
286
+ transform-origin: right center;
250
287
  }
251
288
 
252
- /* Collapsed sidebar: shrink the host to the rail width, hide the panel, and
253
- reveal the rail. Higher specificity than the generic collapse rules, so it
254
- wins regardless of source order. */
289
+ :host([placement="sidebar"][data-side="left"]) .chat {
290
+ inset: 0 auto 0 0;
291
+ transform-origin: left center;
292
+ }
293
+
294
+ /* Collapsed sidebar: shrink the host to the rail width and slide the panel out
295
+ through the edge it docks against. Higher specificity than the generic
296
+ collapse rules, so it wins regardless of source order. */
255
297
  :host([placement="sidebar"][collapsed]) {
256
298
  width: var(--_rail-width);
257
299
  height: 100vh;
258
300
  max-height: 100vh;
259
301
  bottom: 0;
302
+ pointer-events: auto;
260
303
  }
261
304
 
262
305
  :host([placement="sidebar"][collapsed]) .chat {
263
- display: none;
306
+ transform: translateX(100%);
264
307
  }
265
308
 
266
- /* The rail is a sibling of the panel (so it survives the panel being hidden);
267
- shown only for a collapsed sidebar. */
268
- .rail {
269
- display: none;
309
+ :host([placement="sidebar"][data-side="left"][collapsed]) .chat {
310
+ transform: translateX(-100%);
311
+ }
312
+
313
+ @media (prefers-reduced-motion: reduce) {
314
+ :host([placement="sidebar"]),
315
+ :host([placement="sidebar"]) .chat {
316
+ transition: none;
317
+ }
318
+ }
319
+
320
+ /* ── Launcher ───────────────────────────────────────────────────────────────
321
+ What a collapsed widget shrinks to: a round floating button by default, the
322
+ sidebar's slim edge rail under that placement. A sibling of the panel, so it
323
+ survives the panel being hidden, and the only part of a collapsed widget
324
+ that takes pointer events. */
325
+ .launcher {
326
+ display: flex;
327
+ position: absolute;
328
+ inset: var(--_launcher-inset);
329
+ align-items: center;
330
+ justify-content: center;
331
+ width: var(--_launcher-size);
332
+ height: var(--_launcher-size);
333
+ padding: 0;
270
334
  border: none;
335
+ border-radius: var(--_launcher-radius);
336
+ background: var(--_launcher-bg);
337
+ color: var(--_launcher-fg);
338
+ box-shadow: var(--_shadow);
271
339
  font: inherit;
340
+ cursor: pointer;
341
+ pointer-events: auto;
342
+ opacity: 0;
343
+ transform: scale(0.4);
344
+ visibility: hidden;
345
+ transition:
346
+ opacity var(--_motion) var(--_ease),
347
+ transform var(--_motion) var(--_ease-pop),
348
+ visibility var(--_motion) var(--_ease);
349
+ }
350
+
351
+ /* The launcher grows out of the corner the panel shrank into.
352
+ It is laid out at rest rather than display:none, which is what lets it
353
+ animate in *and* out: an element that was not rendered has no before-change
354
+ style to transition from, and one whose display flips to none cannot
355
+ transition at all. visibility keeps it unpaintable, untabbable and
356
+ unclickable in between -- the expanded panel's own controls sit under it and
357
+ must stay reachable. */
358
+ :host([collapsed]) .launcher {
359
+ opacity: 1;
360
+ transform: none;
361
+ visibility: visible;
272
362
  }
273
363
 
274
- :host([placement="sidebar"][collapsed]) .rail {
275
- display: flex;
364
+ :host([collapsed]) .launcher:hover {
365
+ transform: scale(1.06);
366
+ }
367
+
368
+ :host([collapsed]) .launcher:active {
369
+ transform: scale(0.94);
370
+ }
371
+
372
+ .launcher .icon-holder {
373
+ width: var(--_launcher-icon-size);
374
+ height: var(--_launcher-icon-size);
375
+ }
376
+
377
+ /* The unread badge rides the launcher's top-right corner. The ring in the
378
+ widget's own background is what separates it from the launcher underneath,
379
+ whatever colour the host themes either one. */
380
+ .launcher-badge {
276
381
  position: absolute;
382
+ top: -2px;
383
+ right: -2px;
384
+ display: inline-flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ box-sizing: border-box;
388
+ min-width: var(--_badge-size);
389
+ height: var(--_badge-size);
390
+ padding: 0 5px;
391
+ border-radius: 999px;
392
+ background: var(--_badge-bg);
393
+ color: var(--_badge-fg);
394
+ font-size: var(--_badge-font-size);
395
+ font-weight: 600;
396
+ line-height: 1;
397
+ box-shadow: 0 0 0 2px var(--_bg);
398
+ }
399
+
400
+ .launcher-badge[hidden] {
401
+ display: none;
402
+ }
403
+
404
+ /* The sidebar collapses to an edge rail instead: full height, square, flush
405
+ against the dock. It slides in with the panel rather than popping. */
406
+ :host([placement="sidebar"][collapsed]) .launcher {
277
407
  inset: 0;
408
+ width: auto;
409
+ height: auto;
278
410
  align-items: flex-start;
279
- justify-content: center;
280
411
  padding-top: 16px;
281
412
  border: 1px solid var(--_border);
413
+ border-radius: 0;
282
414
  background: var(--_header-bg);
283
415
  color: var(--_header-fg);
284
- cursor: pointer;
416
+ box-shadow: none;
417
+ transform: none;
285
418
  }
286
419
 
420
+ /* Every transition in this file is timed by --_motion, so collapsing it to a
421
+ frame is the whole reduced-motion story: states still change (and display
422
+ still flips at the end of its discrete transition), nothing travels. */
287
423
  @media (prefers-reduced-motion: reduce) {
288
- :host([placement="sidebar"]),
289
- :host([placement="sidebar"]) .chat {
290
- transition: none;
424
+ :host {
425
+ --_motion: var(--ag-ui-motion, 0.001s);
291
426
  }
292
427
  }
293
428
 
@@ -378,27 +513,78 @@ export const STYLES = `
378
513
  background: rgba(255, 255, 255, 0.18);
379
514
  }
380
515
 
381
- /* Collapsed: keep just the header bar — hide the whole body, and let the host
382
- shrink to the header. (A host can also fully hide the element itself.) */
516
+ /* ── Collapse ───────────────────────────────────────────────────────────────
517
+ Collapsing shrinks the widget to the round floating launcher: the panel
518
+ scales down toward the launcher's corner and fades out, the launcher pops in
519
+ from that same point. Both halves are transform and opacity only, so the
520
+ morph runs on the compositor and never reflows the host page.
521
+
522
+ The host box keeps its expanded size. Animating it would animate layout, and
523
+ a dragged --ag-ui-width would then fight the launcher's own size. Nothing
524
+ paints there once the panel is gone, so the box only has to stop swallowing
525
+ clicks: pointer events go to none, and the launcher takes them back.
526
+
527
+ Two placements collapse to something else and restore what they need:
528
+ "sidebar" slides to its rail (below), while "embedded" and "page" keep the
529
+ original header bar — embedded is laid out by the host page, where a
530
+ floating circle would escape the layout, and page is a full-screen route
531
+ with no corner to float in. */
383
532
  :host([collapsed]) {
533
+ pointer-events: none;
534
+ }
535
+
536
+ :host([collapsed]) .chat {
537
+ opacity: 0;
538
+ transform: scale(0.94);
539
+ visibility: hidden;
540
+ }
541
+
542
+ /* visibility is what keeps the panel out of the tab order and the a11y tree at
543
+ rest without display:none killing the transition. It interpolates so that any
544
+ progress below 1 still counts as visible: the panel stays on screen for the
545
+ whole collapse and flips hidden exactly at the end, and on expand it is
546
+ visible from the first frame. */
547
+ .chat {
548
+ transform-origin: bottom right;
549
+ transition:
550
+ opacity var(--_motion) var(--_ease),
551
+ transform var(--_motion) var(--_ease),
552
+ visibility var(--_motion) var(--_ease);
553
+ }
554
+
555
+ :host([placement="bottom-left"]) .chat {
556
+ transform-origin: bottom left;
557
+ }
558
+
559
+ /* The two in-flow placements keep the original collapse: hide the body, let
560
+ the host shrink to the header bar. */
561
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) {
384
562
  height: auto;
385
563
  max-height: none;
564
+ pointer-events: auto;
386
565
  }
387
566
 
388
- :host([collapsed]) .messages,
389
- :host([collapsed]) .input-row,
390
- :host([collapsed]) .skill-chips,
391
- :host([collapsed]) .skill-palette,
392
- :host([collapsed]) .skill-hint {
393
- display: none;
567
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .chat {
568
+ opacity: 1;
569
+ transform: none;
570
+ visibility: visible;
394
571
  }
395
572
 
396
- /* Edge-docked placements pin top *and* bottom, which would otherwise keep the
397
- panel full-height when collapsed; unpin the bottom so it shrinks to the
398
- header. (Floating/bottom-left pin only the bottom, so they already shrink.) */
399
- :host([collapsed][placement="side"]),
400
- :host([collapsed][placement="full"]) {
401
- bottom: auto;
573
+ /* These two keep the header bar, so the launcher must stay out of the way —
574
+ an embedded host is position: static, which would otherwise leave an
575
+ absolutely-positioned circle to escape the layout entirely and land against
576
+ whatever the page happens to position. */
577
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .launcher {
578
+ visibility: hidden;
579
+ opacity: 0;
580
+ }
581
+
582
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
583
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
584
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
585
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-palette,
586
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-hint {
587
+ display: none;
402
588
  }
403
589
 
404
590
  .messages {
@@ -1071,102 +1257,187 @@ export const STYLES = `
1071
1257
  opacity: 0.35;
1072
1258
  }
1073
1259
 
1260
+ /* ── Composer ───────────────────────────────────────────────────────────────
1261
+ One surface owns the border, the background and the focus ring; the field and
1262
+ its tool row sit inside it. The row used to be four siblings stretched to the
1263
+ textarea's height, which gave a paperclip the same visual weight as the field
1264
+ and turned Send into a full-height slab. */
1074
1265
  .input-row {
1075
1266
  display: flex;
1076
- gap: 8px;
1077
1267
  padding: 12px;
1078
1268
  border-top: 1px solid var(--_border);
1079
1269
  }
1080
1270
 
1081
- .input {
1271
+ .composer {
1082
1272
  flex: 1;
1083
- resize: none;
1273
+ min-width: 0;
1274
+ display: flex;
1275
+ flex-direction: column;
1276
+ gap: 4px;
1277
+ padding: 6px 6px 6px 10px;
1084
1278
  background: var(--_input-bg);
1085
1279
  border: 1px solid var(--_border);
1086
- border-radius: 8px;
1087
- padding: 8px 10px;
1280
+ border-radius: var(--_composer-radius);
1281
+ transition: border-color var(--_motion) var(--_ease);
1282
+ }
1283
+
1284
+ .composer:focus-within {
1285
+ border-color: var(--_accent);
1286
+ }
1287
+
1288
+ /* The field grows with its content (sized by #autoGrow) up to the ceiling,
1289
+ then scrolls. border-box keeps that measurement stable: with content-box the
1290
+ padding would be added to every scrollHeight read and the field would creep
1291
+ taller on each keystroke. */
1292
+ .input {
1293
+ box-sizing: border-box;
1294
+ resize: none;
1295
+ max-height: var(--_composer-max-height);
1296
+ overflow-y: auto;
1297
+ padding: 6px 4px 2px;
1298
+ background: transparent;
1299
+ border: none;
1088
1300
  font: inherit;
1089
1301
  color: inherit;
1090
1302
  outline: none;
1091
1303
  }
1092
1304
 
1093
- .input:focus {
1094
- border-color: var(--_accent);
1305
+ .composer-tools {
1306
+ display: flex;
1307
+ align-items: center;
1308
+ gap: 2px;
1095
1309
  }
1096
1310
 
1097
- .send {
1311
+ /* Icon buttons: quiet at rest, so the field is what the eye lands on. */
1312
+ .attach-btn,
1313
+ .voice-btn {
1314
+ display: inline-flex;
1315
+ align-items: center;
1316
+ justify-content: center;
1317
+ width: var(--_tool-btn-size);
1318
+ height: var(--_tool-btn-size);
1319
+ padding: 0;
1098
1320
  border: none;
1099
1321
  border-radius: 8px;
1100
- padding: 0 16px;
1322
+ background: transparent;
1323
+ color: var(--_muted);
1324
+ font: inherit;
1325
+ line-height: 1;
1326
+ cursor: pointer;
1327
+ transition:
1328
+ background-color var(--_motion) var(--_ease),
1329
+ color var(--_motion) var(--_ease);
1330
+ }
1331
+
1332
+ .attach-btn:hover,
1333
+ .voice-btn:hover {
1334
+ background: var(--_hover);
1335
+ color: var(--_fg);
1336
+ }
1337
+
1338
+ .attach-btn:disabled,
1339
+ .voice-btn:disabled {
1340
+ cursor: default;
1341
+ opacity: 0.6;
1342
+ }
1343
+
1344
+ /* Send closes the row on the right: a circle, the only filled control in the
1345
+ composer, so "the thing that acts" reads at a glance. */
1346
+ .send {
1347
+ margin-left: auto;
1348
+ display: inline-flex;
1349
+ align-items: center;
1350
+ justify-content: center;
1351
+ flex: none;
1352
+ width: var(--_send-size);
1353
+ height: var(--_send-size);
1354
+ padding: 0;
1355
+ border: none;
1356
+ border-radius: 50%;
1101
1357
  background: var(--_accent);
1102
1358
  color: #ffffff;
1103
1359
  font: inherit;
1104
- font-weight: 600;
1360
+ line-height: 1;
1105
1361
  cursor: pointer;
1362
+ transition:
1363
+ background-color var(--_motion) var(--_ease),
1364
+ transform var(--_motion) var(--_ease-pop);
1365
+ }
1366
+
1367
+ .send:hover {
1368
+ transform: scale(1.08);
1369
+ }
1370
+
1371
+ .send:active {
1372
+ transform: scale(0.92);
1106
1373
  }
1107
1374
 
1108
1375
  .send:disabled {
1109
1376
  opacity: 0.5;
1110
1377
  cursor: default;
1378
+ transform: none;
1111
1379
  }
1112
1380
 
1113
- /* The composer button doubles as the Stop control while a run is in flight. */
1381
+ /* The composer button doubles as the Stop control while a run is in flight
1382
+ same circle, different glyph, so nothing moves when a run starts. */
1114
1383
  .send[data-state="running"] {
1115
1384
  background: var(--_muted);
1116
1385
  }
1117
1386
 
1118
- /* ── File attachments ───────────────────────────────────────────────────── */
1119
- /* The 📎 picker button sits left of the input; hidden until data-attachments-url. */
1120
- .attach-btn {
1121
- border: 1px solid var(--_border);
1122
- border-radius: 8px;
1123
- padding: 0 10px;
1124
- background: var(--_input-bg);
1125
- color: inherit;
1126
- font: inherit;
1127
- cursor: pointer;
1128
- }
1129
-
1130
- .attach-btn:hover {
1131
- border-color: var(--_accent);
1387
+ .send[data-state="idle"] .send-stop,
1388
+ .send[data-state="running"] .send-send {
1389
+ display: none;
1132
1390
  }
1133
1391
 
1134
- .attach-input {
1135
- display: none;
1392
+ /* Glyphs — one class, painted from the button's own colour so every state
1393
+ (hover, recording, running) carries the icon with it. */
1394
+ .glyph {
1395
+ width: var(--_glyph-size);
1396
+ height: var(--_glyph-size);
1397
+ fill: none;
1398
+ stroke: currentColor;
1399
+ stroke-width: var(--_glyph-stroke);
1400
+ stroke-linecap: round;
1401
+ stroke-linejoin: round;
1136
1402
  }
1137
1403
 
1138
- /* The 🎤 mic button; shown only once #wireVoice mounts it. */
1139
- .voice-slot {
1140
- display: contents;
1404
+ .glyph--solid {
1405
+ fill: currentColor;
1406
+ stroke: none;
1141
1407
  }
1142
1408
 
1143
- .voice-btn {
1144
- border: 1px solid var(--_border);
1145
- border-radius: 8px;
1146
- padding: 0 10px;
1147
- background: var(--_input-bg);
1148
- color: inherit;
1149
- font: inherit;
1150
- cursor: pointer;
1409
+ /* In an icon holder (header brand, launcher) the glyph takes the holder's size
1410
+ rather than the composer's. */
1411
+ .icon-holder .glyph {
1412
+ width: 100%;
1413
+ height: 100%;
1151
1414
  }
1152
1415
 
1153
- .voice-btn:hover {
1154
- border-color: var(--_accent);
1416
+ /* ── File attachments ───────────────────────────────────────────────────── */
1417
+ /* The picker button sits in the composer's tool row; hidden until
1418
+ data-attachments-url. */
1419
+ .attach-input {
1420
+ display: none;
1155
1421
  }
1156
1422
 
1157
- .voice-btn:disabled {
1158
- cursor: default;
1159
- opacity: 0.6;
1423
+ /* The mic button's mount point; filled only once #wireVoice mounts the
1424
+ control. */
1425
+ .voice-slot {
1426
+ display: contents;
1160
1427
  }
1161
1428
 
1162
1429
  /* Recording: a red tint + a gentle pulse so it's clearly "live". */
1163
1430
  .voice-btn[data-state="recording"] {
1164
- border-color: var(--_danger);
1165
1431
  background: var(--_danger);
1166
1432
  color: #ffffff;
1167
1433
  animation: ag-ui-voice-pulse 1.2s ease-in-out infinite;
1168
1434
  }
1169
1435
 
1436
+ .voice-btn[data-state="recording"]:hover {
1437
+ background: var(--_danger);
1438
+ color: #ffffff;
1439
+ }
1440
+
1170
1441
  @keyframes ag-ui-voice-pulse {
1171
1442
  0%, 100% { opacity: 1; }
1172
1443
  50% { opacity: 0.6; }
@@ -1557,16 +1828,35 @@ export const STYLES = `
1557
1828
  color: var(--_danger);
1558
1829
  }
1559
1830
 
1560
- /* Chat-history drawer — a slide-over within the chat panel. */
1831
+ /* Chat-history drawer — a slide-over within the chat panel.
1832
+ The hidden attribute stays the single source of truth for open/closed (no
1833
+ JS-driven animation state): display is transitioned discretely, so on close
1834
+ the drawer stays displayed for the whole exit and is removed only at the end,
1835
+ which is what lets the backdrop fade and the panel slide out. */
1561
1836
  .drawer {
1562
1837
  position: absolute;
1563
1838
  inset: 0;
1564
1839
  z-index: 5;
1565
1840
  display: flex;
1841
+ transition: visibility var(--_motion) var(--_ease);
1566
1842
  }
1567
1843
 
1844
+ /* Closed. The overlay keeps its box (display, not none) so the backdrop and
1845
+ panel inside it stay rendered and can transition both ways; visibility is
1846
+ what takes the whole subtree out of the tab order, the a11y tree and hit
1847
+ testing at rest, and it holds off until the slide has finished. */
1568
1848
  .drawer[hidden] {
1569
- display: none;
1849
+ display: flex;
1850
+ visibility: hidden;
1851
+ pointer-events: none;
1852
+ }
1853
+
1854
+ .drawer[hidden] .drawer-backdrop {
1855
+ opacity: 0;
1856
+ }
1857
+
1858
+ .drawer[hidden] .drawer-panel {
1859
+ transform: translateX(-100%);
1570
1860
  }
1571
1861
 
1572
1862
  .checkpoints {
@@ -1584,10 +1874,21 @@ export const STYLES = `
1584
1874
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
1585
1875
  max-height: 60%;
1586
1876
  overflow-y: auto;
1877
+ transform-origin: top center;
1878
+ transition:
1879
+ opacity var(--_motion) var(--_ease),
1880
+ transform var(--_motion) var(--_ease),
1881
+ visibility var(--_motion) var(--_ease);
1587
1882
  }
1588
1883
 
1884
+ /* Same idiom as the drawer: laid out at rest, hidden by visibility, so the
1885
+ popover can animate open and closed. */
1589
1886
  .checkpoints[hidden] {
1590
- display: none;
1887
+ display: flex;
1888
+ visibility: hidden;
1889
+ pointer-events: none;
1890
+ opacity: 0;
1891
+ transform: scale(0.96) translateY(-6px);
1591
1892
  }
1592
1893
 
1593
1894
  .checkpoints-title {
@@ -1649,6 +1950,8 @@ export const STYLES = `
1649
1950
  position: absolute;
1650
1951
  inset: 0;
1651
1952
  background: rgba(20, 20, 50, 0.32);
1953
+ opacity: 1;
1954
+ transition: opacity var(--_motion) var(--_ease);
1652
1955
  }
1653
1956
 
1654
1957
  .drawer-panel {
@@ -1661,6 +1964,8 @@ export const STYLES = `
1661
1964
  border-right: 1px solid var(--_border);
1662
1965
  box-shadow: var(--_shadow);
1663
1966
  overflow: hidden;
1967
+ transform: none;
1968
+ transition: transform var(--_motion) var(--_ease);
1664
1969
  }
1665
1970
 
1666
1971
  .drawer-header {
@@ -24,8 +24,15 @@ export interface UiStrings {
24
24
  newChat: string;
25
25
  /** Collapse button. */
26
26
  collapse: string;
27
- /** Expand affordance (the sidebar rail toggle). */
27
+ /** Expand affordance (the launcher, and the sidebar rail). */
28
28
  expand: string;
29
+ /**
30
+ * The launcher's label while unread answers are waiting behind it, with
31
+ * `{count}` replaced by how many. It replaces {@link expand} rather than
32
+ * appending to it, so a translation can order the two parts as its language
33
+ * needs.
34
+ */
35
+ expandUnread: string;
29
36
  /** Built-in header theme toggle (light ⇄ dark). */
30
37
  toggleTheme: string;
31
38
 
@@ -204,6 +211,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
204
211
  newChat: "New chat",
205
212
  collapse: "Collapse",
206
213
  expand: "Expand",
214
+ expandUnread: "Expand — {count} unread",
207
215
  toggleTheme: "Toggle theme",
208
216
  copyCode: "Copy",
209
217
  copied: "Copied",
@@ -1,3 +1,4 @@
1
+ import { ICON_VOICE } from "../constants.js";
1
2
  import type { TranscribeHandler } from "../core/transcribe_audio.js";
2
3
  import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
3
4
 
@@ -49,7 +50,12 @@ export class VoiceInput {
49
50
  this.element.type = "button";
50
51
  this.element.className = "voice-btn";
51
52
  this.element.setAttribute("part", "voice-button");
52
- this.element.textContent = "🎤";
53
+ // The mic glyph, in a slot so a host can project its own mark. Author-written
54
+ // constant markup, never user or server data.
55
+ const glyph = document.createElement("slot");
56
+ glyph.name = "icon-voice";
57
+ glyph.innerHTML = ICON_VOICE;
58
+ this.element.append(glyph);
53
59
  this.#setState("idle");
54
60
  this.element.addEventListener("click", () => {
55
61
  void this.toggle();
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.21.0";
1
+ export const VERSION: string = "0.22.0";