@celestia-island/hikari 0.55.261 → 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.261",
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
 
@@ -200,6 +200,11 @@ describe("HkMenu sidebar icon rail", () => {
200
200
 
201
201
  const children = c.querySelector(".hk-menu-sidebar-rail-children");
202
202
  expect(children, "the open group lists its children in the rail").toBeTruthy();
203
+ // The rail wrapper wears the SHARED children class too: the expanded
204
+ // sidebar's 2px margin step reaches the rail through it — that class
205
+ // union IS the vertical-invariance carrier (2026-10-31). A rail-only
206
+ // wrapper would re-introduce the per-density step.
207
+ expect(children!.classList.contains("hk-menu-sidebar-children")).toBe(true);
203
208
  const childRows = railRows(children!);
204
209
  expect(childRows.map((row) => row.getAttribute("aria-label"))).toEqual(["Users", "Audit log"]);
205
210
  for (const row of childRows) {
@@ -238,6 +243,15 @@ describe("HkMenu sidebar icon rail", () => {
238
243
  expect(c.querySelector(".hk-menu-sidebar-group[data-open]"), "the group is still open").toBeTruthy();
239
244
  expect(groupRows(c)[0]!.getAttribute("aria-expanded")).toBe("true");
240
245
  expect(c.textContent).toContain("Users");
246
+ // TWO-SIDED class-union pin: the EXPANDED wrapper must wear the
247
+ // shared class too. Dropping it there removes the 2px step from the
248
+ // expanded sidebar — rows below an open group would move on every
249
+ // flip, the exact drift this contract kills (a rail-only pin let
250
+ // that through).
251
+ expect(
252
+ c.querySelector(".hk-menu-sidebar-group[data-open] > .hk-menu-sidebar-children"),
253
+ "the expanded children wrapper keeps the shared step class",
254
+ ).toBeTruthy();
241
255
 
242
256
  // Reverse direction: collapse it from the expanded sidebar, flip
243
257
  // back into the rail — it must stay collapsed (no resurrection).
@@ -508,10 +522,65 @@ describe("HkMenu rail styles (compiled sheet contracts)", () => {
508
522
  expect(row).toContain("justify-content: center");
509
523
  });
510
524
 
511
- it("keeps open group children in the same one-column rhythm (no indent step)", () => {
525
+ it("keeps open group children at the EXPANDED vertical step (no rail re-step)", () => {
526
+ // Vertical rhythm is density-invariant (2026-10-31 user direction:
527
+ // the collapse toggle must not move rows). The rail children wrapper
528
+ // therefore carries NO margin rule of its own — the expanded
529
+ // sidebar's 2px step from the shared .hk-menu-sidebar-children rule
530
+ // applies in the rail too, and the wrapper stays width-only (its
531
+ // selector survives only inside the comma-grouped width rule, which
532
+ // the exact-line ruleBody skips — the null here asserts no
533
+ // STANDALONE rail-children rule came back).
512
534
  const children = ruleBody(".hk-menu-sidebar[data-collapsed] .hk-menu-sidebar-rail-children");
513
- expect(children, "the rail children rule exists").not.toBeNull();
514
- expect(children).toContain("margin-top: 0");
535
+ expect(children, "a standalone rail-children rule re-stepped the margin").toBeNull();
536
+ const shared = ruleBody(".hk-menu-sidebar-children");
537
+ expect(shared, "the shared children rule exists").not.toBeNull();
538
+ expect(shared).toContain("margin-top: var(--space-2, 2px)");
539
+ // A rule whose selector is EXACTLY a rail wrapper class — top-level,
540
+ // outside any density dress — would restyle the rail children while
541
+ // evading every dress-keyed scan below (the compiled chunk then
542
+ // carries no carrier text). The wrappers' geometry must come from
543
+ // the shared rules alone.
544
+ expect(css.match(/^\s*\.hk-menu-sidebar-rail-(?:children|group)\s*\{/gm)).toBeNull();
545
+ });
546
+
547
+ it("the rail dress authors no vertical rhythm in any spelling", () => {
548
+ // The stronger form of the contract above, so a re-step cannot hide
549
+ // inside a comma-grouped selector the per-selector ruleBody skips
550
+ // AND cannot return through alternate spellings or sister metrics:
551
+ // every rule of the density dress is scanned and banned for ANY
552
+ // margin/padding spelling that reaches a block edge — the shorthand
553
+ // (it sets the block side too: the historical `margin-top: 0`
554
+ // regression spelled `margin: 0` evaded a longhand-only scan) and
555
+ // every vertical longhand, top AND bottom — plus the block-FLOW
556
+ // metrics that move rows just the same (gap/row-gap/height/
557
+ // min-height/max-height: a `gap:` inside the comma-grouped width
558
+ // rule once passed a margin/padding-only scan). Horizontal-only
559
+ // tracks (margin-inline*, padding-inline*) stay legal; absolute
560
+ // INSETS (the caret corner's inset-block-end) are flow-neutral
561
+ // decoration and stay legal. A rail band that truly needs a
562
+ // different block metric must move the BASE rule for BOTH densities.
563
+ // Keyed to BOTH carrier spellings so a future [data-rail]-keyed
564
+ // carrier in this sheet cannot slip the scan.
565
+ const dressRules = css
566
+ .split("}")
567
+ .filter(
568
+ (rule) =>
569
+ rule.includes(".hk-menu-sidebar[data-collapsed]") ||
570
+ rule.includes(".hk-menu-sidebar[data-rail]"),
571
+ );
572
+ expect(dressRules.length, "the rail dress rules exist").toBeGreaterThan(3);
573
+ for (const rule of dressRules) {
574
+ expect(rule, `vertical margin in the rail dress: ${rule}`).not.toMatch(
575
+ /\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
576
+ );
577
+ expect(rule, `vertical padding in the rail dress: ${rule}`).not.toMatch(
578
+ /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
579
+ );
580
+ expect(rule, `block-flow metric in the rail dress: ${rule}`).not.toMatch(
581
+ /\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
582
+ );
583
+ }
515
584
  });
516
585
 
517
586
  it("mirrors the rail caret in RTL with the scaleX convention, never a rotate", () => {
@@ -193,18 +193,18 @@
193
193
  font-weight: 600;
194
194
  }
195
195
 
196
- /* Rail groups keep the rail rhythm at every depth: the children
197
- * wrapper spends the rail's own row gap, with no margin step above
198
- * the first child and never an indent. */
196
+ /* Rail groups keep the ONE-COLUMN rhythm at every depth: width-only,
197
+ * never an indent. The children wrapper deliberately carries NO
198
+ * margin override of its own — the expanded sidebar's 2px step (the
199
+ * shared .hk-menu-sidebar-children rule above) applies in the rail
200
+ * too, because vertical rhythm is density-invariant (2026-10-31 user
201
+ * direction): a rail-specific step would move every row below an open
202
+ * group on each collapse/expand flip. */
199
203
  .hk-menu-sidebar-rail-group,
200
204
  .hk-menu-sidebar-rail-children {
201
205
  width: 100%;
202
206
  }
203
207
 
204
- .hk-menu-sidebar-rail-children {
205
- margin-top: 0;
206
- }
207
-
208
208
  /* Disclosure ink, at the same secondary weight as the expanded
209
209
  * sidebar's .hk-menu-more. The placeholder form lives in the glyph
210
210
  * cell and centers like every other row icon. */
@@ -86,23 +86,27 @@
86
86
  * kit (HkSidebarRailSwap) — the generation's [data-rail] flips with the
87
87
  * swap key, so the leaving generation keeps its dress for the dissolve
88
88
  * while the entering one takes the new one. A root-level attr cannot do
89
- * that: it would restyle the frozen leaving copy mid-dissolve. */
89
+ * that: it would restyle the frozen leaving copy mid-dissolve.
90
+ *
91
+ * VERTICAL RHYTHM IS DENSITY-INVARIANT (2026-10-31 user direction: the
92
+ * collapse toggle must not move rows). The dress authors INLINE geometry
93
+ * only — the header/footer rules below narrow the horizontal padding for
94
+ * the rail column and recentre the header, while every BLOCK metric (the
95
+ * header's 16px band, the body's 20px leading air + scroll tail, the
96
+ * footer's 16px band) stays with the base rules alone. Shrinking the
97
+ * collapsed paddings used to lift the whole nav by up to two paddings'
98
+ * worth on every flip; a band that needs a different vertical dress in
99
+ * rail mode would have to move the base values for BOTH densities, never
100
+ * add a per-density block padding. */
90
101
  .hk-sidebar[data-collapsed],
91
102
  .hk-sidebar-rail-generation[data-rail] {
92
103
  .hk-sidebar-header {
93
104
  justify-content: center;
94
- padding: var(--space-12) var(--space-8);
95
- }
96
-
97
- .hk-sidebar-body {
98
- /* Same end-cap as the expanded body — collapsed rows are icon-only
99
- * but the tail's job (announcing the end of the scroll range) is
100
- * identical, so only the leading air shrinks here. */
101
- padding-block: var(--space-12) var(--hk-sidebar-scroll-tail);
105
+ padding: var(--space-16) var(--space-8);
102
106
  }
103
107
 
104
108
  .hk-sidebar-footer {
105
- padding: var(--space-12) var(--space-8);
109
+ padding: var(--space-16) var(--space-8);
106
110
  }
107
111
  }
108
112
 
@@ -74,16 +74,61 @@ describe("HkSidebar scroll end-cap", () => {
74
74
  expect(body).not.toMatch(/padding-block:[^;]*--space-4/);
75
75
  });
76
76
 
77
- it("keeps the same tail on the collapsed body", () => {
78
- // The density dress is keyed off EITHER carrier — the legacy root's
79
- // [data-collapsed] or the rail swap generation's [data-rail] (the
80
- // generation attr freezes on the leaving copy for the dissolve, a
81
- // root attr cannot). ruleBody needs the arm its brace follows, so the
82
- // legacy arm is asserted as text and the body read from the group.
83
- expect(css).toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
84
- const body = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
85
- expect(body, "the collapsed body rule exists (grouped density carriers)").not.toBeNull();
86
- expect(body).toContain("padding-block: var(--space-12) var(--hk-sidebar-scroll-tail)");
77
+ it("the density dress keeps every vertical metric at the expanded value", () => {
78
+ // Vertical rhythm is density-invariant (2026-10-31 user direction:
79
+ // the collapse toggle must not move rows). The body therefore has no
80
+ // density rule AT ALL — the leading air AND the scroll tail ride the
81
+ // single base rule in both densities — and the header/footer dress
82
+ // rules keep the expanded BLOCK padding, narrowing only the inline
83
+ // track for the rail column.
84
+ expect(css).not.toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
85
+ expect(css).not.toContain(".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
86
+ // The two carrier literals above are the compiled spellings; a brand
87
+ // NEW carrier (a third density hook) is a new dress and must re-run
88
+ // this contract — not mechanically foreseeable here.
89
+ // ruleBody needs the arm its brace follows; the dress compiles to one
90
+ // comma-grouped rule per band, so the generation arm is the one the
91
+ // helper can read (the legacy arm shares its body).
92
+ const header = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-header");
93
+ expect(header, "the collapsed header rule exists (grouped density carriers)").not.toBeNull();
94
+ expect(header).toContain("padding: var(--space-16) var(--space-8)");
95
+ const footer = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-footer");
96
+ expect(footer, "the collapsed footer rule exists (grouped density carriers)").not.toBeNull();
97
+ expect(footer).toContain("padding: var(--space-16) var(--space-8)");
98
+ // The stronger, spelling-proof form (whole-dress scan): EVERY
99
+ // padding declaration in ANY dress rule must be EXACTLY the expanded
100
+ // two-track shorthand — exact, not a prefix: a three-track spelling
101
+ // (`padding: var(--space-16) var(--space-8) var(--space-12)`) drifts
102
+ // the block END past a block-start-only check, and a longhand
103
+ // (`padding-block-start:`) hides from a shorthand-only one; both
104
+ // green-holed earlier rounds. Physical `bottom` longhands are banned
105
+ // beside the logical ones. Horizontal-only tracks (padding-inline*)
106
+ // cannot match the value pin and are rejected the same way; a band
107
+ // that truly needs different block metrics must move the BASE rule
108
+ // for BOTH densities. Margins are banned outright — the bands
109
+ // separate by their paddings alone — and so are the block-FLOW
110
+ // metrics that move rows just the same (gap/row-gap/height/
111
+ // min-height/max-height).
112
+ const dressRules = css
113
+ .split("}")
114
+ .filter((rule) => rule.includes("[data-collapsed]") || rule.includes("[data-rail]"));
115
+ expect(dressRules.length, "the density dress rules exist").toBeGreaterThan(1);
116
+ for (const rule of dressRules) {
117
+ for (const decl of rule.matchAll(
118
+ /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:[^;}]*/g,
119
+ )) {
120
+ expect(
121
+ decl[0].trim(),
122
+ `a dress padding is not the expanded two-track shorthand: ${decl[0]}`,
123
+ ).toBe("padding: var(--space-16) var(--space-8)");
124
+ }
125
+ expect(rule, `a margin crept into the dress: ${rule}`).not.toMatch(
126
+ /\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
127
+ );
128
+ expect(rule, `block-flow metric in the dress: ${rule}`).not.toMatch(
129
+ /\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
130
+ );
131
+ }
87
132
  });
88
133
 
89
134
  it("reads the tail fallback from the scss source, not a drifted literal", () => {
@@ -155,11 +155,12 @@ export default defineComponent({
155
155
  // dissolve), which is where the position is read from — an event-only
156
156
  // save would miss programmatic scrolls (no `scroll` event fires for
157
157
  // them), so the read happens on the ref hand-off itself and the
158
- // onScroll below merely keeps the value fresh between flips. Raw
159
- // scrollTop is an approximation across dresses (rail rows and full
160
- // rows have different heights) — an exact restore would need an
161
- // item-keyed anchor, not worth it for a nav list; the browser clamps
162
- // any overshoot.
158
+ // onScroll below merely keeps the value fresh between flips. For
159
+ // library-owned rows the raw restore is EXACT since the vertical
160
+ // rhythm became density-invariant (2026-10-31: both dresses render
161
+ // the same band paddings and row heights, so y offsets match); the
162
+ // browser clamps any overshoot a host's density-dependent content
163
+ // could still produce.
163
164
  const savedScrollTop = ref(0);
164
165
  const onBodyScroll = (e: Event): void => {
165
166
  savedScrollTop.value = (e.currentTarget as HTMLElement).scrollTop;
@@ -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}