zigmini 0.9.0-beta.3 → 1.0.0-beta.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.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. package/web/workstatus.js +36 -32
package/web/style.css CHANGED
@@ -36,6 +36,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
36
36
  .conv-list li.active { background: #404249; color: #fff; }
37
37
  .conv-list li .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); margin-left: auto; display: none; }
38
38
  .conv-list li.pending .dot { display: block; }
39
+ .main-list { margin-bottom: 12px; }
39
40
  .sidebar-foot { background: #232428; padding: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; position: relative; }
40
41
  .agent-avatar { cursor: pointer; }
41
42
  .custom-icon { border-radius: 50%; object-fit: cover; }
@@ -220,7 +221,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
220
221
  .voice-off #mic, .voice-off #toggle-wake, .voice-off #toggle-clap, .voice-off #toggle-tts { display: none; }
221
222
  .device-requests:empty { display: none; }
222
223
  .last-sent { flex: none; padding: 4px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: default; }
223
- .last-sent::before { content: "마지막 보냄 · "; }
224
+ .last-sent::before { content: attr(data-label); }
224
225
  .last-sent.open { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 30vh; overflow-y: auto; }
225
226
  .device-request { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3a2f12; border-bottom: 1px solid var(--warn); font-size: 14px; }
226
227
  .device-request span { flex: 1; }
@@ -583,6 +584,101 @@ button { font: inherit; color: inherit; cursor: pointer; }
583
584
  .app.status-open .ws-caption { font-size: 13px; padding: 4px 10px; }
584
585
  }
585
586
 
