@celestia-island/hikari 0.55.330 → 0.55.331

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.330",
3
+ "version": "0.55.331",
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",
@@ -131,4 +131,15 @@
131
131
  &-grid {
132
132
  display: grid;
133
133
  }
134
+
135
+ // The shared cell tooltip's content area. The popup chrome (bubble,
136
+ // shadow, placement geometry) is HkTooltip's .hk-tooltip-popup — the
137
+ // heatmap only frames the caller's slot content, usually one row per
138
+ // model with the metering readout on the right.
139
+ &-tip {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 4px;
143
+ min-width: 140px;
144
+ }
134
145
  }
@@ -5,8 +5,17 @@ import { fileURLToPath } from "node:url";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { createApp, defineComponent, h, nextTick, reactive } from "vue";
7
7
 
8
- import HkCalendarHeatmap from "./HkCalendarHeatmap";
9
- import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
8
+ import HkCalendarHeatmap, {
9
+ HEATMAP_GAP_FILL,
10
+ TOOLTIP_DELAY_MS,
11
+ } from "./HkCalendarHeatmap";
12
+ import { HEATMAP_EMPTY_FILL, HEATMAP_LEVEL_OPACITY, shiftIso, type CalendarCell } from "./calendarHeatmap";
13
+
14
+ /** The shared hover popup's show delay rides the component's EXPORTED
15
+ * constant (R5: the old duplicated literal let a shorter delay ship
16
+ * green — the tests must DERIVE, not restate). The behavioral pin
17
+ * lives in the hover test: hidden at delay−1, visible at delay. */
18
+ expect(HEATMAP_GAP_FILL).not.toContain("gradient");
10
19
 
11
20
  const here = dirname(fileURLToPath(import.meta.url));
12
21
 
@@ -237,9 +246,9 @@ describe("HkCalendarHeatmap", () => {
237
246
  expect(cellAt("0").style.opacity).toBe("");
238
247
  });
239
248
 
