@celestia-island/hikari 0.55.262 → 0.55.263

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.262",
3
+ "version": "0.55.263",
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",
@@ -138,3 +138,102 @@ describe("HkBoard3D × real OrbitControls", () => {
138
138
  expect(engine!.controls.maxDistance).toBe(80);
139
139
  });
140
140
  });
141
+
142
+ /* The orbit ELEVATION window: the solar globe pins min === max to become a
143
+ * turntable (user direction 2026-11-01: the sphere must only spin about
144
+ * its vertical axis, never tip / roll). The distance-window suite above
145
+ * pins the same normalisation discipline for the other orbit axis. */
146
+ const DEG = Math.PI / 180;
147
+ const RING_TILT_DEG = 72;
148
+
149
+ describe("HkBoard3D orbit elevation window", () => {
150
+ it("pins the controls' polar angle from the equal prop pair", async () => {
151
+ let engine: Board3DEngine | null = null;
152
+ mountBoard(
153
+ { interactive: true, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
154
+ (e) => (engine = e),
155
+ );
156
+ await nextTick();
157
+ await tickFrames();
158
+
159
+ expect(engine!.controls.minPolarAngle).toBeCloseTo(RING_TILT_DEG * DEG, 6);
160
+ expect(engine!.controls.maxPolarAngle).toBeCloseTo(RING_TILT_DEG * DEG, 6);
161
+ });
162
+
163
+ it("normalises a reversed, out-of-range or non-finite window", async () => {
164
+ let engine: Board3DEngine | null = null;
165
+ mountBoard(
166
+ // 120 > -30 (reversed) and both bounds are outside the sphere.
167
+ { interactive: true, minPolarAngle: 120, maxPolarAngle: -30 },
168
+ (e) => (engine = e),
169
+ );
170
+ await nextTick();
171
+ await tickFrames();
172
+
173
+ expect(engine!.controls.minPolarAngle).toBe(0);
174
+ expect(engine!.controls.maxPolarAngle).toBe(120 * DEG);
175
+
176
+ mountBoard(
177
+ { interactive: true, minPolarAngle: Number.NaN, maxPolarAngle: 999 },
178
+ (e) => (engine = e),
179
+ );
180
+ await nextTick();
181
+ await tickFrames();
182
+ expect(engine!.controls.minPolarAngle).toBe(0);
183
+ expect(engine!.controls.maxPolarAngle).toBeCloseTo(Math.PI, 6);
184
+ });
185
+
186
+ it("relaxes the elevation window when the board is not interactive", async () => {
187
+ let engine: Board3DEngine | null = null;
188
+ mountBoard(
189
+ { interactive: false, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
190
+ (e) => (engine = e),
191
+ );
192
+ await nextTick();
193
+ await tickFrames();
194
+
195
+ // Same contract as the distance window: an externally driven camera
196
+ // is never fought, so a locked elevation must not survive here.
197
+ expect(engine!.controls.minPolarAngle).toBe(0);
198
+ expect(engine!.controls.maxPolarAngle).toBeCloseTo(Math.PI, 6);
199
+ engine!.camera.position.set(0, 6, 0);
200
+ engine!.controls.target.set(0, 0, 0);
201
+ engine!.controls.update();
202
+ expect(engine!.camera.position.y).toBeCloseTo(6, 5);
203
+ });
204
+
205
+ it("keeps the elevation fixed while azimuth alone can orbit (turntable)", async () => {
206
+ let engine: Board3DEngine | null = null;
207
+ mountBoard(
208
+ { interactive: true, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
209
+ (e) => (engine = e),
210
+ );
211
+ await nextTick();
212
+ await tickFrames();
213
+
214
+ const controls = engine!.controls;
215
+ const camera = engine!.camera;
216
+ controls.enableDamping = false;
217
+
218
+ // An external pose far above the ring snaps down onto it: the eye
219
+ // cannot climb toward the pole.
220
+ camera.position.set(0, 6, 0);
221
+ controls.target.set(0, 0, 0);
222
+ controls.update();
223
+ const ringRadius = camera.position.length();
224
+ const fixedY = ringRadius * Math.cos(RING_TILT_DEG * DEG);
225
+ expect(camera.position.y).toBeCloseTo(fixedY, 5);
226
+
227
+ // A vertical orbit input is absorbed; a horizontal one still turns.
228
+ controls.rotateUp(0.8);
229
+ controls.update();
230
+ expect(camera.position.y).toBeCloseTo(fixedY, 5);
231
+ const azimuthBefore = Math.atan2(camera.position.x, camera.position.z);
232
+ controls.rotateLeft(0.6);
233
+ controls.update();
234
+ expect(camera.position.y).toBeCloseTo(fixedY, 5);
235
+ expect(
236
+ Math.abs(Math.atan2(camera.position.x, camera.position.z) - azimuthBefore),
237
+ ).toBeGreaterThan(0.3);
238
+ });
239
+ });
@@ -8,8 +8,12 @@
8
8
  * stereoscopic minimap; the page owns the content:
9
9
  *
10
10
  * - CAMERA: perspective + OrbitControls (damped orbit / wheel zoom /
11
- * right-or-two-finger pan), distance clamps, `frameAll` fit and
12
- * animated `flyTo`;
11
+ * right-or-two-finger pan), orbit distance AND elevation windows,
12
+ * `frameAll` fit and animated `flyTo` — an equal
13
+ * `minPolarAngle`/`maxPolarAngle` pair locks the eye's elevation, so
14
+ * azimuth is the only ORBIT axis left; with panning off the scene
15
+ * then spins about the vertical axis (the turntable behaviour a globe
16
+ * picker needs);
13
17
  * - CONTENT: `engine.setObject(id, def)` registers an Object3D as
14
18
  * addressable content — it joins framing, minimap markers and (when
15
19
  * `pickable`) raycast picking; `label` attaches a CSS2D chip that
@@ -326,6 +330,18 @@ function selectionFrameColor(): THREE.Color {
326
330
  return cssRgbToColor(readCssColor("--color-primary", "21 101 192"));
327
331
  }
328
332
 
333
+ /** Orbit elevation window for the `minPolarAngle` / `maxPolarAngle` props:
334
+ * degrees in, radians out, clamped to the sphere's 0…180° range. A
335
+ * non-finite bound falls back to the OrbitControls default, and a
336
+ * reversed window normalises (same rule as the distance window) instead
337
+ * of leaving OrbitControls with min > max. */
338
+ function polarWindow(minDeg: number, maxDeg: number): { min: number; max: number } {
339
+ const toRad = (deg: number) => (Math.min(180, Math.max(0, deg)) * Math.PI) / 180;
340
+ const min = Number.isFinite(minDeg) ? toRad(minDeg) : 0;
341
+ const max = Number.isFinite(maxDeg) ? toRad(maxDeg) : Math.PI;
342
+ return { min: Math.min(min, max), max: Math.max(min, max) };
343
+ }
344
+
329
345
  export default defineComponent({
330
346
  name: "HkBoard3D",
331
347
  props: {
@@ -358,6 +374,19 @@ export default defineComponent({
358
374
  maxDistance: { type: Number, default: 600 },
359
375
  /** Wire two-finger / right-drag panning. STATIC after mount. */
360
376
  enablePan: { type: Boolean, default: true },
377
+ /** Orbit elevation window in DEGREES from the scene's +Y axis
378
+ * (OrbitControls' polar angle). STATIC after mount (applied to the
379
+ * controls once; at mount it is relaxed to 0…180° when the board
380
+ * mounts non-interactive, because an externally driven camera is
381
+ * never fought — the same rule as the distance window, and like that
382
+ * window it is NOT re-applied on a runtime `interactive` flip). The
383
+ * pair pins how far the eye
384
+ * may climb toward either pole; EQUAL values lock the elevation
385
+ * outright and leave azimuth as the only orbit axis — the turntable
386
+ * behaviour a globe or instrument board needs. A reversed window
387
+ * normalises; the default is the full 0…180° sphere. */
388
+ minPolarAngle: { type: Number, default: 0 },
389
+ maxPolarAngle: { type: Number, default: 180 },
361
390
  /** Options for the lighting rig created on first `applyLighting`
362
391
  * (ambient floor, shadow map size). */
363
392
  lighting: {
@@ -756,15 +785,26 @@ export default defineComponent({
756
785
  // at min forever and defeating every frameAll. Normalise instead.
757
786
  controls.minDistance = Math.min(props.minDistance, props.maxDistance);
758
787
  controls.maxDistance = Math.max(props.minDistance, props.maxDistance);
788
+ // The elevation window: same normalisation discipline as the
789
+ // distance window above, in degrees at the prop boundary. Equal
790
+ // bounds pin the eye to one elevation (turntable/yaw-only orbit).
791
+ const polar = polarWindow(props.minPolarAngle, props.maxPolarAngle);
792
+ controls.minPolarAngle = polar.min;
793
+ controls.maxPolarAngle = polar.max;
759
794
  controls.enablePan = props.enablePan;
760
795
  controls.target.set(initTarget[0], initTarget[1], initTarget[2]);
761
796
  controls.enabled = props.interactive;
762
797
  if (!props.interactive) {
763
798
  // An externally-driven camera (the board is just its renderer)
764
- // must not be fought by the orbit distance window — the panel
765
- // flies to poses far beyond any interactive zoom range.
799
+ // must not be fought by the orbit windows — the panel flies to
800
+ // poses far beyond any interactive zoom range, from any
801
+ // elevation. OrbitControls' update() clamps BOTH bounds every
802
+ // frame regardless of `enabled`, so the elevation window has to
803
+ // be relaxed here too, not just the radius.
766
804
  controls.minDistance = 0;
767
805
  controls.maxDistance = Infinity;
806
+ controls.minPolarAngle = 0;
807
+ controls.maxPolarAngle = Math.PI;
768
808
  }
769
809
  controls.update();
770
810
 
@@ -233,9 +233,9 @@ export const HkSolarLocationSetting = defineComponent({
233
233
  <div class="hk-solar-manual">
234
234
  {/* The globe picker (2026-10-31): 24 equal time-sector
235
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. */}
236
+ an always-on top-center readout included — the IANA
237
+ dropdown it replaced listed hundreds of raw zone ids.
238
+ Still writes the same draftTimeZone the engine applies. */}
239
239
  <HkTimeZoneGlobe
240
240
  modelValue={draftTimeZone.value}
241
241
  onUpdate:modelValue={onSelectTimeZone}
@@ -0,0 +1,263 @@
1
+ // AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createApp, h, nextTick, ref, type Ref } from "vue";
4
+
5
+ /**
6
+ * HkTimeZoneGlobe × HkBoard3D — the picker's WEBGL branch contract.
7
+ *
8
+ * The real board cannot init in happy-dom (no WebGL), so this suite stubs
9
+ * it and keeps the board's whole props surface addressable. Two product
10
+ * directions from 2026-11-01 are template-level promises and belong here:
11
+ *
12
+ * - TURNTABLE: the globe passes equal min/max polar bounds (90° - the
13
+ * eye's 18° ring tilt) so the board can only spin about the vertical
14
+ * axis — the sphere never tips toward a pole or rolls;
15
+ * - READOUT: the SELECTED sector's chip is the globe's own always-on
16
+ * layer pinned to the top-center of the stage — not a pointer-following
17
+ * tooltip (which sat under the finger on a touch drag), not gated on
18
+ * hover, and not rewritten by hover either (a drag re-selects without
19
+ * moving the pointer, so a hover preview would go stale as it is read).
20
+ *
21
+ * The no-WebGL assertions (fallback list, readout stepping aside) live in
22
+ * HkTimeZoneGlobe.test.tsx, which mounts the real board.
23
+ */
24
+
25
+ interface CapturedBoard {
26
+ minPolarAngle?: number;
27
+ maxPolarAngle?: number;
28
+ interactive?: boolean;
29
+ minimap?: boolean;
30
+ minimapCollapsible?: boolean;
31
+ minimapDefaultCollapsed?: boolean;
32
+ enablePan?: boolean;
33
+ minDistance?: number;
34
+ maxDistance?: number;
35
+ /** The globe's onObjectHover listener (preview a wedge). */
36
+ onObjectHover: ((id: string | null) => void) | null;
37
+ /** The globe's onError listener (no-WebGL report). */
38
+ onError: ((message: string) => void) | null;
39
+ }
40
+
41
+ const captured = vi.hoisted(() => ({ boards: [] as CapturedBoard[] }));
42
+
43
+ vi.mock("./HkBoard3D", async () => {
44
+ const vue = await import("vue");
45
+ /** Narrow an undeclared-listener attr without a double cast. */
46
+ function listenerOf<T extends (...args: never[]) => void>(value: unknown): T | null {
47
+ return typeof value === "function" ? (value as T) : null;
48
+ }
49
+ const BoardStub = vue.defineComponent({
50
+ name: "HkBoard3D",
51
+ props: {
52
+ interactive: Boolean,
53
+ minimap: Boolean,
54
+ minimapCollapsible: Boolean,
55
+ minimapDefaultCollapsed: Boolean,
56
+ hoverBox: Boolean,
57
+ transparentBackground: Boolean,
58
+ enablePan: Boolean,
59
+ minPolarAngle: Number,
60
+ maxPolarAngle: Number,
61
+ fov: Number,
62
+ minDistance: Number,
63
+ maxDistance: Number,
64
+ initialPosition: Array,
65
+ initialTarget: Array,
66
+ },
67
+ // Deliberately NO `emits` declaration: the globe's listeners then land
68
+ // in `attrs`, which is exactly how this suite reaches onObjectHover /
69
+ // onError without the stub ever calling emit().
70
+ setup(props, { attrs }) {
71
+ captured.boards.push({
72
+ minPolarAngle: props.minPolarAngle,
73
+ maxPolarAngle: props.maxPolarAngle,
74
+ interactive: props.interactive,
75
+ minimap: props.minimap,
76
+ minimapCollapsible: props.minimapCollapsible,
77
+ minimapDefaultCollapsed: props.minimapDefaultCollapsed,
78
+ enablePan: props.enablePan,
79
+ minDistance: props.minDistance,
80
+ maxDistance: props.maxDistance,
81
+ onObjectHover: listenerOf<(id: string | null) => void>(attrs.onObjectHover),
82
+ onError: listenerOf<(message: string) => void>(attrs.onError),
83
+ });
84
+ return () => null;
85
+ },
86
+ });
87
+ return { default: BoardStub };
88
+ });
89
+
90
+ import HkTimeZoneGlobe from "./HkTimeZoneGlobe";
91
+ import { setLocale } from "../i18n/context";
92
+
93
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
94
+
95
+ function mountGlobe(modelValue = "Asia/Shanghai"): {
96
+ container: HTMLElement;
97
+ zone: Ref<string>;
98
+ } {
99
+ captured.boards.length = 0;
100
+ const container = document.createElement("div");
101
+ document.body.appendChild(container);
102
+ // A live modelValue ref, so a test can drive a re-selection the way the
103
+ // host does (HkSolarLocationSetting writes draftTimeZone).
104
+ const zone = ref(modelValue);
105
+ const app = createApp({
106
+ render: () =>
107
+ h(HkTimeZoneGlobe, {
108
+ modelValue: zone.value,
109
+ "onUpdate:modelValue": (next: string) => {
110
+ zone.value = next;
111
+ },
112
+ }),
113
+ });
114
+ app.mount(container);
115
+ mounts.push({ app, container });
116
+ return { container, zone };
117
+ }
118
+
119
+ async function settle(): Promise<void> {
120
+ await nextTick();
121
+ await new Promise((resolve) => setTimeout(resolve, 20));
122
+ await nextTick();
123
+ }
124
+
125
+ function board(): CapturedBoard {
126
+ expect(captured.boards, "the globe mounted one board").toHaveLength(1);
127
+ return captured.boards[0]!;
128
+ }
129
+
130
+ function readout(): HTMLElement | null {
131
+ return document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe-readout"]');
132
+ }
133
+
134
+ beforeEach(() => {
135
+ localStorage.clear();
136
+ });
137
+
138
+ afterEach(async () => {
139
+ for (const { app, container } of mounts.splice(0)) {
140
+ app.unmount();
141
+ container.remove();
142
+ }
143
+ await setLocale("en");
144
+ });
145
+
146
+ describe("HkTimeZoneGlobe turntable contract", () => {
147
+ it("locks the board's orbit elevation to the eye ring (equal polar bounds)", async () => {
148
+ mountGlobe();
149
+ await settle();
150
+
151
+ const { minPolarAngle, maxPolarAngle, interactive } = board();
152
+ // 90° - EYE_TILT_DEG (18°) = 72°, as a DEGREE-valued board prop.
153
+ expect(minPolarAngle).toBe(72);
154
+ expect(maxPolarAngle).toBe(72);
155
+ expect(minPolarAngle).toBe(maxPolarAngle);
156
+ // The lock only binds on an INTERACTIVE board: a non-interactive board
157
+ // deliberately relaxes the elevation window for its external driver,
158
+ // so the globe must never mount as one.
159
+ expect(interactive).toBe(true);
160
+ });
161
+
162
+ it("keeps the minimap (collapsible, folded by default) and disables panning", async () => {
163
+ mountGlobe();
164
+ await settle();
165
+
166
+ const b = board();
167
+ expect(b.minimap).toBe(true);
168
+ expect(b.minimapCollapsible).toBe(true);
169
+ expect(b.minimapDefaultCollapsed).toBe(true);
170
+ expect(b.enablePan).toBe(false);
171
+ // The picker still owns its own distance window and framing.
172
+ expect(b.minDistance).toBe(2.15);
173
+ expect(b.maxDistance).toBe(3.6);
174
+ });
175
+ });
176
+
177
+ describe("HkTimeZoneGlobe pinned readout", () => {
178
+ it("shows the selected sector with no hover needed (always on)", async () => {
179
+ mountGlobe("Asia/Shanghai");
180
+ await settle();
181
+
182
+ const el = readout();
183
+ expect(el, "the readout renders without any pointer interaction").toBeTruthy();
184
+ expect(el!.textContent).toContain("UTC+8");
185
+ expect(el!.textContent).toContain("Asia/Shanghai");
186
+ // A city from the SELECTED band's second line — "Singapore" is not a
187
+ // substring of the zone id, so only the cities row can satisfy it
188
+ // ("Shanghai" would have been satisfied by "Asia/Shanghai").
189
+ const cities = el!.querySelector<HTMLElement>(".hk-timezone-globe__readout-cities");
190
+ expect(cities, "the cities line renders").toBeTruthy();
191
+ expect(cities!.textContent).toContain("Singapore");
192
+ // A live region, so a COMMITTED re-pick is spoken; hover never
193
+ // rewrites the chip, so hovering cannot flood the announce queue.
194
+ expect(el!.getAttribute("role")).toBe("status");
195
+ expect(el!.getAttribute("aria-live")).toBe("polite");
196
+ expect(el!.getAttribute("aria-atomic")).toBe("true");
197
+ // The old pointer-following tooltip is gone for good.
198
+ expect(document.body.querySelector('[data-test="hk-timezone-globe-tooltip"]')).toBeNull();
199
+ });
200
+
201
+ it("follows a committed re-selection when the host writes a new zone", async () => {
202
+ const { zone } = mountGlobe("Asia/Shanghai");
203
+ await settle();
204
+ expect(readout()!.textContent).toContain("Asia/Shanghai");
205
+
206
+ // The host's write path (draftTimeZone → modelValue), not a click: the
207
+ // chip must re-render on the new band, not stay frozen at mount.
208
+ zone.value = "Africa/Cairo";
209
+ await settle();
210
+ expect(readout()!.textContent).toContain("Africa/Cairo");
211
+ expect(readout()!.textContent).toContain("UTC+2");
212
+ expect(readout()!.textContent).not.toContain("Asia/Shanghai");
213
+ });
214
+
215
+ it("keeps showing the committed selection while a wedge is hovered", async () => {
216
+ mountGlobe("Asia/Shanghai");
217
+ await settle();
218
+
219
+ const onObjectHover = board().onObjectHover;
220
+ expect(onObjectHover, "the globe wired the board's hover event").toBeTruthy();
221
+ onObjectHover!("sector-20"); // offset +9 → Asia/Tokyo
222
+ await settle();
223
+ // The chip is a selection statement: hover only tints the wedge.
224
+ expect(readout()!.textContent).toContain("Asia/Shanghai");
225
+ expect(readout()!.textContent).not.toContain("Asia/Tokyo");
226
+
227
+ onObjectHover!(null);
228
+ await settle();
229
+ expect(readout()!.textContent).toContain("Asia/Shanghai");
230
+ });
231
+
232
+ it("does not repeat an English band name that IS its offset label", async () => {
233
+ await setLocale("en"); // explicit: this contract is locale-dependent
234
+ mountGlobe("Asia/Shanghai");
235
+ await settle();
236
+
237
+ const text = readout()!.textContent ?? "";
238
+ expect(text.match(/UTC\+8/g)).toHaveLength(1);
239
+ expect(text).toContain("Asia/Shanghai");
240
+ });
241
+
242
+ it("keeps the translated band name where the locale names the band (zh-Hans)", async () => {
243
+ await setLocale("zh-Hans");
244
+ mountGlobe("Asia/Shanghai");
245
+ await settle();
246
+
247
+ const text = readout()!.textContent ?? "";
248
+ expect(text).toContain("UTC+8");
249
+ expect(text).toContain("东八区");
250
+ });
251
+
252
+ it("steps aside when the board reports no WebGL (the fallback list is the interface)", async () => {
253
+ mountGlobe("Asia/Shanghai");
254
+ await settle();
255
+ expect(readout()).toBeTruthy();
256
+
257
+ const onError = board().onError;
258
+ expect(onError, "the globe wired the board's error event").toBeTruthy();
259
+ onError!("no webgl (test)");
260
+ await settle();
261
+ expect(readout()).toBeNull();
262
+ });
263
+ });
@@ -1,6 +1,6 @@
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. */
1
+ /* HkTimeZoneGlobe — the manual zone picker's globe stage, always-on
2
+ * readout and no-WebGL fallback list. AI-generated: developed with the
3
+ * ZCode agent (model GLM-5.3); human-directed. */
4
4
 
5
5
  .hk-timezone-globe {
6
6
  display: flex;
@@ -23,35 +23,64 @@
23
23
  touch-action: none;
24
24
  }
25
25
 
26
- .hk-timezone-globe__tooltip {
26
+ /* The always-on sector readout: pinned to the TOP-CENTER of the stage
27
+ * (user direction 2026-11-01). It replaced the pointer-following
28
+ * tooltip, which on a touch screen sat under the very finger dragging
29
+ * the globe. A passive HUD layer — pointer-events none, so a drag that
30
+ * begins on the chip still orbits. */
31
+ .hk-timezone-globe__readout {
27
32
  position: absolute;
33
+ top: var(--space-6, 0.375rem);
34
+ left: 50%;
28
35
  z-index: 2;
29
- pointer-events: none;
36
+ transform: translateX(-50%);
30
37
  display: flex;
31
38
  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);
39
+ align-items: center;
40
+ gap: 1px;
41
+ max-width: calc(100% - var(--space-16, 1rem));
42
+ padding: var(--space-4, 0.25rem) var(--space-10, 0.625rem);
35
43
  border: 1px solid rgb(var(--color-muted) / 0.35);
36
44
  border-radius: var(--radius-sm, 0.375rem);
37
- background: rgb(var(--color-background, 22 24 30) / 0.92);
45
+ background: rgb(var(--color-background, 22 24 30) / 0.88);
38
46
  color: inherit;
39
47
  font-size: var(--text-xs, 0.75rem);
40
- line-height: 1.45;
48
+ line-height: 1.35;
49
+ text-align: center;
50
+ pointer-events: none;
51
+ }
52
+
53
+ .hk-timezone-globe__readout-head {
54
+ display: flex;
55
+ flex-wrap: wrap;
56
+ align-items: baseline;
57
+ justify-content: center;
58
+ gap: 0 var(--space-6, 0.375rem);
59
+ white-space: nowrap;
41
60
  }
42
61
 
43
- .hk-timezone-globe__tooltip-offset {
62
+ .hk-timezone-globe__readout-offset {
44
63
  color: rgb(var(--color-text-secondary, var(--color-muted)));
45
64
  font-weight: 600;
65
+ font-variant-numeric: tabular-nums;
66
+ }
67
+
68
+ .hk-timezone-globe__readout-name {
69
+ font-weight: 600;
46
70
  }
47
71
 
48
- .hk-timezone-globe__tooltip-zone {
72
+ .hk-timezone-globe__readout-zone {
73
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
49
74
  font-family: var(--font-mono, ui-monospace, monospace);
50
75
  font-size: var(--text-xs, 0.75rem);
51
76
  }
52
77
 
53
- .hk-timezone-globe__tooltip-cities {
78
+ .hk-timezone-globe__readout-cities {
79
+ max-width: 100%;
80
+ overflow: hidden;
54
81
  color: rgb(var(--color-text-secondary, var(--color-muted)));
82
+ text-overflow: ellipsis;
83
+ white-space: nowrap;
55
84
  }
56
85
 
57
86
  .hk-timezone-globe__hint {
@@ -116,6 +116,9 @@ describe("HkTimeZoneGlobe fallback list", () => {
116
116
  const root = document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe"]')!;
117
117
  expect(root.getAttribute("aria-label")).toBe("Time zone globe");
118
118
  expect(root.textContent).toContain("Drag to spin the globe");
119
+ // The pinned HUD is a canvas affordance: without WebGL the list IS the
120
+ // interface, so the chip must not cover its rows.
121
+ expect(document.body.querySelector('[data-test="hk-timezone-globe-readout"]')).toBeNull();
119
122
  });
120
123
 
121
124
  it("flows zh-Hans through the sector names and city lists", async () => {
@@ -140,6 +143,36 @@ describe("HkTimeZoneGlobe look contract (source-level)", () => {
140
143
  expect(src).toContain("minimapCollapsible");
141
144
  expect(src).toContain("minimapDefaultCollapsed");
142
145
  });
146
+
147
+ it("pins the readout to the stage's TOP-CENTER, pointer-transparent (user direction 2026-11-01)", () => {
148
+ const scss = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.scss"), "utf8");
149
+ const block = (selector: string): string => {
150
+ const start = scss.indexOf(selector + " {");
151
+ expect(start, selector + " exists").toBeGreaterThanOrEqual(0);
152
+ const end = scss.indexOf("\n}", start);
153
+ expect(end, selector + " closes").toBeGreaterThan(start);
154
+ return scss.slice(start, end);
155
+ };
156
+
157
+ // The readout is absolutely positioned, so its containing block must
158
+ // stay the stage.
159
+ expect(block(".hk-timezone-globe__stage")).toContain("position: relative");
160
+
161
+ const readout = block(".hk-timezone-globe__readout");
162
+ expect(readout, "a HUD layer over the canvas").toContain("position: absolute");
163
+ expect(readout, "top-anchored").toMatch(/top:\s*var\(--space-6/);
164
+ expect(readout, "horizontally centered").toContain("left: 50%");
165
+ expect(readout, "horizontally centered").toContain("transform: translateX(-50%)");
166
+ // The board paints an opaque surface later in the DOM, so the chip is
167
+ // only visible while it stacks above it.
168
+ expect(readout, "above the board surface").toMatch(/z-index:\s*[1-9]/);
169
+ // The chip sits over the very surface the user drags; a drag that
170
+ // begins on it must still reach the canvas.
171
+ expect(readout, "a passive layer").toContain("pointer-events: none");
172
+
173
+ // A long city row must ellipsize inside the chip, not escape it.
174
+ expect(block(".hk-timezone-globe__readout-cities")).toContain("text-overflow: ellipsis");
175
+ });
143
176
  });
144
177
  describe("minimap reset icon contract (user direction 2026-10-31)", () => {
145
178
  it("reset buttons use RotateCcw, never the expand-looking Maximize2", () => {
@@ -13,14 +13,26 @@
13
13
  * white-alpha, the material tints it with the live theme accent, so
14
14
  * one asset serves dark and light themes;
15
15
  * - SECTORS: 24 pickable wedges of 15 deg longitude (theme/
16
- * solarTimeZones.ts) — drag rotates (OrbitControls), settling snaps
17
- * the wedge under the eye INTO the selection, clicking a wedge flies
18
- * the camera to it; both write the wedge's REPRESENTATIVE IANA zone
16
+ * solarTimeZones.ts) — drag spins the globe, settling snaps the wedge
17
+ * under the eye INTO the selection, clicking a wedge flies the
18
+ * camera to it; both write the wedge's REPRESENTATIVE IANA zone
19
19
  * through update:modelValue (the engine stores exact ids, so a
20
20
  * half-hour config survives until the user actually re-picks);
21
- * - TOOLTIP: hovering a wedge surfaces its zone (translated sector
22
- * name + the IANA id) and its representative cities — cities only,
23
- * never countries (border disputes stay out of a theme picker);
21
+ * - TURNTABLE: the board's orbit elevation window is pinned
22
+ * (minPolarAngle === maxPolarAngle) to the eye's ring tilt, so the
23
+ * sphere can only spin about its vertical axis. It never tips toward
24
+ * a pole and never rolls — the north hemisphere keeps its 18° tilt
25
+ * (user direction 2026-11-01: "the globe turns in front of you");
26
+ * - READOUT: the SELECTED sector stays pinned to the TOP-CENTER of the
27
+ * stage — always on, and hover never rewrites it (a drag re-selects
28
+ * without moving the pointer, so a hover preview would be stale at
29
+ * exactly the moment it is read). It replaced the pointer-following
30
+ * tooltip, which on a touch screen landed under the very finger doing
31
+ * the drag (user direction 2026-11-01). The head carries the IANA id
32
+ * with the translated band name (dropped where the locale's band name
33
+ * IS the offset label — English), the second line its representative
34
+ * cities: cities only, never countries (border disputes stay out of a
35
+ * theme picker);
24
36
  * - MINIMAP: the board pins HkMinimap3D bottom-right — a second,
25
37
  * stereoscopic render of the SAME scene, so the operator always sees
26
38
  * where on the sphere the eye currently sits;
@@ -70,6 +82,11 @@ const DEG = Math.PI / 180;
70
82
  /** Eye distance and latitude tilt for the fixed-radius ring orbit. */
71
83
  const EYE_DISTANCE = 2.85;
72
84
  const EYE_TILT_DEG = 18;
85
+ /** The locked orbit elevation (OrbitControls polar angle, degrees from
86
+ * +Y) that pairs with EYE_TILT_DEG: equal min/max polar bounds keep the
87
+ * eye on this one ring, so azimuth is the only orbit axis left — the
88
+ * globe turns in place instead of tipping. */
89
+ const ORBIT_POLAR_DEG = 90 - EYE_TILT_DEG;
73
90
  /** Sphere and wedge radii — the wedges ride slightly above the base to
74
91
  * keep the highlight out of the texture's z-fight band. */
75
92
  const GLOBE_RADIUS = 1;
@@ -120,9 +137,12 @@ export default defineComponent({
120
137
  setup(props, { emit }) {
121
138
  const { t } = useI18n();
122
139
  const engine = shallowRef<Board3DEngine | null>(null);
123
- const stageRef = ref<HTMLElement | null>(null);
124
140
  const hoveredSector = ref<number | null>(null);
125
- const tooltipPos = ref({ x: 0, y: 0 });
141
+ // The stage readout is a HUD for the CANVAS; without WebGL the board's
142
+ // fallback list is the whole interface (every sector already shows its
143
+ // zone there), so the HUD steps aside instead of covering its first
144
+ // rows.
145
+ const webglFailed = ref(false);
126
146
 
127
147
  // Per-mount three handles the board does NOT own (we created them,
128
148
  // we dispose them — the board never disposes consumer objects).
@@ -138,10 +158,13 @@ export default defineComponent({
138
158
 
139
159
  const selectedSector = computed(() => sectorIndexForZone(props.modelValue));
140
160
 
141
- const selectedInfo = computed(() => {
142
- const idx = hoveredSector.value ?? selectedSector.value;
143
- return SOLAR_TIME_ZONE_SECTORS[idx] ?? null;
144
- });
161
+ // The pinned readout reports the COMMITTED selection, never the
162
+ // hovered wedge. Hover already answers "what am I pointing at" on the
163
+ // globe itself (the wedge highlight); the always-on chip must keep
164
+ // answering "what is selected", because a drag that re-selects does
165
+ // not move the pointer — a hover preview there would go stale at
166
+ // exactly the moment the user is looking at it.
167
+ const selectedInfo = computed(() => SOLAR_TIME_ZONE_SECTORS[selectedSector.value] ?? null);
145
168
 
146
169
  function citiesLine(sector: { cities: readonly string[] }): string {
147
170
  const sep = t("hikari::theme.solar.citySeparator", ", ");
@@ -281,11 +304,8 @@ export default defineComponent({
281
304
  hoveredSector.value = m ? parseInt(m[1] ?? "", 10) : null;
282
305
  }
283
306
 
284
- function onPointerMove(ev: PointerEvent): void {
285
- const stage = stageRef.value;
286
- if (!stage) return;
287
- const rect = stage.getBoundingClientRect();
288
- tooltipPos.value = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
307
+ function onBoardError(): void {
308
+ webglFailed.value = true;
289
309
  }
290
310
 
291
311
  onBeforeUnmount(() => {
@@ -337,6 +357,11 @@ export default defineComponent({
337
357
 
338
358
  return () => {
339
359
  const info = selectedInfo.value;
360
+ // In `en`, a band's translated name IS its offset label ("UTC+8"), so
361
+ // rendering both would read "UTC+8 UTC+8". Keep the name only where
362
+ // the locale actually names the band differently.
363
+ const infoOffset = info ? offsetLabel(info.offset) : "";
364
+ const infoName = info ? t(sectorNameKey(info.offset)) : "";
340
365
  const initialEye = ringPosition(sectorCenterLngDeg(
341
366
  SOLAR_TIME_ZONE_SECTORS[selectedSector.value]?.offset ?? 0,
342
367
  ));
@@ -347,7 +372,25 @@ export default defineComponent({
347
372
  aria-label={t("hikari::theme.solar.globeLabel")}
348
373
  data-test="hk-timezone-globe"
349
374
  >
350
- <div class="hk-timezone-globe__stage" ref={stageRef} onPointermove={onPointerMove}>
375
+ <div class="hk-timezone-globe__stage">
376
+ {info && !webglFailed.value && (
377
+ <div
378
+ class="hk-timezone-globe__readout"
379
+ data-test="hk-timezone-globe-readout"
380
+ role="status"
381
+ aria-live="polite"
382
+ aria-atomic="true"
383
+ >
384
+ <span class="hk-timezone-globe__readout-head">
385
+ <span class="hk-timezone-globe__readout-offset">{infoOffset}</span>
386
+ {infoName !== infoOffset && (
387
+ <span class="hk-timezone-globe__readout-name">{infoName}</span>
388
+ )}
389
+ <span class="hk-timezone-globe__readout-zone">{info.zone}</span>
390
+ </span>
391
+ <span class="hk-timezone-globe__readout-cities">{citiesLine(info)}</span>
392
+ </div>
393
+ )}
351
394
  <HkBoard3D
352
395
  interactive
353
396
  minimap
@@ -356,6 +399,10 @@ export default defineComponent({
356
399
  hoverBox={false}
357
400
  transparentBackground
358
401
  enablePan={false}
402
+ // Turntable: equal polar bounds pin the eye to its ring, so
403
+ // the globe spins about its vertical axis only.
404
+ minPolarAngle={ORBIT_POLAR_DEG}
405
+ maxPolarAngle={ORBIT_POLAR_DEG}
359
406
  fov={38}
360
407
  minDistance={2.15}
361
408
  maxDistance={3.6}
@@ -364,25 +411,10 @@ export default defineComponent({
364
411
  onReady={onReady}
365
412
  onObjectClick={onObjectClick}
366
413
  onObjectHover={onObjectHover}
414
+ onError={onBoardError}
367
415
  >
368
416
  {{ fallback: fallbackList }}
369
417
  </HkBoard3D>
370
- {info && hoveredSector.value !== null && (
371
- <div
372
- class="hk-timezone-globe__tooltip"
373
- data-test="hk-timezone-globe-tooltip"
374
- style={{
375
- left: tooltipPos.value.x + 14 + "px",
376
- top: tooltipPos.value.y + 14 + "px",
377
- }}
378
- >
379
- <span class="hk-timezone-globe__tooltip-offset">
380
- {offsetLabel(info.offset)} · {t(sectorNameKey(info.offset))}
381
- </span>
382
- <span class="hk-timezone-globe__tooltip-zone">{info.zone}</span>
383
- <span class="hk-timezone-globe__tooltip-cities">{citiesLine(info)}</span>
384
- </div>
385
- )}
386
418
  </div>
387
419
  <p class="hk-timezone-globe__hint">{t("hikari::theme.solar.globeHint")}</p>
388
420
  </div>
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Deliberately a BORDERLESS world: the map carries continent silhouettes
7
7
  * and TECTONIC PLATE boundary lines only — never national borders, never
8
- * country labels (city names in the picker's tooltip are the only named
8
+ * country labels (city names in the picker's readout are the only named
9
9
  * places, and names of cities carry no border disputes). The 15°
10
10
  * meridian graticule doubles as the 24 time-sector edges the picker
11
11
  * selects between.
@@ -15,9 +15,9 @@
15
15
  * offset (offset hours / 4 degrees... equivalently hours × 15°) — the
16
16
  * picked wedge and the engine's derived fix AGREE by construction;
17
17
  * - representative zones are the canonical IANA ids for each band;
18
- * - representative CITIES are display-only examples (the tooltip lists
19
- * them translated; deliberately never the countries — city names
20
- * carry no border disputes);
18
+ * - representative CITIES are display-only examples (the globe's pinned
19
+ * readout lists them translated; deliberately never the countries —
20
+ * city names carry no border disputes);
21
21
  * - half-hour zones (Asia/Kolkata, Asia/Yangon, …) still SELECT fine:
22
22
  * `sectorIndexForZone` rounds the zone's standard offset to the
23
23
  * nearest whole-hour band, and the stored zone id keeps the exact
@@ -37,9 +37,9 @@ export interface SolarTimeZoneSector {
37
37
  offset: number;
38
38
  /** Representative IANA zone id — what selecting the wedge stores. */
39
39
  zone: string;
40
- /** Representative city slugs for the hover tooltip (locale keys live
41
- * under `hikari::theme.solar.city.<slug>`, translated per language;
42
- * cities only, never countries). */
40
+ /** Representative city slugs for the globe's pinned readout (locale
41
+ * keys live under `hikari::theme.solar.city.<slug>`, translated per
42
+ * language; cities only, never countries). */
43
43
  cities: readonly string[];
44
44
  }
45
45