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