@celestia-island/hikari 0.55.230 → 0.55.232

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.230",
3
+ "version": "0.55.232",
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",
@@ -1,5 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import { HkAvatarButton } from "./HkAvatarButton";
5
8
  import { HkAdminHeader } from "./HkAdminHeader";
@@ -107,6 +110,61 @@ describe("HkAvatarButton", () => {
107
110
  });
108
111
  });
109
112
 
113
+ describe("HkAvatarButton sheet contract", () => {
114
+ // happy-dom has no layout engine, so the geometry is pinned at the
115
+ // source level — the same approach the downstream chest tripwire
116
+ // (headerAvatarGeometry.test.ts) takes against the INSTALLED sheet.
117
+ const sheet = readFileSync(
118
+ join(dirname(fileURLToPath(import.meta.url)), "..", "styles", "admin-tokens.scss"),
119
+ "utf8",
120
+ );
121
+
122
+ /** Declarations of the rule whose whole prelude IS `selector`
123
+ * (a compound/descendant spelling is a different rule and does not
124
+ * match), comments stripped; null when no such rule exists — every
125
+ * assertion treats null as a failure, never as "no objection". */
126
+ function ruleDeclarations(selector: string): Array<[string, string]> | null {
127
+ const code = sheet
128
+ .replace(/\/\*[\s\S]*?\*\//g, "")
129
+ .replace(/(^|[^:])\/\/[^\n]*/gm, "$1");
130
+ for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
131
+ const open = code.indexOf("{", at + selector.length);
132
+ if (open < 0) continue;
133
+ if (code.slice(at + selector.length, open).trim() !== "") continue;
134
+ let boundary = at - 1;
135
+ while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
136
+ if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
137
+ const body = code.slice(open + 1, code.indexOf("}", open));
138
+ const out: Array<[string, string]> = [];
139
+ for (const part of body.split(";")) {
140
+ const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
141
+ if (m) out.push([m[1], m[2].replace(/\s+/g, " ")]);
142
+ }
143
+ return out;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ it("the trigger is a BLOCK — hosts may nest it in a plain wrapper div", () => {
149
+ // The chat face wraps HeaderLeft's trigger in a menu-anchor <div>:
150
+ // an inline-block button there joins a line box and the strut's
151
+ // descender space lands BELOW it, riding the avatar ~4px high inside
152
+ // a perfectly centred wrapper (2026-10-30 user report). Removing
153
+ // this declaration is the mutation this test must catch.
154
+ const rule = ruleDeclarations(".s-admin-header-avatar");
155
+ expect(rule, "admin-tokens must declare .s-admin-header-avatar").not.toBeNull();
156
+ // Positive control for the extractor itself: the geometry pair the
157
+ // downstream tripwire also reads. If these stop matching, the parser
158
+ // above is broken — fix it, not the assertion below.
159
+ expect(rule).toContainEqual(["width", "var(--space-28)"]);
160
+ expect(rule).toContainEqual(["height", "var(--space-28)"]);
161
+ expect(rule, "the trigger must not re-enter inline formatting").toContainEqual([
162
+ "display",
163
+ "block",
164
+ ]);
165
+ });
166
+ });
167
+
110
168
  describe("HkAdminHeader × HkAvatarButton / identity slot", () => {
111
169
  it("never renders aria-expanded when no expanded prop is given (drawer-mode contract)", () => {
112
170
  // R2 S3-2: menu engines read this pair; a drawer-mode trigger has no
@@ -0,0 +1,90 @@
1
+ // HkCalendarHeatmap — GitHub-style calendar heatmap anatomy.
2
+ //
3
+ // Self-sufficient styling (the component self-sufficiency guard): every
4
+ // class lives here, no host atomic/utility layer. The dynamic geometry
5
+ // (cell size, gap, week-column count, rail row rhythm) stays inline in
6
+ // the component; this sheet owns the static layout and typography.
7
+
8
+ .hk-calendar-heatmap {
9
+ // The legend row: scale on the LEFT, toolbar slot on the RIGHT.
10
+ &-head {
11
+ display: flex;
12
+ flex-wrap: wrap;
13
+ align-items: center;
14
+ justify-content: space-between;
15
+ column-gap: 16px;
16
+ row-gap: 8px;
17
+ }
18
+
19
+ &-legend {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: 4px;
23
+ font-size: 10px;
24
+ color: var(--hi-color-muted);
25
+ }
26
+
27
+ // w-max + auto margins center the whole calendar (rail included) on
28
+ // wide cards while letting narrow screens scroll from the left edge —
29
+ // auto margins resolve to zero when the content overflows, so mobile
30
+ // keeps its natural scroll origin.
31
+ &-scroll {
32
+ margin-top: 12px;
33
+ overflow-x: auto;
34
+ padding-bottom: 4px;
35
+ }
36
+
37
+ &-body {
38
+ display: flex;
39
+ width: max-content;
40
+ margin-inline: auto;
41
+ gap: 8px;
42
+ }
43
+
44
+ // Weekday rail: the first (Monday) and last (Sunday) rows carry the
45
+ // labels (the wowsp convention); sticky so it survives horizontal
46
+ // scroll on narrow screens.
47
+ &-rail {
48
+ position: sticky;
49
+ left: 0;
50
+ z-index: 1;
51
+ display: flex;
52
+ flex-direction: column;
53
+ flex-shrink: 0;
54
+ padding-right: 2px;
55
+ font-size: 10px;
56
+ line-height: 14px;
57
+ color: var(--hi-color-muted);
58
+ background: var(--hi-color-surface);
59
+
60
+ > span {
61
+ white-space: nowrap;
62
+ }
63
+ }
64
+
65
+ &-cells {
66
+ flex: 1;
67
+ min-width: 0;
68
+ }
69
+
70
+ // Month markers, one slot per week column; labels overflow their
71
+ // 14px column into the empty cells ahead (the GitHub convention — a
72
+ // month marker is always followed by unmarked columns), so no
73
+ // truncating: a clipped "O…" tells nothing.
74
+ &-markers {
75
+ display: grid;
76
+ margin-bottom: 4px;
77
+ font-size: 10px;
78
+ line-height: 12px;
79
+ color: var(--hi-color-muted);
80
+
81
+ > span {
82
+ white-space: nowrap;
83
+ }
84
+ }
85
+
86
+ // The grid itself: cells flow column-first into 7 rows.
87
+ &-grid {
88
+ display: grid;
89
+ }
90
+ }
@@ -0,0 +1,240 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
3
+
4
+ import HkCalendarHeatmap from "./HkCalendarHeatmap";
5
+ import { HEATMAP_LEVEL_OPACITY, todayIso } from "./calendarHeatmap";
6
+
7
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
8
+
9
+ function mount(node: ReturnType<typeof h>) {
10
+ const container = document.createElement("div");
11
+ document.body.appendChild(container);
12
+ const app = createApp({ render: () => node });
13
+ app.mount(container);
14
+ mounts.push({ app, container });
15
+ return container;
16
+ }
17
+
18
+ afterEach(() => {
19
+ for (const { app, container } of mounts.splice(0)) {
20
+ app.unmount();
21
+ container.remove();
22
+ }
23
+ });
24
+
25
+ /** A Monday, NOT the 1st — the leading-partial-month shape. */
26
+ const MID_MONTH_MON = "2025-10-27";
27
+ /** A Monday AND the 1st — the clean head shape. */
28
+ const CLEAN_HEAD_MON = "2025-12-01";
29
+
30
+ const daysFrom = (start: string, count: number, value = 0) =>
31
+ Array.from({ length: count }, (_, i) => {
32
+ const d = new Date(`${start}T00:00:00Z`);
33
+ d.setUTCDate(d.getUTCDate() + i);
34
+ return { date: d.toISOString().slice(0, 10), value };
35
+ });
36
+
37
+ describe("HkCalendarHeatmap", () => {
38
+ it("renders the legend (less → more, five swatches) and the grid anatomy", () => {
39
+ const c = mount(
40
+ h(HkCalendarHeatmap, {
41
+ days: daysFrom(CLEAN_HEAD_MON, 7),
42
+ first: null,
43
+ unit: "度",
44
+ }),
45
+ );
46
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
47
+ expect(grid).toBeTruthy();
48
+ expect(grid.getAttribute("role")).toBe("img");
49
+ // One row of days pads into exactly one week.
50
+ expect(grid.getAttribute("aria-label")).toContain("1");
51
+ const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
52
+ expect(legend?.textContent).toContain("Less");
53
+ expect(legend?.textContent).toContain("More");
54
+ expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
55
+ HEATMAP_LEVEL_OPACITY.length,
56
+ );
57
+ // One week of days, all level 0 (no consumption): plain muted cells.
58
+ expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
59
+ });
60
+
61
+ it("suppresses the leading month marker when the window starts mid-month", async () => {
62
+ const c = mount(
63
+ h(HkCalendarHeatmap, {
64
+ // Trailing-October tail + November: the partial October head is
65
+ // unlabeled, November's Monday carries the only marker.
66
+ days: daysFrom(MID_MONTH_MON, 10),
67
+ first: null,
68
+ }),
69
+ );
70
+ await nextTick();
71
+ const markers = c.querySelectorAll(
72
+ ".hk-calendar-heatmap-markers > span",
73
+ ) as NodeListOf<HTMLElement>;
74
+ expect(markers.length).toBeGreaterThanOrEqual(2);
75
+ expect(markers[0].textContent).toBe(""); // partial October — suppressed
76
+ expect(markers[1].textContent).not.toBe(""); // November — labeled
77
+ });
78
+
79
+ it("shows the leading month marker when the grid's first column opens its month", async () => {
80
+ const c = mount(
81
+ h(HkCalendarHeatmap, {
82
+ days: daysFrom(CLEAN_HEAD_MON, 9),
83
+ first: null,
84
+ }),
85
+ );
86
+ await nextTick();
87
+ const markers = c.querySelectorAll(
88
+ ".hk-calendar-heatmap-markers > span",
89
+ ) as NodeListOf<HTMLElement>;
90
+ expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
91
+ });
92
+
93
+ it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
94
+ // A one-day series AT today, anchored at first = today: the head
95
+ // padding hatches as "before", the tail as "after" — both phases in
96
+ // one grid, all deterministic against the real clock.
97
+ const today = todayIso();
98
+ const c = mount(
99
+ h(HkCalendarHeatmap, {
100
+ days: [{ date: today, value: 5 }],
101
+ first: today,
102
+ }),
103
+ );
104
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
105
+ const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
106
+ expect(gaps.length).toBeGreaterThan(0);
107
+ const phases = new Set(
108
+ Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
109
+ );
110
+ expect(phases.has("before")).toBe(true);
111
+ expect(phases.has("after")).toBe(true);
112
+ for (const gap of gaps) {
113
+ expect(gap.getAttribute("aria-hidden")).toBe("true");
114
+ expect(gap.getAttribute("title")).toBeNull();
115
+ expect(gap.style.pointerEvents).toBe("none");
116
+ }
117
+ });
118
+
119
+ it("tooltips day cells with the unit when one is given", () => {
120
+ const c = mount(
121
+ h(HkCalendarHeatmap, {
122
+ days: daysFrom(CLEAN_HEAD_MON, 1, 5),
123
+ first: null,
124
+ unit: "度",
125
+ }),
126
+ );
127
+ const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
128
+ expect(cell).toBeTruthy();
129
+ // The UTC zone is explicit (R2: a negative-offset host must not
130
+ // land the tooltip a day off) — the date is TZ-deterministic.
131
+ expect(cell.getAttribute("title")).toContain("Dec 1, 2025");
132
+ expect(cell.getAttribute("title")).toContain("5");
133
+ expect(cell.getAttribute("title")).toContain("度");
134
+ });
135
+
136
+ it("wires the max prop into the cell buckets, not just data-max", () => {
137
+ const c = mount(
138
+ h(HkCalendarHeatmap, {
139
+ // Peak 10 in-window; the pinned max 100 flattens both days to
140
+ // level 1 (R2 S1: the prop was a dead wire to data-max).
141
+ days: [{ date: CLEAN_HEAD_MON, value: 10 }, { date: "2025-12-02", value: 1 }],
142
+ first: null,
143
+ max: 100,
144
+ }),
145
+ );
146
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
147
+ expect(grid.getAttribute("data-max")).toBe("100");
148
+ expect(grid.querySelectorAll('[data-level="1"]')).toHaveLength(2);
149
+ expect(grid.querySelectorAll('[data-level="4"]')).toHaveLength(0);
150
+ });
151
+
152
+ it("tooltips day cells without a dangling separator when unitless", () => {
153
+ const c = mount(
154
+ h(HkCalendarHeatmap, {
155
+ days: daysFrom(CLEAN_HEAD_MON, 1, 5),
156
+ first: null,
157
+ }),
158
+ );
159
+ const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
160
+ // The unitless title ends at the count — no trailing "· <empty>".
161
+ expect(cell.getAttribute("title")).toMatch(/· 5$/);
162
+ });
163
+
164
+ it("labels the weekday rail on the first and last rows only", () => {
165
+ const c = mount(
166
+ h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
167
+ );
168
+ const rail = c.querySelector(".hk-calendar-heatmap-rail") as HTMLElement;
169
+ const rows = rail.querySelectorAll(":scope > span") as NodeListOf<HTMLElement>;
170
+ expect(rows).toHaveLength(7);
171
+ expect(rows[0].textContent).not.toBe(""); // Monday
172
+ expect(rows[6].textContent).not.toBe(""); // Sunday
173
+ expect(rows[3].textContent).toBe(""); // middle rows unlabeled
174
+ });
175
+
176
+ it("renders the toolbar slot right of the legend", () => {
177
+ const Toolbar = defineComponent({
178
+ name: "FakeToolbar",
179
+ setup: () => () => h("div", { "data-fake-toolbar": "" }, "tabs"),
180
+ });
181
+ const c = mount(
182
+ h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }, {
183
+ toolbar: () => h(Toolbar),
184
+ }),
185
+ );
186
+ expect(c.querySelector("[data-fake-toolbar]")?.textContent).toBe("tabs");
187
+ });
188
+
189
+ it("renders an empty grid without markers for an empty series", () => {
190
+ const c = mount(h(HkCalendarHeatmap, { days: [], first: null }));
191
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
192
+ expect(grid).toBeTruthy();
193
+ expect(grid.querySelectorAll("[data-level]")).toHaveLength(0);
194
+ expect(c.querySelectorAll(".hk-calendar-heatmap-markers > span")).toHaveLength(0);
195
+ });
196
+
197
+ it("re-takes today per recompute — a refetch across UTC midnight un-gaps the new today cell", async () => {
198
+ // R2 S2 + R3 变异留证: today frozen at setup rendered the next
199
+ // day as an "after" gap even after the caller refetched a series
200
+ // that includes it. A recompute (the refetch's new `days`) must
201
+ // re-evaluate todayIso() — the crossed-midnight day becomes a day.
202
+ // (A rerender with NO dep change legitimately keeps the cached
203
+ // cells — computed semantics, nothing changed.)
204
+ vi.useFakeTimers();
205
+ try {
206
+ vi.setSystemTime(new Date("2025-12-01T10:00:00Z"));
207
+ // Dense series through the mount-time today: Fri 11-28 .. Mon 12-01.
208
+ const days = daysFrom("2025-11-28", 4);
209
+ const state = reactive({ days });
210
+ // The vnode is built INSIDE the render so the reactive `days`
211
+ // re-renders — the shared eager mount helper would capture one
212
+ // static vnode.
213
+ const container = document.createElement("div");
214
+ document.body.appendChild(container);
215
+ const app = createApp({
216
+ render: () => h(HkCalendarHeatmap, { days: state.days, first: null, unit: "度" }),
217
+ });
218
+ app.mount(container);
219
+ mounts.push({ app, container });
220
+ const grid = container.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
221
+ const tomorrowAtMount = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
222
+ expect(tomorrowAtMount.getAttribute("data-gap")).toBe("after");
223
+
224
+ // Cross UTC midnight; the refetched series now includes the new
225
+ // today (12-02, 3 度) — a new array instance, so the cells
226
+ // computed recomputes and re-takes today.
227
+ vi.setSystemTime(new Date("2025-12-02T10:00:00Z"));
228
+ state.days = [...days, { date: "2025-12-02", value: 3 }];
229
+ // Two ticks: the parent's render → the child's props update → the
230
+ // child's re-render land in sequence.
231
+ await nextTick();
232
+ await nextTick();
233
+ const nowToday = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
234
+ expect(nowToday.getAttribute("data-gap")).toBeNull();
235
+ expect(nowToday.getAttribute("data-level")).toBe("4");
236
+ } finally {
237
+ vi.useRealTimers();
238
+ }
239
+ });
240
+ });
@@ -0,0 +1,261 @@
1
+ import { computed, defineComponent, type PropType } from "vue";
2
+
3
+ import { useI18n } from "../i18n/context";
4
+ import {
5
+ bucketLevel,
6
+ calendarGrid,
7
+ HEATMAP_LEVEL_OPACITY,
8
+ monthMarkers,
9
+ todayIso,
10
+ type CalendarCell,
11
+ type HkCalendarHeatmapDay,
12
+ } from "./calendarHeatmap";
13
+ import "./HkCalendarHeatmap.scss";
14
+
15
+ /** The no-data cell (before the account's first activity / after
16
+ * today): muted diagonal hatch over the surface — the wowsp-style
17
+ * "not yet" cell. Rendered inert: no tooltip, no pointer events. */
18
+ const GAP_HATCH =
19
+ "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)";
20
+
21
+ /** Minimal {name} interpolation for the component's i18n strings — the
22
+ * hikari context serves plain strings (no ICU), the placeholders stay
23
+ * in the locale bundles so translators keep the full sentence. */
24
+ function fill(template: string, vars: Record<string, string | number>): string {
25
+ return template.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ""));
26
+ }
27
+
28
+ /**
29
+ * HkCalendarHeatmap — a GitHub-style calendar heatmap of daily values.
30
+ *
31
+ * Upstreamed from the shittim-chest quota page's degree-usage section
32
+ * (2026-10 user direction: a calendar heatmap is a big business
33
+ * component — the rich-text-viewer table — and the leading-month
34
+ * marker refinement belongs in the library, not the consumer). A
35
+ * legend (less → more over the primary token), a Monday-first weekday
36
+ * rail, month markers, and hatched no-data gap cells that never claim
37
+ * a fake zero.
38
+ *
39
+ * The leading month is suppressed when the window starts mid-month:
40
+ * the first column shows only the tail of its month, so the marker is
41
+ * rendered only when the grid's first column actually opens its month
42
+ * (Monday = the 1st) — a label over a partial head would claim a
43
+ * month start that is not visible.
44
+ *
45
+ * Dumb by design: the caller owns the fetch lifecycle and passes the
46
+ * settled dense series in (`days`, oldest → newest, zero-filled,
47
+ * inclusive of today) plus the account's first activity date (`first`,
48
+ * null = never). All math lives in ./calendarHeatmap (pure, pinned by
49
+ * its own tests). The `toolbar` slot rides right of the legend — the
50
+ * consumer's range tabs / controls belong there.
51
+ */
52
+ export default defineComponent({
53
+ name: "HkCalendarHeatmap",
54
+ props: {
55
+ /** The dense daily series, oldest → newest, inclusive of today. */
56
+ days: {
57
+ type: Array as PropType<HkCalendarHeatmapDay[]>,
58
+ required: true,
59
+ },
60
+ /** The account's first activity date (null = never). */
61
+ first: { type: String as PropType<string | null>, default: null },
62
+ /** The unit the legend/tooltip names ("" = unitless values). */
63
+ unit: { type: String, default: "" },
64
+ /** Peak day pinned across windows (reaches the cell buckets, not
65
+ * just the data-max attribute); null = derive from `days`. */
66
+ max: { type: Number as PropType<number | null>, default: null },
67
+ /** Square-cell scale (GitHub-scale: a 53-week grid stays compact). */
68
+ cellSize: { type: Number, default: 14 },
69
+ cellGap: { type: Number, default: 3 },
70
+ },
71
+ setup(props, { slots }) {
72
+ const { t } = useI18n();
73
+
74
+ // The rendered calendar follows the selected range; `today` is
75
+ // taken per recompute (R2: frozen at setup, a long-mounted
76
+ // component crossing UTC midnight rendered its today cell as an
77
+ // "after" gap until the next remount), and the `max` prop pins the
78
+ // intensity scale — it must reach the buckets, not just the
79
+ // data-max attribute.
80
+ const cells = computed(() =>
81
+ calendarGrid(props.days, todayIso(), props.first, props.max),
82
+ );
83
+ const markers = computed(() => monthMarkers(cells.value));
84
+ const weeks = computed(() => Math.ceil(cells.value.length / 7));
85
+ const max = computed(() =>
86
+ props.max ?? props.days.reduce((m, d) => Math.max(m, d.value), 0),
87
+ );
88
+
89
+ const columns = computed(
90
+ () => `repeat(${weeks.value}, ${props.cellSize}px)`,
91
+ );
92
+
93
+ // Locale-reactive (the HkDateTimePicker convention): a computed
94
+ // over the context's locale so a locale switch re-renders the
95
+ // calendar's Intl-formatted labels. All formatters format the UTC
96
+ // midnight instant WITH an explicit UTC zone (R2: on a negative-
97
+ // offset host the weekday rail shifted a full row and tooltips
98
+ // landed a day off — the series' day boundary is UTC).
99
+ const locale = computed(() => useI18n().locale);
100
+ const weekdayLabel = (idx: number) =>
101
+ new Intl.DateTimeFormat(locale.value, {
102
+ weekday: "short",
103
+ timeZone: "UTC",
104
+ }).format(
105
+ // Any Monday-first week works — take 2024-01-01 (a Monday).
106
+ new Date(Date.UTC(2024, 0, 1 + idx)),
107
+ );
108
+ const monthLabel = (m: string) => {
109
+ if (!m) return "";
110
+ return new Intl.DateTimeFormat(locale.value, {
111
+ month: "short",
112
+ timeZone: "UTC",
113
+ }).format(new Date(Date.UTC(2024, Number(m) - 1, 1)));
114
+ };
115
+ const dateLabel = (iso: string) =>
116
+ new Intl.DateTimeFormat(locale.value, {
117
+ dateStyle: "medium",
118
+ timeZone: "UTC",
119
+ }).format(new Date(`${iso}T00:00:00Z`));
120
+
121
+ const cellTitle = (d: Extract<CalendarCell, { kind: "day" }>) =>
122
+ fill(
123
+ props.unit
124
+ ? t("hikari::calendarHeatmap.cellTitle", "{date} · {n} {unit}")
125
+ : t("hikari::calendarHeatmap.cellTitleNoUnit", "{date} · {n}"),
126
+ { date: dateLabel(d.date), n: d.value, unit: props.unit },
127
+ );
128
+
129
+ const cellStyle = (cell: CalendarCell) =>
130
+ cell.kind === "gap"
131
+ ? {
132
+ borderRadius: "3px",
133
+ background: GAP_HATCH,
134
+ pointerEvents: "none" as const,
135
+ }
136
+ : {
137
+ borderRadius: "3px",
138
+ background:
139
+ cell.level === 0
140
+ ? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
141
+ : "var(--hi-color-primary)",
142
+ opacity: cell.level === 0 ? undefined : HEATMAP_LEVEL_OPACITY[cell.level],
143
+ };
144
+
145
+ return () => (
146
+ <div class="hk-calendar-heatmap">
147
+ {/* The legend row: the scale in the caller's one unit on the
148
+ * LEFT, the toolbar slot on the RIGHT — the strip belongs to
149
+ * the centered grid's tail (2026-10-15 chest user direction,
150
+ * carried upstream with the component). */}
151
+ <div class="hk-calendar-heatmap-head">
152
+ <span
153
+ class="hk-calendar-heatmap-legend"
154
+ data-testid="hk-calendar-heatmap-legend"
155
+ >
156
+ {t("hikari::calendarHeatmap.less", "Less")}
157
+ {HEATMAP_LEVEL_OPACITY.map((o, i) => (
158
+ <span
159
+ key={i}
160
+ data-legend-level={i}
161
+ style={{
162
+ width: "10px",
163
+ height: "10px",
164
+ borderRadius: "2px",
165
+ background:
166
+ i === 0
167
+ ? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
168
+ : "var(--hi-color-primary)",
169
+ opacity: i === 0 ? undefined : o,
170
+ display: "inline-block",
171
+ }}
172
+ />
173
+ ))}
174
+ {t("hikari::calendarHeatmap.more", "More")}
175
+ </span>
176
+ {slots.toolbar?.({})}
177
+ </div>
178
+
179
+ {/* The scroll wrapper centers the whole calendar (rail included)
180
+ * on wide cards while letting narrow screens scroll from the
181
+ * left edge (see the SCSS). */}
182
+ <div class="hk-calendar-heatmap-scroll">
183
+ <div class="hk-calendar-heatmap-body">
184
+ {/* Weekday rail: the first (Monday) and last (Sunday) rows
185
+ * carry the labels, the wowsp convention. */}
186
+ <div
187
+ aria-hidden="true"
188
+ class="hk-calendar-heatmap-rail"
189
+ style={{ rowGap: `${props.cellGap}px` }}
190
+ >
191
+ {[0, 1, 2, 3, 4, 5, 6].map((row) => (
192
+ <span
193
+ key={row}
194
+ style={{ height: `${props.cellSize}px`, lineHeight: `${props.cellSize}px` }}
195
+ >
196
+ {row === 0 || row === 6 ? weekdayLabel(row) : ""}
197
+ </span>
198
+ ))}
199
+ </div>
200
+
201
+ <div class="hk-calendar-heatmap-cells">
202
+ {/* Month markers, one slot per week column. A leading
203
+ * partial month renders its empty slot (see
204
+ * monthMarkers). */}
205
+ <div
206
+ class="hk-calendar-heatmap-markers"
207
+ style={{
208
+ gridTemplateColumns: columns.value,
209
+ columnGap: `${props.cellGap}px`,
210
+ }}
211
+ >
212
+ {markers.value.map((m, i) => (
213
+ <span key={i}>{monthLabel(m)}</span>
214
+ ))}
215
+ </div>
216
+
217
+ {/* The grid itself: cells flow column-first into 7 rows. */}
218
+ <div
219
+ data-testid="hk-calendar-heatmap"
220
+ data-max={max.value}
221
+ role="img"
222
+ aria-label={fill(
223
+ t(
224
+ "hikari::calendarHeatmap.summary",
225
+ "Daily usage across {weeks} weeks",
226
+ ),
227
+ { weeks: weeks.value },
228
+ )}
229
+ class="hk-calendar-heatmap-grid"
230
+ style={{
231
+ gridTemplateColumns: columns.value,
232
+ gridTemplateRows: `repeat(7, ${props.cellSize}px)`,
233
+ gridAutoFlow: "column",
234
+ gap: `${props.cellGap}px`,
235
+ }}
236
+ >
237
+ {cells.value.map((cell, i) => (
238
+ <span
239
+ key={i}
240
+ {...(cell.kind === "gap"
241
+ ? {
242
+ "data-gap": cell.phase,
243
+ "data-date": cell.date,
244
+ "aria-hidden": "true",
245
+ }
246
+ : {
247
+ "data-level": cell.level,
248
+ "data-date": cell.date,
249
+ title: cellTitle(cell),
250
+ })}
251
+ style={cellStyle(cell)}
252
+ />
253
+ ))}
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ );
260
+ },
261
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ bucketLevel,
5
+ calendarGrid,
6
+ monthMarkers,
7
+ shiftIso,
8
+ todayIso,
9
+ weekdayIndex,
10
+ type HkCalendarHeatmapDay,
11
+ } from "./calendarHeatmap";
12
+
13
+ /** A Monday, for grid-shape assertions. */
14
+ const MON = "2025-12-01";
15
+
16
+ describe("weekdayIndex", () => {
17
+ it("rotates getUTCDay to Monday-first", () => {
18
+ expect(weekdayIndex("2025-12-01")).toBe(0); // Monday
19
+ expect(weekdayIndex("2025-12-07")).toBe(6); // Sunday
20
+ expect(weekdayIndex("2025-12-02")).toBe(1); // Tuesday
21
+ });
22
+ });
23
+
24
+ describe("shiftIso", () => {
25
+ it("shifts in UTC without local-timezone leaks", () => {
26
+ expect(shiftIso("2025-12-01", 7)).toBe("2025-12-08");
27
+ expect(shiftIso("2025-12-01", -1)).toBe("2025-11-30");
28
+ expect(shiftIso("2026-01-01", -1)).toBe("2025-12-31"); // year wrap
29
+ });
30
+ });
31
+
32
+ describe("todayIso", () => {
33
+ it("returns a UTC ISO date", () => {
34
+ expect(todayIso()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
35
+ });
36
+ });
37
+
38
+ describe("bucketLevel", () => {
39
+ it("keeps zero and negative days at level 0", () => {
40
+ expect(bucketLevel(0, 10)).toBe(0);
41
+ expect(bucketLevel(-3, 10)).toBe(0);
42
+ expect(bucketLevel(5, 0)).toBe(0); // degenerate window
43
+ });
44
+
45
+ it("scales against the window max with a ceil floor of 1", () => {
46
+ expect(bucketLevel(1, 100)).toBe(1); // smallest consumption shows
47
+ expect(bucketLevel(50, 100)).toBe(2);
48
+ expect(bucketLevel(75, 100)).toBe(3);
49
+ expect(bucketLevel(100, 100)).toBe(4);
50
+ expect(bucketLevel(1000, 100)).toBe(4); // capped at peak
51
+ });
52
+ });
53
+
54
+ describe("calendarGrid maxOverride — the max prop's wire to the buckets", () => {
55
+ it("pins the intensity scale against the override, not the window's own peak", () => {
56
+ const days = [{ date: MON, value: 10 }, { date: "2025-12-02", value: 1 }];
57
+ // Without an override the levels ride the window's own peak (10).
58
+ expect(calendarGrid(days, "2025-12-03", null).map((c) =>
59
+ c.kind === "day" ? c.level : -1,
60
+ )).toEqual([4, 1, 0, ...Array(4).fill(-1)]);
61
+ // With maxOverride=100 the same days flatten — the caller's pinned
62
+ // scale reaches the buckets (R2 S1: the prop was a dead wire).
63
+ expect(calendarGrid(days, "2025-12-03", null, 100).map((c) =>
64
+ c.kind === "day" ? c.level : -1,
65
+ )).toEqual([1, 1, 0, ...Array(4).fill(-1)]);
66
+ });
67
+
68
+ it("derives from the days when the override is null or omitted", () => {
69
+ const days = [{ date: MON, value: 5 }];
70
+ expect(calendarGrid(days, "2025-12-03", null, null)[0]).toMatchObject({
71
+ level: 4,
72
+ });
73
+ });
74
+ });
75
+
76
+ describe("calendarGrid", () => {
77
+ it("pads a one-row series into a full week with real dates", () => {
78
+ const cells = calendarGrid(
79
+ [{ date: MON, value: 5 }],
80
+ "2025-12-03",
81
+ null,
82
+ );
83
+ expect(cells).toHaveLength(7);
84
+ expect(cells[0]).toMatchObject({ kind: "day", date: MON });
85
+ // Today and after: the tail of the always-full-year window.
86
+ expect(cells[2]).toMatchObject({ kind: "day", date: "2025-12-03" });
87
+ expect(cells[3]).toMatchObject({ kind: "gap", date: "2025-12-04", phase: "after" });
88
+ expect(cells[6]).toMatchObject({ kind: "gap", phase: "after" });
89
+ });
90
+
91
+ it("hatches the before-first region and never claims a fake zero", () => {
92
+ const cells = calendarGrid(
93
+ [{ date: "2025-12-05", value: 5 }],
94
+ "2025-12-07",
95
+ "2025-12-05",
96
+ );
97
+ // 12-01..12-04: gaps (before); 12-05..12-07: days.
98
+ expect(cells[0]).toMatchObject({ kind: "gap", phase: "before" });
99
+ expect(cells[3]).toMatchObject({ kind: "gap", phase: "before" });
100
+ expect(cells[4]).toMatchObject({ kind: "day", date: "2025-12-05" });
101
+ });
102
+
103
+ it("counts head padding before the series' first row as plain empty days, never gaps", () => {
104
+ // The server didn't ship 12-01..12-02 (window starts 12-03) — but
105
+ // no `first` anchor exists, so they are empty days, not "not yet".
106
+ const cells = calendarGrid(
107
+ [{ date: "2025-12-03", value: 5 }],
108
+ "2025-12-03",
109
+ null,
110
+ );
111
+ expect(cells[0]).toMatchObject({ kind: "day", date: "2025-12-01", level: 0 });
112
+ expect(cells[1]).toMatchObject({ kind: "day", date: "2025-12-02", level: 0 });
113
+ });
114
+
115
+ it("clamps a malformed future `first` to hatch nothing", () => {
116
+ const cells = calendarGrid(
117
+ [{ date: MON, value: 5 }],
118
+ "2025-12-03",
119
+ "2026-01-01",
120
+ );
121
+ expect(cells.some((c) => c.kind === "gap" && c.phase === "before")).toBe(false);
122
+ });
123
+
124
+ it("returns an empty grid for an empty series", () => {
125
+ expect(calendarGrid([], "2025-12-03", null)).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe("monthMarkers — the leading-month suppression", () => {
130
+ it("shows the first marker only when the grid's first column opens its month", () => {
131
+ // Grid starts exactly on Dec 1 (a Monday AND the 1st); one row of
132
+ // days pads into exactly one week.
133
+ const cells = calendarGrid(
134
+ [{ date: MON, value: 0 }],
135
+ "2025-12-09",
136
+ null,
137
+ );
138
+ expect(cells).toHaveLength(7);
139
+ expect(cells[0]).toMatchObject({ date: "2025-12-01" });
140
+ expect(monthMarkers(cells)).toEqual(["12"]);
141
+ });
142
+
143
+ it("suppresses a leading month that starts mid-month; the next month is labeled", () => {
144
+ // Grid starts Oct 27 (a Monday, NOT the 1st) and runs into November;
145
+ // two full weeks of cells.
146
+ const days: HkCalendarHeatmapDay[] = [
147
+ { date: "2025-10-27", value: 0 },
148
+ { date: "2025-10-28", value: 0 },
149
+ { date: "2025-10-29", value: 0 },
150
+ { date: "2025-10-30", value: 0 },
151
+ { date: "2025-10-31", value: 0 },
152
+ { date: "2025-11-01", value: 0 },
153
+ { date: "2025-11-02", value: 0 },
154
+ { date: "2025-11-03", value: 0 },
155
+ { date: "2025-11-04", value: 0 },
156
+ { date: "2025-11-05", value: 0 },
157
+ ];
158
+ const cells = calendarGrid(days, "2025-11-05", null);
159
+ expect(cells).toHaveLength(14);
160
+ // The partial October tail is unlabeled; November's Monday opens.
161
+ expect(monthMarkers(cells)).toEqual(["", "11"]);
162
+ });
163
+
164
+ it("labels no month when the whole grid sits inside one partial month", () => {
165
+ const cells = calendarGrid(
166
+ [{ date: "2025-10-13", value: 0 }],
167
+ "2025-10-20",
168
+ null,
169
+ );
170
+ expect(monthMarkers(cells)).toEqual([""]);
171
+ });
172
+
173
+ it("keeps the later markers honest about consecutive same-month columns", () => {
174
+ // Two December Mondays: only the first carries the label.
175
+ const days: HkCalendarHeatmapDay[] = [
176
+ { date: MON, value: 0 },
177
+ { date: "2025-12-02", value: 0 },
178
+ { date: "2025-12-03", value: 0 },
179
+ { date: "2025-12-04", value: 0 },
180
+ { date: "2025-12-05", value: 0 },
181
+ { date: "2025-12-06", value: 0 },
182
+ { date: "2025-12-07", value: 0 },
183
+ { date: "2025-12-08", value: 0 },
184
+ { date: "2025-12-09", value: 0 },
185
+ { date: "2025-12-10", value: 0 },
186
+ ];
187
+ const cells = calendarGrid(days, "2025-12-10", null);
188
+ expect(monthMarkers(cells)).toEqual(["12", ""]);
189
+ });
190
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Calendar-heatmap math — pure helpers for HkCalendarHeatmap.
3
+ *
4
+ * Upstreamed from the shittim-chest quota page's degree-usage section
5
+ * (2026-10 user direction: a calendar heatmap is a big business
6
+ * component — the rich-text-viewer table — not a view-local helper).
7
+ * The server ships a DENSE daily series (oldest → newest, zero-filled,
8
+ * inclusive of today, plus the account's first activity date).
9
+ * Everything the component renders — the full-year week grid with its
10
+ * hatched no-data gaps, the month markers — derives here so each
11
+ * derivation is unit-testable without a component mount.
12
+ */
13
+
14
+ /** Opacity per bucket (0–4) over the primary token; bucket 0 is the
15
+ * muted empty cell. Five steps so "no consumption" stays visually
16
+ * distinct from "the smallest consumption on record". Exported for the
17
+ * component and its tests — the scale is part of the contract. */
18
+ export const HEATMAP_LEVEL_OPACITY = ["0.12", "0.22", "0.42", "0.66", "0.95"] as const;
19
+
20
+ /** One input day of the dense series. `value` may be negative or zero
21
+ * (a "no consumption" day) — it still buckets to level 0. */
22
+ export type HkCalendarHeatmapDay = { date: string; value: number };
23
+
24
+ /** Monday-first weekday index of an ISO date (Mon = 0 … Sun = 6). */
25
+ export function weekdayIndex(iso: string): number {
26
+ const d = new Date(`${iso}T00:00:00Z`);
27
+ // getUTCDay: Sun = 0 … Sat = 6 → rotate to Monday-first.
28
+ return (d.getUTCDay() + 6) % 7;
29
+ }
30
+
31
+ /** ISO date ± n days (UTC arithmetic only — no local timezone leaks). */
32
+ export function shiftIso(iso: string, days: number): string {
33
+ const d = new Date(`${iso}T00:00:00Z`);
34
+ d.setUTCDate(d.getUTCDate() + days);
35
+ return d.toISOString().slice(0, 10);
36
+ }
37
+
38
+ /** Today as an ISO date (UTC — the series' day boundary). */
39
+ export function todayIso(): string {
40
+ return new Date().toISOString().slice(0, 10);
41
+ }
42
+
43
+ /**
44
+ * One heatmap cell: a real day of the series, or a gap the account has
45
+ * no data for — before its first activity, or after today (the rest of
46
+ * the always-full-year window). Gaps render hatched and inert; they
47
+ * never claim a fake "0 consumption" for a day that never was.
48
+ */
49
+ export type CalendarCell =
50
+ | { kind: "day"; date: string; value: number; level: 0 | 1 | 2 | 3 | 4 }
51
+ | { kind: "gap"; date: string; phase: "before" | "after" };
52
+
53
+ /**
54
+ * Pad the dense series into the week-aligned calendar grid: head cells
55
+ * back to the window's first Monday, tail cells to Sunday — so a
56
+ * trailing-year window ALWAYS lands on a full-year-wide grid, however
57
+ * young the account is. Every cell carries its real calendar date;
58
+ * cells outside [first … today] become gaps (hatched, inert).
59
+ *
60
+ * `maxOverride` pins the intensity scale (R2: the component's `max`
61
+ * prop must reach the buckets, not just the data-max attribute — a
62
+ * caller pinning the scale across windows would otherwise get levels
63
+ * computed against each window's own peak). null = derive from `days`.
64
+ */
65
+ export function calendarGrid(
66
+ days: HkCalendarHeatmapDay[],
67
+ today: string,
68
+ first: string | null,
69
+ maxOverride?: number | null,
70
+ ): CalendarCell[] {
71
+ if (days.length === 0) return [];
72
+ const lead = weekdayIndex(days[0].date);
73
+ const gridStart = shiftIso(days[0].date, -lead);
74
+ const total = lead + days.length;
75
+ const span = total + ((7 - (total % 7)) % 7);
76
+ const max = maxOverride ?? days.reduce((m, d) => Math.max(m, d.value), 0);
77
+ const valueByDate = new Map(days.map((d) => [d.date, d.value]));
78
+ // A malformed `first` from the future hatches nothing (the clamp
79
+ // keeps the before-check honest).
80
+ const hatchFrom = first && first <= today ? first : null;
81
+ const cells: CalendarCell[] = [];
82
+ for (let i = 0; i < span; i++) {
83
+ const date = shiftIso(gridStart, i);
84
+ if (date > today) {
85
+ cells.push({ kind: "gap", date, phase: "after" });
86
+ continue;
87
+ }
88
+ if (hatchFrom && date < hatchFrom) {
89
+ cells.push({ kind: "gap", date, phase: "before" });
90
+ continue;
91
+ }
92
+ // Dates in the head padding before the series' first row predate
93
+ // the requested window (the server didn't ship them) — they count
94
+ // as plain empty days, never gaps.
95
+ const value = valueByDate.get(date) ?? 0;
96
+ cells.push({ kind: "day", date, value, level: bucketLevel(value, max) });
97
+ }
98
+ return cells;
99
+ }
100
+
101
+ /** The heatmap's intensity bucket: 0 (none) … 4 (peak), scaled against
102
+ * the window's max day. Zero/negative days are bucket 0; every
103
+ * consuming day lands in 1..4 (ceil, so even the smallest consumption
104
+ * shows one step above empty). */
105
+ export function bucketLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {
106
+ if (value <= 0 || max <= 0) return 0;
107
+ return Math.min(4, Math.max(1, Math.ceil((value / max) * 4))) as 0 | 1 | 2 | 3 | 4;
108
+ }
109
+
110
+ /**
111
+ * Month markers for the week columns: a short label on each column
112
+ * whose Monday opens a month, "" otherwise.
113
+ *
114
+ * The leading month is the 2026-10 refinement this extraction carries:
115
+ * a trailing-year window (or any window) usually starts MID-month, so
116
+ * the first column shows only the tail of its month. A label there
117
+ * would claim a month whose start is not visible on the chart — the
118
+ * GitHub convention is to suppress it. The first marker is therefore
119
+ * shown ONLY when the grid's first column actually opens its month
120
+ * (its Monday is the 1st); every later marker keeps the previous
121
+ * rule (this column's Monday opens a month the previous column's
122
+ * Monday did not). Every grid cell carries a real calendar date, so
123
+ * the marker can never lie about padding.
124
+ */
125
+ export function monthMarkers(cells: CalendarCell[]): string[] {
126
+ const weeks = Math.ceil(cells.length / 7);
127
+ const markers: string[] = [];
128
+ let prevMonth = -1;
129
+ for (let w = 0; w < weeks; w++) {
130
+ const monday = cells[w * 7];
131
+ if (!monday) break;
132
+ const month = Number(monday.date.slice(5, 7));
133
+ const opensMonth =
134
+ w === 0 ? monday.date.slice(8, 10) === "01" : month !== prevMonth;
135
+ markers.push(opensMonth ? String(month) : "");
136
+ prevMonth = month;
137
+ }
138
+ return markers;
139
+ }
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "أقل من",
218
218
  "hikari::filterPanel.op.lte": "كحد أقصى",
219
219
  "hikari::filterPanel.op.gte": "على الأقل",
220
- "hikari::filterPanel.op.gt": "أكبر من"
220
+ "hikari::filterPanel.op.gt": "أكبر من",
221
+ "hikari::calendarHeatmap.less": "أقل",
222
+ "hikari::calendarHeatmap.more": "أكثر",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "الاستخدام اليومي على مدى {weeks} أسبوعًا"
221
226
  },
222
227
  "hikari::affixPicker.unset": "غير محدد",
223
228
  "hikari::affixPicker.title": "الخيارات",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "kleiner als",
218
218
  "hikari::filterPanel.op.lte": "höchstens",
219
219
  "hikari::filterPanel.op.gte": "mindestens",
220
- "hikari::filterPanel.op.gt": "größer als"
220
+ "hikari::filterPanel.op.gt": "größer als",
221
+ "hikari::calendarHeatmap.less": "Weniger",
222
+ "hikari::calendarHeatmap.more": "Mehr",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Tägliche Nutzung über {weeks} Wochen"
221
226
  },
222
227
  "hikari::affixPicker.unset": "Nicht festgelegt",
223
228
  "hikari::affixPicker.title": "Optionen",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "less than",
218
218
  "hikari::filterPanel.op.lte": "at most",
219
219
  "hikari::filterPanel.op.gte": "at least",
220
- "hikari::filterPanel.op.gt": "greater than"
220
+ "hikari::filterPanel.op.gt": "greater than",
221
+ "hikari::calendarHeatmap.less": "Less",
222
+ "hikari::calendarHeatmap.more": "More",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Daily usage across {weeks} weeks"
221
226
  },
222
227
  "hikari::affixPicker.title": "Options",
223
228
  "hikari::affixPicker.search": "Search",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "menor que",
218
218
  "hikari::filterPanel.op.lte": "como máximo",
219
219
  "hikari::filterPanel.op.gte": "al menos",
220
- "hikari::filterPanel.op.gt": "mayor que"
220
+ "hikari::filterPanel.op.gt": "mayor que",
221
+ "hikari::calendarHeatmap.less": "Menos",
222
+ "hikari::calendarHeatmap.more": "Más",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Uso diario durante {weeks} semanas"
221
226
  },
222
227
  "hikari::affixPicker.unset": "Sin establecer",
223
228
  "hikari::affixPicker.title": "Opciones",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "inférieur à",
218
218
  "hikari::filterPanel.op.lte": "au plus",
219
219
  "hikari::filterPanel.op.gte": "au moins",
220
- "hikari::filterPanel.op.gt": "supérieur à"
220
+ "hikari::filterPanel.op.gt": "supérieur à",
221
+ "hikari::calendarHeatmap.less": "Moins",
222
+ "hikari::calendarHeatmap.more": "Plus",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Utilisation quotidienne sur {weeks} semaines"
221
226
  },
222
227
  "hikari::affixPicker.unset": "Non défini",
223
228
  "hikari::affixPicker.title": "Options",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "未満",
218
218
  "hikari::filterPanel.op.lte": "以下",
219
219
  "hikari::filterPanel.op.gte": "以上",
220
- "hikari::filterPanel.op.gt": "より大きい"
220
+ "hikari::filterPanel.op.gt": "より大きい",
221
+ "hikari::calendarHeatmap.less": "少",
222
+ "hikari::calendarHeatmap.more": "多",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "直近 {weeks} 週間の毎日の使用量"
221
226
  },
222
227
  "hikari::affixPicker.unset": "未設定",
223
228
  "hikari::affixPicker.title": "オプション",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "미만",
218
218
  "hikari::filterPanel.op.lte": "이하",
219
219
  "hikari::filterPanel.op.gte": "이상",
220
- "hikari::filterPanel.op.gt": "초과"
220
+ "hikari::filterPanel.op.gt": "초과",
221
+ "hikari::calendarHeatmap.less": "적음",
222
+ "hikari::calendarHeatmap.more": "많음",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "최근 {weeks}주간의 일일 사용량"
221
226
  },
222
227
  "hikari::affixPicker.unset": "미설정",
223
228
  "hikari::affixPicker.title": "옵션",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "menor que",
218
218
  "hikari::filterPanel.op.lte": "no máximo",
219
219
  "hikari::filterPanel.op.gte": "pelo menos",
220
- "hikari::filterPanel.op.gt": "maior que"
220
+ "hikari::filterPanel.op.gt": "maior que",
221
+ "hikari::calendarHeatmap.less": "Menos",
222
+ "hikari::calendarHeatmap.more": "Mais",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Uso diário ao longo de {weeks} semanas"
221
226
  },
222
227
  "hikari::affixPicker.unset": "Não definido",
223
228
  "hikari::affixPicker.title": "Opções",
@@ -217,7 +217,12 @@
217
217
  "hikari::filterPanel.op.lt": "меньше",
218
218
  "hikari::filterPanel.op.lte": "не более",
219
219
  "hikari::filterPanel.op.gte": "не менее",
220
- "hikari::filterPanel.op.gt": "больше"
220
+ "hikari::filterPanel.op.gt": "больше",
221
+ "hikari::calendarHeatmap.less": "Меньше",
222
+ "hikari::calendarHeatmap.more": "Больше",
223
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
224
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
225
+ "hikari::calendarHeatmap.summary": "Ежедневное использование за {weeks} недель"
221
226
  },