587
+ /* 에이전트 (agent-sessions todo 10, design D5): the sidebar group above 대화, the chat header's name, stale badge and
588
+ 편집, and the 에이전트 dialog (list, editor, 공통 기본값, 보관함) on the 설정 card. Tokens, 12px labels, 13px text,
589
+ 4px/6px/8px radii and the #4e5058 tag outline are the existing ones. */
590
+ .agent-group { margin-bottom: 12px; }
591
+ .agent-manage { font-size: 16px; }
592
+ .agent-list li { gap: 8px; }
593
+ .agent-list li .agent-icon { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border-radius: 50%; background: var(--bg-input); font-size: 13px; line-height: 1; }
594
+ .agent-list li.active .agent-icon { background: #4e5058; }
595
+ .agent-list li .agent-conv-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
596
+ .agent-list li .agent-run { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); display: none; }
597
+ .agent-list li.running .agent-run { display: block; }
598
+ .agent-list li .agent-stale-dot { flex: none; font-size: 11px; color: var(--warn); display: none; }
599
+ .agent-list li.stale .agent-stale-dot { display: inline; }
600
+ .agent-list li .dot { margin-left: 0; }
601
+ .agent-list li.agent-empty, .agent-list li.agent-empty:hover { padding: 4px 8px 6px; font-size: 12px; color: var(--muted); background: none; cursor: default; }
602
+ .agent-stale:focus-visible, .pe-clear:focus-visible, .pe-list-row:focus-visible { outline: 2px solid #a3acff; outline-offset: 2px; }
603
+ .agent-head { display: flex; align-items: center; gap: 8px; min-width: 0; flex: none; }
604
+ .agent-head[hidden] { display: none; }
605
+ .agent-head-icon { width: 26px; height: 26px; flex: none; display: grid; place-items: center; border-radius: 50%; background: var(--bg-input); font-size: 14px; line-height: 1; }
606
+ .agent-head-name { min-width: 0; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
607
+ .agent-stale { flex: none; border: 1px solid var(--warn); border-radius: 10px; background: rgba(240, 178, 50, 0.16); color: var(--warn); font: inherit; font-size: 12px; font-weight: 600; padding: 2px 8px; white-space: nowrap; }
608
+ .agent-stale:hover { background: var(--warn); color: #111; }
609
+ .agent-stale[hidden] { display: none; }
610
+ .agents-card { width: min(100%, 640px); height: min(100%, 860px); }
611
+ .agents-head { gap: 8px; }
612
+ .agents-nav { display: flex; flex-wrap: wrap; gap: 6px; }
613
+ .agents-nav .text-btn[aria-current="true"] { background: var(--accent); color: #fff; }
614
+ .agents-status { margin: 0; padding: 8px 16px 0; font-size: 12px; color: var(--muted); }
615
+ .agents-status:empty { display: none; }
616
+ .agents-status.error { color: #f38b8d; }
617
+ .agents-status.ok { color: var(--ok); }
618
+ .agents-body { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
619
+ .pe-section { display: flex; flex-direction: column; gap: 8px; }
620
+ .pe-section + .pe-section { border-top: 1px solid var(--border); padding-top: 12px; }
621
+ .pe-section .section-label { padding: 0 0 2px; }
622
+ .pe-group { margin-top: 6px; justify-content: flex-start; flex-wrap: wrap; gap: 4px 8px; }
623
+ .pe-group > span:first-child { flex: none; }
624
+ .pe-group-help { font-weight: 400; text-transform: none; }
625
+ .pe-note { margin: 0; font-size: 12px; line-height: 1.5; color: var(--muted); }
626
+ .pe-row { display: grid; grid-template-columns: minmax(120px, 160px) minmax(0, 1fr); gap: 4px 12px; align-items: start; }
627
+ .pe-label { font-size: 13px; padding-top: 6px; }
628
+ .pe-label small { display: block; font-size: 12px; color: var(--muted); font-weight: 400; }
629
+ .pe-control { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
630
+ .pe-control-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; }
631
+ .pe-input { flex: 1 1 160px; min-width: 0; padding: 6px 8px; font: inherit; font-size: 13px; color: var(--text); background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; }
632
+ .pe-input:focus-visible { outline: 2px solid #a3acff; outline-offset: 1px; }
633
+ .pe-input[aria-invalid="true"] { border-color: var(--danger); }
634
+ .pe-input.pe-number { flex: 0 1 140px; font-variant-numeric: tabular-nums; }
635
+ .pe-input.pe-select { flex: 0 1 180px; }
636
+ .pe-toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 0; cursor: pointer; }
637
+ .pe-toggle input { width: 16px; height: 16px; margin: 0; }
638
+ .pe-textarea { width: 100%; min-height: 72px; resize: vertical; padding: 8px; font: inherit; font-size: 13px; line-height: 1.5; color: var(--text); background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; }
639
+ .pe-textarea.pe-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 96px; }
640
+ .pe-textarea.pe-body { min-height: 180px; }
641
+ .pe-origin { flex: none; font-size: 11px; color: var(--muted); border: 1px solid #4e5058; border-radius: 4px; padding: 0 5px; white-space: nowrap; }
642
+ .pe-origin.agent, .pe-origin.defaults { color: #a3acff; border-color: #5865f2; }
643
+ .pe-clear { background: none; border: 0; padding: 2px 4px; border-radius: 4px; color: #00a8fc; font-size: 12px; }
644
+ .pe-clear:hover { text-decoration: underline; }
645
+ .pe-error { margin: 0; font-size: 12px; color: #f38b8d; }
646
+ .pe-error:empty { display: none; }
647
+ .pe-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
648
+ .pe-actions .pe-spacer { flex: 1; }
649
+ .pe-actions .text-btn.primary { background: var(--accent); color: #fff; }
650
+ .pe-actions .text-btn.primary:disabled, .pe-actions .text-btn.danger:disabled, .pe-actions .text-btn:disabled { opacity: 0.5; cursor: default; }
651
+ .text-btn.danger { background: var(--danger); color: #fff; }
652
+ .pe-delete { display: flex; flex-direction: column; gap: 8px; }
653
+ .pe-delete[hidden] { display: none; }
654
+ .pe-delete .pe-input { flex: none; width: 100%; max-width: 320px; }
655
+ .pe-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; }
656
+ .pe-list-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--bg-card); border: 1px solid #4e5058; border-radius: 6px; }
657
+ .pe-list-row .agent-icon { width: 28px; height: 28px; flex: none; display: grid; place-items: center; border-radius: 50%; background: var(--bg-input); font-size: 15px; line-height: 1; }
658
+ .pe-list-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
659
+ .pe-list-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
660
+ .pe-list-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
661
+ .pe-list-actions { flex: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
662
+ .pe-empty { font-size: 13px; color: var(--muted); text-align: center; padding: 16px 8px; }
663
+ .pe-files { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
664
+ .pe-file { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 13px; border-radius: 4px; }
665
+ .pe-file:nth-child(odd) { background: var(--bg-card); }
666
+ .pe-file-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
667
+ .pe-file-name.directory { color: var(--muted); }
668
+ .pe-file-size { flex: none; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
669
+ .pe-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
670
+ @media (max-width: 640px) {
671
+ .agent-head-name { max-width: 120px; }
672
+ .pe-row { grid-template-columns: 1fr; }
673
+ /* An agent conversation's header: the avatar and the agent block share the first row, the action buttons wrap below. */
674
+ .chat-head:has(.agent-head:not([hidden])) { flex-wrap: wrap; }
675
+ .agent-head { flex: 1 1 calc(100% - 44px); flex-wrap: wrap; }
676
+ .pe-label { padding-top: 0; }
677
+ .agents-body .text-btn, .agent-stale, .pe-clear { min-height: 44px; }
678
+ .pe-list-row { flex-wrap: wrap; }
679
+ .pe-list-actions { width: 100%; justify-content: flex-start; }
680
+ }
681
+
586
682
  /* Bug 90: the page-drawn tooltip (web/tooltip.js). */
587
683
  .zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
588
684
  .zgw-tip[hidden] { display: none; }
@@ -606,8 +702,41 @@ button { font: inherit; color: inherit; cursor: pointer; }
606
702
  .security-allowed-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; }
607
703
  .security-allowed-item { padding: 8px; border: 1px solid var(--border); border-radius: 4px; min-width: 0; }
608
704
  .security-allowed-item .security-value { display: block; font-size: 12px; }
609
- #security-allowed-confirm[hidden] { display: none; }
705
+ #security-allowed-confirm[hidden], #security-baseline-confirm[hidden] { display: none; }
706
+ .security-baseline-auto { border-style: dashed; }
707
+ /* Improvement 232 (todo 21): a disabled control in the section (locked list, blocked repository toggle) reads as disabled, like .pe-actions. */
708
+ #security-check .text-btn:disabled { opacity: 0.5; cursor: default; }
709
+ .formal-tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
710
+ .formal-tool { display: flex; flex-direction: column; gap: 4px; }
711
+ #formal-verification .text-btn:disabled { opacity: 0.5; cursor: default; }
712
+ /* Bug 259: a tool row's Install button (a compact .text-btn as tall as the switch) sits between the info icon and the
713
+ switch and never shrinks; the label beside it wraps, so the row fits a 360px wide screen. */
714
+ .formal-install { flex: none; padding: 2px 8px; }
715
+ /* Settings switches (improvement af188563): a role=switch button drawn as a track with a thumb, its label beside it, the
716
+ 1-2 line summary under the row, and the info icon whose tooltip (web/tooltip.js) carries the full description. The
717
+ colours are the existing ones: the #4e5058 outline for off, --ok for on, the #a3acff focus ring. */
718
+ .switch-row { display: flex; align-items: center; gap: 8px; min-height: 28px; }
719
+ .switch-label { flex: 1; min-width: 0; font-size: 13px; line-height: 1.5; }
720
+ .switch-status { flex: none; font-size: 12px; color: var(--muted); }
721
+ .switch-status[hidden] { display: none; }
722
+ .switch-summary { margin: 0; }
723
+ .switch { position: relative; flex: none; width: 36px; height: 20px; padding: 0; border: 0; border-radius: 10px; background: #4e5058; cursor: pointer; transition: background 150ms ease; }
724
+ .switch::before { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform 150ms ease; }
725
+ .switch[aria-checked="true"] { background: var(--ok); }
726
+ .switch[aria-checked="true"]::before { transform: translateX(16px); }
727
+ .switch:disabled { opacity: 0.5; cursor: default; }
728
+ .info-tip { flex: none; display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border: 1px solid #4e5058; border-radius: 50%; background: none; color: var(--muted); font: 700 11px/1 ui-monospace, monospace; cursor: help; }
729
+ .info-tip:hover { color: var(--text); border-color: var(--text); }
730
+ .switch:focus-visible, .info-tip:focus-visible { outline: 2px solid #a3acff; outline-offset: 2px; }
731
+ @media (prefers-reduced-motion: reduce) {
732
+ .switch, .switch::before { transition: none; }
733
+ }
610
734
  @media (max-width: 640px) {
611
- .security-actions .text-btn, .security-inbox-item .text-btn, #security-check .text-btn { min-height: 44px; }
735
+ .security-actions .text-btn, .security-inbox-item .text-btn, #security-check .text-btn, #formal-verification .text-btn { min-height: 44px; }
736
+ /* The 44px hit areas of the switch and the info icon, without growing their drawings. */
737
+ .switch-row { min-height: 44px; }
738
+ .switch::after, .info-tip::after { content: ""; position: absolute; inset: -12px -4px; }
739
+ .info-tip { position: relative; }
740
+ .info-tip::after { inset: -13px; }
612
741
  }
613
742
  .security-card .security-value { white-space: pre-wrap; overflow-wrap: anywhere; }
package/web/tooltip.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
2
2
  // tooltip, so a `title` moves to `data-tip` and one shared element shows it on hover and keyboard focus, linked with
3
- // aria-describedby. Bug 161: only elements marked `data-tip-keep` (새 세션, the voice buttons, 확인함, 이벤트) keep a
4
- // visible tip; every other `title` moves to `aria-description`, so neither the page nor the browser shows a tip for it.
3
+ // aria-describedby. Bug 161: only elements marked `data-tip-keep` keep a visible tip; every other `title` moves to
4
+ // `aria-description`, so neither the page nor the browser shows a tip for it. The allowed set: 새 세션, the voice buttons,
5
+ // 확인함, 이벤트, and (improvement af188563) the `.info-tip` icons of the settings switches (message check, output check,
6
+ // formal verification and its per-tool rows), whose tip is the item's full description (`data-i18n-tip` in index.html,
7
+ // `data-tip` set by app.js for the tool rows).
5
8
 
6
9
  export const TIP_GAP = 6;
7
10
  export const TIP_MARGIN = 8;
package/web/turn-stats.js CHANGED
@@ -1,10 +1,14 @@
1
+ import strings from "./i18n/turn-stats.js";
2
+ import { registerStrings, t } from "./i18n.js";
3
+
4
+ registerStrings(strings);
1
5
  /** Unknown fields are omitted, not rendered as zero. Model time excludes tools and wall time. */
2
6
  export function turnStatsLabel(stats) {
3
7
  if (!stats || typeof stats !== "object") return "";
4
8
  const parts = [];
5
9
  if (Number.isFinite(stats.tokensPerSecond) && stats.tokensPerSecond > 0) parts.push(`TPS ${stats.tokensPerSecond.toFixed(1)} tok/s`);
6
- if (Number.isFinite(stats.modelDurationMs) && stats.modelDurationMs > 0) parts.push(`모델 처리 ${(stats.modelDurationMs / 1000).toFixed(1)}초`);
7
- if (Number.isFinite(stats.cacheHitPercent) && stats.cacheHitPercent >= 0 && stats.cacheHitPercent <= 100) parts.push(`캐시 적중 ${stats.cacheHitPercent.toFixed(1)}%`);
10
+ if (Number.isFinite(stats.modelDurationMs) && stats.modelDurationMs > 0) parts.push(t("turnStats.model", { seconds: (stats.modelDurationMs / 1000).toFixed(1) }));
11
+ if (Number.isFinite(stats.cacheHitPercent) && stats.cacheHitPercent >= 0 && stats.cacheHitPercent <= 100) parts.push(t("turnStats.cache", { percent: stats.cacheHitPercent.toFixed(1) }));
8
12
  return parts.join(" · ");
9
13
  }
10
14
 
package/web/update.js CHANGED
@@ -1,17 +1,25 @@
1
- // Plan Todo 22: the new version notice of the web UI (the header line and the 설정 "업데이트" area). Only decisions live
1
+ // Plan Todo 22: the new version notice of the web UI (the header line and the settings update area). Only decisions live
2
2
  // here, with no DOM, so test/unit/update-web.test.ts and the mutation run drive them directly; app.js paints the result.
3
- // A failed registry lookup is shown with a hint to press "지금 확인" (POST /api/update/check) again.
3
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
4
+ import strings from "./i18n/update.js";
5
+
6
+ registerStrings(strings);
4
7
 
5
8
  export const UPDATE_STATUS_PATH = "/api/update/status";
6
9
  export const UPDATE_CHECK_PATH = "/api/update/check";
7
10
  export const UPDATE_PATH = "/api/update";
8
11
  export const UPDATE_CONSENT_PATH = "/api/update/consent";
9
12
  export const UPDATE_STATE_PATH = "/api/update/state";
10
- export const NOT_CHECKED_LINE = "아직 새 버전을 확인하지 않았습니다.";
11
- export const UP_TO_DATE_LINE = "최신 버전을 쓰고 있습니다.";
12
- export const CHECKOUT_LINE = "이 설치는 저장소에서 배포합니다. 앱 안 업데이트는 npm 설치에서만 씁니다.";
13
- export const RESTART_NOTICE = "업데이트하면 게이트웨이와 브리지가 다시 시작됩니다. 그동안 연결과 진행 중인 대화·작업이 잠시 끊길 수 있습니다.";
14
- const RETRY_HINT = "지금 확인을 눌러 다시 시도하세요.";
13
+ export let NOT_CHECKED_LINE = t("update.notChecked");
14
+ export let UP_TO_DATE_LINE = t("update.upToDate");
15
+ export let CHECKOUT_LINE = t("update.checkout");
16
+ export let RESTART_NOTICE = t("update.restartNotice");
17
+ onLanguageChange(() => {
18
+ NOT_CHECKED_LINE = t("update.notChecked");
19
+ UP_TO_DATE_LINE = t("update.upToDate");
20
+ CHECKOUT_LINE = t("update.checkout");
21
+ RESTART_NOTICE = t("update.restartNotice");
22
+ });
15
23
 
16
24
  /** A `version.available` payload or a GET /api/update/status body with a newer version; null for anything else. */
17
25
  export function parseNotice(data) {
@@ -24,32 +32,32 @@ export function parseNotice(data) {
24
32
 
25
33
  export function noticeLine(notice) {
26
34
  if (notice === null) return null;
27
- return `새 버전 ${notice.latest}이 나왔습니다 (지금 ${notice.current}).`;
35
+ return t("update.noticeLine", { latest: notice.latest, current: notice.current });
28
36
  }
29
37
 
30
- /** The 설정 "업데이트" area for a GET /api/update/status body (null when the gateway did not answer). */
38
+ /** The settings update area for a GET /api/update/status body (null when the gateway did not answer). */
31
39
  export function updateView(status) {
32
40
  if (status === null || typeof status !== "object" || typeof status.current !== "string") {
33
- return { current: "지금 버전: 알 수 없음", latest: NOT_CHECKED_LINE, note: null, changelogUrl: null, error: null, warning: null };
41
+ return { current: t("update.currentUnknown"), latest: t("update.notChecked"), note: null, changelogUrl: null, error: null, warning: null };
34
42
  }
35
43
  const notice = parseNotice(status);
36
- const current = `지금 버전: ${status.current}`;
37
- const note = status.updatable === true ? null : CHECKOUT_LINE;
44
+ const current = t("update.current", { version: status.current });
45
+ const note = status.updatable === true ? null : t("update.checkout");
38
46
  const failure = typeof status.error === "string" && status.error !== "" ? status.error : null;
39
- const error = failure === null ? null : `마지막 확인에 실패했습니다 (${failure}). ${RETRY_HINT}`;
47
+ const error = failure === null ? null : t("update.lastCheckFailed", { error: failure });
40
48
  if (notice !== null) {
41
- return { current, latest: `새 버전: ${notice.latest}`, note, changelogUrl: notice.changelogUrl, error, warning: notice.updatable ? RESTART_NOTICE : null };
49
+ return { current, latest: t("update.latest", { version: notice.latest }), note, changelogUrl: notice.changelogUrl, error, warning: notice.updatable ? t("update.restartNotice") : null };
42
50
  }
43
51
  const checked = typeof status.checkedAt === "string" && typeof status.latest === "string";
44
52
  if (!checked && failure !== null) {
45
- return { current, latest: `새 버전을 확인하지 못했습니다 (${failure}). ${RETRY_HINT}`, note, changelogUrl: null, error: null, warning: null };
53
+ return { current, latest: t("update.checkFailed", { error: failure }), note, changelogUrl: null, error: null, warning: null };
46
54
  }
47
- return { current, latest: checked ? UP_TO_DATE_LINE : NOT_CHECKED_LINE, note, changelogUrl: null, error, warning: null };
55
+ return { current, latest: checked ? t("update.upToDate") : t("update.notChecked"), note, changelogUrl: null, error, warning: null };
48
56
  }
49
57
 
50
58
  /** The line when POST /api/update/check itself failed (status 0: the gateway did not answer). */
51
59
  export function checkFailedLine(status) {
52
- return `새 버전을 확인하지 못했습니다 (${status === 0 ? "게이트웨이 응답 없음" : `HTTP ${status}`}). ${RETRY_HINT}`;
60
+ return t("update.checkFailed", { error: status === 0 ? t("update.gatewayNoResponse") : `HTTP ${status}` });
53
61
  }
54
62
 
55
63
  // Plan Todo 23: the update button, its questions (new permission notice, shared schema, running turn) and its progress.
@@ -60,7 +68,7 @@ export function checkFailedLine(status) {
60
68
  */
61
69
  export function questionGate(counters) {
62
70
  const n = counters ? counters.pending + counters.needsReview : 0;
63
- return n > 0 ? `답을 기다리는 질문이 ${n}개 있습니다. 확인함에서 먼저 답해 주세요. 답한 뒤 업데이트할 수 있습니다.` : null;
71
+ return n > 0 ? t("update.questionsPending", { count: n }) : null;
64
72
  }
65
73
 
66
74
  export function canUpdate(status) {
@@ -70,8 +78,8 @@ export function canUpdate(status) {
70
78
  /** What the page does next with a POST /api/update answer. */
71
79
  export function updateStep(status, body) {
72
80
  const b = body !== null && typeof body === "object" ? body : {};
73
- const message = typeof b.message === "string" ? b.message : `업데이트 요청 실패 (HTTP ${status})`;
74
- if (status === 202 && typeof b.id === "string") return { kind: "started", id: b.id, line: `업데이트를 시작했습니다: ${b.from} → ${b.to}` };
81
+ const message = typeof b.message === "string" ? b.message : t("update.requestFailed", { status });
82
+ if (status === 202 && typeof b.id === "string") return { kind: "started", id: b.id, line: t("update.started", { from: b.from, to: b.to }) };
75
83
  const notice = b.notice;
76
84
  if (b.code === "consent_required" && notice !== null && typeof notice === "object" && Number.isInteger(notice.version) && typeof notice.text === "string") {
77
85
  return { kind: "consent", message, notice: { version: notice.version, text: notice.text } };
@@ -85,8 +93,8 @@ export function updateStep(status, body) {
85
93
  /** The line for an `update.result` payload. */
86
94
  export function resultLine(result) {
87
95
  if (result === null || typeof result !== "object" || typeof result.ok !== "boolean") return null;
88
- if (result.ok) return `업데이트를 마쳤습니다: ${result.from} → ${result.to}. 페이지를 새로 고치세요.`;
89
- return `${typeof result.message === "string" ? result.message : "업데이트에 실패했습니다."}${typeof result.logPath === "string" && result.logPath !== "" ? ` (로그: ${result.logPath})` : ""}`;
96
+ if (result.ok) return t("update.completed", { from: result.from, to: result.to });
97
+ return `${typeof result.message === "string" ? result.message : t("update.failed")}${typeof result.logPath === "string" && result.logPath !== "" ? ` ${t("update.logPath", { path: result.logPath })}` : ""}`;
90
98
  }
91
99
 
92
100
  /**
@@ -101,5 +109,5 @@ export function progressLine(data, loadedVersion = null) {
101
109
  return resultLine(data.result);
102
110
  }
103
111
  if (typeof data.label !== "string" || data.phase === "done") return null;
104
- return `업데이트 중 (${data.from} → ${data.to}): ${data.label}`;
112
+ return t("update.progress", { from: data.from, to: data.to, label: data.label });
105
113
  }
package/web/voice.js CHANGED
@@ -2,6 +2,11 @@
2
2
  // servers). Only decisions live here, with no DOM, so test/unit/voice-web.test.ts and the mutation run can exercise
3
3
  // them directly. Contract: .omo/evidence/improvement-71/design.md, "Clients".
4
4
 
5
+ import dict from "./i18n/voice.js";
6
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
7
+
8
+ registerStrings(dict);
9
+
5
10
  export const VOICE_SETTING_PATH = "/api/settings/voice";
6
11
  /** Todo 26: an npm install's voice install (GET status, POST start; the POST works on the web port only). */
7
12
  export const VOICE_INSTALL_PATH = "/api/settings/voice/install";
@@ -10,8 +15,13 @@ export const VOICE_OFF_CLASS = "voice-off";
10
15
  export const VOICE_CONTROL_IDS = Object.freeze(["mic", "toggle-wake", "toggle-clap", "toggle-tts"]);
11
16
  /** The web slash commands that need the voice servers: out of the menu and refused while voice is off. */
12
17
  export const VOICE_COMMANDS = Object.freeze(["tts on", "tts off", "wake on", "wake off", "clap on", "clap off", "read"]);
13
- export const VOICE_DISABLED_LINE = "음성 기능이 꺼져 있습니다";
14
- export const VOICE_OFF_STATUS_LINE = "옵션: 음성 꺼짐 (음성 답변·호출어·박수 감지를 쓸 수 없습니다)";
18
+ /** Live bindings: they follow the UI language, so importers always read the current text. */
19
+ export let VOICE_DISABLED_LINE = t("voice.disabledLine");
20
+ export let VOICE_OFF_STATUS_LINE = t("voice.offStatusLine");
21
+ onLanguageChange(() => {
22
+ VOICE_DISABLED_LINE = t("voice.disabledLine");
23
+ VOICE_OFF_STATUS_LINE = t("voice.offStatusLine");
24
+ });
15
25
  export const TTS_KEY = "zgw.tts.enabled";
16
26
  export const CLAP_KEY = "zgw.clap.enabled";
17
27
 
@@ -50,7 +60,7 @@ export function restorePlan(storage, micAvailable) {
50
60
 
51
61
  /** The header toggle's text and pressed state. */
52
62
  export function voiceToggleView(enabled) {
53
- return { text: enabled ? "음성 켜짐" : "음성 꺼짐", pressed: enabled };
63
+ return { text: enabled ? t("voice.toggle.on") : t("voice.toggle.off"), pressed: enabled };
54
64
  }
55
65
 
56
66
  /** The PUT that changes the setting (web port only; the gateway answers {"enabled":...} and publishes voice.updated). */
@@ -64,9 +74,9 @@ export function voiceInstallView(status) {
64
74
  return { hidden: true, text: "", disabled: true };
65
75
  }
66
76
  const progress = status.progress ?? {};
67
- if (progress.state === "running") return { hidden: false, text: `음성 설치 중 ${progress.index}/${progress.total}: ${progress.message}`, disabled: true };
68
- const size = `약 ${Math.round(status.estimateBytes / 1024 ** 3)}GB`;
69
- return { hidden: false, text: progress.state === "failed" ? `음성 설치 다시 시도 (${size})` : `음성 설치 (${size})`, disabled: false };
77
+ if (progress.state === "running") return { hidden: false, text: t("voice.install.running", { index: progress.index, total: progress.total, message: progress.message }), disabled: true };
78
+ const size = t("voice.install.size", { gb: Math.round(status.estimateBytes / 1024 ** 3) });
79
+ return { hidden: false, text: t(progress.state === "failed" ? "voice.install.retry" : "voice.install.start", { size }), disabled: false };
70
80
  }
71
81
 
72
82
  /** A voice.install.progress payload applied to the last known status; "done" marks voice as installed. */
@@ -78,6 +88,6 @@ export function installStatusWith(status, progress) {
78
88
  /** The /옵션 line. With voice off the three features are reported as unavailable instead of their stored states. */
79
89
  export function optionStatusLine(voiceEnabled, { tts, wake, clap }) {
80
90
  if (!voiceEnabled) return VOICE_OFF_STATUS_LINE;
81
- const onOff = (on) => (on ? "켜짐" : "꺼짐");
82
- return `옵션: 음성 켜짐 · 음성 답변 ${onOff(tts)} · 호출어 ${onOff(wake)} · 박수 감지 ${onOff(clap)}`;
91
+ const onOff = (on) => t(on ? "voice.state.on" : "voice.state.off");
92
+ return t("voice.status.line", { tts: onOff(tts), wake: onOff(wake), clap: onOff(clap) });
83
93
  }
package/web/wake.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export function matchWake(text: string): { command: string } | null;
2
+ export function isTripleHey(text: string): boolean;
3
+ export function isCancelWord(text: string): boolean;
4
+ export function encodeWav(samples: ArrayLike<number>, sampleRate: number): Blob;
package/web/wake.js CHANGED
@@ -1,4 +1,8 @@
1
- const WAKE_RE = /(?:^|[\s,.!?])(?:헤이|해이|헤의|해의|에이|hey|hi)[\s,.!?~-]*(?:지그|지급|지구|지기|찌그|치그|지크|직|zig|jig)[\s,.!?~-]*/i;
1
+ import dict from "./i18n/wake.js";
2
+
3
+ const words = (key) => [...new Set([dict.en[key], dict.ko[key]].flatMap((value) => value.split(",")))].join("|");
4
+
5
+ const WAKE_RE = new RegExp(`(?:^|[\\s,.!?])(?:${words("wake.hey")})[\\s,.!?~-]*(?:${words("wake.name")})[\\s,.!?~-]*`, "i");
2
6
  const WAKE_MAX_OFFSET = 6;
3
7
 
4
8
  export function matchWake(text) {
@@ -7,8 +11,8 @@ export function matchWake(text) {
7
11
  return { command: text.slice(m.index + m[0].length).trim() };
8
12
  }
9
13
 
10
- const TRIPLE_HEY_RE = /^[\s,.!?~-]*(?:(?:헤이|해이|헤의|해의|에이|hey)[\s,.!?~-]*){3,}$/i;
11
- const CANCEL_RE = /^[\s,.!?~-]*(?:취소|그만|아니야|아니|멈춰)[\s,.!?~-]*$/;
14
+ const TRIPLE_HEY_RE = new RegExp(`^[\\s,.!?~-]*(?:(?:${words("wake.hey.triple")})[\\s,.!?~-]*){3,}$`, "i");
15
+ const CANCEL_RE = new RegExp(`^[\\s,.!?~-]*(?:${words("wake.cancel")})[\\s,.!?~-]*$`);
12
16
 
13
17
  export function isTripleHey(text) {
14
18
  return TRIPLE_HEY_RE.test(text);
package/web/workid.js CHANGED
@@ -1,8 +1,18 @@
1
+ import dict from "./i18n/workid.js";
2
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
3
+
1
4
  // Improvement 76: a right-click (or the ContextMenu key / Shift+F10) on a work card in the work status drawing copies the
2
5
  // card's full gateway work id. Only work cards react; every other element keeps the browser's own menu.
3
6
 
4
- export const ID_COPIED = "ID 복사함";
5
- export const ID_COPY_FAILED = "복사 실패";
7
+ registerStrings(dict);
8
+
9
+ /** Live bindings: they follow the UI language, so importers always read the current text. */
10
+ export let ID_COPIED = t("workid.copied");
11
+ export let ID_COPY_FAILED = t("workid.copyFailed");
12
+ onLanguageChange(() => {
13
+ ID_COPIED = t("workid.copied");
14
+ ID_COPY_FAILED = t("workid.copyFailed");
15
+ });
6
16
  const WORK_PREFIX = "work:";
7
17
 
8
18
  /** The full work id of the work card that holds [target] (`g.ws-node` with `data-key="work:<id>"`), or null. */
package/web/workstatus.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // What the gateway is working on (state.snapshot `workStatus` + `work.status` frames): DTO validation, socket generation
2
2
  // and revision gating, the view and the hub diagram drawn inside the sidebar region above the profile block.
3
+ import strings from "./i18n/workstatus.js";
4
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
5
+
6
+ registerStrings(strings);
3
7
 
4
8
  /** Work cards beside the fixed main card (gateway WORK_SLOTS). */
5
9
  export const SLOT_COUNT = 6;
@@ -17,30 +21,30 @@ export function writeFolded(storage, folded) {
17
21
 
18
22
  /** The fold button's accessible name: what activating it does. */
19
23
  export function foldLabel(folded) {
20
- return folded ? "작업 상태 펼치기" : "작업 상태 숨기기";
21
- }
22
-
23
- export const STATE_LABELS = {
24
- running: "진행 중",
25
- waiting_input: "입력 대기",
26
- waiting: "대기 중",
27
- unresponsive: "응답 없음",
28
- broken: "고장",
29
- unknown: "확인 중",
30
- idle: "쉬는 중",
31
- completed: "완료",
32
- failed: "실패",
33
- cancelled: "취소됨",
34
- };
24
+ return t(folded ? "workstatus.fold.expand" : "workstatus.fold.hide");
25
+ }
26
+
27
+ const STATES = ["running", "waiting_input", "waiting", "unresponsive", "broken", "unknown", "idle", "completed", "failed", "cancelled"];
28
+ export let STATE_LABELS = stateLabels();
35
29
  /** A work waiting between turns on its sub-tasks (gateway `waitingFor: "children"`). */
36
- export const CHILD_WAIT_LABEL = "하위 작업 대기";
30
+ export let CHILD_WAIT_LABEL = t("workstatus.state.children");
31
+
32
+ function stateLabels() {
33
+ return Object.fromEntries(STATES.map((state) => [state, t(`workstatus.state.${state}`)]));
34
+ }
35
+
36
+ // The exported labels follow the UI language.
37
+ onLanguageChange(() => {
38
+ STATE_LABELS = stateLabels();
39
+ CHILD_WAIT_LABEL = t("workstatus.state.children");
40
+ });
37
41
  const SESSION_STATES = new Set(["running", "idle", "unknown"]);
38
42
  const WORK_STATES = new Set(["running", "waiting", "waiting_input", "unresponsive", "broken", "completed", "failed", "cancelled", "unknown"]);
39
43
  /** Finished works and works the gateway's watcher judged broken are the only ones the user can clear from their slot. */
40
44
  const FINISHED = new Set(["completed", "failed", "cancelled", "broken"]);
41
45
  /** The watcher's reason line is short by contract; anything longer is cut here. */
42
46
  const NOTE_MAX = 160;
43
- const KIND_LABELS = { session: "세션", work: "작업" };
47
+ const KIND_LABELS = { session: "workstatus.kind.session", work: "workstatus.kind.work" };
44
48
 
45
49
  function record(value) {
46
50
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -119,10 +123,10 @@ export function relativeTime(iso, now) {
119
123
  const at = Date.parse(iso);
120
124
  if (Number.isNaN(at)) return "";
121
125
  const seconds = Math.max(0, Math.floor((now - at) / 1000));
122
- if (seconds < 60) return "방금";
123
- if (seconds < 3600) return `${Math.floor(seconds / 60)}분 전`;
124
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}시간 전`;
125
- return `${Math.floor(seconds / 86400)}일 전`;
126
+ if (seconds < 60) return t("workstatus.time.now");
127
+ if (seconds < 3600) return t("workstatus.time.minutes", { count: Math.floor(seconds / 60) });
128
+ if (seconds < 86400) return t("workstatus.time.hours", { count: Math.floor(seconds / 3600) });
129
+ return t("workstatus.time.days", { count: Math.floor(seconds / 86400) });
126
130
  }
127
131
 
128
132
  /**
@@ -140,7 +144,7 @@ export function workStatusView(status, conversationTitles, now) {
140
144
  key: `${item.kind}:${item.id}`,
141
145
  id: item.id,
142
146
  kind: item.kind,
143
- kindLabel: KIND_LABELS[item.kind],
147
+ kindLabel: t(KIND_LABELS[item.kind]),
144
148
  title: item.title,
145
149
  state: item.state,
146
150
  stateLabel: item.waitingFor === "children" ? CHILD_WAIT_LABEL : STATE_LABELS[item.state],
@@ -191,8 +195,8 @@ export function incomingReports(previous, view) {
191
195
 
192
196
  /** The text under the diagram: the state of the region, or the latest change. */
193
197
  export function captionText(view) {
194
- if (view.mode === "unavailable") return "작업 상태를 확인하는 중입니다.";
195
- if (view.mode === "empty" || (view.slots.every((slot) => slot === null) && view.running === 0)) return "지금 하고 있는 작업이 없습니다.";
198
+ if (view.mode === "unavailable") return t("workstatus.caption.unavailable");
199
+ if (view.mode === "empty" || (view.slots.every((slot) => slot === null) && view.running === 0)) return t("workstatus.caption.empty");
196
200
  return `${view.caption.title} · ${view.caption.stateLabel} · ${view.caption.when}`;
197
201
  }
198
202
 
@@ -480,8 +484,8 @@ export function renderWorkStatus(parts, view, previous, motion) {
480
484
  for (let i = 0; i < 9; i++) {
481
485
  pubG.append(svg("circle", { cx: pub.x + 9 + (i % 3) * 5, cy: pub.y + 11 + Math.floor(i / 3) * 6, r: 1.3, class: i % 4 === 0 ? "ws-grid-on" : "ws-grid" }));
482
486
  }
483
- pubG.append(svg("text", { x: pub.x + 30, y: pub.y + 16, class: "ws-pub-name" }, fitLabel(parts.userName ?? "나", 7.6)));
484
- pubG.append(svg("text", { x: pub.x + 30, y: pub.y + 28, class: "ws-pub-state" }, "사용자"));
487
+ pubG.append(svg("text", { x: pub.x + 30, y: pub.y + 16, class: "ws-pub-name" }, fitLabel(parts.userName ?? t("workstatus.user.me"), 7.6)));
488
+ pubG.append(svg("text", { x: pub.x + 30, y: pub.y + 28, class: "ws-pub-state" }, t("workstatus.user")));
485
489
  nodes.append(pubG);
486
490
 
487
491
  const { hub } = layout;
@@ -500,7 +504,7 @@ export function renderWorkStatus(parts, view, previous, motion) {
500
504
  if (slot.empty) {
501
505
  const g = svg("g", { class: "ws-slot-empty", "data-slot": slot.index, "aria-hidden": "true" });
502
506
  g.append(svg("rect", { x: slot.x + 0.5, y: slot.y + 0.5, width: slot.w - 1, height: slot.h - 1, rx: 4, class: "ws-empty-box" }));
503
- g.append(svg("text", { x: slot.x + slot.w / 2, y: slot.y + slot.h / 2 + 4, class: "ws-empty-label" }, "빈 칸"));
507
+ g.append(svg("text", { x: slot.x + slot.w / 2, y: slot.y + slot.h / 2 + 4, class: "ws-empty-label" }, t("workstatus.slot.empty")));
504
508
  nodes.append(g);
505
509
  continue;
506
510
  }
@@ -508,7 +512,7 @@ export function renderWorkStatus(parts, view, previous, motion) {
508
512
  links.append(svg("path", { d: node.link, class: `ws-link ws-${node.state}`, style: `--hue:${node.hue}` }));
509
513
  hubG.append(svg("circle", { cx: node.anchor.x.toFixed(1), cy: node.anchor.y.toFixed(1), r: 2.4, class: "ws-port", style: `--hue:${node.hue}` }));
510
514
  const described = `${node.title}, ${node.stateLabel}${node.note ? ` (${node.note})` : ""}, ${node.when}`;
511
- const label = node.finished ? `${described}. 누르면 이 칸을 비웁니다.` : described;
515
+ const label = node.finished ? `${described}. ${t("workstatus.slot.clear")}` : described;
512
516
  const g = svg("g", {
513
517
  class: `ws-node ws-${node.state}${node.finished ? " ws-dismissable" : ""}`, style: `--hue:${node.hue}`, "data-key": node.key, "data-slot": node.index,
514
518
  tabindex: "0", role: node.finished ? "button" : "img", "aria-label": label,
@@ -518,7 +522,7 @@ export function renderWorkStatus(parts, view, previous, motion) {
518
522
  node.lines.forEach((line, i) => g.append(svg("text", { x: node.x + 9, y: node.y + 21 + i * 16, class: "ws-node-label" }, line)));
519
523
  g.append(svg("circle", { cx: node.x + 12, cy: node.y + 57, r: 3, class: "ws-node-dot" }));
520
524
  g.append(svg("text", { x: node.x + 20, y: node.y + 61, class: "ws-node-state" }, node.stateLabel));
521
- g.append(svg("text", { x: node.x + 9, y: node.y + 80, class: "ws-node-when" }, node.finished ? `${node.when} · 눌러 비우기` : node.when));
525
+ g.append(svg("text", { x: node.x + 9, y: node.y + 80, class: "ws-node-when" }, node.finished ? `${node.when} · ${t("workstatus.slot.clear.short")}` : node.when));
522
526
  g.addEventListener("pointerenter", () => showTip(tip, region, g, node.title, node.note));
523
527
  g.addEventListener("pointerleave", () => hideTip(tip));
524
528
  g.addEventListener("focus", () => showTip(tip, region, g, node.title, node.note));
@@ -572,10 +576,10 @@ export function renderWorkStatus(parts, view, previous, motion) {
572
576
  caption.textContent = text;
573
577
  caption.title = text;
574
578
  more.hidden = view.queued === 0;
575
- more.textContent = view.queued > 0 ? `+${view.queued} 대기` : "";
576
- more.title = view.queued > 0 ? `빈 칸을 기다리는 작업 ${view.queued}개` : "";
579
+ more.textContent = view.queued > 0 ? t("workstatus.queued.badge", { count: view.queued }) : "";
580
+ more.title = view.queued > 0 ? t("workstatus.queued.title", { count: view.queued }) : "";
577
581
  const rows = [view.publisher, ...view.slots.filter(Boolean)].filter(Boolean);
578
582
  list.replaceChildren(...rows.map((row) => el("li", "", `${row.kindLabel} ${row.title}: ${row.stateLabel}${row.note ? ` (${row.note})` : ""}, ${row.when}${row.owner ? ` (${row.owner})` : ""}`)));
579
- if (view.queued > 0) list.append(el("li", "", `빈 칸을 기다리는 작업 ${view.queued}개`));
583
+ if (view.queued > 0) list.append(el("li", "", t("workstatus.queued.item", { count: view.queued })));
580
584
  return signatures;
581
585
  }