@celestia-island/hikari 0.4.4 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/README.md +1 -1
  2. package/package.json +22 -7
  3. package/src/DemoApp.tsx +491 -0
  4. package/src/animation/registerAnimations.test.ts +94 -0
  5. package/src/animation/registerAnimations.ts +103 -0
  6. package/src/components/HkAboutModal.scss +95 -0
  7. package/src/components/HkAboutModal.tsx +114 -0
  8. package/src/components/HkAdaptiveDialog.scss +22 -0
  9. package/src/components/HkAdaptiveDialog.test.tsx +271 -0
  10. package/src/components/HkAdaptiveDialog.tsx +167 -0
  11. package/src/components/HkAdminHeader.test.tsx +185 -0
  12. package/src/components/HkAdminHeader.tsx +308 -0
  13. package/src/components/HkAdminShell.test.tsx +237 -0
  14. package/src/components/HkAdminShell.tsx +129 -0
  15. package/src/components/HkAdminTablePage.tsx +70 -0
  16. package/src/components/HkAdminWidgets.test.ts +89 -0
  17. package/src/components/HkAlert.tsx +1 -1
  18. package/src/components/HkAltSignIn.scss +55 -0
  19. package/src/components/HkAltSignIn.test.tsx +88 -0
  20. package/src/components/HkAltSignIn.tsx +92 -0
  21. package/src/components/HkAttachmentModal.scss +49 -0
  22. package/src/components/HkAttachmentModal.tsx +318 -0
  23. package/src/components/HkAuthCard.tsx +50 -0
  24. package/src/components/HkAuthSubmitButton.scss +12 -0
  25. package/src/components/HkAuthSubmitButton.tsx +94 -0
  26. package/src/components/HkAvatar.scss +93 -0
  27. package/src/components/HkAvatar.tsx +2 -2
  28. package/src/components/HkBlockingToast.scss +123 -0
  29. package/src/components/HkBlockingToast.test.tsx +221 -0
  30. package/src/components/HkBlockingToast.tsx +182 -0
  31. package/src/components/HkBreadcrumb.scss +624 -0
  32. package/src/components/HkBreadcrumb.tsx +2 -2
  33. package/src/components/HkButton.scss +16 -2
  34. package/src/components/HkCaptchaModal.scss +14 -0
  35. package/src/components/HkCaptchaModal.tsx +62 -0
  36. package/src/components/HkCaptchaWidget.scss +30 -0
  37. package/src/components/HkCaptchaWidget.tsx +181 -0
  38. package/src/components/HkCard.scss +1 -1
  39. package/src/components/HkChatTypes.ts +65 -0
  40. package/src/components/HkCheckbox.scss +4 -9
  41. package/src/components/HkCheckbox.tsx +18 -6
  42. package/src/components/HkColorPicker.scss +120 -3
  43. package/src/components/HkColorPicker.tsx +130 -19
  44. package/src/components/HkColorPickerTouch.test.tsx +145 -0
  45. package/src/components/HkColorSchemeDialog.scss +52 -0
  46. package/src/components/HkColorSchemeDialog.test.ts +201 -0
  47. package/src/components/HkColorSchemeDialog.tsx +96 -0
  48. package/src/components/HkColorSchemeEditor.test.ts +270 -0
  49. package/src/components/HkColorSchemeEditor.tsx +410 -0
  50. package/src/components/HkConfirmDialog.scss +43 -3
  51. package/src/components/HkConfirmDialog.tsx +7 -4
  52. package/src/components/HkConnectionInfo.ts +80 -0
  53. package/src/components/HkConnectionStatus.tsx +158 -0
  54. package/src/components/HkCookieConsent.scss +22 -0
  55. package/src/components/HkCookieConsent.test.tsx +70 -0
  56. package/src/components/HkCookieConsent.tsx +56 -0
  57. package/src/components/HkCountdownDigit.tsx +105 -0
  58. package/src/components/HkDatePicker.scss +361 -0
  59. package/src/components/HkDatePicker.test.ts +459 -0
  60. package/src/components/HkDatePicker.tsx +568 -0
  61. package/src/components/HkDateTimePicker.scss +65 -37
  62. package/src/components/HkDateTimePicker.test.ts +344 -0
  63. package/src/components/HkDateTimePicker.tsx +249 -82
  64. package/src/components/HkDraggableGrid.scss +4 -1
  65. package/src/components/HkDraggableList.scss +4 -1
  66. package/src/components/HkDrawer.scss +27 -13
  67. package/src/components/HkDrawer.test.tsx +73 -0
  68. package/src/components/HkDrawer.tsx +157 -10
  69. package/src/components/HkErrorBoundary.scss +5 -2
  70. package/src/components/HkErrorBoundary.tsx +7 -4
  71. package/src/components/HkExpansionPanel.scss +143 -0
  72. package/src/components/HkExpansionPanel.test.tsx +109 -0
  73. package/src/components/HkExpansionPanel.tsx +101 -0
  74. package/src/components/HkFab.scss +162 -0
  75. package/src/components/HkFab.test.tsx +217 -0
  76. package/src/components/HkFab.tsx +214 -0
  77. package/src/components/HkHoverRevealAction.scss +6 -0
  78. package/src/components/HkHoverRevealAction.test.ts +68 -0
  79. package/src/components/HkHoverRevealAction.tsx +84 -6
  80. package/src/components/HkIcon.scss +123 -0
  81. package/src/components/HkIcon.tsx +7 -23
  82. package/src/components/HkIconButton.scss +170 -0
  83. package/src/components/HkIconButton.test.tsx +75 -0
  84. package/src/components/HkIconButton.tsx +4 -3
  85. package/src/components/HkIconButtonVars.scss +180 -0
  86. package/src/components/HkInput.affix.test.ts +253 -0
  87. package/src/components/HkInput.enter.test.ts +88 -0
  88. package/src/components/HkInput.scss +127 -8
  89. package/src/components/HkInput.tsx +245 -13
  90. package/src/components/HkKeywordSearchModal.scss +13 -0
  91. package/src/components/HkKeywordSearchModal.tsx +46 -9
  92. package/src/components/HkLabel.scss +34 -0
  93. package/src/components/HkLabel.test.tsx +61 -0
  94. package/src/components/HkLabel.tsx +45 -0
  95. package/src/components/HkListTransition.scss +26 -0
  96. package/src/components/HkListTransition.tsx +57 -0
  97. package/src/components/HkLocalePicker.scss +37 -0
  98. package/src/components/HkLocalePicker.tsx +133 -0
  99. package/src/components/HkLocalePickerPopup.tsx +35 -19
  100. package/src/components/HkLocalizedInput.scss +277 -0
  101. package/src/components/HkLocalizedInput.test.tsx +693 -0
  102. package/src/components/HkLocalizedInput.tsx +495 -0
  103. package/src/components/HkLogWindow.scss +121 -0
  104. package/src/components/HkLogWindow.tsx +195 -0
  105. package/src/components/HkLogo.scss +1 -1
  106. package/src/components/HkLogo.tsx +1 -1
  107. package/src/components/HkMarkdownRenderer.scss +22 -2
  108. package/src/components/HkMarkdownRenderer.tsx +33 -3
  109. package/src/components/HkMediaControlBar.tsx +20 -13
  110. package/src/components/HkMenu.scss +200 -0
  111. package/src/components/HkMenu.test.ts +779 -0
  112. package/src/components/HkMenu.tsx +770 -0
  113. package/src/components/HkMenuActionItem.scss +58 -0
  114. package/src/components/HkMenuActionItem.tsx +41 -0
  115. package/src/components/HkMenuIdentityItem.scss +70 -0
  116. package/src/components/HkMenuIdentityItem.tsx +42 -0
  117. package/src/components/HkMenuPanel.scss +20 -0
  118. package/src/components/HkMenuPanel.test.tsx +118 -0
  119. package/src/components/HkMenuPanel.tsx +32 -0
  120. package/src/components/HkMinimap.tsx +16 -8
  121. package/src/components/HkModal.autofollow.test.ts +44 -0
  122. package/src/components/HkModal.scss +170 -53
  123. package/src/components/HkModal.sheetgap.test.ts +82 -0
  124. package/src/components/HkModal.test.tsx +334 -0
  125. package/src/components/HkModal.tsx +314 -68
  126. package/src/components/HkModalBreadcrumb.scss +4 -1
  127. package/src/components/HkModalBreadcrumb.test.tsx +111 -0
  128. package/src/components/HkModalBreadcrumb.tsx +34 -11
  129. package/src/components/HkModelCatalog.ts +98 -0
  130. package/src/components/HkModelTag.scss +130 -0
  131. package/src/components/HkModelTag.test.tsx +111 -0
  132. package/src/components/HkModelTag.tsx +190 -0
  133. package/src/components/HkNavItem.scss +13 -2
  134. package/src/components/HkNavItem.tsx +2 -1
  135. package/src/components/HkNavSidebar.tsx +99 -0
  136. package/src/components/HkNumberInput.scss +12 -0
  137. package/src/components/HkNumberInput.tsx +15 -8
  138. package/src/components/HkPageHeader.scss +79 -0
  139. package/src/components/HkPageHeader.test.ts +67 -0
  140. package/src/components/HkPageHeader.tsx +51 -0
  141. package/src/components/HkPasswordInput.scss +29 -17
  142. package/src/components/HkPasswordInput.test.ts +281 -0
  143. package/src/components/HkPasswordInput.tsx +337 -59
  144. package/src/components/HkPickerPane.scss +158 -0
  145. package/src/components/HkPlaceholderMarquee.scss +109 -0
  146. package/src/components/HkPlaceholderMarquee.test.ts +305 -0
  147. package/src/components/HkPlaceholderMarquee.tsx +170 -0
  148. package/src/components/HkPopover.scss +134 -10
  149. package/src/components/HkPopover.sheetdock.test.ts +34 -0
  150. package/src/components/HkPopover.surfacevars.test.ts +20 -0
  151. package/src/components/HkPopover.titleSuppress.test.tsx +265 -0
  152. package/src/components/HkPopover.tsx +225 -50
  153. package/src/components/HkPopoverSheet.test.tsx +130 -0
  154. package/src/components/HkPopupSelect.scss +30 -10
  155. package/src/components/HkPopupSelect.tsx +21 -1
  156. package/src/components/HkProgressDialog.scss +31 -0
  157. package/src/components/HkProgressDialog.tsx +37 -19
  158. package/src/components/HkProtocolModal.scss +26 -0
  159. package/src/components/HkProtocolModal.tsx +112 -0
  160. package/src/components/HkRadio.scss +1 -16
  161. package/src/components/HkRadio.tsx +5 -2
  162. package/src/components/HkRichInput.scss +162 -0
  163. package/src/components/HkRichInput.tsx +335 -0
  164. package/src/components/HkRollingNumber.test.ts +90 -0
  165. package/src/components/HkRollingNumber.tsx +39 -1
  166. package/src/components/HkScrollContainer.scss +71 -54
  167. package/src/components/HkScrollContainer.test.ts +160 -0
  168. package/src/components/HkScrollContainer.tsx +187 -160
  169. package/src/components/HkSearchInput.tsx +3 -3
  170. package/src/components/HkSecretRevealModal.scss +61 -0
  171. package/src/components/HkSecretRevealModal.tsx +91 -0
  172. package/src/components/HkSelect.scss +263 -12
  173. package/src/components/HkSelect.sheetdock.test.ts +30 -0
  174. package/src/components/HkSelect.test.tsx +88 -0
  175. package/src/components/HkSelect.tsx +59 -74
  176. package/src/components/HkSelectPanel.test.tsx +666 -0
  177. package/src/components/HkSelectPanel.tsx +595 -0
  178. package/src/components/HkSelectSheet.test.tsx +132 -0
  179. package/src/components/HkSelectionGrid.scss +1 -1
  180. package/src/components/HkSelectionGrid.tsx +2 -1
  181. package/src/components/HkSelectionWaterfall.scss +1 -1
  182. package/src/components/HkShareBar.scss +40 -0
  183. package/src/components/HkShareBar.tsx +50 -0
  184. package/src/components/HkSidebar.scss +15 -10
  185. package/src/components/HkSidebar.tsx +26 -2
  186. package/src/components/HkSignInCard.test.ts +154 -0
  187. package/src/components/HkSignInCard.tsx +155 -0
  188. package/src/components/HkSlider.scss +99 -0
  189. package/src/components/HkSlider.test.tsx +155 -0
  190. package/src/components/HkSlider.tsx +173 -0
  191. package/src/components/HkSpinner.scss +36 -1
  192. package/src/components/HkSpinner.test.tsx +63 -0
  193. package/src/components/HkSpinner.tsx +16 -1
  194. package/src/components/HkSplash.scss +1 -1
  195. package/src/components/HkSplash.tsx +3 -1
  196. package/src/components/HkStatCard.scss +73 -0
  197. package/src/components/HkStatCard.tsx +46 -0
  198. package/src/components/HkStatusBar.test.tsx +178 -0
  199. package/src/components/HkStatusBar.tsx +379 -0
  200. package/src/components/HkStatusPill.scss +73 -0
  201. package/src/components/HkStatusPill.tsx +41 -0
  202. package/src/components/HkStepFlow.scss +75 -0
  203. package/src/components/HkStepFlow.test.tsx +246 -0
  204. package/src/components/HkStepFlow.tsx +115 -0
  205. package/src/components/HkSwitch.scss +5 -17
  206. package/src/components/HkSwitch.tsx +6 -3
  207. package/src/components/HkTable.scss +20 -1
  208. package/src/components/HkTable.tsx +28 -3
  209. package/src/components/HkTabs.scss +222 -48
  210. package/src/components/HkTabs.test.ts +517 -0
  211. package/src/components/HkTabs.tsx +567 -56
  212. package/src/components/HkTag.tsx +2 -1
  213. package/src/components/HkTextarea.tsx +5 -0
  214. package/src/components/HkThemeToggle.scss +232 -0
  215. package/src/components/HkThemeToggle.test.ts +231 -0
  216. package/src/components/HkThemeToggle.tsx +289 -0
  217. package/src/components/HkTimeline.scss +134 -1
  218. package/src/components/HkTimeline.test.tsx +319 -0
  219. package/src/components/HkTimeline.tsx +303 -54
  220. package/src/components/HkToast.scss +34 -5
  221. package/src/components/HkToast.test.tsx +109 -0
  222. package/src/components/HkToast.tsx +57 -8
  223. package/src/components/HkTokenUsageBadge.scss +53 -0
  224. package/src/components/HkTokenUsageBadge.test.tsx +59 -0
  225. package/src/components/HkTokenUsageBadge.tsx +54 -0
  226. package/src/components/HkTokenUsagePanel.scss +95 -0
  227. package/src/components/HkTokenUsagePanel.tsx +140 -0
  228. package/src/components/HkToolBlock.scss +428 -0
  229. package/src/components/HkToolBlock.tsx +639 -0
  230. package/src/components/HkTooltip.scss +14 -2
  231. package/src/components/HkTooltip.tsx +25 -2
  232. package/src/components/HkTrendChart.tsx +27 -16
  233. package/src/components/HkVoiceInputPopup.scss +77 -0
  234. package/src/components/HkVoiceInputPopup.tsx +97 -0
  235. package/src/components/HkZoomToolbar.scss +4 -1
  236. package/src/components/HkZoomToolbar.tsx +7 -4
  237. package/src/components/_hikari-vars.scss +6 -0
  238. package/src/composables/highlightLanguages.ts +109 -0
  239. package/src/composables/iconRegistry.ts +127 -0
  240. package/src/composables/messaging/browserTransport.ts +96 -0
  241. package/src/composables/messaging/index.ts +204 -0
  242. package/src/composables/messaging/messaging.test.ts +256 -0
  243. package/src/composables/messaging/toastTransport.ts +26 -0
  244. package/src/composables/messaging/types.ts +115 -0
  245. package/src/composables/useActionBar.ts +66 -0
  246. package/src/composables/useAttachments.ts +154 -0
  247. package/src/composables/useConnectionProbe.ts +142 -0
  248. package/src/composables/useDeferredTransition.ts +16 -0
  249. package/src/composables/useEngineHealth.ts +69 -0
  250. package/src/composables/useHighlight.ts +124 -0
  251. package/src/composables/useMeasuredHighlight.ts +150 -0
  252. package/src/composables/useOverlayScrollbar.test.ts +221 -0
  253. package/src/composables/useOverlayScrollbar.ts +296 -0
  254. package/src/composables/usePageTitle.ts +37 -0
  255. package/src/composables/useReducedMotion.ts +34 -0
  256. package/src/composables/useResourceListModal.tsx +147 -0
  257. package/src/composables/useSafeArea.ts +137 -0
  258. package/src/composables/useSendShortcut.ts +35 -0
  259. package/src/composables/useSizeMorph.test.ts +156 -0
  260. package/src/composables/useSizeMorph.ts +149 -0
  261. package/src/composables/useSurfaceTransition.test.ts +96 -0
  262. package/src/composables/useSurfaceTransition.ts +71 -0
  263. package/src/composables/useZoomPan.ts +192 -0
  264. package/src/demo.scss +9 -1
  265. package/src/demo.ts +5 -1
  266. package/src/i18n/locales/ar/chat.json +38 -0
  267. package/src/i18n/locales/ar/components.json +141 -40
  268. package/src/i18n/locales/ar/components.toml +59 -0
  269. package/src/i18n/locales/ar/platform.json +65 -0
  270. package/src/i18n/locales/de/chat.json +38 -0
  271. package/src/i18n/locales/de/components.json +139 -38
  272. package/src/i18n/locales/de/components.toml +59 -0
  273. package/src/i18n/locales/de/platform.json +65 -0
  274. package/src/i18n/locales/en/chat.json +38 -0
  275. package/src/i18n/locales/en/components.json +104 -3
  276. package/src/i18n/locales/en/components.toml +59 -0
  277. package/src/i18n/locales/en/platform.json +65 -0
  278. package/src/i18n/locales/es/chat.json +38 -0
  279. package/src/i18n/locales/es/components.json +141 -40
  280. package/src/i18n/locales/es/components.toml +59 -0
  281. package/src/i18n/locales/es/platform.json +65 -0
  282. package/src/i18n/locales/fr/chat.json +38 -0
  283. package/src/i18n/locales/fr/components.json +137 -36
  284. package/src/i18n/locales/fr/components.toml +59 -0
  285. package/src/i18n/locales/fr/platform.json +65 -0
  286. package/src/i18n/locales/ja/chat.json +38 -0
  287. package/src/i18n/locales/ja/components.json +104 -3
  288. package/src/i18n/locales/ja/components.toml +59 -0
  289. package/src/i18n/locales/ja/platform.json +65 -0
  290. package/src/i18n/locales/ko/chat.json +38 -0
  291. package/src/i18n/locales/ko/components.json +104 -3
  292. package/src/i18n/locales/ko/components.toml +59 -0
  293. package/src/i18n/locales/ko/platform.json +65 -0
  294. package/src/i18n/locales/pt/chat.json +38 -0
  295. package/src/i18n/locales/pt/components.json +141 -40
  296. package/src/i18n/locales/pt/components.toml +59 -0
  297. package/src/i18n/locales/pt/platform.json +65 -0
  298. package/src/i18n/locales/ru/chat.json +38 -0
  299. package/src/i18n/locales/ru/components.json +141 -40
  300. package/src/i18n/locales/ru/components.toml +59 -0
  301. package/src/i18n/locales/ru/platform.json +65 -0
  302. package/src/i18n/locales/zh-Hans/chat.json +38 -0
  303. package/src/i18n/locales/zh-Hans/components.json +109 -4
  304. package/src/i18n/locales/zh-Hans/components.toml +59 -0
  305. package/src/i18n/locales/zh-Hans/platform.json +65 -0
  306. package/src/i18n/locales/zh-Hant/chat.json +38 -0
  307. package/src/i18n/locales/zh-Hant/components.json +109 -4
  308. package/src/i18n/locales/zh-Hant/components.toml +59 -0
  309. package/src/i18n/locales/zh-Hant/platform.json +65 -0
  310. package/src/index.ts +225 -6
  311. package/src/plugins/bootScriptsPlugin.ts +47 -0
  312. package/src/plugins/buildHashPlugin.ts +67 -0
  313. package/src/plugins/index.ts +2 -0
  314. package/src/render.ts +1 -1
  315. package/src/runtime/animationBus.test.ts +112 -0
  316. package/src/runtime/animationBus.ts +33 -2
  317. package/src/runtime/backStack.test.ts +334 -0
  318. package/src/runtime/backStack.ts +377 -0
  319. package/src/runtime/env.ts +4 -0
  320. package/src/runtime/index.ts +15 -3
  321. package/src/runtime/intervalBus.test.ts +118 -0
  322. package/src/runtime/intervalBus.ts +189 -0
  323. package/src/runtime/mobileViewport.test.ts +132 -0
  324. package/src/runtime/mobileViewport.ts +157 -0
  325. package/src/runtime/pageLifecycle.ts +91 -0
  326. package/src/runtime/useBlockingToast.ts +112 -0
  327. package/src/runtime/useClipboard.ts +28 -0
  328. package/src/runtime/useConfirm.ts +15 -7
  329. package/src/runtime/useMediaQuery.test.ts +67 -0
  330. package/src/runtime/useMediaQuery.ts +89 -0
  331. package/src/runtime/useOverlay.test.ts +160 -0
  332. package/src/runtime/useOverlay.ts +59 -12
  333. package/src/runtime/usePopupManager.test.ts +200 -0
  334. package/src/runtime/usePopupManager.ts +106 -10
  335. package/src/runtime/useToast.test.ts +90 -0
  336. package/src/runtime/useToast.ts +20 -2
  337. package/src/styles/admin-tokens.scss +744 -0
  338. package/src/styles/index.scss +5 -3
  339. package/src/styles/theme/_glass.scss +33 -0
  340. package/src/styles/theme/_layout.scss +123 -0
  341. package/src/styles/theme/_scrollbar.scss +73 -0
  342. package/src/styles/theme/_tokens.scss +69 -0
  343. package/src/styles/theme/base.scss +617 -0
  344. package/src/styles/theme/foundation.scss +219 -0
  345. package/src/styles/theme/mixins.scss +793 -0
  346. package/src/styles/theme/themes.scss +25 -0
  347. package/src/styles/theme/variables.scss +125 -0
  348. package/src/theme/_field.scss +116 -0
  349. package/src/theme/fontContext.test.ts +179 -0
  350. package/src/theme/fontContext.ts +169 -0
  351. package/src/theme/index.ts +19 -2
  352. package/src/theme/presets.ts +86 -18
  353. package/src/theme/theme.scss +61 -1
  354. package/src/theme/tokenGroups.test.ts +534 -0
  355. package/src/theme/tokenGroups.ts +552 -0
  356. package/src/theme/useSolarTime.test.ts +113 -0
  357. package/src/theme/useSolarTime.ts +95 -20
  358. package/src/theme/useTheme.test.ts +72 -0
  359. package/src/theme/useTheme.ts +144 -9
  360. package/src/tokens.scss +110 -0
  361. package/src/utils/base64.ts +31 -0
  362. package/src/utils/connectivity.ts +51 -0
  363. package/src/utils/dom.test.ts +131 -0
  364. package/src/utils/dom.ts +133 -0
  365. package/src/utils/download.ts +22 -0
  366. package/src/utils/fileType.ts +127 -0
  367. package/src/utils/format.ts +98 -0
  368. package/src/utils/fuzzy.ts +77 -0
  369. package/src/utils/fuzzySearch.test.ts +87 -0
  370. package/src/utils/healthProbe.ts +64 -0
  371. package/src/utils/objectPath.ts +59 -0
  372. package/src/utils/password.ts +53 -0
  373. package/src/utils/pow.test.ts +45 -0
  374. package/src/utils/pow.ts +226 -0
  375. package/src/utils/powNonce.ts +95 -0
  376. package/src/utils/validation.test.ts +78 -0
  377. package/src/DemoApp.vue +0 -400
  378. package/src/components/HkCollapse.tsx +0 -48
  379. package/src/components/HkMorphingTabs.scss +0 -141
  380. package/src/components/HkMorphingTabs.tsx +0 -113
  381. package/src/composables/useBreakpoint.ts +0 -41
  382. package/src/composables/useClipboard.ts +0 -37
  383. package/src/composables/useConfirm.ts +0 -35
  384. package/src/composables/useToast.ts +0 -2
  385. package/src/runtime/useAsyncData.ts +0 -30