222
227
  "hikari::affixPicker.unset": "Не задано",
223
228
  "hikari::affixPicker.title": "Параметры",
@@ -221,7 +221,12 @@
221
221
  "hikari::filterPanel.op.lt": "小于",
222
222
  "hikari::filterPanel.op.lte": "不超过",
223
223
  "hikari::filterPanel.op.gte": "不低于",
224
- "hikari::filterPanel.op.gt": "大于"
224
+ "hikari::filterPanel.op.gt": "大于",
225
+ "hikari::calendarHeatmap.less": "少",
226
+ "hikari::calendarHeatmap.more": "多",
227
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
228
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
229
+ "hikari::calendarHeatmap.summary": "近 {weeks} 周的每日用量"
225
230
  },
226
231
  "hikari::affixPicker.title": "选项",
227
232
  "hikari::affixPicker.search": "搜索",
@@ -221,7 +221,12 @@
221
221
  "hikari::filterPanel.op.lt": "小於",
222
222
  "hikari::filterPanel.op.lte": "不超過",
223
223
  "hikari::filterPanel.op.gte": "不低於",
224
- "hikari::filterPanel.op.gt": "大於"
224
+ "hikari::filterPanel.op.gt": "大於",
225
+ "hikari::calendarHeatmap.less": "少",
226
+ "hikari::calendarHeatmap.more": "多",
227
+ "hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
228
+ "hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
229
+ "hikari::calendarHeatmap.summary": "近 {weeks} 週的每日用量"
225
230
  },
