@mushi-mushi/web 0.4.1 → 0.5.1

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/dist/index.js CHANGED
@@ -216,294 +216,607 @@ function getAvailableLocales() {
216
216
  // src/styles.ts
217
217
  function getWidgetStyles(theme) {
218
218
  const isDark = theme === "dark";
219
- const accent = "#7c3aed";
220
- const accentHover = "#6d28d9";
221
- const accentBgLight = isDark ? "#2e2440" : "#f5f3ff";
222
- const accentBgSelected = isDark ? "#2e2440" : "#ede9fe";
223
- const border = isDark ? "#3f3f46" : "#e4e4e7";
224
- const bgSurface = isDark ? "#18181b" : "#ffffff";
225
- const bgMuted = isDark ? "#27272a" : "#fafafa";
226
- const textMuted = isDark ? "#a1a1aa" : "#71717a";
219
+ const paper = isDark ? "#0F0E0C" : "#F8F4ED";
220
+ const ink = isDark ? "#F2EBDD" : "#0E0D0B";
221
+ const inkMuted = isDark ? "#928B7E" : "#5C5852";
222
+ const inkFaint = isDark ? "#5A5650" : "#9A9489";
223
+ const rule = isDark ? "rgba(242,235,221,0.10)" : "rgba(14,13,11,0.10)";
224
+ const ruleStrong = isDark ? "rgba(242,235,221,0.18)" : "rgba(14,13,11,0.16)";
225
+ const vermillion = isDark ? "#FF5A47" : "#E03C2C";
226
+ const vermillionWash = isDark ? "rgba(255,90,71,0.12)" : "rgba(224,60,44,0.08)";
227
+ const vermillionInk = isDark ? "#FFE5E0" : "#7A1F15";
228
+ const fontDisplay = `'Iowan Old Style', 'Palatino Linotype', 'Palatino', 'Book Antiqua', 'Cambria', Georgia, 'Times New Roman', serif`;
229
+ const fontBody = `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Display', 'Segoe UI', sans-serif`;
230
+ const fontMono = `ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
231
+ const easeStamp = "cubic-bezier(0.22, 1, 0.36, 1)";
227
232
  return `
228
233
  :host {
229
234
  all: initial;
230
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
235
+ font-family: ${fontBody};
231
236
  font-size: 14px;
232
- line-height: 1.5;
233
- color: ${isDark ? "#e4e4e7" : "#18181b"};
234
- }
235
-
236
- * {
237
- box-sizing: border-box;
238
- margin: 0;
239
- padding: 0;
240
- }
237
+ line-height: 1.55;
238
+ color: ${ink};
239
+ -webkit-font-smoothing: antialiased;
240
+ -moz-osx-font-smoothing: grayscale;
241
+ font-feature-settings: 'ss01', 'cv11'; /* nicer system-ui glyphs where supported */
242
+ }
243
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
244
+ button { font-family: inherit; }
241
245
 
242
- /* Trigger button */
246
+ /* \u2500\u2500 Trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
247
+ A small "stamp card" \u2014 soft rounded square (4px radius), paper
248
+ background, vermillion bottom edge that reads as the inked face
249
+ of a real \u5370\u9451. A pulsing dot in the top-right hints there's a
250
+ channel here without needing a notification badge. */
243
251
  .mushi-trigger {
244
252
  position: fixed;
245
- width: 48px;
246
- height: 48px;
247
- border-radius: 50%;
248
- border: none;
253
+ width: 52px;
254
+ height: 52px;
255
+ border: 1px solid ${ruleStrong};
256
+ border-radius: 4px;
257
+ background: ${paper};
258
+ color: ${ink};
249
259
  cursor: pointer;
250
260
  display: flex;
251
261
  align-items: center;
252
262
  justify-content: center;
263
+ font-family: ${fontDisplay};
253
264
  font-size: 22px;
254
- background: ${bgMuted};
255
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
256
- transition: transform 0.2s ease, box-shadow 0.2s ease;
265
+ line-height: 1;
266
+ box-shadow:
267
+ 0 1px 0 ${rule},
268
+ 0 6px 14px -8px rgba(14,13,11,0.35),
269
+ inset 0 -3px 0 ${vermillion};
270
+ transition: transform 200ms ${easeStamp}, box-shadow 200ms ${easeStamp};
271
+ overflow: visible;
272
+ isolation: isolate;
273
+ }
274
+ .mushi-trigger::after {
275
+ content: '';
276
+ position: absolute;
277
+ top: 6px;
278
+ right: 6px;
279
+ width: 6px;
280
+ height: 6px;
281
+ border-radius: 50%;
282
+ background: ${vermillion};
283
+ box-shadow: 0 0 0 0 ${vermillion};
284
+ animation: mushi-pulse 2.4s ${easeStamp} infinite;
257
285
  }
258
286
  .mushi-trigger:hover {
259
- transform: scale(1.08);
260
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
287
+ transform: translateY(-2px) rotate(-1.5deg);
288
+ box-shadow:
289
+ 0 1px 0 ${rule},
290
+ 0 14px 24px -10px rgba(14,13,11,0.45),
291
+ inset 0 -3px 0 ${vermillion};
292
+ }
293
+ .mushi-trigger:active {
294
+ transform: translateY(0) rotate(0);
295
+ box-shadow:
296
+ 0 1px 0 ${rule},
297
+ 0 2px 4px -2px rgba(14,13,11,0.35),
298
+ inset 0 -2px 0 ${vermillion};
299
+ }
300
+ .mushi-trigger:focus-visible {
301
+ outline: 2px solid ${vermillion};
302
+ outline-offset: 3px;
303
+ }
304
+ .mushi-trigger.bottom-right { bottom: 24px; right: 24px; }
305
+ .mushi-trigger.bottom-left { bottom: 24px; left: 24px; }
306
+ .mushi-trigger.top-right { top: 24px; right: 24px; }
307
+ .mushi-trigger.top-left { top: 24px; left: 24px; }
308
+
309
+ @keyframes mushi-pulse {
310
+ 0% { box-shadow: 0 0 0 0 ${vermillion}; opacity: 1; }
311
+ 70% { box-shadow: 0 0 0 8px rgba(224,60,44,0); opacity: 0.5; }
312
+ 100% { box-shadow: 0 0 0 0 rgba(224,60,44,0); opacity: 1; }
261
313
  }
262
- .mushi-trigger.bottom-right { bottom: 20px; right: 20px; }
263
- .mushi-trigger.bottom-left { bottom: 20px; left: 20px; }
264
- .mushi-trigger.top-right { top: 20px; right: 20px; }
265
- .mushi-trigger.top-left { top: 20px; left: 20px; }
266
314
 
267
- /* Panel */
315
+ /* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
316
+ Paper-card. Sharper corners (6px) than typical SaaS modals
317
+ (which default to 12-16px and read as plastic). Two-layer shadow:
318
+ one hairline that sells the paper edge, one diffuse that lifts
319
+ the panel off the underlying app. No backdrop-filter \u2014 we want
320
+ the widget to feel like it sits ON the page, not blur INTO it. */
268
321
  .mushi-panel {
269
322
  position: fixed;
270
- width: 380px;
271
- max-height: 560px;
272
- border-radius: 12px;
273
- background: ${bgSurface};
274
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
275
- border: 1px solid ${border};
323
+ width: 384px;
324
+ max-width: calc(100vw - 32px);
325
+ max-height: min(640px, calc(100vh - 120px));
326
+ background: ${paper};
327
+ border: 1px solid ${ruleStrong};
328
+ border-radius: 6px;
329
+ box-shadow:
330
+ 0 1px 0 ${rule},
331
+ 0 24px 56px -20px rgba(14,13,11,0.30),
332
+ 0 8px 16px -8px rgba(14,13,11,0.20);
276
333
  overflow: hidden;
277
334
  display: flex;
278
335
  flex-direction: column;
336
+ transform-origin: var(--mushi-origin, bottom right);
279
337
  }
280
- .mushi-panel.open {
281
- animation: mushi-slide-in 0.25s ease forwards;
282
- }
338
+ .mushi-panel.open { animation: mushi-stamp-in 320ms ${easeStamp} both; }
283
339
  .mushi-panel.closed { display: none; }
284
- .mushi-panel.bottom-right { bottom: 76px; right: 20px; }
285
- .mushi-panel.bottom-left { bottom: 76px; left: 20px; }
286
- .mushi-panel.top-right { top: 76px; right: 20px; }
287
- .mushi-panel.top-left { top: 76px; left: 20px; }
340
+ .mushi-panel.bottom-right { bottom: 88px; right: 24px; --mushi-origin: bottom right; }
341
+ .mushi-panel.bottom-left { bottom: 88px; left: 24px; --mushi-origin: bottom left; }
342
+ .mushi-panel.top-right { top: 88px; right: 24px; --mushi-origin: top right; }
343
+ .mushi-panel.top-left { top: 88px; left: 24px; --mushi-origin: top left; }
288
344
 
289
- @keyframes mushi-slide-in {
290
- from { opacity: 0; transform: translateY(8px) scale(0.98); }
291
- to { opacity: 1; transform: translateY(0) scale(1); }
345
+ @keyframes mushi-stamp-in {
346
+ 0% { opacity: 0; transform: scale(0.94) translateY(6px); }
347
+ 60% { opacity: 1; }
348
+ 100% { opacity: 1; transform: scale(1) translateY(0); }
292
349
  }
293
350
 
294
- /* Header */
351
+ /* \u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
352
+ Editorial masthead: small mono eyebrow ("MUSHI / REPORT") on top,
353
+ serif display headline below, mono step counter on the far right.
354
+ A single hairline separates header from body \u2014 no card stacking. */
295
355
  .mushi-header {
296
- padding: 14px 16px;
297
- border-bottom: 1px solid ${border};
298
- display: flex;
356
+ padding: 18px 20px 14px;
357
+ border-bottom: 1px solid ${rule};
358
+ display: grid;
359
+ grid-template-columns: auto 1fr auto;
360
+ align-items: end;
361
+ gap: 12px;
362
+ }
363
+ .mushi-header-mark {
364
+ display: inline-flex;
299
365
  align-items: center;
300
- gap: 8px;
366
+ justify-content: center;
367
+ width: 22px;
368
+ height: 22px;
369
+ border-radius: 3px;
370
+ background: ${vermillion};
371
+ color: #FAF7F0;
372
+ font-family: ${fontDisplay};
373
+ font-size: 14px;
374
+ font-weight: 600;
375
+ line-height: 1;
376
+ letter-spacing: -0.02em;
377
+ transform: rotate(-3deg);
378
+ flex-shrink: 0;
379
+ }
380
+ .mushi-header-titles {
381
+ min-width: 0;
382
+ display: flex;
383
+ flex-direction: column;
384
+ gap: 2px;
385
+ }
386
+ .mushi-header-eyebrow {
387
+ font-family: ${fontMono};
388
+ font-size: 10px;
389
+ letter-spacing: 0.18em;
390
+ text-transform: uppercase;
391
+ color: ${inkMuted};
301
392
  }
302
393
  .mushi-header h3 {
303
- font-size: 15px;
394
+ font-family: ${fontDisplay};
395
+ font-size: 19px;
396
+ font-weight: 500;
397
+ line-height: 1.15;
398
+ letter-spacing: -0.01em;
399
+ color: ${ink};
400
+ }
401
+ .mushi-header-meta {
402
+ align-self: start;
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 6px;
406
+ }
407
+ .mushi-step-counter {
408
+ font-family: ${fontMono};
409
+ font-size: 11px;
410
+ color: ${inkMuted};
411
+ letter-spacing: 0.06em;
412
+ tab-size: 2ch;
413
+ padding-top: 2px;
414
+ }
415
+ .mushi-step-counter b {
304
416
  font-weight: 600;
305
- flex: 1;
417
+ color: ${ink};
306
418
  }
307
419
  .mushi-close, .mushi-back {
308
420
  background: none;
309
421
  border: none;
310
422
  cursor: pointer;
311
- font-size: 18px;
312
- color: ${textMuted};
313
423
  padding: 4px;
314
- border-radius: 4px;
424
+ color: ${inkMuted};
425
+ font-family: ${fontBody};
426
+ font-size: 14px;
315
427
  line-height: 1;
428
+ border-radius: 3px;
429
+ transition: color 150ms ${easeStamp};
316
430
  }
317
- .mushi-close:hover, .mushi-back:hover {
318
- background: ${bgMuted};
431
+ .mushi-close:hover, .mushi-back:hover { color: ${vermillion}; }
432
+ .mushi-close:focus-visible, .mushi-back:focus-visible {
433
+ outline: 1.5px solid ${vermillion};
434
+ outline-offset: 2px;
319
435
  }
320
436
 
321
- /* Body */
437
+ /* \u2500\u2500 Body \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
438
+ Generous left/right padding (22px) so type breathes. Vertical
439
+ padding tighter at top because the header rule already creates
440
+ breathing room. */
322
441
  .mushi-body {
323
- padding: 12px 16px;
442
+ padding: 8px 22px 16px;
324
443
  overflow-y: auto;
325
444
  flex: 1;
445
+ scrollbar-width: thin;
446
+ scrollbar-color: ${inkFaint} transparent;
326
447
  }
448
+ .mushi-body::-webkit-scrollbar { width: 6px; }
449
+ .mushi-body::-webkit-scrollbar-thumb { background: ${inkFaint}; border-radius: 3px; }
327
450
 
328
- /* Step 1: Category options */
451
+ /* \u2500\u2500 Step 1: Categories as a contents-page list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
452
+ No boxes. Hairline rules between rows. Hovering a row pulls a
453
+ vermillion arrow in from the right and tints the row label \u2014
454
+ reads like flipping through an index card. */
329
455
  .mushi-option-btn {
330
- display: flex;
456
+ display: grid;
457
+ grid-template-columns: auto 1fr auto;
331
458
  align-items: center;
332
- gap: 12px;
459
+ gap: 14px;
333
460
  width: 100%;
334
- padding: 12px 14px;
335
- margin-bottom: 8px;
336
- border-radius: 10px;
337
- border: 1px solid ${border};
338
- background: ${bgMuted};
461
+ padding: 14px 0;
462
+ border: none;
463
+ border-bottom: 1px solid ${rule};
464
+ background: transparent;
339
465
  cursor: pointer;
340
- font-size: 14px;
341
466
  color: inherit;
342
467
  text-align: left;
343
- transition: border-color 0.15s, background 0.15s, transform 0.1s;
344
- }
345
- .mushi-option-btn:hover {
346
- border-color: ${isDark ? "#a78bfa" : accent};
347
- background: ${accentBgLight};
348
- transform: translateX(2px);
468
+ transition: padding 220ms ${easeStamp}, color 220ms ${easeStamp};
469
+ position: relative;
349
470
  }
471
+ .mushi-option-btn:last-child { border-bottom: none; }
472
+ .mushi-option-btn:hover { padding-left: 6px; color: ${vermillion}; }
473
+ .mushi-option-btn:hover .mushi-option-arrow { opacity: 1; transform: translateX(0); color: ${vermillion}; }
350
474
  .mushi-option-btn:focus-visible {
351
- outline: 2px solid ${accent};
352
- outline-offset: 2px;
475
+ outline: none;
476
+ padding-left: 6px;
477
+ box-shadow: inset 2px 0 0 ${vermillion};
478
+ }
479
+ .mushi-option-icon {
480
+ font-size: 18px;
481
+ line-height: 1;
482
+ flex-shrink: 0;
483
+ filter: ${isDark ? "none" : "grayscale(0.15)"};
484
+ }
485
+ .mushi-option-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
486
+ .mushi-option-label {
487
+ font-family: ${fontDisplay};
488
+ font-size: 16px;
489
+ font-weight: 500;
490
+ letter-spacing: -0.005em;
491
+ line-height: 1.2;
492
+ }
493
+ .mushi-option-desc {
494
+ font-size: 12px;
495
+ color: ${inkMuted};
496
+ letter-spacing: 0.005em;
497
+ }
498
+ .mushi-option-arrow {
499
+ font-family: ${fontMono};
500
+ font-size: 14px;
501
+ color: ${inkFaint};
502
+ opacity: 0;
503
+ transform: translateX(-4px);
504
+ transition: opacity 220ms ${easeStamp}, transform 220ms ${easeStamp}, color 220ms ${easeStamp};
353
505
  }
354
- .mushi-option-icon { font-size: 20px; flex-shrink: 0; }
355
- .mushi-option-text { display: flex; flex-direction: column; gap: 2px; }
356
- .mushi-option-label { font-weight: 500; }
357
- .mushi-option-desc { font-size: 12px; color: ${textMuted}; }
358
506
 
359
- /* Step 2: Intent chips */
507
+ /* \u2500\u2500 Step 2: Selected-category breadcrumb + intent text-buttons \u2500
508
+ Breadcrumb is a thin chip with the kanji-stamp aesthetic carried
509
+ over (vermillion left rule). Intents are inline TEXT buttons
510
+ with vermillion underlines on hover \u2014 not pill-shaped chips,
511
+ which is the SaaS default and not what we are. */
360
512
  .mushi-selected-category {
361
- display: flex;
513
+ display: inline-flex;
362
514
  align-items: center;
363
515
  gap: 8px;
364
- padding: 8px 12px;
365
- border-radius: 8px;
366
- background: ${accentBgSelected};
367
- font-size: 13px;
368
- font-weight: 500;
369
- margin-bottom: 12px;
370
- }
516
+ padding: 6px 10px 6px 12px;
517
+ border-left: 2px solid ${vermillion};
518
+ background: ${vermillionWash};
519
+ color: ${vermillionInk};
520
+ font-family: ${fontMono};
521
+ font-size: 11px;
522
+ letter-spacing: 0.12em;
523
+ text-transform: uppercase;
524
+ margin: 4px 0 14px;
525
+ border-radius: 0 3px 3px 0;
526
+ }
527
+ .mushi-selected-category span:first-child { font-size: 14px; }
371
528
  .mushi-intents {
372
529
  display: flex;
373
- flex-wrap: wrap;
374
- gap: 8px;
530
+ flex-direction: column;
531
+ gap: 2px;
375
532
  }
376
533
  .mushi-intent-btn {
377
- padding: 8px 16px;
378
- border-radius: 20px;
379
- border: 1px solid ${border};
380
- background: ${bgMuted};
534
+ display: flex;
535
+ align-items: center;
536
+ justify-content: space-between;
537
+ gap: 12px;
538
+ padding: 12px 0;
539
+ border: none;
540
+ border-bottom: 1px solid ${rule};
541
+ background: transparent;
381
542
  cursor: pointer;
382
- font-size: 13px;
383
543
  color: inherit;
384
- transition: border-color 0.15s, background 0.15s;
385
- }
386
- .mushi-intent-btn:hover {
387
- border-color: ${isDark ? "#a78bfa" : accent};
388
- background: ${accentBgLight};
544
+ text-align: left;
545
+ font-family: ${fontDisplay};
546
+ font-size: 15px;
547
+ transition: padding 220ms ${easeStamp}, color 220ms ${easeStamp};
389
548
  }
549
+ .mushi-intent-btn::after {
550
+ content: '\u2192';
551
+ font-family: ${fontMono};
552
+ font-size: 13px;
553
+ color: ${inkFaint};
554
+ opacity: 0;
555
+ transform: translateX(-4px);
556
+ transition: opacity 220ms ${easeStamp}, transform 220ms ${easeStamp};
557
+ }
558
+ .mushi-intent-btn:last-child { border-bottom: none; }
559
+ .mushi-intent-btn:hover { padding-left: 6px; color: ${vermillion}; }
560
+ .mushi-intent-btn:hover::after { opacity: 1; transform: translateX(0); color: ${vermillion}; }
390
561
  .mushi-intent-btn:focus-visible {
391
- outline: 2px solid ${accent};
392
- outline-offset: 2px;
562
+ outline: none;
563
+ padding-left: 6px;
564
+ box-shadow: inset 2px 0 0 ${vermillion};
393
565
  }
394
566
 
395
- /* Step 3: Details */
567
+ /* \u2500\u2500 Step 3: Borderless textarea + minimal attach pills \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
568
+ The textarea has no box around it \u2014 just a hairline underline
569
+ that turns vermillion on focus. Encourages writing rather than
570
+ form-filling. */
396
571
  .mushi-textarea {
397
572
  width: 100%;
398
- min-height: 90px;
399
- padding: 10px 12px;
400
- border-radius: 8px;
401
- border: 1px solid ${border};
402
- background: ${bgMuted};
403
- color: inherit;
404
- font-family: inherit;
573
+ min-height: 96px;
574
+ padding: 8px 0 10px;
575
+ border: none;
576
+ border-bottom: 1px solid ${ruleStrong};
577
+ background: transparent;
578
+ color: ${ink};
579
+ font-family: ${fontBody};
405
580
  font-size: 14px;
581
+ line-height: 1.5;
406
582
  resize: vertical;
407
583
  outline: none;
408
- transition: border-color 0.15s, box-shadow 0.15s;
584
+ transition: border-color 200ms ${easeStamp};
409
585
  }
410
- .mushi-textarea:focus {
411
- border-color: ${isDark ? "#a78bfa" : accent};
412
- box-shadow: 0 0 0 2px ${isDark ? "rgba(167,139,250,0.2)" : "rgba(124,58,237,0.1)"};
586
+ .mushi-textarea::placeholder {
587
+ color: ${inkFaint};
588
+ font-style: italic;
413
589
  }
590
+ .mushi-textarea:focus { border-bottom-color: ${vermillion}; }
414
591
 
415
592
  .mushi-attachments {
416
593
  display: flex;
417
- gap: 8px;
418
- margin-top: 10px;
594
+ flex-wrap: wrap;
595
+ gap: 6px;
596
+ margin-top: 12px;
419
597
  }
420
598
  .mushi-attach-btn {
421
- padding: 6px 12px;
422
- border-radius: 6px;
423
- border: 1px solid ${border};
424
- background: none;
599
+ display: inline-flex;
600
+ align-items: center;
601
+ gap: 6px;
602
+ padding: 5px 10px;
603
+ border: 1px solid ${ruleStrong};
604
+ border-radius: 3px;
605
+ background: transparent;
606
+ color: ${inkMuted};
607
+ font-family: ${fontMono};
608
+ font-size: 11px;
609
+ letter-spacing: 0.04em;
610
+ text-transform: uppercase;
425
611
  cursor: pointer;
426
- font-size: 12px;
427
- color: ${textMuted};
428
- transition: border-color 0.15s, color 0.15s;
612
+ transition: color 180ms ${easeStamp}, border-color 180ms ${easeStamp}, background 180ms ${easeStamp};
429
613
  }
430
614
  .mushi-attach-btn:hover {
431
- border-color: ${isDark ? "#a78bfa" : accent};
432
- color: inherit;
615
+ color: ${ink};
616
+ border-color: ${ink};
433
617
  }
434
618
  .mushi-attach-btn.active {
435
- border-color: ${isDark ? "#a78bfa" : accent};
436
- color: ${isDark ? "#a78bfa" : accent};
437
- background: ${accentBgLight};
619
+ color: ${vermillion};
620
+ border-color: ${vermillion};
621
+ background: ${vermillionWash};
622
+ }
623
+ .mushi-attach-btn:focus-visible {
624
+ outline: 2px solid ${vermillion};
625
+ outline-offset: 2px;
438
626
  }
439
627
 
440
- /* Footer */
628
+ /* \u2500\u2500 Footer + submit (vermillion stamp) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
629
+ Submit button is the heaviest visual moment in the widget \u2014
630
+ vermillion fill, mono-caps label, send arrow. Holds an ink-
631
+ bloom pseudo-element that animates outward when pressed. */
441
632
  .mushi-footer {
442
- padding: 12px 16px;
443
- border-top: 1px solid ${border};
633
+ padding: 14px 22px 16px;
634
+ border-top: 1px solid ${rule};
444
635
  display: flex;
445
636
  align-items: center;
446
- justify-content: flex-end;
637
+ justify-content: space-between;
638
+ gap: 12px;
639
+ }
640
+ .mushi-footer-hint {
641
+ font-family: ${fontMono};
642
+ font-size: 10px;
643
+ letter-spacing: 0.10em;
644
+ text-transform: uppercase;
645
+ color: ${inkFaint};
447
646
  }
448
647
  .mushi-submit {
449
- padding: 8px 24px;
450
- border-radius: 8px;
451
- border: none;
452
- background: ${accent};
453
- color: #ffffff;
454
- font-size: 14px;
455
- font-weight: 500;
648
+ position: relative;
649
+ display: inline-flex;
650
+ align-items: center;
651
+ gap: 8px;
652
+ padding: 10px 18px;
653
+ border: 1px solid ${vermillion};
654
+ border-radius: 3px;
655
+ background: ${vermillion};
656
+ color: #FAF7F0;
657
+ font-family: ${fontMono};
658
+ font-size: 11px;
659
+ letter-spacing: 0.16em;
660
+ text-transform: uppercase;
456
661
  cursor: pointer;
457
- transition: background 0.15s;
662
+ overflow: hidden;
663
+ transition: transform 180ms ${easeStamp}, box-shadow 180ms ${easeStamp};
664
+ box-shadow: 0 2px 0 ${isDark ? "#7A1F15" : "#9A2A1E"};
665
+ }
666
+ .mushi-submit::after {
667
+ content: '';
668
+ position: absolute;
669
+ inset: 0;
670
+ background: radial-gradient(circle at center, rgba(255,255,255,0.35) 0%, transparent 60%);
671
+ opacity: 0;
672
+ transform: scale(0.4);
673
+ transition: opacity 280ms ${easeStamp}, transform 380ms ${easeStamp};
674
+ pointer-events: none;
675
+ }
676
+ .mushi-submit:hover {
677
+ transform: translateY(-1px);
678
+ box-shadow: 0 3px 0 ${isDark ? "#7A1F15" : "#9A2A1E"};
679
+ }
680
+ .mushi-submit:hover::after { opacity: 1; transform: scale(1.4); }
681
+ .mushi-submit:active { transform: translateY(1px); box-shadow: 0 1px 0 ${isDark ? "#7A1F15" : "#9A2A1E"}; }
682
+ .mushi-submit:disabled {
683
+ cursor: wait;
684
+ opacity: 0.7;
458
685
  }
459
- .mushi-submit:hover { background: ${accentHover}; }
460
- .mushi-submit:disabled { opacity: 0.5; cursor: not-allowed; }
461
686
  .mushi-submit:focus-visible {
462
- outline: 2px solid ${accent};
463
- outline-offset: 2px;
687
+ outline: 2px solid ${vermillion};
688
+ outline-offset: 3px;
464
689
  }
690
+ .mushi-submit-arrow {
691
+ display: inline-block;
692
+ transition: transform 220ms ${easeStamp};
693
+ }
694
+ .mushi-submit:hover .mushi-submit-arrow { transform: translateX(3px); }
465
695
 
466
- /* Step indicator dots */
696
+ /* \u2500\u2500 Step indicator (numeral ledger) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
697
+ Replaces the generic three-dots with a typographic series:
698
+ "01 \u2014 02 \u2014 03". The active step uses serif numerals, the
699
+ others use mono so the active one literally reads heavier. */
467
700
  .mushi-step-indicator {
468
701
  display: flex;
702
+ align-items: center;
469
703
  justify-content: center;
470
- gap: 6px;
471
- padding: 10px;
472
- }
473
- .mushi-dot {
474
- width: 6px;
475
- height: 6px;
476
- border-radius: 50%;
477
- background: ${border};
478
- transition: background 0.2s, transform 0.2s;
479
- }
480
- .mushi-dot.active {
481
- background: ${accent};
482
- transform: scale(1.3);
483
- }
484
- .mushi-dot.done {
485
- background: ${isDark ? "#a78bfa" : "#8b5cf6"};
704
+ gap: 8px;
705
+ padding: 10px 22px 14px;
706
+ color: ${inkFaint};
707
+ font-family: ${fontMono};
708
+ font-size: 11px;
709
+ letter-spacing: 0.10em;
710
+ }
711
+ .mushi-step-num {
712
+ display: inline-flex;
713
+ align-items: baseline;
714
+ gap: 4px;
715
+ transition: color 200ms ${easeStamp};
716
+ }
717
+ .mushi-step-num.done { color: ${inkMuted}; text-decoration: line-through; text-decoration-color: ${inkFaint}; }
718
+ .mushi-step-num.active {
719
+ color: ${vermillion};
720
+ font-family: ${fontDisplay};
721
+ font-size: 14px;
722
+ font-weight: 600;
723
+ letter-spacing: 0;
486
724
  }
725
+ .mushi-step-sep { width: 14px; height: 1px; background: ${rule}; }
487
726
 
488
- /* Success */
727
+ /* \u2500\u2500 Success: \u6731\u5370 stamp animation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
728
+ The success state is the signature moment. A vermillion ring
729
+ scribes itself, then a "RECEIVED" mono-caps label fades in at
730
+ the centre, evoking a hanko being pressed onto the form. */
489
731
  .mushi-success {
490
732
  text-align: center;
491
- padding: 32px 16px;
733
+ padding: 28px 16px 20px;
734
+ }
735
+ .mushi-success-stamp {
736
+ position: relative;
737
+ width: 96px;
738
+ height: 96px;
739
+ margin: 0 auto 16px;
740
+ display: inline-flex;
741
+ align-items: center;
742
+ justify-content: center;
743
+ }
744
+ .mushi-success-stamp svg {
745
+ position: absolute;
746
+ inset: 0;
747
+ width: 100%;
748
+ height: 100%;
749
+ }
750
+ .mushi-success-stamp circle {
751
+ fill: none;
752
+ stroke: ${vermillion};
753
+ stroke-width: 3;
754
+ stroke-dasharray: 280;
755
+ stroke-dashoffset: 280;
756
+ transform: rotate(-90deg);
757
+ transform-origin: center;
758
+ animation: mushi-stamp-ring 700ms ${easeStamp} 80ms forwards;
759
+ }
760
+ .mushi-success-stamp-label {
761
+ font-family: ${fontDisplay};
762
+ font-size: 18px;
763
+ font-weight: 600;
764
+ color: ${vermillion};
765
+ letter-spacing: 0.04em;
766
+ transform: rotate(-6deg);
767
+ opacity: 0;
768
+ animation: mushi-stamp-press 360ms ${easeStamp} 600ms forwards;
769
+ }
770
+ .mushi-success-headline {
771
+ font-family: ${fontDisplay};
772
+ font-size: 18px;
773
+ font-weight: 500;
774
+ color: ${ink};
775
+ margin-bottom: 4px;
492
776
  }
493
- .mushi-success-icon {
494
- font-size: 40px;
495
- margin-bottom: 12px;
777
+ .mushi-success-meta {
778
+ font-family: ${fontMono};
779
+ font-size: 11px;
780
+ letter-spacing: 0.10em;
781
+ text-transform: uppercase;
782
+ color: ${inkMuted};
496
783
  }
497
- .mushi-success p {
498
- color: ${textMuted};
499
- font-size: 14px;
784
+
785
+ @keyframes mushi-stamp-ring {
786
+ to { stroke-dashoffset: 0; }
787
+ }
788
+ @keyframes mushi-stamp-press {
789
+ 0% { opacity: 0; transform: rotate(-6deg) scale(1.3); }
790
+ 60% { opacity: 1; transform: rotate(-6deg) scale(0.94); }
791
+ 100% { opacity: 1; transform: rotate(-6deg) scale(1); }
500
792
  }
501
793
 
502
- /* Error */
794
+ /* \u2500\u2500 Error \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
795
+ Inline editorial note rather than a red box. Vermillion left
796
+ rule keeps the same accent language. */
503
797
  .mushi-error {
504
- color: #ef4444;
798
+ margin-top: 10px;
799
+ padding: 8px 0 8px 10px;
800
+ border-left: 2px solid ${vermillion};
801
+ color: ${vermillion};
505
802
  font-size: 12px;
506
- margin-top: 8px;
803
+ font-family: ${fontMono};
804
+ letter-spacing: 0.02em;
805
+ }
806
+
807
+ /* \u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
808
+ Honour the OS preference: kill every transition + animation
809
+ except the focus underline (which is critical feedback). */
810
+ @media (prefers-reduced-motion: reduce) {
811
+ *,
812
+ *::before,
813
+ *::after {
814
+ animation-duration: 0.001ms !important;
815
+ animation-iteration-count: 1 !important;
816
+ transition-duration: 0.001ms !important;
817
+ }
818
+ .mushi-success-stamp circle { stroke-dashoffset: 0; }
819
+ .mushi-success-stamp-label { opacity: 1; }
507
820
  }
508
821
  `;
509
822
  }
@@ -516,6 +829,18 @@ var CATEGORY_ICONS = {
516
829
  confusing: "\u{1F615}",
517
830
  other: "\u{1F4DD}"
518
831
  };
832
+ function pad2(n) {
833
+ return n < 10 ? `0${n}` : String(n);
834
+ }
835
+ var TOTAL_STEPS = 3;
836
+ var STEP_NUMBER = {
837
+ category: 1,
838
+ intent: 2,
839
+ details: 3
840
+ };
841
+ function isSubmitShortcut(e) {
842
+ return (e.metaKey || e.ctrlKey) && e.key === "Enter";
843
+ }
519
844
  var MushiWidget = class {
520
845
  host;
521
846
  shadow;
@@ -529,11 +854,28 @@ var MushiWidget = class {
529
854
  screenshotAttached = false;
530
855
  elementSelected = false;
531
856
  submitting = false;
857
+ /** Captured at the moment of submit so the success ledger metadata
858
+ * ("REPORT · 14:23:07 JST") doesn't drift while the success step
859
+ * is on screen. */
860
+ submittedAt = null;
861
+ /** Pending success-state + auto-close timers. Tracked so destroy()
862
+ * can clear them — otherwise a host that unmounts mid-submit leaks
863
+ * this MushiWidget reference (and re-renders into a detached shadow
864
+ * root) for up to ~3.3s after destroy. */
865
+ successTimer = null;
866
+ autoCloseTimer = null;
532
867
  constructor(config = {}, callbacks) {
533
868
  this.config = {
534
869
  position: config.position ?? "bottom-right",
535
870
  theme: config.theme ?? "auto",
536
- triggerText: config.triggerText ?? "\u{1F41B}",
871
+ // Falsy-OR (NOT `??`) on purpose: `triggerText: ''` is semantically
872
+ // nonsense — it would render a labelless, glyphless trigger button
873
+ // that users can't see or aim at. Treat empty string the same as
874
+ // omitted so any caller that wires this to a cleared form input or
875
+ // pastes a legacy snippet that emitted `triggerText: ""` (see
876
+ // apps/admin/src/lib/sdkSnippets.ts widgetLines history) still gets
877
+ // the default 🐛 and a visible button.
878
+ triggerText: config.triggerText || "\u{1F41B}",
537
879
  expandedTitle: config.expandedTitle ?? "",
538
880
  mode: config.mode ?? "conversational",
539
881
  locale: config.locale ?? "auto",
@@ -549,12 +891,27 @@ var MushiWidget = class {
549
891
  document.body.appendChild(this.host);
550
892
  this.render();
551
893
  }
894
+ updateConfig(config = {}) {
895
+ this.config = {
896
+ ...this.config,
897
+ ...config.position ? { position: config.position } : {},
898
+ ...config.theme ? { theme: config.theme } : {},
899
+ ...config.triggerText !== void 0 ? { triggerText: config.triggerText || "\u{1F41B}" } : {},
900
+ ...config.expandedTitle !== void 0 ? { expandedTitle: config.expandedTitle } : {},
901
+ ...config.mode ? { mode: config.mode } : {},
902
+ ...config.locale ? { locale: config.locale } : {},
903
+ ...config.zIndex !== void 0 ? { zIndex: config.zIndex } : {}
904
+ };
905
+ this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
906
+ this.render();
907
+ }
552
908
  open(options) {
553
909
  if (this.isOpen) return;
554
910
  this.isOpen = true;
555
911
  this.screenshotAttached = false;
556
912
  this.elementSelected = false;
557
913
  this.submitting = false;
914
+ this.submittedAt = null;
558
915
  if (options?.category) {
559
916
  this.selectedCategory = options.category;
560
917
  this.selectedIntent = null;
@@ -585,6 +942,14 @@ var MushiWidget = class {
585
942
  if (this.isOpen) this.render();
586
943
  }
587
944
  destroy() {
945
+ if (this.successTimer !== null) {
946
+ clearTimeout(this.successTimer);
947
+ this.successTimer = null;
948
+ }
949
+ if (this.autoCloseTimer !== null) {
950
+ clearTimeout(this.autoCloseTimer);
951
+ this.autoCloseTimer = null;
952
+ }
588
953
  this.host.remove();
589
954
  }
590
955
  getTheme() {
@@ -606,6 +971,8 @@ var MushiWidget = class {
606
971
  trigger.className = `mushi-trigger ${pos}`;
607
972
  trigger.textContent = this.config.triggerText;
608
973
  trigger.setAttribute("aria-label", t.widget.trigger);
974
+ trigger.setAttribute("aria-haspopup", "dialog");
975
+ trigger.setAttribute("aria-expanded", String(this.isOpen));
609
976
  trigger.style.zIndex = String(this.config.zIndex);
610
977
  trigger.addEventListener("click", () => {
611
978
  if (this.isOpen) this.close();
@@ -615,6 +982,7 @@ var MushiWidget = class {
615
982
  const panel = document.createElement("div");
616
983
  panel.className = `mushi-panel ${pos}${this.isOpen ? " open" : " closed"}`;
617
984
  panel.setAttribute("role", "dialog");
985
+ panel.setAttribute("aria-modal", "true");
618
986
  panel.setAttribute("aria-label", t.widget.title);
619
987
  panel.style.zIndex = String(this.config.zIndex + 1);
620
988
  if (this.isOpen) {
@@ -636,37 +1004,67 @@ var MushiWidget = class {
636
1004
  return this.renderSuccessStep();
637
1005
  }
638
1006
  }
639
- renderHeader(title, showBack = false) {
1007
+ /**
1008
+ * Editorial masthead. Always carries:
1009
+ * • the brand mark (虫 kanji on vermillion, "MUSHI" in mono above)
1010
+ * • the page title (serif display)
1011
+ * • the close affordance
1012
+ *
1013
+ * On sub-steps it additionally renders a back button (replacing the
1014
+ * "MUSHI" eyebrow with a "← BACK" mono link) and a step counter
1015
+ * ledger ("02 / 03") on the far right.
1016
+ */
1017
+ renderHeader(opts) {
640
1018
  const t = this.locale;
1019
+ const { title, showBack = false, step, eyebrow } = opts;
1020
+ const eyebrowHtml = showBack ? `<button type="button" class="mushi-back" data-action="back" aria-label="${t.widget.back}">\u2190 ${t.widget.back}</button>` : `<span class="mushi-header-eyebrow">${eyebrow ?? "Mushi \xB7 Report"}</span>`;
1021
+ const counterHtml = step ? `<span class="mushi-step-counter" aria-label="Step ${step} of ${TOTAL_STEPS}"><b>${pad2(step)}</b> / ${pad2(TOTAL_STEPS)}</span>` : "";
641
1022
  return `
642
1023
  <div class="mushi-header">
643
- ${showBack ? `<button class="mushi-back" data-action="back" aria-label="${t.widget.back}">\u2190</button>` : ""}
644
- <h3>${title}</h3>
645
- <button class="mushi-close" data-action="close" aria-label="${t.widget.close}">\u2715</button>
1024
+ <div class="mushi-header-mark" aria-hidden="true">\u866B</div>
1025
+ <div class="mushi-header-titles">
1026
+ ${eyebrowHtml}
1027
+ <h3>${title}</h3>
1028
+ </div>
1029
+ <div class="mushi-header-meta">
1030
+ ${counterHtml}
1031
+ <button type="button" class="mushi-close" data-action="close" aria-label="${t.widget.close}">\u2715</button>
1032
+ </div>
646
1033
  </div>
647
1034
  `;
648
1035
  }
1036
+ /**
1037
+ * Numeral step indicator: "01 — 02 — 03", with the active step in
1038
+ * vermillion serif and completed steps struck through in mono.
1039
+ * Replaces the original three-dot indicator (a generic SaaS pattern).
1040
+ */
1041
+ renderStepIndicator(currentStep) {
1042
+ const segments = [];
1043
+ for (let i = 1; i <= TOTAL_STEPS; i++) {
1044
+ const cls = i < currentStep ? "mushi-step-num done" : i === currentStep ? "mushi-step-num active" : "mushi-step-num";
1045
+ segments.push(`<span class="${cls}">${pad2(i)}</span>`);
1046
+ if (i < TOTAL_STEPS) segments.push('<span class="mushi-step-sep" aria-hidden="true"></span>');
1047
+ }
1048
+ return `<div class="mushi-step-indicator" aria-hidden="true">${segments.join("")}</div>`;
1049
+ }
649
1050
  renderCategoryStep() {
650
1051
  const t = this.locale;
651
1052
  const categories = ["bug", "slow", "visual", "confusing", "other"].map((id) => `
652
- <button class="mushi-option-btn" data-category="${id}" role="radio" aria-checked="false">
653
- <span class="mushi-option-icon">${CATEGORY_ICONS[id]}</span>
1053
+ <button type="button" class="mushi-option-btn" data-category="${id}" role="radio" aria-checked="false">
1054
+ <span class="mushi-option-icon" aria-hidden="true">${CATEGORY_ICONS[id]}</span>
654
1055
  <div class="mushi-option-text">
655
1056
  <span class="mushi-option-label">${t.step1.categories[id]}</span>
656
1057
  <span class="mushi-option-desc">${t.step1.categoryDescriptions[id]}</span>
657
1058
  </div>
1059
+ <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
658
1060
  </button>
659
1061
  `).join("");
660
1062
  return `
661
- ${this.renderHeader(t.step1.heading)}
1063
+ ${this.renderHeader({ title: t.step1.heading, step: STEP_NUMBER.category })}
662
1064
  <div class="mushi-body" role="radiogroup" aria-label="${t.step1.heading}">
663
1065
  ${categories}
664
1066
  </div>
665
- <div class="mushi-step-indicator">
666
- <span class="mushi-dot active"></span>
667
- <span class="mushi-dot"></span>
668
- <span class="mushi-dot"></span>
669
- </div>
1067
+ ${this.renderStepIndicator(STEP_NUMBER.category)}
670
1068
  `;
671
1069
  }
672
1070
  renderIntentStep() {
@@ -674,32 +1072,28 @@ var MushiWidget = class {
674
1072
  const cat = this.selectedCategory;
675
1073
  const intents = t.step2.intents[cat] || [];
676
1074
  const options = intents.map((intent) => `
677
- <button class="mushi-intent-btn" data-intent="${intent}">
1075
+ <button type="button" class="mushi-intent-btn" data-intent="${intent}">
678
1076
  ${intent}
679
1077
  </button>
680
1078
  `).join("");
681
1079
  return `
682
- ${this.renderHeader(t.step2.heading, true)}
1080
+ ${this.renderHeader({ title: t.step2.heading, showBack: true, step: STEP_NUMBER.intent })}
683
1081
  <div class="mushi-body">
684
1082
  <div class="mushi-selected-category">
685
- <span>${CATEGORY_ICONS[cat]}</span>
1083
+ <span aria-hidden="true">${CATEGORY_ICONS[cat]}</span>
686
1084
  <span>${t.step1.categories[cat]}</span>
687
1085
  </div>
688
1086
  <div class="mushi-intents">
689
1087
  ${options}
690
1088
  </div>
691
1089
  </div>
692
- <div class="mushi-step-indicator">
693
- <span class="mushi-dot done"></span>
694
- <span class="mushi-dot active"></span>
695
- <span class="mushi-dot"></span>
696
- </div>
1090
+ ${this.renderStepIndicator(STEP_NUMBER.intent)}
697
1091
  `;
698
1092
  }
699
1093
  renderDetailsStep() {
700
1094
  const t = this.locale;
701
1095
  return `
702
- ${this.renderHeader(t.step3.heading, true)}
1096
+ ${this.renderHeader({ title: t.step3.heading, showBack: true, step: STEP_NUMBER.details })}
703
1097
  <div class="mushi-body">
704
1098
  <textarea
705
1099
  class="mushi-textarea"
@@ -709,35 +1103,47 @@ var MushiWidget = class {
709
1103
  autofocus
710
1104
  ></textarea>
711
1105
  <div class="mushi-attachments">
712
- <button class="mushi-attach-btn${this.screenshotAttached ? " active" : ""}" data-action="screenshot">
1106
+ <button type="button" class="mushi-attach-btn${this.screenshotAttached ? " active" : ""}" data-action="screenshot">
713
1107
  \u{1F4F8} ${this.screenshotAttached ? t.step3.screenshotAttached : t.step3.screenshotButton}
714
1108
  </button>
715
- <button class="mushi-attach-btn${this.elementSelected ? " active" : ""}" data-action="element">
1109
+ <button type="button" class="mushi-attach-btn${this.elementSelected ? " active" : ""}" data-action="element">
716
1110
  \u{1F3AF} ${this.elementSelected ? t.step3.elementSelected : t.step3.elementButton}
717
1111
  </button>
718
1112
  </div>
719
1113
  <div class="mushi-error" style="display:none" role="alert"></div>
720
1114
  </div>
721
1115
  <div class="mushi-footer">
722
- <button class="mushi-submit" data-action="submit"${this.submitting ? " disabled" : ""}>
723
- ${this.submitting ? t.widget.submitting : t.widget.submit}
1116
+ <span class="mushi-footer-hint" aria-hidden="true">\u2318 + ENTER \u2192 send</span>
1117
+ <button type="button" class="mushi-submit" data-action="submit"${this.submitting ? " disabled" : ""}>
1118
+ <span>${this.submitting ? t.widget.submitting : t.widget.submit}</span>
1119
+ <span class="mushi-submit-arrow" aria-hidden="true">\u2192</span>
724
1120
  </button>
725
1121
  </div>
726
- <div class="mushi-step-indicator">
727
- <span class="mushi-dot done"></span>
728
- <span class="mushi-dot done"></span>
729
- <span class="mushi-dot active"></span>
730
- </div>
1122
+ ${this.renderStepIndicator(STEP_NUMBER.details)}
731
1123
  `;
732
1124
  }
1125
+ /**
1126
+ * Editorial success state: 朱印-style red stamp ring with the kanji
1127
+ * 受 ("received") at its centre, the localised "thank you" string
1128
+ * in serif below, and a mono ledger receipt ("REPORT · HH:MM:SS").
1129
+ * The ring + label animations are defined in styles.ts so this stays
1130
+ * pure markup and `prefers-reduced-motion` flips them to the final
1131
+ * frame instantly.
1132
+ */
733
1133
  renderSuccessStep() {
734
1134
  const t = this.locale;
1135
+ const stamp = this.submittedAt ?? /* @__PURE__ */ new Date();
1136
+ const time = stamp.toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
735
1137
  return `
736
- ${this.renderHeader(t.widget.title)}
1138
+ ${this.renderHeader({ title: t.widget.title, eyebrow: "Mushi \xB7 Receipt" })}
737
1139
  <div class="mushi-body">
738
1140
  <div class="mushi-success">
739
- <div class="mushi-success-icon">\u2705</div>
740
- <p>${t.widget.submitted}</p>
1141
+ <div class="mushi-success-stamp" aria-hidden="true">
1142
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet"><circle cx="50" cy="50" r="44"/></svg>
1143
+ <span class="mushi-success-stamp-label">\u53D7</span>
1144
+ </div>
1145
+ <div class="mushi-success-headline">${t.widget.submitted}</div>
1146
+ <div class="mushi-success-meta">REPORT \xB7 ${time}</div>
741
1147
  </div>
742
1148
  </div>
743
1149
  `;
@@ -775,7 +1181,7 @@ var MushiWidget = class {
775
1181
  panel.querySelector('[data-action="element"]')?.addEventListener("click", () => {
776
1182
  this.callbacks.onElementSelectorRequest?.();
777
1183
  });
778
- panel.querySelector('[data-action="submit"]')?.addEventListener("click", () => {
1184
+ const submitReport = () => {
779
1185
  const textarea = panel.querySelector(".mushi-textarea");
780
1186
  const description = textarea?.value?.trim() ?? "";
781
1187
  const errorEl = panel.querySelector(".mushi-error");
@@ -788,27 +1194,43 @@ var MushiWidget = class {
788
1194
  return;
789
1195
  }
790
1196
  this.submitting = true;
1197
+ this.submittedAt = /* @__PURE__ */ new Date();
791
1198
  this.render();
792
1199
  this.callbacks.onSubmit({
793
1200
  category: this.selectedCategory,
794
1201
  description,
795
1202
  intent: this.selectedIntent ?? void 0
796
1203
  });
797
- setTimeout(() => {
1204
+ this.successTimer = setTimeout(() => {
1205
+ this.successTimer = null;
798
1206
  this.submitting = false;
799
1207
  this.step = "success";
800
1208
  this.render();
801
- setTimeout(() => {
1209
+ this.autoCloseTimer = setTimeout(() => {
1210
+ this.autoCloseTimer = null;
802
1211
  if (this.step === "success") this.close();
803
- }, 2500);
1212
+ }, 2800);
804
1213
  }, 500);
805
- });
1214
+ };
1215
+ panel.querySelector('[data-action="submit"]')?.addEventListener("click", submitReport);
806
1216
  panel.addEventListener("keydown", (e) => {
807
- if (e.key === "Escape") this.close();
1217
+ if (e.key === "Escape") {
1218
+ this.close();
1219
+ return;
1220
+ }
1221
+ if (this.step === "details" && isSubmitShortcut(e)) {
1222
+ e.preventDefault();
1223
+ submitReport();
1224
+ }
808
1225
  });
809
1226
  }
810
1227
  trapFocus(panel) {
811
1228
  requestAnimationFrame(() => {
1229
+ const textarea = panel.querySelector("textarea");
1230
+ if (textarea) {
1231
+ textarea.focus();
1232
+ return;
1233
+ }
812
1234
  const focusable = panel.querySelectorAll("button, textarea, [tabindex]");
813
1235
  if (focusable.length > 0) focusable[0].focus();
814
1236
  });
@@ -1396,6 +1818,8 @@ var Mushi = class {
1396
1818
  }
1397
1819
  };
1398
1820
  function createInstance(config) {
1821
+ const bootstrapConfig = config;
1822
+ let activeConfig = config;
1399
1823
  const log = config.debug ?? false ? createLogger({ scope: "mushi", level: "debug", format: "pretty" }) : noopLogger;
1400
1824
  const apiClient = createApiClient({
1401
1825
  projectId: config.projectId,
@@ -1406,20 +1830,50 @@ function createInstance(config) {
1406
1830
  const offlineQueue = createOfflineQueue(config.offline);
1407
1831
  const rateLimiter = createRateLimiter({ maxBurst: 10, refillRate: 1, refillIntervalMs: 5e3 });
1408
1832
  const piiScrubber = createPiiScrubber();
1409
- const consoleCap = config.capture?.console !== false ? createConsoleCapture() : null;
1410
- const networkCap = config.capture?.network !== false ? createNetworkCapture() : null;
1411
- const perfCap = config.capture?.performance !== false ? createPerformanceCapture() : null;
1412
- const screenshotCap = config.capture?.screenshot !== "off" ? createScreenshotCapture() : null;
1413
- const elementSelector = config.capture?.elementSelector !== false ? createElementSelector() : null;
1833
+ let consoleCap = null;
1834
+ let networkCap = null;
1835
+ let perfCap = null;
1836
+ let screenshotCap = null;
1837
+ let elementSelector = null;
1838
+ function syncCaptureModules() {
1839
+ if (activeConfig.capture?.console !== false) {
1840
+ consoleCap ??= createConsoleCapture();
1841
+ } else {
1842
+ consoleCap?.destroy();
1843
+ consoleCap = null;
1844
+ }
1845
+ if (activeConfig.capture?.network !== false) {
1846
+ networkCap ??= createNetworkCapture();
1847
+ } else {
1848
+ networkCap?.destroy();
1849
+ networkCap = null;
1850
+ }
1851
+ if (activeConfig.capture?.performance !== false) {
1852
+ perfCap ??= createPerformanceCapture();
1853
+ } else {
1854
+ perfCap?.destroy();
1855
+ perfCap = null;
1856
+ }
1857
+ screenshotCap = activeConfig.capture?.screenshot !== "off" ? screenshotCap ?? createScreenshotCapture() : null;
1858
+ if (!screenshotCap) pendingScreenshot = null;
1859
+ if (activeConfig.capture?.elementSelector !== false) {
1860
+ elementSelector ??= createElementSelector();
1861
+ } else {
1862
+ elementSelector?.deactivate();
1863
+ elementSelector = null;
1864
+ pendingElement = null;
1865
+ }
1866
+ }
1414
1867
  const listeners = /* @__PURE__ */ new Map();
1415
1868
  function emit(type, data) {
1416
1869
  listeners.get(type)?.forEach((handler) => handler({ type, data }));
1417
1870
  }
1418
- let userInfo = null;
1419
- const customMetadata = {};
1420
1871
  let pendingScreenshot = null;
1421
1872
  let pendingElement = null;
1422
1873
  let pendingProactiveTrigger = null;
1874
+ let userInfo = null;
1875
+ const customMetadata = {};
1876
+ syncCaptureModules();
1423
1877
  const widget = new MushiWidget(config.widget, {
1424
1878
  onSubmit: async ({ category, description, intent }) => {
1425
1879
  log.info("Report submitted", { category, intent });
@@ -1442,13 +1896,13 @@ function createInstance(config) {
1442
1896
  emit("widget:closed");
1443
1897
  },
1444
1898
  onScreenshotRequest: async () => {
1445
- if (!screenshotCap) return;
1899
+ if (!screenshotCap || activeConfig.capture?.screenshot === "off") return;
1446
1900
  log.debug("Taking screenshot");
1447
1901
  pendingScreenshot = await screenshotCap.take();
1448
1902
  widget.setScreenshotAttached(pendingScreenshot !== null);
1449
1903
  },
1450
1904
  onElementSelectorRequest: async () => {
1451
- if (!elementSelector) return;
1905
+ if (!elementSelector || activeConfig.capture?.elementSelector === false) return;
1452
1906
  log.debug("Element selector activated");
1453
1907
  const el = await elementSelector.activate();
1454
1908
  if (el) {
@@ -1502,6 +1956,34 @@ function createInstance(config) {
1502
1956
  offlineQueue.flush(apiClient).then((result) => {
1503
1957
  if (result.sent > 0) log.info("Synced offline reports", { sent: result.sent });
1504
1958
  });
1959
+ function applyRuntimeConfig(runtime) {
1960
+ if (runtime.enabled === false) {
1961
+ activeConfig = bootstrapConfig;
1962
+ clearCachedRuntimeConfig(config.projectId);
1963
+ syncCaptureModules();
1964
+ widget.updateConfig(activeConfig.widget);
1965
+ log.debug("Runtime SDK config disabled; using bootstrap config", { version: runtime.version });
1966
+ return;
1967
+ }
1968
+ activeConfig = mergeRuntimeConfig(activeConfig, runtime);
1969
+ syncCaptureModules();
1970
+ if (runtime.widget) widget.updateConfig(activeConfig.widget);
1971
+ log.debug("Applied runtime SDK config", { version: runtime.version });
1972
+ }
1973
+ if (config.runtimeConfig !== false) {
1974
+ const cached = readCachedRuntimeConfig(config.projectId);
1975
+ if (cached) applyRuntimeConfig(cached);
1976
+ apiClient.getSdkConfig().then((result) => {
1977
+ if (result.ok && result.data) {
1978
+ cacheRuntimeConfig(config.projectId, result.data);
1979
+ applyRuntimeConfig(result.data);
1980
+ } else if (result.error) {
1981
+ log.debug("Runtime SDK config unavailable", result.error);
1982
+ }
1983
+ }).catch((err) => {
1984
+ log.debug("Runtime SDK config fetch failed", { error: err instanceof Error ? err.message : String(err) });
1985
+ });
1986
+ }
1505
1987
  log.info("Initialized", { projectId: config.projectId });
1506
1988
  async function submitReport(category, description, intent) {
1507
1989
  const filterResult = preFilter.check(description);
@@ -1551,9 +2033,9 @@ function createInstance(config) {
1551
2033
  description: scrubbedDescription,
1552
2034
  userIntent: intent,
1553
2035
  environment: captureEnvironment(),
1554
- consoleLogs: consoleCap?.getEntries(),
1555
- networkLogs: networkCap?.getEntries(),
1556
- performanceMetrics: perfCap?.getMetrics(),
2036
+ consoleLogs: activeConfig.capture?.console === false ? void 0 : consoleCap?.getEntries(),
2037
+ networkLogs: activeConfig.capture?.network === false ? void 0 : networkCap?.getEntries(),
2038
+ performanceMetrics: activeConfig.capture?.performance === false ? void 0 : perfCap?.getMetrics(),
1557
2039
  screenshotDataUrl: pendingScreenshot ?? void 0,
1558
2040
  selectedElement: pendingElement ?? void 0,
1559
2041
  metadata: {
@@ -1628,6 +2110,9 @@ function createInstance(config) {
1628
2110
  close() {
1629
2111
  widget.close();
1630
2112
  },
2113
+ updateConfig(runtimeConfig) {
2114
+ applyRuntimeConfig(runtimeConfig);
2115
+ },
1631
2116
  destroy() {
1632
2117
  proactiveTriggers?.destroy();
1633
2118
  proactiveManager?.reset();
@@ -1696,6 +2181,50 @@ function createInstance(config) {
1696
2181
  };
1697
2182
  return sdk;
1698
2183
  }
2184
+ function mergeRuntimeConfig(config, runtime) {
2185
+ return {
2186
+ ...config,
2187
+ widget: {
2188
+ ...config.widget,
2189
+ ...runtime.widget
2190
+ },
2191
+ capture: {
2192
+ ...config.capture,
2193
+ ...runtime.capture
2194
+ }
2195
+ };
2196
+ }
2197
+ function runtimeConfigCacheKey(projectId) {
2198
+ return `mushi:sdk-config:${projectId}`;
2199
+ }
2200
+ function readCachedRuntimeConfig(projectId) {
2201
+ if (typeof localStorage === "undefined") return null;
2202
+ try {
2203
+ const raw = localStorage.getItem(runtimeConfigCacheKey(projectId));
2204
+ if (!raw) return null;
2205
+ const parsed = JSON.parse(raw);
2206
+ return parsed.config ?? null;
2207
+ } catch {
2208
+ return null;
2209
+ }
2210
+ }
2211
+ function cacheRuntimeConfig(projectId, config) {
2212
+ if (typeof localStorage === "undefined") return;
2213
+ try {
2214
+ localStorage.setItem(runtimeConfigCacheKey(projectId), JSON.stringify({
2215
+ cachedAt: Date.now(),
2216
+ config
2217
+ }));
2218
+ } catch {
2219
+ }
2220
+ }
2221
+ function clearCachedRuntimeConfig(projectId) {
2222
+ if (typeof localStorage === "undefined") return;
2223
+ try {
2224
+ localStorage.removeItem(runtimeConfigCacheKey(projectId));
2225
+ } catch {
2226
+ }
2227
+ }
1699
2228
  function createNoopInstance() {
1700
2229
  return {
1701
2230
  report: () => {
@@ -1711,6 +2240,8 @@ function createNoopInstance() {
1711
2240
  },
1712
2241
  close: () => {
1713
2242
  },
2243
+ updateConfig: () => {
2244
+ },
1714
2245
  destroy: () => {
1715
2246
  instance = null;
1716
2247
  },