@celestia-island/hikari 0.55.249 → 0.55.250

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAffixPicker.scss +15 -43
  3. package/src/components/HkMenuIdentityItem.scss +4 -0
  4. package/src/components/HkSolarLocationSetting.test.tsx +31 -21
  5. package/src/components/HkSolarLocationSetting.tsx +13 -14
  6. package/src/components/HkTagInput.scss +39 -49
  7. package/src/components/HkTagInput.tsx +6 -0
  8. package/src/components/HkTemplateField.scss +24 -10
  9. package/src/components/HkTimeZoneGlobe.scss +125 -0
  10. package/src/components/HkTimeZoneGlobe.test.tsx +128 -0
  11. package/src/components/HkTimeZoneGlobe.tsx +385 -0
  12. package/src/components/HkUserPicker.scss +11 -18
  13. package/src/components/HkUserPicker.tsx +5 -0
  14. package/src/components/HkWaterfall.styles.test.ts +97 -37
  15. package/src/components/_menu-item.contract.test.ts +66 -0
  16. package/src/components/_menu-item.scss +19 -2
  17. package/src/i18n/locales/ar/platform.json +90 -1
  18. package/src/i18n/locales/de/platform.json +90 -1
  19. package/src/i18n/locales/en/platform.json +90 -1
  20. package/src/i18n/locales/es/platform.json +90 -1
  21. package/src/i18n/locales/fr/platform.json +90 -1
  22. package/src/i18n/locales/ja/platform.json +90 -1
  23. package/src/i18n/locales/ko/platform.json +90 -1
  24. package/src/i18n/locales/pt/platform.json +90 -1
  25. package/src/i18n/locales/ru/platform.json +90 -1
  26. package/src/i18n/locales/zh-Hans/platform.json +90 -1
  27. package/src/i18n/locales/zh-Hant/platform.json +90 -1
  28. package/src/styles/admin-tokens.scss +9 -0
  29. package/src/theme/solarGlobeAlignment.test.ts +88 -0
  30. package/src/theme/solarGlobeMap.test.ts +66 -0
  31. package/src/theme/solarGlobeMap.ts +193 -0
  32. package/src/theme/solarTimeZoneLocales.test.ts +92 -0
  33. package/src/theme/solarTimeZones.test.ts +139 -0
  34. package/src/theme/solarTimeZones.ts +190 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.249",
3
+ "version": "0.55.250",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -4,6 +4,8 @@
4
4
  * their own class on top via chipClass), while the popup — search,
5
5
  * selected tags, option rows, custom row — is fully standardized here. */
6
6
 
7
+ @use "./menu-item" as mi;
8
+
7
9
  /* ── chip base ────────────────────────────────────────────────────────
8
10
  * Neutral clickable chip; hosts override visually via their own class
9
11
  * (HkPhoneInput's dial chip, HkLocalizedInput's badge chip, …). */
@@ -232,55 +234,25 @@
232
234
  display: flex;
233
235
  flex-direction: column;
234
236
  gap: 1px;
235
- padding: var(--space-4, 4px) var(--space-6, 6px) var(--space-8, 8px);
237
+ /* GEOMETRY-NEUTRAL band (menu-item WIDTH invariant): block-axis rhythm
238
+ * only, NO inline term — the hosting panel's inset (popout 8px / sheet
239
+ * 16px) is the one horizontal inset these rows get. The old 6px inline
240
+ * term stacked a second inset and pulled the rows' wash 6px inside
241
+ * every sibling family's in the same sheet. */
242
+ padding-block: var(--space-4, 4px) var(--space-8, 8px);
236
243
  }
237
244
 