226
231
  "hikari::affixPicker.title": "選項",
227
232
  "hikari::affixPicker.search": "搜尋",
package/src/index.ts CHANGED
@@ -644,6 +644,20 @@ export { default as HkDivider } from "./components/HkDivider";
644
644
  export { default as HkDiffViewer } from "./components/HkDiffViewer";
645
645
  export { parseUnifiedDiff, computeGaps } from "./components/diffParse";
646
646
  export type { DiffLine, DiffHunk, ParsedDiff, UnchangedGap } from "./components/diffParse";
647
+ export { default as HkCalendarHeatmap } from "./components/HkCalendarHeatmap";
648
+ // The H- alias rides the HCard/HkCard family convention: consumers
649
+ // import either name, one component underneath.
650
+ export { default as HCalendarHeatmap } from "./components/HkCalendarHeatmap";
651
+ export {
652
+ bucketLevel,
653
+ calendarGrid,
654
+ HEATMAP_LEVEL_OPACITY,
655
+ monthMarkers,
656
+ todayIso,
657
+ weekdayIndex,
658
+ shiftIso,
659
+ } from "./components/calendarHeatmap";
660
+ export type { CalendarCell, HkCalendarHeatmapDay } from "./components/calendarHeatmap";
647
661
  export { default as HkDockBar } from "./components/HkDockBar";
648
662
  export { default as HkDrawer } from "./components/HkDrawer";
649
663
  export { default as HkEmptyState } from "./components/HkEmptyState";
@@ -574,6 +574,16 @@
574
574
  }
575
575
 
576
576
  .s-admin-header-avatar {
577
+ /* Block, never the UA's inline-block: hosts may nest the trigger in a
578
+ * plain block wrapper (the chat face's menu-anchor <div> around
579
+ * HeaderLeft's HkAvatarButton), and an inline-block button there joins
580
+ * a line box — its baseline (the bottom margin edge, because of
581
+ * `overflow: hidden`) sits on the strut's baseline, so the strut's
582
+ * descender space lands BELOW the avatar and the button rides ~4px
583
+ * high inside a perfectly centred wrapper (2026-10-30 user report:
584
+ * 前台头像略微偏上,后台正中). A direct flex/grid parent blockifies
585
+ * this away, so the console face is unaffected either way. */
586
+ display: block;
577
587
  position: relative;
578
588
  width: var(--space-28);
579
589
  height: var(--space-28);