240
- it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
249
+ it("renders gap cells dimmed below the empty level, with no level claim", () => {
241
250
  // A one-day series AT today, anchored at first = today: the head
242
- // padding hatches as "before", the tail as "after". The clock is
251
+ // padding dims as "before", the tail as "after". The clock is
243
252
  // PINNED to a Wednesday: with the real clock this test carried
244
253
  // both-phase expectations into a Monday (no head padding) or a
245
254
  // Sunday (no tail), so it went red two days out of seven
@@ -266,13 +275,213 @@ describe("HkCalendarHeatmap", () => {
266
275
  for (const gap of gaps) {
267
276
  expect(gap.getAttribute("aria-hidden")).toBe("true");
268
277
  expect(gap.getAttribute("title")).toBeNull();
269
- expect(gap.style.pointerEvents).toBe("none");
278
+ // The 2026-11 direction: the diagonal hatch is GONE — a gap
279
+ // claims no gradient, no level, no primary ink. (The fill's
280
+ // VALUE rides HEATMAP_GAP_FILL — happy-dom drops color-mix()
281
+ // from a parsed background, so the constant itself is pinned
282
+ // at this file's head and by the source contract below.)
283
+ expect(gap.getAttribute("style") ?? "").not.toContain("gradient");
284
+ expect(gap.getAttribute("style") ?? "").not.toContain("--hi-color-primary");
285
+ expect(gap.getAttribute("data-level")).toBeNull();
286
+ // The ladder must read gap < empty < data (R2 2026-11): the
287
+ // dimmed wash sits BELOW the empty level's wash, parsed from
288
+ // the two exported constants so a percentage drift on either
289
+ // side flips this even with every other gate green.
290
+ const pct = (fill: string) =>
291
+ Number(/(\d+)%/.exec(fill)?.[1] ?? NaN);
292
+ expect(pct(HEATMAP_GAP_FILL)).toBeLessThan(pct(HEATMAP_EMPTY_FILL));
270
293
  }
271
294
  } finally {
272
295
  vi.useRealTimers();
273
296
  }
274
297
  });
275
298
 
299
+ it("paints gap cells with HEATMAP_GAP_FILL, not a hatch (source contract)", () => {
300
+ // happy-dom cannot observe the gap fill through a parsed style, so
301
+ // this pin reads the sheet's wiring: the gap branch of cellStyle
302
+ // must carry the exported dimmed-wash constant — a re-introduced
303
+ // repeating-linear-gradient hatch (or a hand-written fill) turns
304
+ // this red.
305
+ const sheet = readFileSync(join(here, "HkCalendarHeatmap.tsx"), "utf8");
306
+ expect(sheet).not.toContain("repeating-linear-gradient");
307
+ expect(sheet).toContain("background: HEATMAP_GAP_FILL");
308
+ });
309
+
310
+ it("without a tooltip slot, day cells keep the native title and no popup ever mounts", async () => {
311
+ vi.useFakeTimers();
312
+ try {
313
+ const c = mount(
314
+ h(HkCalendarHeatmap, {
315
+ days: daysFrom(CLEAN_HEAD_MON, 1, 5),
316
+ first: null,
317
+ }),
318
+ );
319
+ const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
320
+ expect(cell.getAttribute("title")).toContain("Dec 1, 2025");
321
+ cell.dispatchEvent(new Event("pointerover", { bubbles: true }));
322
+ await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS);
323
+ await nextTick();
324
+ expect(document.querySelector(".hk-calendar-heatmap-tip")).toBeNull();
325
+ // The old contract stands in full: the gaps stay INERT (R2 2026-11
326
+ // restore — the dimmed wash is presentational, and without a slot
327
+ // nothing can ever engage them). The 5-day fixture spans a whole
328
+ // week, so a guaranteed-gap mount (one day + first anchor) pins it.
329
+ const inert = mount(
330
+ h(HkCalendarHeatmap, {
331
+ days: [{ date: "2025-12-03", value: 5 }],
332
+ first: "2025-12-03",
333
+ }),
334
+ );
335
+ const gap = inert.querySelector("[data-gap]") as HTMLElement;
336
+ expect(gap.style.pointerEvents).toBe("none");
337
+ } finally {
338
+ vi.useRealTimers();
339
+ }
340
+ });
341
+
342
+ it("with a tooltip slot, hovering a day cell opens the shared popup with the caller's content", async () => {
343
+ vi.useFakeTimers();
344
+ try {
345
+ const c = mount(
346
+ h(HkCalendarHeatmap, {
347
+ days: daysFrom(CLEAN_HEAD_MON, 1, 5),
348
+ first: null,
349
+ }, {
350
+ tooltip: ({ cell }: { cell: CalendarCell }) =>
351
+ h("div", { "data-tip-for": cell.date }, `tip:${cell.kind}`),
352
+ }),
353
+ );
354
+ // The slot owns the explanation: no native title (it would double
355
+ // up under the popup).
356
+ const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
357
+ expect(cell.getAttribute("title")).toBeNull();
358
+
359
+ // Lazy: nothing is teleported until the first engage.
360
+ expect(document.querySelector(".hk-calendar-heatmap-tip")).toBeNull();
361
+ cell.dispatchEvent(new Event("pointerover", { bubbles: true }));
362
+ // Behavioral lower bound (R5): the popup is NOT visible one
363
+ // millisecond before the delay — the tests derive the window
364
+ // from the exported constant, so a shorter delay ships red.
365
+ await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS - 1);
366
+ await nextTick();
367
+ const early = document.querySelector(
368
+ ".hk-calendar-heatmap-tip.hk-tooltip-visible",
369
+ );
370
+ expect(early, "no visible popup before the delay elapses").toBeNull();
371
+ await vi.advanceTimersByTimeAsync(1);
372
+ await nextTick();
373
+ const popup = document.querySelector(
374
+ ".hk-calendar-heatmap-tip",
375
+ ) as HTMLElement | null;
376
+ expect(popup, "the shared popup is up after the delay").not.toBeNull();
377
+ expect(popup!.getAttribute("role")).toBe("tooltip");
378
+ expect(popup!.textContent).toBe("tip:day");
379
+ expect(popup!.querySelector('[data-tip-for="2025-12-01"]')).not.toBeNull();
380
+ expect(popup!.classList.contains("hk-tooltip-visible")).toBe(true);
381
+
382
+ // Leaving the grid hides it (the popup element stays mounted —
383
+ // lazy on FIRST engage, not per hover).
384
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
385
+ grid.dispatchEvent(new Event("pointerleave", { bubbles: false }));
386
+ await nextTick();
387
+ expect(
388
+ document.querySelector(".hk-calendar-heatmap-tip")!.classList.contains("hk-tooltip-visible"),
389
+ ).toBe(false);
390
+ } finally {
391
+ vi.useRealTimers();
392
+ }
393
+ });
394
+
395
+ it("the tooltip slot also fires for gap cells — the caller explains 'no data'", async () => {
396
+ vi.useFakeTimers();
397
+ try {
398
+ vi.setSystemTime(new Date("2025-12-03T10:00:00Z")); // a Wednesday
399
+ const c = mount(
400
+ h(HkCalendarHeatmap, {
401
+ days: [{ date: "2025-12-03", value: 5 }],
402
+ first: "2025-12-03",
403
+ }, {
404
+ tooltip: ({ cell }: { cell: CalendarCell }) =>
405
+ h("div", { "data-tip-kind": cell.kind }, JSON.stringify(cell)),
406
+ }),
407
+ );
408
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
409
+ const gap = grid.querySelector("[data-gap]") as HTMLElement;
410
+ // With the slot the gaps are the popup's engage surface: they
411
+ // must stay hoverable (synthetic events bypass hit-testing, so
412
+ // only this pin catches a regression to unconditional none).
413
+ expect(gap.style.pointerEvents).not.toBe("none");
414
+ gap.dispatchEvent(new Event("pointerover", { bubbles: true }));
415
+ await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS);
416
+ await nextTick();
417
+ const popup = document.querySelector(".hk-calendar-heatmap-tip") as HTMLElement;
418
+ const payload = JSON.parse(
419
+ popup.querySelector("[data-tip-kind]")!.textContent ?? "{}",
420
+ );
421
+ expect(payload).toMatchObject({ kind: "gap", date: gap.getAttribute("data-date") });
422
+ } finally {
423
+ vi.useRealTimers();
424
+ }
425
+ });
426
+
427
+ it("a touch pointerleave does not close what a tap opened; a desktop leave does", async () => {
428
+ // Browsers fire the boundary-release chain (pointerleave on the
429
+ // tapped cell) right after a touch pointerup — implicit capture
430
+ // release. The tap-toggle owns touch dismissal, so that synthetic
431
+ // leave must NOT hide the popup (R2 2026-11; the mouse leave path
432
+ // stays). happy-dom does not model the implicit release, which is
433
+ // exactly why this needs its own pin.
434
+ vi.useFakeTimers();
435
+ try {
436
+ const c = mount(
437
+ h(HkCalendarHeatmap, {
438
+ days: daysFrom(CLEAN_HEAD_MON, 1, 5),
439
+ first: null,
440
+ }, {
441
+ tooltip: ({ cell }: { cell: CalendarCell }) =>
442
+ h("div", { "data-tip-for": cell.date }, `tip:${cell.kind}`),
443
+ }),
444
+ );
445
+ const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
446
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
447
+
448
+ // A touch tap arms + shows at once.
449
+ cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
450
+ await nextTick();
451
+ const popup = () => document.querySelector(".hk-calendar-heatmap-tip") as HTMLElement | null;
452
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
453
+
454
+ // The implicit-release leave: same gesture, must not hide.
455
+ grid.dispatchEvent(new PointerEvent("pointerleave", { pointerType: "touch" }));
456
+ await nextTick();
457
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
458
+
459
+ // A mouse (pointerType-less event reads undefined) leave hides.
460
+ grid.dispatchEvent(new PointerEvent("pointerleave", {}));
461
+ await nextTick();
462
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
463
+
464
+ // The tap-toggle owns touch dismissal, so BOTH directions stay
465
+ // pinned: a second tap on the SAME cell closes it…
466
+ cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
467
+ await nextTick();
468
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
469
+ cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
470
+ await nextTick();
471
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
472
+
473
+ // …and a tap OUTSIDE the grid dismisses a re-opened tooltip.
474
+ cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
475
+ await nextTick();
476
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
477
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
478
+ await nextTick();
479
+ expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
480
+ } finally {
481
+ vi.useRealTimers();
482
+ }
483
+ });
484
+
276
485
  it("tooltips day cells with the unit when one is given", () => {
277
486
  const c = mount(
278
487
  h(HkCalendarHeatmap, {
@@ -1,6 +1,9 @@
1
- import { computed, defineComponent, ref, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, ref, Teleport, watch, type PropType, type SlotsType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
+ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
5
+ import { applyTooltipPosition } from "../runtime/tooltipPosition";
6
+ import { ensureOverlayRoot } from "../overlay/overlayRoot";
4
7
  import HkScrollContainer from "./HkScrollContainer";
5
8
  import {
6
9
  calendarGrid,
@@ -20,10 +23,23 @@ import "./HkCalendarHeatmap.scss";
20
23
  const LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
21
24
 
22
25
  /** The no-data cell (before the account's first activity / after
23
- * today): muted diagonal hatch over the surface — the wowsp-style
24
- * "not yet" cell. Rendered inert: no tooltip, no pointer events. */
25
- const GAP_HATCH =
26
- "repeating-linear-gradient(45deg, color-mix(in srgb, var(--hi-color-muted) 70%, transparent) 0px, color-mix(in srgb, var(--hi-color-muted) 70%, transparent) 2px, transparent 2px, transparent 4px)";
26
+ * today): a quiet, recessed wash — the muted token at a fainter
27
+ * strength than the EMPTY level's wash (40%), so the visual ladder
28
+ * reads gap < empty < data with no third channel. (2026-11 user
29
+ * direction: the old diagonal hatch read as noise at a year-wide
30
+ * grid's density — a flat dimmer cell carries the same "no data yet"
31
+ * claim without the stripes.) Deliberately NOT pointer-inert: with a
32
+ * `tooltip` slot the caller also explains gaps ("the account has no
33
+ * data for this date"), so cells stay hover targets. */
34
+ export const HEATMAP_GAP_FILL =
35
+ "color-mix(in srgb, var(--hi-color-muted) 20%, transparent)";
36
+
37
+ /** The hover tooltip's show delay — HkTooltip's default, so the two
38
+ * hover surfaces in a page share one cadence. */
39
+ /** The hover tooltip's show delay — HkTooltip's default, so the two
40
+ * hover surfaces in a page share one cadence. Exported so consumers
41
+ * (and the tests) derive from it instead of duplicating the literal. */
42
+ export const TOOLTIP_DELAY_MS = 300;
27
43
 
28
44
  /** Minimal {name} interpolation for the component's i18n strings — the
29
45
  * hikari context serves plain strings (no ICU), the placeholders stay
@@ -32,6 +48,12 @@ function fill(template: string, vars: Record<string, string | number>): string {
32
48
  return template.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ""));
33
49
  }
34
50
 
51
+ /** Stable per-instance popup id. The popup carries role="tooltip" and
52
+ * this id; callers' slot content that wants an aria-describedby link
53
+ * can reference it, but the component binds no describedby itself —
54
+ * the armed cell is a delegated span, not a managed wrapper. */
55
+ let popupSeq = 0;
56
+
35
57
  /**
36
58
  * HkCalendarHeatmap — a GitHub-style calendar heatmap of daily values.
37
59
  *
@@ -40,7 +62,7 @@ function fill(template: string, vars: Record<string, string | number>): string {
40
62
  * component — the rich-text-viewer table — and the leading-month
41
63
  * marker refinement belongs in the library, not the consumer). A
42
64
  * legend (less → more over the primary token), a Monday-first weekday
43
- * rail, month markers, and hatched no-data gap cells that never claim
65
+ * rail, month markers, and dimmed no-data gap cells that never claim
44
66
  * a fake zero.
45
67
  *
46
68
  * The leading month is never mislabeled: a month is labeled on the
@@ -55,6 +77,15 @@ function fill(template: string, vars: Record<string, string | number>): string {
55
77
  * its own tests). The `toolbar` slot rides right of the legend — the
56
78
  * consumer's range tabs / controls belong there.
57
79
  *
80
+ * Tooltips (2026-11 user direction): every cell — day AND gap — carries
81
+ * a caller-decided tooltip through the `tooltip` slot (payload
82
+ * `{ cell }`, the same `CalendarCell` the grid renders). The component
83
+ * owns the machinery (one lazy, teleported hover popup in the shared
84
+ * HkTooltip chrome, re-anchored per frame while visible) so a caller
85
+ * never mounts ~370 popup instances; it decides only the CONTENT.
86
+ * Without the slot the old behaviour stands: day cells keep their
87
+ * native `title`, gap cells stay silent.
88
+ *
58
89
  * Scrolling (2026-11-08): the calendar body rides HkScrollContainer
59
90
  * (axis="horizontal", align="center") so the strip uses hikari's own
60
91
  * auto-hiding overlay scrollbar and NEVER a native bar — a chart that
@@ -95,6 +126,14 @@ export default defineComponent({
95
126
  * contract. Pass "start" to restore the old GitHub-style origin. */
96
127
  anchor: { type: String as PropType<"start" | "end">, default: "end" },
97
128
  },
129
+ slots: Object as SlotsType<{
130
+ /** Right of the legend: the consumer's range tabs / controls. */
131
+ toolbar?: (props: Record<string, never>) => unknown;
132
+ /** One cell's tooltip content. Fires for day AND gap cells; the
133
+ * component renders it in a single shared hover popup (the
134
+ * HkTooltip chrome), re-anchored to the hovered cell per frame. */
135
+ tooltip?: (props: { cell: CalendarCell }) => unknown;
136
+ }>,
98
137
  setup(props, { slots }) {
99
138
  const { t } = useI18n();
100
139
 
@@ -155,12 +194,174 @@ export default defineComponent({
155
194
  { date: dateLabel(d.date), n: d.value, unit: props.unit },
156
195
  );
157
196
 
197
+ // ── the caller-decided cell tooltip ────────────────────────────────
198
+ // One shared popup for the whole grid (HkTooltip's chrome and lazy
199
+ // machinery) — a caller slot decides the CONTENT per cell; the
200
+ // component never mounts per-cell popup instances. Inactive unless
201
+ // the slot is present: without it, day cells keep their native
202
+ // `title` and gap cells stay silent (the pre-tooltip contract).
203
+ const hasTooltipSlot = computed(() => !!slots.tooltip);
204
+
205
+ // date → cell lookup for the delegated hover (cells already carry
206
+ // data-date; no extra attributes on the grid's 370 spans).
207
+ const cellByDate = computed(() => {
208
+ const map = new Map<string, CalendarCell>();
209
+ for (const cell of cells.value) map.set(cell.date, cell);
210
+ return map;
211
+ });
212
+
213
+ const tip = ref<{ cell: CalendarCell; el: HTMLElement } | null>(null);
214
+ const tipVisible = ref(false);
215
+ const tipPopupId = `hk-calendar-heatmap-tip-${++popupSeq}`;
216
+ const tipManager = usePopupManager();
217
+ let tipHandle: PopupHandle | null = null;
218
+ const tipZIndex = ref<number | null>(null);
219
+ const tipEverShown = ref(false);
220
+ let tipShowTimer: ReturnType<typeof setTimeout> | null = null;
221
+ let tipPositionFrame: number | null = null;
222
+ let tipArmedCell: string | null = null;
223
+
224
+ function tipEngage() {
225
+ if (tipEverShown.value) return;
226
+ tipEverShown.value = true;
227
+ if (!tipHandle) tipHandle = tipManager.register("tooltip", false);
228
+ tipZIndex.value = tipHandle.zIndex;
229
+ }
230
+
231
+ function tipStopTracking() {
232
+ if (tipPositionFrame !== null) cancelAnimationFrame(tipPositionFrame);
233
+ tipPositionFrame = null;
234
+ }
235
+
236
+ function tipUpdatePosition() {
237
+ if (!tip.value || !popupRef.value) return;
238
+ applyTooltipPosition(popupRef.value, tip.value.el.getBoundingClientRect(), "top");
239
+ }
240
+
241
+ function tipTrackWhileVisible() {
242
+ if (tipPositionFrame !== null) return;
243
+ const tick = () => {
244
+ tipPositionFrame = null;
245
+ if (!tipVisible.value) return;
246
+ // The hovered cell can move without a pointer event (the strip's
247
+ // horizontal scroll) — re-read its rect per frame while visible.
248
+ tipUpdatePosition();
249
+ tipPositionFrame = requestAnimationFrame(tick);
250
+ };
251
+ tipPositionFrame = requestAnimationFrame(tick);
252
+ }
253
+
254
+ function tipShowNow() {
255
+ if (tipShowTimer !== null) {
256
+ clearTimeout(tipShowTimer);
257
+ tipShowTimer = null;
258
+ }
259
+ tipVisible.value = true;
260
+ tipTrackWhileVisible();
261
+ }
262
+
263
+ function tipHide() {
264
+ if (tipShowTimer !== null) {
265
+ clearTimeout(tipShowTimer);
266
+ tipShowTimer = null;
267
+ }
268
+ tipArmedCell = null;
269
+ tipVisible.value = false;
270
+ tipStopTracking();
271
+ }
272
+
273
+ /** The cell a pointer event landed on (delegated: the spans carry
274
+ * data-date; find the closest one from the event target). */
275
+ function cellFromEvent(e: Event): { cell: CalendarCell; el: HTMLElement } | null {
276
+ const target = e.target as HTMLElement | null;
277
+ const span = target?.closest?.("[data-date]") as HTMLElement | null;
278
+ if (!span) return null;
279
+ const date = span.getAttribute("data-date");
280
+ if (!date) return null;
281
+ const cell = cellByDate.value.get(date);
282
+ return cell ? { cell, el: span } : null;
283
+ }
284
+
285
+ function onGridPointerover(e: PointerEvent) {
286
+ if (!hasTooltipSlot.value) return;
287
+ if (performance.now() - lastTouchAt < 600) return;
288
+ const hit = cellFromEvent(e);
289
+ if (!hit) return;
290
+ if (tipArmedCell === hit.cell.date) return;
291
+ tipArmedCell = hit.cell.date;
292
+ tip.value = hit;
293
+ tipEngage();
294
+ // Cell→cell travel with the popup already up swaps content in
295
+ // place (no hide/show flicker); a cold hover waits the delay.
296
+ if (tipVisible.value) tipShowNow();
297
+ else {
298
+ if (tipShowTimer !== null) clearTimeout(tipShowTimer);
299
+ tipShowTimer = setTimeout(tipShowNow, TOOLTIP_DELAY_MS);
300
+ }
301
+ }
302
+
303
+ function onGridPointerleave(e: PointerEvent) {
304
+ if (!hasTooltipSlot.value) return;
305
+ // Touch's tap-toggle owns dismissal: the boundary-release chain
306
+ // after a touch pointerup fires pointerleave on the tapped cell
307
+ // in the same gesture (implicit capture release), which would
308
+ // close the tooltip the tap just opened (R2). Desktop leave hides.
309
+ if (e.pointerType === "touch") return;
310
+ tipHide();
311
+ }
312
+
313
+ // Touch has no hover: a tap arms the tooltip for that cell at once,
314
+ // and a tap anywhere else dismisses it (the outside-listener lives
315
+ // only while a touch tip is up).
316
+ let lastTouchAt = -Infinity;
317
+ const popupRef = ref<HTMLElement | null>(null);
318
+ const gridRef = ref<HTMLElement | null>(null);
319
+ function onOutsideTap(e: PointerEvent) {
320
+ const target = e.target as HTMLElement | null;
321
+ if (gridRef.value?.contains(target ?? null)) return;
322
+ tipHide();
323
+ document.removeEventListener("pointerdown", onOutsideTap, true);
324
+ }
325
+
326
+ function onGridPointerdown(e: PointerEvent) {
327
+ if (!hasTooltipSlot.value || e.pointerType === "mouse") return;
328
+ lastTouchAt = performance.now();
329
+ const hit = cellFromEvent(e);
330
+ if (!hit) return;
331
+ if (tipVisible.value && tipArmedCell === hit.cell.date) {
332
+ tipHide();
333
+ document.removeEventListener("pointerdown", onOutsideTap, true);
334
+ return;
335
+ }
336
+ tipArmedCell = hit.cell.date;
337
+ tip.value = hit;
338
+ tipEngage();
339
+ tipShowNow();
340
+ document.removeEventListener("pointerdown", onOutsideTap, true);
341
+ document.addEventListener("pointerdown", onOutsideTap, true);
342
+ }
343
+
344
+ onBeforeUnmount(() => {
345
+ tipHide();
346
+ document.removeEventListener("pointerdown", onOutsideTap, true);
347
+ if (tipHandle) {
348
+ tipManager.unregister(tipHandle.id);
349
+ tipHandle = null;
350
+ }
351
+ });
352
+
158
353
  const cellStyle = (cell: CalendarCell) =>
159
354
  cell.kind === "gap"
160
355
  ? {
161
356
  borderRadius: "3px",
162
- background: GAP_HATCH,
163
- pointerEvents: "none" as const,
357
+ background: HEATMAP_GAP_FILL,
358
+ // The dimmed wash is presentational, but without a tooltip
359
+ // slot the gaps stay INERT exactly as before (R2): with the
360
+ // slot they must stay hoverable — they are the popup's
361
+ // engage surface.
362
+ pointerEvents: hasTooltipSlot.value
363
+ ? undefined
364
+ : ("none" as const),
164
365
  }
165
366
  : {
166
367
  borderRadius: "3px",
@@ -298,8 +499,12 @@ export default defineComponent({
298
499
  ))}
299
500
  </div>
300
501
 
301
- {/* The grid itself: cells flow column-first into 7 rows. */}
502
+ {/* The grid itself: cells flow column-first into 7 rows.
503
+ * Hover is delegated on the grid (cells carry data-date);
504
+ * with a `tooltip` slot every cell — gaps included — is a
505
+ * live hover target for the shared popup below. */}
302
506
  <div
507
+ ref={gridRef}
303
508
  data-testid="hk-calendar-heatmap"
304
509
  data-max={max.value}
305
510
  role="img"
@@ -317,6 +522,9 @@ export default defineComponent({
317
522
  gridAutoFlow: "column",
318
523
  gap: `${props.cellGap}px`,
319
524
  }}
525
+ onPointerover={onGridPointerover}
526
+ onPointerleave={onGridPointerleave}
527
+ onPointerdown={onGridPointerdown}
320
528
  >
321
529
  {cells.value.map((cell, i) => (
322
530
  <span
@@ -330,7 +538,10 @@ export default defineComponent({
330
538
  : {
331
539
  "data-level": cell.level,
332
540
  "data-date": cell.date,
333
- title: cellTitle(cell),
541
+ // With a tooltip slot the shared popup owns the
542
+ // cell's explanation — a native `title` would
543
+ // double up on hover (and never fires for gaps).
544
+ title: hasTooltipSlot.value ? undefined : cellTitle(cell),
334
545
  })}
335
546
  style={cellStyle(cell)}
336
547
  />
@@ -339,6 +550,31 @@ export default defineComponent({
339
550
  </div>
340
551
  </div>
341
552
  </HkScrollContainer>
553
+
554
+ {/* The shared cell tooltip: one lazy popup for the whole grid,
555
+ * teleported to the overlay root and pinned to the hovered cell
556
+ * with the shared HkTooltip geometry (zoom-aware, clamped,
557
+ * pixel-snapped). Content is the caller's `tooltip` slot for
558
+ * `tip.cell`; the popup mounts on first engage and stays until
559
+ * unmount so re-shows never churn. */}
560
+ <Teleport to={ensureOverlayRoot()}>
561
+ {tipEverShown.value && tip.value && (
562
+ <div
563
+ ref={popupRef}
564
+ id={tipPopupId}
565
+ role="tooltip"
566
+ class={[
567
+ "hk-tooltip-popup",
568
+ "hk-tooltip-top",
569
+ tipVisible.value ? "hk-tooltip-visible" : "",
570
+ "hk-calendar-heatmap-tip",
571
+ ]}
572
+ style={tipZIndex.value != null ? { zIndex: tipZIndex.value } : {}}
573
+ >
574
+ {slots.tooltip?.({ cell: tip.value.cell })}
575
+ </div>
576
+ )}
577
+ </Teleport>
342
578
  </div>
343
579
  );
344
580
  },
@@ -5,16 +5,22 @@
5
5
  * bare alpha tint — a lone chip over wallpaper reads as a hole).
6
6
  */
7
7
 
8
+ /* Both flex links must be allowed to shrink below their content or the
9
+ * chip's text truncation never arms: min-width:auto would keep the chip
10
+ * at its nowrap min-content width forever. With no consumer width bound
11
+ * the chip simply renders at its natural width. */
8
12
  .hk-filter-chip-anchor {
9
13
  position: relative;
10
14
  display: inline-flex;
11
15
  align-items: center;
16
+ min-width: 0;
12
17
  }
13
18
 
14
19
  .hk-filter-chip {
15
20
  display: inline-flex;
16
21
  align-items: center;
17
22
  gap: var(--space-6);
23
+ min-width: 0;
18
24
  /* Vertical padding = trigger padding + one track step: the chip shares
19
25
  * its row with segmented HkTabs-style chrome, whose track adds
20
26
  * var(--space-2) around its triggers (the wowsp filter bar's alignment
@@ -60,6 +66,15 @@
60
66
  }
61
67
  }
62
68
 
69
+ /* Long filter values ellipsize once a consumer bounds the row (both
70
+ * flex links above unlock shrinking); unbounded, the chip renders at
71
+ * its natural nowrap width and nothing truncates. */
72
+ .hk-filter-chip-text {
73
+ min-width: 0;
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ }
77
+
63
78
  .hk-filter-chip-caret {
64
79
  opacity: 0.6;
65
80
  flex: 0 0 auto;
@@ -228,6 +228,12 @@
228
228
 
229
229
  /* Keyboard highlight face — identical values to the popup-select item's
230
230
  * [data-highlighted]; hover comes from mi.item's own guarded :hover. */
231
+ /* The add row's label rides the shared menu-item label grammar
232
+ * (flex fill, optical offset, italic cushion, ellipsis). */
233
+ .hk-filter-add-item-label {
234
+ @include mi.label;
235
+ }
236
+
231
237
  .hk-filter-add-item[data-highlighted] {
232
238
  background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
233
239
  border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
@@ -12,6 +12,13 @@
12
12
  align-items: center;
13
13
  }
14
14
 
15
+ /* Popup body: the panel and the mobile confirm footer stack in one
16
+ * column (the panel owns its own bounded width). */
17
+ .hk-filter-trigger-body {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
15
22
  .hk-filter-trigger-footer {
16
23
  display: flex;
17
24
  justify-content: flex-end;
@@ -114,7 +114,7 @@ describe("calendarGrid", () => {
114
114
  expect(cells[6]).toMatchObject({ kind: "gap", phase: "after" });
115
115
  });
116
116
 
117
- it("hatches the before-first region and never claims a fake zero", () => {
117
+ it("dims the before-first region and never claims a fake zero", () => {
118
118
  const cells = calendarGrid(
119
119
  [{ date: "2025-12-05", value: 5 }],
120
120
  "2025-12-07",
@@ -138,7 +138,7 @@ describe("calendarGrid", () => {
138
138
  expect(cells[1]).toMatchObject({ kind: "day", date: "2025-12-02", level: 0 });
139
139
  });
140
140
 
141
- it("clamps a malformed future `first` to hatch nothing", () => {
141
+ it("clamps a malformed future `first` to dim nothing", () => {
142
142
  const cells = calendarGrid(
143
143
  [{ date: MON, value: 5 }],
144
144
  "2025-12-03",
@@ -151,10 +151,10 @@ describe("calendarGrid", () => {
151
151
  expect(calendarGrid([], "2025-12-03", null)).toEqual([]);
152
152
  });
153
153
 
154
- it("never hatches a day the server shipped, even past a skewed `today`", () => {
154
+ it("never dims a day the server shipped, even past a skewed `today`", () => {
155
155
  // A skewed server clock (out of contract): the series runs one day
156
156
  // past `today`. The shipped day must render as a real day — every
157
- // day of the series vanishing behind the "after" hatch is the
157
+ // day of the series vanishing behind the "after" dim is the
158
158
  // failure mode this guards.
159
159
  const cells = calendarGrid(
160
160
  [
@@ -169,7 +169,7 @@ describe("calendarGrid", () => {
169
169
  value: 3,
170
170
  level: 3,
171
171
  });
172
- // The unshipped padding past it still hatches.
172
+ // The unshipped padding past it still dims.
173
173
  expect(cells.find((c) => c.date === "2025-12-03")).toMatchObject({
174
174
  kind: "gap",
175
175
  phase: "after",
@@ -7,7 +7,7 @@
7
7
  * The server ships a DENSE daily series (oldest → newest, zero-filled,
8
8
  * inclusive of today, plus the account's first activity date).
9
9
  * Everything the component renders — the full-year week grid with its
10
- * hatched no-data gaps, the month markers — derives here so each
10
+ * dimmed no-data gaps, the month markers — derives here so each
11
11
  * derivation is unit-testable without a component mount.
12
12
  */
13
13
 
@@ -71,8 +71,9 @@ export function todayIso(): string {
71
71
  /**
72
72
  * One heatmap cell: a real day of the series, or a gap the account has
73
73
  * no data for — before its first activity, or after today (the rest of
74
- * the always-full-year window). Gaps render hatched and inert; they
75
- * never claim a fake "0 consumption" for a day that never was.
74
+ * the always-full-year window). Gaps render as the dimmed no-data wash
75
+ * (the gap channel sits BELOW the empty level); they never claim a
76
+ * fake "0 consumption" for a day that never was.
76
77
  */
77
78
  export type CalendarCell =
78
79
  | { kind: "day"; date: string; value: number; level: 0 | 1 | 2 | 3 | 4 }
@@ -83,7 +84,10 @@ export type CalendarCell =
83
84
  * back to the window's first Monday, tail cells to Sunday — so a
84
85
  * trailing-year window ALWAYS lands on a full-year-wide grid, however
85
86
  * young the account is. Every cell carries its real calendar date;
86
- * cells outside [first … today] become gaps (hatched, inert).
87
+ * cells outside [first … today] become gaps (dimmed; inert without
88
+ * the tooltip slot — with one they are the popup's engage surface).
89
+ * The Rust/WASM mirror keeps them inert unconditionally (no popup
90
+ * runtime in that form).
87
91
  *
88
92
  * `maxOverride` pins the intensity scale (R2: the component's `max`
89
93
  * prop must reach the buckets, not just the data-max attribute — a
@@ -103,17 +107,17 @@ export function calendarGrid(
103
107
  const span = total + ((7 - (total % 7)) % 7);
104
108
  const max = maxOverride ?? days.reduce((m, d) => Math.max(m, d.value), 0);
105
109
  const valueByDate = new Map(days.map((d) => [d.date, d.value]));
106
- // A malformed `first` from the future hatches nothing (the clamp
110
+ // A malformed `first` from the future dims nothing (the clamp
107
111
  // keeps the before-check honest).
108
- const hatchFrom = first && first <= today ? first : null;
112
+ const gapFrom = first && first <= today ? first : null;
109
113
  const cells: CalendarCell[] = [];
110
114
  for (let i = 0; i < span; i++) {
111
115
  const date = shiftIso(gridStart, i);
112
- // Never hatch a date the server actually shipped: a series whose
116
+ // Never dim a date the server actually shipped: a series whose
113
117
  // last row runs past `today` (a skewed server clock, out of
114
118
  // contract) would otherwise render EVERY one of its days as a
115
- // future gap — the data would vanish behind the hatch. (The
116
- // before-first hatch below deliberately does NOT consult the map:
119
+ // future gap — the data would vanish behind the dimming. (The
120
+ // before-first dimming below deliberately does NOT consult the map:
117
121
  // the server fills the pre-anchor stretch with dense zeros as
118
122
  // filler, and the account's first-activity anchor is what decides
119
123
  // that no data existed, however many filler rows arrive.)
@@ -121,7 +125,7 @@ export function calendarGrid(
121
125
  cells.push({ kind: "gap", date, phase: "after" });
122
126
  continue;
123
127
  }
124
- if (hatchFrom && date < hatchFrom) {
128
+ if (gapFrom && date < gapFrom) {
125
129
  cells.push({ kind: "gap", date, phase: "before" });
126
130
  continue;
127
131
  }