238
245
  .hk-affix-row {
239
- display: flex;
240
- align-items: center;
241
- gap: var(--space-8, 8px);
242
- width: 100%;
243
- min-height: 2.25rem;
244
- padding: var(--space-4, 4px) var(--space-8, 8px);
245
- border: none;
246
- border-radius: var(--radius-sm, 6px);
247
- background: transparent;
248
- color: rgb(var(--color-text));
249
- font-size: var(--text-sm, 0.8125rem);
250
- text-align: start;
251
- cursor: pointer;
252
- appearance: none;
253
- transition: background 0.1s ease;
254
-
255
- &:hover {
256
- background: rgb(var(--color-primary) / 10%);
257
- outline: none;
258
- }
259
-
260
- /* Keyboard position is drawn INSIDE the row (HkSelectionGrid branch-fix
261
- * pattern) and distinct from the hover tint, so overlapping popup/sheet
262
- * chrome cannot shave a per-side outer ring and focus reads as focus. */
263
- &:focus-visible {
264
- background: rgb(var(--color-primary) / 10%);
265
- outline: 2px solid rgb(var(--color-primary));
266
- outline-offset: -2px;
267
- }
268
-
269
- &[data-active] {
270
- background: rgb(var(--color-primary) / 16%);
271
- font-weight: 600;
272
- }
246
+ /* Unified menu-item spec (_menu-item.scss): one row grammar with the
247
+ * select options and menu rows around it — metrics (34px / 6×12,
248
+ * 44px / 10×16 inside sheets via the shared registration list),
249
+ * reserved 1px border, guarded hover, primary wash + strong border on
250
+ * [data-active], disabled dim, 2px inset focus ring. The sheet reach
251
+ * comes from the registration list, not from here. */
252
+ @include mi.item;
273
253
 
274
254
  &[data-disabled] {
275
- opacity: 0.5;
276
255
  cursor: default;
277
-
278
- /* The button stays keyboard-focusable (data-disabled only), so the
279
- * unpickable row must not paint the pickable tint on focus either. */
280
- &:hover,
281
- &:focus-visible {
282
- background: transparent;
283
- }
284
256
  }
285
257
 
286
258
  /* The "Use <query>" free-text row reads as an add affordance. Truncation
@@ -8,6 +8,10 @@
8
8
  gap: var(--space-10, 10px);
9
9
  width: 100%;
10
10
  padding: var(--space-8, 8px) var(--space-12, 12px);
11
+ /* The 1px transparent border is RESERVED, not decorative: the
12
+ HkMenuActionItem rows beside this block draw their content edge at
13
+ 1px + pad-x, so an unreserved block sat 1px left of them. */
14
+ border: 1px solid transparent;
11
15
  border-radius: var(--radius-sm, 4px);
12
16
  min-width: 0;
13
17
  }