@@ -0,0 +1,167 @@
1
+ /**
2
+ * HkAdaptiveDialog — one dialog contract, two shells.
3
+ *
4
+ * Renders HkModal on desktop viewports and HkDrawer side="bottom" on
5
+ * mobile (switched by `useBreakpoint().isMobile`, i.e. width < 768), so
6
+ * one `v-model` component gives a centered dialog on wide screens and a
7
+ * slide-up sheet on phones. The shell only picks which inner component
8
+ * renders and normalizes props/slots — overlay, teleport, z-index
9
+ * stacking, body scroll lock, focus handling and Escape-to-close all
10
+ * come from the inner components via usePopupManager and are never
11
+ * re-implemented here.
12
+ *
13
+ * Slot mapping:
14
+ * default -> body of whichever shell renders
15
+ * header -> HkModal sub-header (below the title bar); on mobile the
16
+ * drawer header is composed so the title stays in the title
17
+ * bar and the slot content rides under it (visual parity
18
+ * with the modal)
19
+ * footer -> footer slot of either shell; wins over footerActions,
20
+ * matching HkModal's own precedence
21
+ *
22
+ * footerActions pass straight into HkModal's prop on desktop; HkDrawer
23
+ * has no footerActions prop, so on mobile the shell renders the
24
+ * equivalent HButton row inside the drawer's footer slot (same order,
25
+ * variants, loading, disabled and onClick handlers).
26
+ *
27
+ * Caveat: if the viewport crosses the mobile breakpoint while a leave
28
+ * transition is still in flight, the inner Transition unmounts without
29
+ * firing its after-leave hook — the forwarded `afterLeave` emit and
30
+ * HkModal's focus restore are skipped for that close (no resource leak:
31
+ * popup handle and scroll lock are released on unmount). Consumers
32
+ * relying on afterLeave (e.g. focus hand-off) should not depend on it
33
+ * firing during a live shell flip.
34
+ */
35
+ import { defineComponent, type PropType } from "vue";
36
+
37
+ import { useBreakpoint } from "../runtime/useBreakpoint";
38
+ import "./HkAdaptiveDialog.scss";
39
+ import HButton from "./HkButton";
40
+ import HDrawer from "./HkDrawer";
41
+ import HModal, { type ModalAction } from "./HkModal";
42
+
43
+ export default defineComponent({
44
+ name: "HkAdaptiveDialog",
45
+ props: {
46
+ modelValue: { type: Boolean, required: true },
47
+ title: { type: String, default: undefined },
48
+ closable: { type: Boolean, default: true },
49
+ /** Desktop modal width (HkModal `width`). */
50
+ width: { type: String, default: "32rem" },
51
+ /** Mobile drawer height (HkDrawer `size`). HkDrawer's bottom-side CSS
52
+ * clamps panels at maxHeight 70vh, so the default equals the cap;
53
+ * pass `panelClass` with a custom max-height for taller sheets. */
54
+ mobileSize: { type: String, default: "70vh" },
55
+ footerActions: {
56
+ type: Array as PropType<ModalAction[]>,
57
+ default: undefined,
58
+ },
59
+ /** Extra classes for the mobile drawer panel — same escape hatch as
60
+ * HkDrawer.panelClass (attrs fallthrough cannot reach a teleported
61
+ * panel). Desktop has no equivalent hook on HkModal. */
62
+ panelClass: { type: String, default: undefined },
63
+ },
64
+ emits: {
65
+ "update:modelValue": (_value: boolean) => true,
66
+ afterLeave: () => true,
67
+ },
68
+ setup(props, { emit, slots }) {
69
+ const { isMobile } = useBreakpoint();
70
+
71
+ function onUpdate(v: boolean) {
72
+ emit("update:modelValue", v);
73
+ }
74
+
75
+ function onAfterLeave() {
76
+ emit("afterLeave");
77
+ }
78
+
79
+ /** Drawer footer: the footer slot wins over footerActions (same
80
+ * precedence HkModal applies); with no slot, the actions render as
81
+ * the same HButton row the modal would show. Returns undefined when
82
+ * there is nothing to render so the drawer skips its footer band. */
83
+ function renderDrawerFooter() {
84
+ if (slots.footer) return slots.footer();
85
+ if (props.footerActions && props.footerActions.length > 0) {
86
+ return (
87
+ <div class="hk-adaptive-dialog-footer">
88
+ {props.footerActions.map((action, i) => (
89
+ <HButton
90
+ key={i}
91
+ variant={action.variant ?? "secondary"}
92
+ size="sm"
93
+ loading={action.loading}
94
+ disabled={action.disabled}
95
+ onClick={action.onClick}
96
+ >
97
+ {action.label}
98
+ </HButton>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /** True when the drawer should render a footer band at all. */
107
+ function hasDrawerFooter() {
108
+ return Boolean(slots.footer) || Boolean(props.footerActions && props.footerActions.length > 0);
109
+ }
110
+
111
+ /** HkDrawer's header slot replaces the title span, so on mobile the
112
+ * shell composes title + slot content itself — keeping the title in
113
+ * the title bar with the slot under it, like the modal sub-header. */
114
+ function renderDrawerHeader() {
115
+ if (!slots.header) return undefined;
116
+ return () => (
117
+ <div class="hk-adaptive-dialog-drawer-header">
118
+ {props.title ? (
119
+ <span class="hk-drawer-title">{props.title}</span>
120
+ ) : null}
121
+ {slots.header!()}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return () => {
127
+ if (isMobile.value) {
128
+ return (
129
+ <HDrawer
130
+ modelValue={props.modelValue}
131
+ onUpdate:modelValue={onUpdate}
132
+ onAfterLeave={onAfterLeave}
133
+ side="bottom"
134
+ size={props.mobileSize}
135
+ title={props.title}
136
+ closable={props.closable}
137
+ panelClass={props.panelClass}
138
+ >
139
+ {{
140
+ default: () => slots.default?.(),
141
+ header: renderDrawerHeader(),
142
+ footer: hasDrawerFooter() ? () => renderDrawerFooter() : undefined,
143
+ }}
144
+ </HDrawer>
145
+ );
146
+ }
147
+
148
+ return (
149
+ <HModal
150
+ modelValue={props.modelValue}
151
+ onUpdate:modelValue={onUpdate}
152
+ onAfterLeave={onAfterLeave}
153
+ title={props.title}
154
+ closable={props.closable}
155
+ width={props.width}
156
+ footerActions={props.footerActions}
157
+ >
158
+ {{
159
+ default: () => slots.default?.(),
160
+ header: slots.header ? () => slots.header!() : undefined,
161
+ footer: slots.footer ? () => slots.footer!() : undefined,
162
+ }}
163
+ </HModal>
164
+ );
165
+ };
166
+ },
167
+ });
@@ -0,0 +1,185 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ // HPopover teleports to body — stubbing Teleport stringifies its vnode
5
+ // children. Replace just HPopover with an inline passthrough that renders
6
+ // its default slot while open; the rest of hikari stays real.
7
+ vi.mock("@celestia-island/hikari", async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import("@celestia-island/hikari")>();
9
+ const { defineComponent, h } = await import("vue");
10
+ const HPopoverStub = defineComponent({
11
+ name: "HPopover",
12
+ props: {
13
+ modelValue: { type: Boolean, default: false },
14
+ placement: { type: String, default: "bottom" },
15
+ },
16
+ setup(props, { slots }) {
17
+ return () =>
18
+ props.modelValue
19
+ ? h("div", { class: "popover-stub" }, slots.default?.())
20
+ : null;
21
+ },
22
+ });
23
+ return { ...actual, HPopover: HPopoverStub };
24
+ });
25
+
26
+ import { HkAdminHeader } from "./HkAdminHeader";
27
+
28
+ /**
29
+ * HkAdminHeader contract tests for the generalized avatar/identity
30
+ * behaviors ported from the chest plana-legacy fork:
31
+ * - avatarAction "menu" (default) toggles the user dropdown; "drawer"
32
+ * emits avatarClick so the shell can open its nav drawer.
33
+ * - the identity block (name/email/groups) leads the menu; an empty
34
+ * identity renders the signing-in placeholder + force-sign-out escape.
35
+ * - an empty title hides the context-title node entirely (pages carry
36
+ * their own in-page title in the HPageHeader convention).
37
+ *
38
+ * (Repo test convention: raw createApp + container queries, no
39
+ * @vue/test-utils dependency.)
40
+ */
41
+
42
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
43
+
44
+ function mount(node: ReturnType<typeof h>) {
45
+ const container = document.createElement("div");
46
+ document.body.appendChild(container);
47
+ const app = createApp({ render: () => node });
48
+ app.mount(container);
49
+ mounts.push({ app, container });
50
+ return container;
51
+ }
52
+
53
+ afterEach(() => {
54
+ for (const { app, container } of mounts.splice(0)) {
55
+ app.unmount();
56
+ container.remove();
57
+ }
58
+ });
59
+
60
+ type HeaderProps = Record<string, unknown>;
61
+
62
+ function headerNode(props: HeaderProps = {}, slots?: Record<string, unknown>) {
63
+ return h(HkAdminHeader, { username: "alice", ...props } as never, slots as never);
64
+ }
65
+
66
+ const avatarButton = (c: HTMLElement) =>
67
+ c.querySelector('button[aria-haspopup]') as HTMLButtonElement | null;
68
+
69
+ async function click(el: Element | null) {
70
+ el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
71
+ await Promise.resolve();
72
+ await Promise.resolve();
73
+ }
74
+
75
+ describe("HkAdminHeader", () => {
76
+ it("toggles the user dropdown on avatar click in menu mode", async () => {
77
+ const c = mount(headerNode({ logoutLabel: "Log out" }));
78
+ const btn = avatarButton(c);
79
+ expect(btn?.getAttribute("aria-haspopup")).toBe("menu");
80
+
81
+ await click(btn);
82
+ const menu = c.querySelector(".popover-stub");
83
+ // The dropdown leads with the identity block and ends with logout.
84
+ expect(menu?.textContent).toContain("alice");
85
+ expect(c.querySelector(".s-user-header")).toBeTruthy();
86
+ expect(menu?.textContent).toContain("Log out");
87
+
88
+ await click(btn);
89
+ expect(c.querySelector(".s-user-header")).toBeNull();
90
+ });
91
+
92
+ it("emits avatarClick instead of the dropdown in drawer mode", async () => {
93
+ const clicks: number[] = [];
94
+ const c = mount(headerNode({
95
+ avatarAction: "drawer",
96
+ logoutLabel: "Log out",
97
+ onAvatarClick: () => clicks.push(1),
98
+ }));
99
+ const btn = avatarButton(c);
100
+ expect(btn?.getAttribute("aria-haspopup")).toBe("dialog");
101
+
102
+ await click(btn);
103
+ expect(clicks).toHaveLength(1);
104
+ // The dropdown must NOT open in drawer mode, and the username stays
105
+ // hidden — the identity block lives in the drawer footer.
106
+ expect(c.querySelector(".s-user-header")).toBeNull();
107
+ expect(c.textContent).not.toContain("alice");
108
+ });
109
+
110
+ it("renders the signing-in placeholder with the force-sign-out escape when the identity is empty", async () => {
111
+ const onForceSignOut = vi.fn();
112
+ const c = mount(headerNode({
113
+ username: "",
114
+ userEmail: "",
115
+ signingInLabel: "Signing in…",
116
+ forceSignOutLabel: "Sign out now",
117
+ onForceSignOut,
118
+ }));
119
+ await click(avatarButton(c));
120
+
121
+ expect(c.querySelector(".s-user-header--pending")).toBeTruthy();
122
+ expect(c.textContent).toContain("Signing in…");
123
+ // No action items above an absent identity.
124
+ expect(c.textContent).not.toContain("Avatar");
125
+
126
+ await click(c.querySelector(".s-popup-menu-item"));
127
+ expect(onForceSignOut).toHaveBeenCalledTimes(1);
128
+ });
129
+
130
+ it("hides the context title entirely when it is empty", () => {
131
+ const withTitle = mount(headerNode({ title: "Dashboard" }));
132
+ expect(withTitle.querySelector("h2")?.textContent).toBe("Dashboard");
133
+
134
+ const withoutTitle = mount(headerNode());
135
+ expect(withoutTitle.querySelector("h2")).toBeNull();
136
+ });
137
+
138
+ it("renders no emergency-stop control by default", () => {
139
+ const c = mount(headerNode({ username: "alice" }));
140
+ expect(c.textContent.toLowerCase()).not.toContain("emergency");
141
+ });
142
+
143
+ it("renders the goToFrontend row above logout and emits goToFrontend", async () => {
144
+ const onGoToFrontend = vi.fn();
145
+ const c = mount(headerNode({
146
+ logoutLabel: "Log out",
147
+ goToFrontendLabel: "Go to Frontend",
148
+ onGoToFrontend,
149
+ }));
150
+ // Opt-in: absent without the label prop…
151
+ const bare = mount(headerNode({ logoutLabel: "Log out" }));
152
+ await click(avatarButton(bare));
153
+ expect(bare.textContent).not.toContain("Go to Frontend");
154
+
155
+ // …and present (directly above logout, mirroring the drawer user
156
+ // panel's row order) with the label prop.
157
+ await click(avatarButton(c));
158
+ const rows = [...c.querySelectorAll(".s-popup-menu-item")];
159
+ const labels = rows.map((r) => r.textContent?.trim());
160
+ expect(labels.indexOf("Go to Frontend")).toBeGreaterThan(-1);
161
+ expect(labels.indexOf("Log out")).toBe(labels.indexOf("Go to Frontend") + 1);
162
+
163
+ await click(rows[labels.indexOf("Go to Frontend")]);
164
+ expect(onGoToFrontend).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it("passes the locale trigger ref OBJECT through the slot so the picker anchors to the button", async () => {
168
+ let captured: unknown = null;
169
+ const c = mount(headerNode(
170
+ { username: "alice" },
171
+ {
172
+ "locale-picker": (scope: { triggerRef: unknown }) => {
173
+ captured = scope.triggerRef;
174
+ return null;
175
+ },
176
+ },
177
+ ));
178
+ await click(avatarButton(c));
179
+ // The slot must receive the ref object itself (reactive), not its
180
+ // frozen .value snapshot from the first render (null before the
181
+ // ref attached).
182
+ expect(captured).toBeTruthy();
183
+ expect((captured as { value?: unknown }).value).toBeInstanceOf(HTMLElement);
184
+ });
185
+ });
@@ -0,0 +1,308 @@
1
+ import { defineComponent, ref, type PropType, type VNode } from "vue";
2
+ import { Camera, ExternalLink, Languages, LogOut, Menu } from "lucide-vue-next";
3
+ import { HBadge, HButton, HPopover, HSpinner } from "@celestia-island/hikari";
4
+ import { useI18n } from "../i18n/context";
5
+
6
+ export interface LocaleOption {
7
+ code: string;
8
+ label: string;
9
+ }
10
+
11
+ export const HkAdminHeader = defineComponent({
12
+ name: "HkAdminHeader",
13
+ props: {
14
+ /** Optional context title — empty (the default) hides the node
15
+ * entirely, for layouts whose pages carry their own in-page title
16
+ * (the HPageHeader convention). */
17
+ title: { type: String, default: "" },
18
+ showHamburger: { type: Boolean, default: false },
19
+ compact: { type: Boolean, default: false },
20
+ actions: { type: Array as PropType<VNode[]>, default: () => [] },
21
+ username: { type: String, default: "" },
22
+ avatarUrl: { type: String, default: "" },
23
+ userEmail: { type: String, default: "" },
24
+ userGroups: { type: Array as PropType<{ id: string; name: string }[]>, default: () => [] },
25
+ /** What the avatar trigger does:
26
+ * - "menu" (default, desktop): toggle the user dropdown popover
27
+ * (identity, avatar edit, language, logout).
28
+ * - "drawer" (mobile): emit `avatarClick` so the shell opens its nav
29
+ * drawer, whose `userPanel` footer carries the same user content.
30
+ * The username stays hidden in this mode — the identity block
31
+ * lives in the drawer, so a header username would read as a
32
+ * duplicated stray control. */
33
+ avatarAction: { type: String as PropType<"menu" | "drawer">, default: "menu" },
34
+ /** Placeholder row shown while the identity is still loading (a
35
+ * fetchUser race on hard refresh) — the action items stay hidden
36
+ * until there is an identity to act on. */
37
+ signingInLabel: { type: String, default: undefined },
38
+ /** Pending-state escape hatch: typically wired to logout (clears
39
+ * cookies and returns to the login page) when the network wedges
40
+ * the session restore and the user wants to break out manually. */
41
+ onForceSignOut: { type: Function, default: undefined },
42
+ forceSignOutLabel: { type: String, default: undefined },
43
+ showEmergencyStop: { type: Boolean, default: false },
44
+ emergencyStopActive: { type: Boolean, default: false },
45
+ emergencyStopActiveLabel: { type: String, default: "" },
46
+ emergencyStopActiveTitle: { type: String, default: "" },
47
+ emergencyStopLabel: { type: String, default: "" },
48
+ emergencyStopTitle: { type: String, default: "" },
49
+ emergencyStopLoading: { type: Boolean, default: false },
50
+ avatarMenuLabel: { type: String, default: undefined },
51
+ /** Accessible label for the avatar trigger button. */
52
+ avatarTriggerLabel: { type: String, default: undefined },
53
+ localeMenuLabel: { type: String, default: undefined },
54
+ logoutLabel: { type: String, default: undefined },
55
+ adminGroupLabel: { type: String, default: undefined },
56
+ /** "Go to frontend" menu row (external-face link, rendered directly
57
+ * above logout). Hidden unless a label is provided — admin-only
58
+ * panels without a consumer-facing face simply omit the prop and
59
+ * keep the menu at avatar/language/logout. */
60
+ goToFrontendLabel: { type: String, default: undefined },
61
+ },
62
+ emits: {
63
+ logout: () => true,
64
+ goToFrontend: () => true,
65
+ hamburger: () => true,
66
+ avatarClick: () => true,
67
+ emergencyStop: () => true,
68
+ },
69
+ setup(props, { emit, slots }) {
70
+ const { t } = useI18n();
71
+ const userMenuOpen = ref(false);
72
+ const userTriggerRef = ref<HTMLElement>();
73
+ const avatarModalOpen = ref(false);
74
+ const avatarFailed = ref(false);
75
+ const localeMenuOpen = ref(false);
76
+ const localeTriggerRef = ref<HTMLElement | null>(null);
77
+
78
+ function onAvatarClick(e: MouseEvent) {
79
+ e.stopPropagation();
80
+ if (props.avatarAction === "drawer") {
81
+ emit("avatarClick");
82
+ return;
83
+ }
84
+ userMenuOpen.value = !userMenuOpen.value;
85
+ }
86
+
87
+ return () => (
88
+ <header
89
+ class={[
90
+ "s-glass-header",
91
+ props.compact ? "px-4 gap-2" : "px-6 gap-3",
92
+ ]}
93
+ >
94
+ {props.showHamburger && (
95
+ <HButton
96
+ variant="ghost"
97
+ size="sm"
98
+ onClick={() => emit("hamburger")}
99
+ >
100
+ <Menu size={20} class="w-5 h-5" />
101
+ </HButton>
102
+ )}
103
+
104
+ <div ref={userTriggerRef} class="flex items-center gap-2 min-w-0">
105
+ <button
106
+ class={[
107
+ "w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0",
108
+ props.avatarUrl
109
+ ? ""
110
+ : "bg-primary/10 border-2 border-primary/15 hover:border-primary/30",
111
+ ]}
112
+ aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
113
+ aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
114
+ aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
115
+ onClick={onAvatarClick}
116
+ >
117
+ {props.avatarUrl && !avatarFailed.value ? (
118
+ <img
119
+ src={props.avatarUrl}
120
+ alt={props.username}
121
+ class="w-full h-full object-cover"
122
+ onError={() => { avatarFailed.value = true; }}
123
+ />
124
+ ) : (
125
+ <span class="text-xs font-bold text-primary/70">
126
+ {props.username?.charAt(0).toUpperCase() || "?"}
127
+ </span>
128
+ )}
129
+ {props.avatarAction === "menu" && (
130
+ <div class="absolute inset-0 rounded-full bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
131
+ <Camera size={10} class="text-white" />
132
+ </div>
133
+ )}
134
+ </button>
135
+ {props.avatarAction === "menu" && (
136
+ <span class="text-sm font-semibold text-text truncate max-w-[8rem]">
137
+ {props.username}
138
+ </span>
139
+ )}
140
+ </div>
141
+
142
+ <HPopover
143
+ modelValue={userMenuOpen.value}
144
+ onUpdate:modelValue={(v: boolean) => {
145
+ userMenuOpen.value = v;
146
+ // Closing the outer menu must also close the locale submenu,
147
+ // or it stays orphaned for the next open.
148
+ if (!v) localeMenuOpen.value = false;
149
+ }}
150
+ placement="bottom-start"
151
+ anchorRef={userTriggerRef.value ?? null}
152
+ class="w-56"
153
+ sheetOnMobile
154
+ title={t("hikari::adminHeader.avatarTrigger")}
155
+ >
156
+ {/* Empty identity (fetchUser race on a hard refresh): render a
157
+ single subtle placeholder row instead of the action items —
158
+ "Change Avatar"/"Logout" floating above no identity read as a
159
+ broken menu. The force-sign-out escape hatch lets the user
160
+ break out of a wedged session restore. */}
161
+ {!props.username && !props.userEmail ? (
162
+ <div>
163
+ <div class="s-user-header s-user-header--pending">
164
+ <HSpinner size="sm" />
165
+ <div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
166
+ </div>
167
+ <button
168
+ class="s-popup-menu-item"
169
+ onClick={() => props.onForceSignOut?.()}
170
+ >
171
+ <LogOut size={14} />
172
+ {props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out")}
173
+ </button>
174
+ </div>
175
+ ) : (
176
+ <div>
177
+ {/* Identity header FIRST — nickname, login email, permission
178
+ badges — so every account surface reads identically. */}
179
+ <div class="s-user-header">
180
+ {props.username && <div class="s-user-header-name">{props.username}</div>}
181
+ {props.userEmail && <div class="s-user-header-email">{props.userEmail}</div>}
182
+ {props.userGroups && props.userGroups.length > 0 && (
183
+ <div class="s-user-header-groups">
184
+ {props.userGroups.map((g: { id: string; name: string }) => (
185
+ <HBadge
186
+ key={g.id}
187
+ variant={g.name === "Administrators" ? "error" : "primary"}
188
+ size="sm"
189
+ >
190
+ {g.name === "Administrators"
191
+ ? (props.adminGroupLabel ?? t("hikari::adminHeader.adminGroup", "Administrators"))
192
+ : g.name}
193
+ </HBadge>
194
+ ))}
195
+ </div>
196
+ )}
197
+ </div>
198
+ <button
199
+ class="s-popup-menu-item"
200
+ onClick={() => {
201
+ userMenuOpen.value = false;
202
+ avatarModalOpen.value = true;
203
+ }}
204
+ >
205
+ <Camera size={14} />
206
+ {props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
207
+ </button>
208
+ {/* The language trigger owns the locale anchor: the ref sits
209
+ on the BUTTON itself (not a wrapper div) so the picker
210
+ popup anchors to it instead of falling back to inline
211
+ rendering. The ref OBJECT is passed through the slot —
212
+ reading .value here would freeze null from the first
213
+ render before the ref attached. */}
214
+ <button
215
+ ref={localeTriggerRef}
216
+ class="s-popup-menu-item"
217
+ onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
218
+ >
219
+ <Languages size={14} />
220
+ {props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
221
+ </button>
222
+ {slots["locale-picker"]?.({
223
+ open: localeMenuOpen.value,
224
+ onUpdateOpen: (v: boolean) => {
225
+ localeMenuOpen.value = v;
226
+ if (!v) userMenuOpen.value = false;
227
+ },
228
+ triggerRef: localeTriggerRef,
229
+ })}
230
+ {slots["user-menu-extra"]?.()}
231
+ {/* Frontend link — mirrors the drawer user panel's
232
+ "go to frontend" row (same icon/position: directly
233
+ above logout). Opt-in via the label prop so admin-only
234
+ panels keep the menu at avatar/language/logout. */}
235
+ {props.goToFrontendLabel ? (
236
+ <button
237
+ class="s-popup-menu-item"
238
+ onClick={() => {
239
+ userMenuOpen.value = false;
240
+ emit("goToFrontend");
241
+ }}
242
+ >
243
+ <ExternalLink size={14} />
244
+ {props.goToFrontendLabel}
245
+ </button>
246
+ ) : null}
247
+ <button
248
+ class="s-popup-menu-item"
249
+ onClick={() => {
250
+ userMenuOpen.value = false;
251
+ emit("logout");
252
+ }}
253
+ >
254
+ <LogOut size={14} />
255
+ {props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
256
+ </button>
257
+ </div>
258
+ )}
259
+ </HPopover>
260
+
261
+ {/* The page title lives INSIDE each page in the HPageHeader
262
+ convention (big title left, tool buttons right). The bar
263
+ renders an optional context title only when one is given —
264
+ empty hides the node, keeping the bar to identity + theme
265
+ controls. */}
266
+ {props.title && (
267
+ <h2 class="text-sm font-semibold text-text truncate min-w-0">
268
+ {props.title}
269
+ </h2>
270
+ )}
271
+ <div class="ml-auto flex items-center gap-1.5 shrink-0">
272
+ {props.showEmergencyStop && (
273
+ <button
274
+ class={[
275
+ "px-3 py-1 rounded-md text-xs font-bold border transition-all",
276
+ props.emergencyStopLoading ? "opacity-50 cursor-wait" : "cursor-pointer",
277
+ props.emergencyStopActive
278
+ ? "bg-red-600 text-white border-red-700 animate-pulse"
279
+ : "bg-red-600/10 text-red-500 border-red-500/40 hover:bg-red-600/25",
280
+ ]}
281
+ disabled={props.emergencyStopLoading}
282
+ title={props.emergencyStopActive
283
+ ? props.emergencyStopActiveTitle
284
+ : props.emergencyStopTitle}
285
+ onClick={() => emit("emergencyStop")}
286
+ >
287
+ {props.emergencyStopActive
288
+ ? props.emergencyStopActiveLabel
289
+ : props.emergencyStopLabel}
290
+ </button>
291
+ )}
292
+ {slots["emergency-stop-extra"]?.()}
293
+ {(props.actions || []).map((vnode, i) => (
294
+ <span key={i} class="flex items-center gap-1">
295
+ {vnode}
296
+ </span>
297
+ ))}
298
+ {slots["theme-toggle"]?.()}
299
+ </div>
300
+
301
+ {slots["avatar-modal"]?.({
302
+ open: avatarModalOpen.value,
303
+ onUpdateOpen: (v: boolean) => { avatarModalOpen.value = v; },
304
+ })}
305
+ </header>
306
+ );
307
+ },
308
+ });