@@ -7,7 +7,6 @@ import { setLocale } from "../i18n/context";
7
7
  import {
8
8
  SOLAR_LOCATION_STRATEGY_STORAGE_KEY,
9
9
  SOLAR_MANUAL_LOCATION_STORAGE_KEY,
10
- listTimeZones,
11
10
  manualLocationOf,
12
11
  setSolarLocationStrategy,
13
12
  setSolarManualLocation,
@@ -21,7 +20,7 @@ import {
21
20
  * HkSolarLocationSetting — the solar clock's location-strategy row.
22
21
  * The strategy engine (theme/useSolarTime.ts) has its own suite; these
23
22
  * cases pin the COMPONENT contract: the live summary row, the secondary
24
- * HkModal editor (strategy tabs / IANA zone select / explicit
23
+ * HkModal editor (strategy tabs / time-zone globe / explicit
25
24
  * hemisphere), OK applying through setSolarManualLocation →
26
25
  * setSolarLocationStrategy → refreshThemeClock with storage evidence,
27
26
  * and cancel leaving the engine untouched.
@@ -127,15 +126,18 @@ function clickStrategy(key: "timezone" | "ip" | "manual"): void {
127
126
  clickStrategyTab({ timezone: "Time zone", ip: "IP location", manual: "Manual" }[key]);
128
127
  }
129
128
 
130
- /** Opens the manual pane's IANA zone select and picks a zone. */
129
+ /** Picks a zone on the manual pane's globe (the fallback sector list in
130
+ * the test DOM — happy-dom has no WebGL). */
131
131
  async function pickTimeZone(zone: string): Promise<void> {
132
- const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
133
- expect(trigger, "zone select trigger renders").toBeTruthy();
134
- trigger!.click();
135
- await settle();
136
- const option = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
137
- .find((el) => el.textContent?.trim() === zone);
138
- expect(option, `zone option "${zone}" renders`).toBeTruthy();
132
+ // The picker is a defineAsyncComponent chunk — wait it in.
133
+ let option: HTMLButtonElement | undefined;
134
+ for (let i = 0; i < 20 && !option; i++) {
135
+ option = document.body.querySelector<HTMLButtonElement>(
136
+ '[data-test="hk-timezone-globe-option"][data-zone="' + zone + '"]',
137
+ ) ?? undefined;
138
+ if (!option) await settle();
139
+ }
140
+ expect(option, `globe sector for "${zone}" renders`).toBeTruthy();
139
141
  option!.click();
140
142
  await settle();
141
143
  }
@@ -239,7 +241,13 @@ describe("HkSolarLocationSetting editor", () => {
239
241
  const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor")!;
240
242
  expect(editor.getAttribute("data-strategy")).toBe("manual");
241
243
  expect(document.body.querySelector(".hk-solar-manual")).toBeTruthy();
242
- expect(document.body.querySelector<HTMLButtonElement>(".hk-select-trigger")).toBeTruthy();
244
+ // The globe is a defineAsyncComponent chunk — wait it in.
245
+ let globe: HTMLElement | null = null;
246
+ for (let i = 0; i < 20 && !globe; i++) {
247
+ globe = document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe"]');
248
+ if (!globe) await settle();
249
+ }
250
+ expect(globe, "globe picker renders in the manual pane").toBeTruthy();
243
251
  const hemi = hemisphereTriggers();
244
252
  expect(hemi.map((b) => b.textContent?.trim())).toEqual(["Northern", "Southern"]);
245
253
  // Draft default: the system zone, northern hemisphere (explicit
@@ -357,23 +365,25 @@ describe("HkSolarLocationSetting i18n", () => {
357
365
  ]);
358
366
  });
359
367
 
360
- it("seeds the zone list from the engine's listTimeZones catalog", async () => {
368
+ it("picks from the globe's 24-sector wheel, not the raw IANA catalog", async () => {
361
369
  const container = mountSetting();
362
370
  await openEditor(container);
363
371
  clickStrategy("manual");
364
372
  await settle();
365
373
 
366
- const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
367
- trigger!.click();
368
- await settle();
374
+ await pickTimeZone("Australia/Sydney");
369
375
 
370
- const rendered = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
371
- .map((el) => el.textContent?.trim());
372
- const catalog = listTimeZones();
373
- expect(rendered.length).toBe(catalog.length);
376
+ const rendered = [...document.body.querySelectorAll<HTMLButtonElement>('[data-test="hk-timezone-globe-option"]')]
377
+ .map((el) => el.dataset.zone);
378
+ // 24 representative zones — NOT the hundreds-of-ids IANA catalog
379
+ // the picker replaced.
380
+ expect(rendered).toHaveLength(24);
381
+ expect(new Set(rendered).size).toBe(24);
374
382
  expect(rendered).toContain("Asia/Shanghai");
375
- // A catalog-present zone on every ICU: "UTC" itself is NOT part of
376
- // Intl.supportedValuesOf("timeZone") on some ICU builds.
377
383
  expect(rendered).toContain("Australia/Sydney");
384
+ // The draft (and its resolved readout) carries the picked zone id.
385
+ const resolved = document.body.querySelector<HTMLElement>('[data-test="hk-solar-resolved"]');
386
+ const geo = manualLocationOf({ timeZone: "Australia/Sydney", hemisphere: "north" });
387
+ expect(resolved!.textContent).toContain(geo.lng.toFixed(1));
378
388
  });
379
389
  });
@@ -1,12 +1,11 @@
1
1
  // AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
2
- import { computed, defineComponent, ref } from "vue";
2
+ import { computed, defineAsyncComponent, defineComponent, ref } from "vue";
3
3
  import { Clock, Globe2, MapPin } from "lucide-vue-next";
4
4
 
5
5
  import { useI18n } from "../i18n/context";
6
6
  import { refreshThemeClock } from "../theme/useTheme";
7
7
  import {
8
8
  hydrateSolarLocationState,
9
- listTimeZones,
10
9
  manualLocationOf,
11
10
  setSolarLocationStrategy,
12
11
  setSolarManualLocation,
@@ -19,10 +18,15 @@ import {
19
18
  } from "../theme/useSolarTime";
20
19
  import HkButton from "./HkButton";
21
20
  import HkModal, { type ModalAction } from "./HkModal";
22
- import HkSelect, { type HkSelectOption } from "./HkSelect";
23
21
  import HTabs, { type TabItem } from "./HkTabs";
24
22
  import "./HkSolarLocationSetting.scss";
25
23
 
24
+ // The globe picker loads lazily (defineAsyncComponent, module level —
25
+ // the async wrapper is created once, import() is module-cached):
26
+ // three.js and the board only enter the bundle graph when a host
27
+ // actually opens the manual pane — the settings row stays weightless.
28
+ const HkTimeZoneGlobe = defineAsyncComponent(() => import("./HkTimeZoneGlobe"));
29
+
26
30
  /**
27
31
  * HkSolarLocationSetting — the solar clock's location STRATEGY row
28
32
  * (2026-10-31 product direction: strategies, not a fixed cascade).
@@ -89,13 +93,6 @@ export const HkSolarLocationSetting = defineComponent({
89
93
  open.value = true;
90
94
  }
91
95
 
92
- /** IANA zone list for the manual pane's select. Plain data (no
93
- * vnodes), static for the process lifetime — cached once. */
94
- let timeZoneOptionsCache: HkSelectOption[] | null = null;
95
- function timeZoneOptions(): HkSelectOption[] {
96
- timeZoneOptionsCache ??= listTimeZones().map((id) => ({ value: id, label: id }));
97
- return timeZoneOptionsCache;
98
- }
99
96
 
100
97
  // ── Live summary (the row's label) ────────────────────────────────
101
98
  const summaryText = computed(() => {
@@ -234,12 +231,14 @@ export const HkSolarLocationSetting = defineComponent({
234
231
  <p class="hk-solar-hint">{hintText.value}</p>
235
232
  {draftStrategy.value === "manual" && (
236
233
  <div class="hk-solar-manual">
237
- <HkSelect
234
+ {/* The globe picker (2026-10-31): 24 equal time-sector
235
+ wedges on a spinning, borderless world-plates sphere,
236
+ translated tooltip included — the IANA dropdown it
237
+ replaced listed hundreds of raw zone ids. Still
238
+ writes the same draftTimeZone the engine applies. */}
239
+ <HkTimeZoneGlobe
238
240
  modelValue={draftTimeZone.value}
239
241
  onUpdate:modelValue={onSelectTimeZone}
240
- options={timeZoneOptions()}
241
- label={t("hikari::theme.solar.strategyTimezone")}
242
- panelTitle={t("hikari::theme.solar.strategyTimezone")}
243
242
  />
244
243
  <div class="hk-solar-hemisphere">
245
244
  <label class="hk-solar-field-label">{t("hikari::theme.solar.hemisphere")}</label>
@@ -8,6 +8,7 @@
8
8
  * stretching the panel). Chips and selected rows are reorderable; the
9
9
  * drag states (`data-dragging` / `data-drop`) live at the bottom. */
10
10
 
11
+ @use "./menu-item" as mi;
11
12
  @use "../styles/polyfill" as *;
12
13
 
13
14
  .hk-tag-input-wrapper {
@@ -231,56 +232,41 @@
231
232
  display: flex;
232
233
  flex-direction: column;
233
234
  gap: 1px;
234
- padding: var(--space-4, 4px) var(--space-6, 6px) var(--space-8, 8px);
235
+ /* GEOMETRY-NEUTRAL band (menu-item WIDTH invariant): block-axis rhythm
236
+ * only — the hosting panel's inset is the one horizontal inset these
237
+ * rows get (the old 6px inline term stacked a second inset). */
238
+ padding-block: var(--space-4, 4px) var(--space-8, 8px);
235
239
  }
236
240
 
237
241
  .hk-tag-input-row {
238
- display: flex;
239
- align-items: center;
240
- gap: var(--space-8, 8px);
241
- width: 100%;
242
- min-height: 2.25rem;
243
- padding: var(--space-4, 4px) var(--space-8, 8px);
244
- border-radius: var(--radius-sm);
245
- color: rgb(var(--color-text));
246
- font-size: var(--text-sm);
247
- text-align: start;
248
- cursor: pointer;
249
- transition: background 0.1s ease;
250
-
251
- &:hover,
252
- &:focus-visible {
253
- background: rgb(var(--color-primary) / 10%);
254
- outline: none;
255
- }
256
-
257
- /* Selected = the tag is in use; the check glyph carries the state and
258
- * the row stays clickable so it can be toggled back off. */
259
- &[data-selected] {
260
- font-weight: 600;
261
-
262
- &:hover {
263
- background: rgb(var(--color-primary) / 16%);
264
- }
265
- }
266
-
267
- /* Keyboard cursor (`aria-activedescendant`): the field's own focus-ring
268
- * language, inset so the highlight never shifts the row's metrics and
269
- * never clips at the list edge. Focus itself stays in the field. */
242
+ /* Unified menu-item spec (_menu-item.scss): one row grammar with the
243
+ * select options and menu rows around it — metrics, reserved border,
244
+ * guarded hover, checked face, focus ring; the sheet reach comes from
245
+ * the shared registration list. The tsx emits the grammar's
246
+ * `data-checked` for the selected face (so mi.item's hover guard
247
+ * protects it) ALONGSIDE this family's legacy `data-selected`
248
+ * (tests/hosts key on it). `data-active` is the field's
249
+ * aria-activedescendant keyboard cursor (focus itself stays in the
250
+ * field) — mi.item has no HIGHLIGHT face, and its SELECTED face
251
+ * would bleed through a partial restatement, so the cursor face is
252
+ * restated in full below; mi.item's hover guard already excludes
253
+ * data-active rows. */
254
+ @include mi.item;
255
+
256
+ /* [data-active] is the field's aria-activedescendant keyboard cursor —
257
+ the grammar's HIGHLIGHT role. mi.item has no cursor face, but its
258
+ [data-active]/[data-checked] SELECTED face would bleed through a
259
+ partial restatement, so the cursor face restates the full set back
260
+ to the row's resting values: wash + inset ring only, reserved
261
+ border transparent, normal text color and weight. */
270
262
  &[data-active] {
271
- background: rgb(var(--color-primary) / 12%);
263
+ background: rgb(var(--color-primary) / 8%);
264
+ border-color: transparent;
265
+ color: rgb(var(--color-text) / 88%);
266
+ font-weight: var(--hk-menu-item-weight);
272
267
  box-shadow: inset 0 0 0 1px rgb(var(--color-focused-border));
273
268
  }
274
269
 
275
- &[data-disabled] {
276
- opacity: 0.5;
277
- cursor: default;
278
-
279
- &:hover {
280
- background: transparent;
281
- }
282
- }
283
-
284
270
  /* The "Use “<query>”" free-text row reads as an add affordance. */
285
271
  &[data-custom="true"] {
286
272
  color: rgb(var(--color-primary));
@@ -393,13 +379,17 @@
393
379
  align-items: center;
394
380
  justify-content: center;
395
381
  flex: none;
396
- /* The largest target the row rhythm allows: the row's content box is the
397
- 2.25rem row minus its 4px block padding, so the handle grows to that
398
- without changing the option rows' height (the 44px touch guidance
399
- cannot fit in a list row — the sheet scrolls through them instead). */
382
+ /* The largest target the row rhythm allows: the row's content box is
383
+ the grammar row's min-height minus its block padding (34px − 2×6px =
384
+ 22px; 44px − 2×10px = 24px inside sheets), so the handle grows to
385
+ that without changing the option rows' height — the 44px touch
386
+ guidance cannot fit in a list row, the sheet scrolls through them
387
+ instead. The grip was sized for the RETIRED 2.25rem/4px stock once:
388
+ at 28px it overflowed the 22px box and every selected+reorderable
389
+ row grew 6px taller than its neighbours. */
400
390
  width: 1.5rem;
401
- height: 1.75rem;
402
- margin-inline-end: -4px;
391
+ height: 1.375rem;
392
+ margin-inline-end: -6px;
403
393
  color: rgb(var(--color-muted));
404
394
  opacity: 0.6;
405
395
  cursor: grab;
@@ -1053,6 +1053,12 @@ export const HkTagInput = defineComponent({
1053
1053
  aria-selected={selected}
1054
1054
  aria-disabled={inert || undefined}
1055
1055
  data-selected={selected || undefined}
1056
+ /* Grammar face attribute (_menu-item.scss):
1057
+ * mi.item's checked face AND its hover guard
1058
+ * key on data-checked — emitting it beside the
1059
+ * legacy data-selected keeps the selected row's
1060
+ * face from being flipped by the hover rule. */
1061
+ data-checked={selected || undefined}
1056
1062
  data-disabled={inert || undefined}
1057
1063
  data-active={activeId === stop.id || undefined}
1058
1064
  data-reorder={reorderable ? "true" : undefined}
@@ -173,7 +173,13 @@
173
173
  display: flex;
174
174
  flex-direction: column;
175
175
  gap: 2px;
176
- padding: var(--space-4, 4px);
176
+ /* GEOMETRY-NEUTRAL band (menu-item WIDTH invariant): the level's gap
177
+ * owns the rhythm and the hosting surface's inset is the one
178
+ * horizontal inset these rows get — the old 4px all-sides pad was the
179
+ * exact "band that pads itself" anti-pattern (a foot/head row in this
180
+ * level would have landed 8px offset from these rows). Flex stays:
181
+ * display is layout, not inset. */
182
+ padding-block: var(--space-4, 4px);
177
183
  }
178
184
 
179
185
  // The group is a role="group" WRAPPER carrying the accessible name in
@@ -211,21 +217,29 @@
211
217
  }
212
218
 
213
219
  .hk-tpl-row {
214
- display: flex;
220
+ /* Unified menu-item spec (_menu-item.scss): metrics, reserved border,
221
+ * guarded hover, focus ring, sheet tokens via the shared registration
222
+ * list (this row renders in TWO sheet contexts — the vocabulary
223
+ * HkMenu sheet and the chip editor's sheetOnMobile popover). The
224
+ * stacked two-line shape restates the flex axis after the include. */
225
+ @include mi.item;
215
226
  flex-direction: column;
216
227
  align-items: flex-start;
217
228
  gap: 1px;
218
- padding: var(--space-4, 4px) var(--space-8, 8px);
219
- border: none;
220
- border-radius: var(--radius-sm, 4px);
221
- background: transparent;
222
229
  color: var(--hi-color-text-primary);
223
- text-align: start;
224
- cursor: pointer;
225
230
 
226
- &:hover,
231
+ /* `data-active` is this family's aria-activedescendant keyboard cursor
232
+ * (the grammar's HIGHLIGHT role, not its selected face — mi.item's
233
+ * hover guard already excludes data-active rows). mi.item has no
234
+ * cursor face and its SELECTED face would bleed through a partial
235
+ * restatement, so this face restates the full set at the grammar's
236
+ * hover values — INCLUDING the weight back to the row's resting 500
237
+ * (the selected face's 600 otherwise lands on the cursor row). */
227
238
  &[data-active] {
228
- background: color-mix(in srgb, var(--hi-color-primary) 10%, transparent);
239
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
240
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
241
+ color: rgb(var(--color-text));
242
+ font-weight: var(--hk-menu-item-weight);
229
243
  }
230
244
  }
231
245
 
@@ -0,0 +1,125 @@
1
+ /* HkTimeZoneGlobe — the manual zone picker's globe stage, tooltip and
2
+ * no-WebGL fallback list. AI-generated: developed with the ZCode agent
3
+ * (model GLM-5.3); human-directed. */
4
+
5
+ .hk-timezone-globe {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space-6, 0.375rem);
9
+ min-width: 0;
10
+ }
11
+
12
+ /* The 3D stage: a fixed-height viewport (the board's canvas and its
13
+ * bottom-right minimap both fill this box). */
14
+ .hk-timezone-globe__stage {
15
+ position: relative;
16
+ height: 15rem;
17
+ min-height: 12rem;
18
+ border-radius: var(--radius-sm, 0.375rem);
19
+ overflow: hidden;
20
+ /* A whisper of surface so the transparent-background board reads as
21
+ * a framed instrument, not a hole in the modal. */
22
+ background: rgb(var(--color-muted) / 0.06);
23
+ touch-action: none;
24
+ }
25
+
26
+ .hk-timezone-globe__tooltip {
27
+ position: absolute;
28
+ z-index: 2;
29
+ pointer-events: none;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: var(--space-4, 0.25rem);
33
+ max-width: 15rem;
34
+ padding: var(--space-8, 0.5rem) var(--space-10, 0.625rem);
35
+ border: 1px solid rgb(var(--color-muted) / 0.35);
36
+ border-radius: var(--radius-sm, 0.375rem);
37
+ background: rgb(var(--color-background, 22 24 30) / 0.92);
38
+ color: inherit;
39
+ font-size: var(--text-xs, 0.75rem);
40
+ line-height: 1.45;
41
+ }
42
+
43
+ .hk-timezone-globe__tooltip-offset {
44
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
45
+ font-weight: 600;
46
+ }
47
+
48
+ .hk-timezone-globe__tooltip-zone {
49
+ font-family: var(--font-mono, ui-monospace, monospace);
50
+ font-size: var(--text-xs, 0.75rem);
51
+ }
52
+
53
+ .hk-timezone-globe__tooltip-cities {
54
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
55
+ }
56
+
57
+ .hk-timezone-globe__hint {
58
+ margin: 0;
59
+ color: rgb(var(--color-muted));
60
+ font-size: var(--text-xs, 0.75rem);
61
+ line-height: 1.5;
62
+ }
63
+
64
+ /* ── No-WebGL fallback: the same 24 sectors as a flat list ─────────────── */
65
+ .hk-timezone-globe__list {
66
+ display: flex;
67
+ flex-direction: column;
68
+ max-height: 17rem;
69
+ overflow-y: auto;
70
+ border: 1px solid rgb(var(--color-muted) / 0.25);
71
+ border-radius: var(--radius-sm, 0.375rem);
72
+ }
73
+
74
+ .hk-timezone-globe__option {
75
+ display: grid;
76
+ grid-template-columns: auto 1fr;
77
+ grid-template-areas:
78
+ "offset name"
79
+ "zone zone"
80
+ "cities cities";
81
+ gap: 0 var(--space-6, 0.375rem);
82
+ padding: var(--space-6, 0.375rem) var(--space-8, 0.5rem);
83
+ border: 0;
84
+ border-bottom: 1px solid rgb(var(--color-muted) / 0.12);
85
+ background: transparent;
86
+ color: inherit;
87
+ font: inherit;
88
+ text-align: left;
89
+ cursor: pointer;
90
+ }
91
+
92
+ .hk-timezone-globe__option:last-child {
93
+ border-bottom: 0;
94
+ }
95
+
96
+ .hk-timezone-globe__option:hover {
97
+ background: rgb(var(--color-muted) / 0.08);
98
+ }
99
+
100
+ .hk-timezone-globe__option--selected {
101
+ background: rgb(var(--color-primary) / 0.12);
102
+ }
103
+
104
+ .hk-timezone-globe__option-offset {
105
+ grid-area: offset;
106
+ font-weight: 600;
107
+ font-variant-numeric: tabular-nums;
108
+ }
109
+
110
+ .hk-timezone-globe__option-name {
111
+ grid-area: name;
112
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
113
+ }
114
+
115
+ .hk-timezone-globe__option-zone {
116
+ grid-area: zone;
117
+ font-family: var(--font-mono, ui-monospace, monospace);
118
+ font-size: var(--text-xs, 0.75rem);
119
+ }
120
+
121
+ .hk-timezone-globe__option-cities {
122
+ grid-area: cities;
123
+ color: rgb(var(--color-muted));
124
+ font-size: var(--text-xs, 0.75rem);
125
+ }