@celestia-island/hikari 0.55.233 → 0.55.235

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.233",
3
+ "version": "0.55.235",
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",
@@ -39,8 +39,10 @@ export const HkAdminShell = defineComponent({
39
39
  sidebarWidth: { type: String, default: "224px" },
40
40
  /** Desktop icon-rail capability, forwarded to HkFaceShell (see its
41
41
  * prop doc): opt in here and the admin sidebar gains the circular
42
- * hover-reveal toggle, the one-icon rail and the overlay peek
43
- * drawer. Declared on the wrapper on purpose — the same
42
+ * hover-reveal toggle STRADDLING the sidebar's content-facing border,
43
+ * the one-icon rail and the overlay peek drawer (with the peek
44
+ * suppressed while the pointer rests on the toggle, and a pinned
45
+ * panel on expand). Declared on the wrapper on purpose — the same
44
46
  * honest-forwarding rule as contentMode (a stray attr must not
45
47
  * reach the inner shell's declared prop through fallthrough). */
46
48
  sidebarCollapsible: { type: Boolean, default: false },
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as barrel from "../index";
3
+
4
+ /**
5
+ * The public barrel guard for the calendar heatmap's exports (R3 on the
6
+ * S3-fix wave: deleting any of these lines from index.ts kept the ENTIRE
7
+ * suite green, because every other test reaches the module through the
8
+ * direct "./calendarHeatmap" path — only an external consumer would have
9
+ * noticed). Same shape as HkFaceShell.barrel.test.ts.
10
+ *
11
+ * This file deliberately does NOT mock "@celestia-island/hikari": the
12
+ * package's self-reference resolves to the very same src/index.ts module
13
+ * as "../index", so a vi.mock here would replace the barrel under test
14
+ * with the mock's own namespace.
15
+ */
16
+ describe("the public barrel", () => {
17
+ it("exports the heatmap component under both family names", () => {
18
+ const hk = barrel.HkCalendarHeatmap as unknown as { name?: string };
19
+ expect(hk, "HkCalendarHeatmap must be re-exported").toBeTruthy();
20
+ expect(hk.name).toBe("HkCalendarHeatmap");
21
+ // The H- alias rides the HCard/HkCard family convention.
22
+ expect(barrel.HCalendarHeatmap).toBe(barrel.HkCalendarHeatmap);
23
+ });
24
+
25
+ it("exports the calendar grid math and the fill ladder", () => {
26
+ for (const name of [
27
+ "bucketLevel",
28
+ "calendarGrid",
29
+ "heatmapCellFill",
30
+ "heatmapCellOpacity",
31
+ "monthMarkers",
32
+ "todayIso",
33
+ "weekdayIndex",
34
+ "shiftIso",
35
+ ] as const) {
36
+ expect(typeof barrel[name], `${name} must be re-exported`).toBe("function");
37
+ }
38
+ expect(Array.isArray(barrel.HEATMAP_LEVEL_OPACITY)).toBe(true);
39
+ expect(barrel.HEATMAP_LEVEL_OPACITY).toHaveLength(4);
40
+ expect(typeof barrel.HEATMAP_EMPTY_FILL).toBe("string");
41
+ // The fill ladder the legend and the cells share: level 0 is the
42
+ // empty wash, never the primary token.
43
+ expect(barrel.heatmapCellFill(0)).toBe(barrel.HEATMAP_EMPTY_FILL);
44
+ expect(barrel.heatmapCellFill(0)).not.toContain("primary");
45
+ expect(barrel.heatmapCellOpacity(0)).toBeUndefined();
46
+ expect(barrel.heatmapCellOpacity(4)).toBe(barrel.HEATMAP_LEVEL_OPACITY[3]);
47
+ });
48
+ });
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, reactive } from "vue";
3
3
 
4
4
  import HkCalendarHeatmap from "./HkCalendarHeatmap";
5
- import { HEATMAP_LEVEL_OPACITY, todayIso } from "./calendarHeatmap";
5
+ import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
6
6
 
7
7
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
8
8
 
@@ -16,6 +16,9 @@ function mount(node: ReturnType<typeof h>) {
16
16
  }
17
17
 
18
18
  afterEach(() => {
19
+ // A failed assertion inside a fake-clock test must not leak the frozen
20
+ // clock into the rest of the file.
21
+ vi.useRealTimers();
19
22
  for (const { app, container } of mounts.splice(0)) {
20
23
  app.unmount();
21
24
  container.remove();
@@ -35,7 +38,7 @@ const daysFrom = (start: string, count: number, value = 0) =>
35
38
  });
36
39
 
37
40
  describe("HkCalendarHeatmap", () => {
38
- it("renders the legend (less → more, five swatches) and the grid anatomy", () => {
41
+ it("renders the legend (less → more, the empty swatch + one per level) and the grid anatomy", () => {
39
42
  const c = mount(
40
43
  h(HkCalendarHeatmap, {
41
44
  days: daysFrom(CLEAN_HEAD_MON, 7),
@@ -51,18 +54,35 @@ describe("HkCalendarHeatmap", () => {
51
54
  const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
52
55
  expect(legend?.textContent).toContain("Less");
53
56
  expect(legend?.textContent).toContain("More");
57
+ // The empty swatch plus one per CONSUMING level.
54
58
  expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
55
- HEATMAP_LEVEL_OPACITY.length,
59
+ 1 + HEATMAP_LEVEL_OPACITY.length,
56
60
  );
61
+ const swatches = c.querySelectorAll("[data-legend-level]") as NodeListOf<HTMLElement>;
62
+ // The empty swatch carries NO opacity step (its wash lives in the
63
+ // fill, HEATMAP_EMPTY_FILL — happy-dom drops color-mix() values, so
64
+ // the ladder is asserted through the opacity channel it does keep).
65
+ expect(swatches[0].style.opacity).toBe("");
66
+ // The swatch levels run 0..4 in order, empty first.
67
+ expect(
68
+ Array.from(swatches, (s) => s.getAttribute("data-legend-level")),
69
+ ).toEqual(["0", "1", "2", "3", "4"]);
70
+ // Consuming levels index the table by level - 1: 1 -> [0] … 4 -> [3].
71
+ expect(swatches[1].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[0]);
72
+ expect(swatches[2].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
73
+ expect(swatches[3].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[2]);
74
+ expect(swatches[4].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
57
75
  // One week of days, all level 0 (no consumption): plain muted cells.
58
76
  expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
59
77
  });
60
78
 
61
- it("suppresses the leading month marker when the window starts mid-month", async () => {
79
+ it("labels the next month on the column carrying its 1st, never the partial head month", async () => {
62
80
  const c = mount(
63
81
  h(HkCalendarHeatmap, {
64
- // Trailing-October tail + November: the partial October head is
65
- // unlabeled, November's Monday carries the only marker.
82
+ // Trailing-October tail + November. October's 1st is not on the
83
+ // axis, so no slot may carry October; November's 1st (Nov 1, a
84
+ // Saturday) sits in the HEAD column, which is where its marker
85
+ // goes.
66
86
  days: daysFrom(MID_MONTH_MON, 10),
67
87
  first: null,
68
88
  }),
@@ -72,8 +92,63 @@ describe("HkCalendarHeatmap", () => {
72
92
  ".hk-calendar-heatmap-markers > span",
73
93
  ) as NodeListOf<HTMLElement>;
74
94
  expect(markers.length).toBeGreaterThanOrEqual(2);
75
- expect(markers[0].textContent).toBe(""); // partial October — suppressed
76
- expect(markers[1].textContent).not.toBe(""); // November — labeled
95
+ const texts = Array.from(markers, (m) => m.textContent);
96
+ // The head month (its 1st is off-axis) is never labeled.
97
+ expect(texts.some((t) => t?.startsWith("Oct"))).toBe(false);
98
+ expect(texts[0]).not.toBe(""); // November's 1st is in this column
99
+ expect(texts[1]).toBe("");
100
+ });
101
+
102
+ it("labels a month on the column carrying its 1st, not one column late", async () => {
103
+ // Grid opens Mon Sep 29: Oct 1 (a Wednesday) is in the head column.
104
+ const c = mount(
105
+ h(HkCalendarHeatmap, {
106
+ days: Array.from({ length: 14 }, (_, i) => ({
107
+ date: shiftIso("2025-09-29", i),
108
+ value: 0,
109
+ })),
110
+ first: null,
111
+ }),
112
+ );
113
+ await nextTick();
114
+ const markers = c.querySelectorAll(
115
+ ".hk-calendar-heatmap-markers > span",
116
+ ) as NodeListOf<HTMLElement>;
117
+ expect(markers[0].textContent).not.toBe(""); // October, mid-column
118
+ expect(markers[1].textContent).toBe("");
119
+ });
120
+
121
+ it("reserves trailing room exactly when the last column carries a marker", async () => {
122
+ const padOf = async (days: { date: string; value: number }[]) => {
123
+ const c = mount(h(HkCalendarHeatmap, { days, first: null }));
124
+ await nextTick();
125
+ const markersEl = c.querySelector(
126
+ ".hk-calendar-heatmap-markers",
127
+ ) as HTMLElement;
128
+ const texts = Array.from(
129
+ markersEl.querySelectorAll(":scope > span"),
130
+ (m) => m.textContent,
131
+ );
132
+ return {
133
+ // Logical property: hikari ships RTL hosts (HkMenu reads
134
+ // documentElement.dir), so the reserved room rides inline-end
135
+ // rather than a physical side.
136
+ pad: markersEl.style.paddingInlineEnd,
137
+ lastSet: texts.length > 0 && texts[texts.length - 1] !== "",
138
+ };
139
+ };
140
+
141
+ // Positive branch: a single week opening on Dec 1 leaves the marker
142
+ // on the LAST column — its ink needs the reserved room.
143
+ const marked = await padOf(daysFrom(CLEAN_HEAD_MON, 7));
144
+ expect(marked.lastSet).toBe(true);
145
+ expect(marked.pad).not.toBe("");
146
+
147
+ // Negative branch: ending on Nov 7 (a Friday) pads the axis to
148
+ // Nov 3–9, so the November marker sits one column in, not last.
149
+ const unmarked = await padOf(daysFrom("2025-10-27", 12));
150
+ expect(unmarked.lastSet).toBe(false);
151
+ expect(unmarked.pad).toBe("");
77
152
  });
78
153
 
79
154
  it("shows the leading month marker when the grid's first column opens its month", async () => {
@@ -90,29 +165,97 @@ describe("HkCalendarHeatmap", () => {
90
165
  expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
91
166
  });
92
167
 
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();
168
+ it("fills the cells through the shared ladder: consuming levels primary, level 0 not", () => {
169
+ // happy-dom drops color-mix() but keeps var(), so the
170
+ // empty-vs-primary distinction IS observable here — a hardcoded
171
+ // primary for level 0 (or a hardcoded fill that ignores the
172
+ // helper) turns this red (R3: the call sites had no pin).
98
173
  const c = mount(
99
174
  h(HkCalendarHeatmap, {
100
- days: [{ date: today, value: 5 }],
101
- first: today,
175
+ days: [
176
+ { date: CLEAN_HEAD_MON, value: 10 },
177
+ { date: "2025-12-02", value: 0 },
178
+ ],
179
+ first: null,
102
180
  }),
103
181
  );
104
182
  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")),
183
+ const styleOf = (level: string) =>
184
+ (grid.querySelector(`[data-level="${level}"]`) as HTMLElement).getAttribute(
185
+ "style",
186
+ ) ?? "";
187
+ expect(styleOf("4")).toContain("var(--hi-color-primary)");
188
+ expect(styleOf("0")).not.toContain("--hi-color-primary");
189
+ // The legend rides the same decision: its empty swatch is not
190
+ // primary, every consuming swatch is.
191
+ const swatches = c.querySelectorAll(
192
+ "[data-legend-level]",
193
+ ) as NodeListOf<HTMLElement>;
194
+ expect(swatches[0].getAttribute("style") ?? "").not.toContain(
195
+ "--hi-color-primary",
196
+ );
197
+ for (const level of [1, 2, 3, 4]) {
198
+ expect(swatches[level].getAttribute("style") ?? "").toContain(
199
+ "var(--hi-color-primary)",
200
+ );
201
+ }
202
+ });
203
+
204
+ it("applies the opacity ladder by level - 1, leaving level 0 without an opacity step", () => {
205
+ // The empty cell's wash lives in its FILL, never in the opacity
206
+ // table (the old table's index 0 was dead weight) — level 0 must
207
+ // carry no opacity while every consuming level maps to [level - 1].
208
+ const c = mount(
209
+ h(HkCalendarHeatmap, {
210
+ days: [
211
+ { date: CLEAN_HEAD_MON, value: 10 },
212
+ { date: "2025-12-02", value: 5 },
213
+ { date: "2025-12-03", value: 0 },
214
+ ],
215
+ first: null,
216
+ }),
109
217
  );
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");
218
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
219
+ const cellAt = (level: string) =>
220
+ grid.querySelector(`[data-level="${level}"]`) as HTMLElement;
221
+ expect(cellAt("4").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
222
+ expect(cellAt("2").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
223
+ expect(cellAt("0").style.opacity).toBe("");
224
+ });
225
+
226
+ it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
227
+ // A one-day series AT today, anchored at first = today: the head
228
+ // padding hatches as "before", the tail as "after". The clock is
229
+ // PINNED to a Wednesday: with the real clock this test carried
230
+ // both-phase expectations into a Monday (no head padding) or a
231
+ // Sunday (no tail), so it went red two days out of seven
232
+ // (R3 2026-10-30, proven with a mocked clock).
233
+ vi.useFakeTimers();
234
+ try {
235
+ vi.setSystemTime(new Date("2025-12-03T10:00:00Z")); // a Wednesday
236
+ const c = mount(
237
+ h(HkCalendarHeatmap, {
238
+ days: [{ date: "2025-12-03", value: 5 }],
239
+ first: "2025-12-03",
240
+ }),
241
+ );
242
+ const grid = c.querySelector(
243
+ '[data-testid="hk-calendar-heatmap"]',
244
+ ) as HTMLElement;
245
+ const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
246
+ expect(gaps.length).toBeGreaterThan(0);
247
+ const phases = new Set(
248
+ Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
249
+ );
250
+ expect(phases.has("before")).toBe(true);
251
+ expect(phases.has("after")).toBe(true);
252
+ for (const gap of gaps) {
253
+ expect(gap.getAttribute("aria-hidden")).toBe("true");
254
+ expect(gap.getAttribute("title")).toBeNull();
255
+ expect(gap.style.pointerEvents).toBe("none");
256
+ }
257
+ } finally {
258
+ vi.useRealTimers();
116
259
  }
117
260
  });
118
261
 
@@ -2,9 +2,9 @@ import { computed, defineComponent, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import {
5
- bucketLevel,
6
5
  calendarGrid,
7
- HEATMAP_LEVEL_OPACITY,
6
+ heatmapCellFill,
7
+ heatmapCellOpacity,
8
8
  monthMarkers,
9
9
  todayIso,
10
10
  type CalendarCell,
@@ -12,6 +12,12 @@ import {
12
12
  } from "./calendarHeatmap";
13
13
  import "./HkCalendarHeatmap.scss";
14
14
 
15
+ /** The legend's five levels, empty first — the same ladder the cells
16
+ * ride, so the swatches come from the one fill/opacity decision
17
+ * (`heatmapCellFill` / `heatmapCellOpacity`) rather than a second,
18
+ * hand-written copy of the colors. */
19
+ const LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
20
+
15
21
  /** The no-data cell (before the account's first activity / after
16
22
  * today): muted diagonal hatch over the surface — the wowsp-style
17
23
  * "not yet" cell. Rendered inert: no tooltip, no pointer events. */
@@ -36,11 +42,10 @@ function fill(template: string, vars: Record<string, string | number>): string {
36
42
  * rail, month markers, and hatched no-data gap cells that never claim
37
43
  * a fake zero.
38
44
  *
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.
45
+ * The leading month is never mislabeled: a month is labeled on the
46
+ * column that carries its 1st, so a window starting mid-month has no
47
+ * label over its partial head (that month's 1st is not on the axis),
48
+ * and no month lands one column late.
44
49
  *
45
50
  * Dumb by design: the caller owns the fetch lifecycle and passes the
46
51
  * settled dense series in (`days`, oldest → newest, zero-filled,
@@ -90,33 +95,35 @@ export default defineComponent({
90
95
  () => `repeat(${weeks.value}, ${props.cellSize}px)`,
91
96
  );
92
97
 
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).
98
+ // Locale-reactive formatter cache (the HkDateTimePicker convention):
99
+ // ONE Intl.DateTimeFormat per shape per locale, held in a computed
100
+ // over the context's locale — not one per rendered cell, which a
101
+ // full-year grid would pay ~370 times over. All formatters format
102
+ // the UTC midnight instant WITH an explicit UTC zone (R2: on a
103
+ // negative-offset host the weekday rail shifted a full row and
104
+ // tooltips landed a day off — the series' day boundary is UTC).
99
105
  const locale = computed(() => useI18n().locale);
106
+ const formatters = computed(() => {
107
+ const loc = locale.value;
108
+ return {
109
+ weekday: new Intl.DateTimeFormat(loc, { weekday: "short", timeZone: "UTC" }),
110
+ month: new Intl.DateTimeFormat(loc, { month: "short", timeZone: "UTC" }),
111
+ date: new Intl.DateTimeFormat(loc, { dateStyle: "medium", timeZone: "UTC" }),
112
+ };
113
+ });
100
114
  const weekdayLabel = (idx: number) =>
101
- new Intl.DateTimeFormat(locale.value, {
102
- weekday: "short",
103
- timeZone: "UTC",
104
- }).format(
115
+ formatters.value.weekday.format(
105
116
  // Any Monday-first week works — take 2024-01-01 (a Monday).
106
117
  new Date(Date.UTC(2024, 0, 1 + idx)),
107
118
  );
108
119
  const monthLabel = (m: string) => {
109
120
  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)));
121
+ return formatters.value.month.format(
122
+ new Date(Date.UTC(2024, Number(m) - 1, 1)),
123
+ );
114
124
  };
115
125
  const dateLabel = (iso: string) =>
116
- new Intl.DateTimeFormat(locale.value, {
117
- dateStyle: "medium",
118
- timeZone: "UTC",
119
- }).format(new Date(`${iso}T00:00:00Z`));
126
+ formatters.value.date.format(new Date(`${iso}T00:00:00Z`));
120
127
 
121
128
  const cellTitle = (d: Extract<CalendarCell, { kind: "day" }>) =>
122
129
  fill(
@@ -135,13 +142,24 @@ export default defineComponent({
135
142
  }
136
143
  : {
137
144
  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],
145
+ background: heatmapCellFill(cell.level),
146
+ opacity: heatmapCellOpacity(cell.level),
143
147
  };
144
148
 
149
+ // A marker may overflow its 14px column into the empty columns
150
+ // ahead (the GitHub convention — a month label is always followed
151
+ // by unmarked columns). A marker on the LAST column has nothing
152
+ // ahead: its ink would be clipped by the scroll container, so that
153
+ // one case reserves inline-end room for it. The logical property
154
+ // matters: hikari ships RTL hosts (HkMenu reads
155
+ // documentElement.dir), and there the label runs the other way.
156
+ const markersTrailingPad = computed(() => {
157
+ const m = markers.value;
158
+ return m.length > 0 && m[m.length - 1] !== ""
159
+ ? `${props.cellSize * 2 + props.cellGap}px`
160
+ : undefined;
161
+ });
162
+
145
163
  return () => (
146
164
  <div class="hk-calendar-heatmap">
147
165
  {/* The legend row: the scale in the caller's one unit on the
@@ -154,19 +172,16 @@ export default defineComponent({
154
172
  data-testid="hk-calendar-heatmap-legend"
155
173
  >
156
174
  {t("hikari::calendarHeatmap.less", "Less")}
157
- {HEATMAP_LEVEL_OPACITY.map((o, i) => (
175
+ {LEGEND_LEVELS.map((level) => (
158
176
  <span
159
- key={i}
160
- data-legend-level={i}
177
+ key={level}
178
+ data-legend-level={level}
161
179
  style={{
162
180
  width: "10px",
163
181
  height: "10px",
164
182
  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,
183
+ background: heatmapCellFill(level),
184
+ opacity: heatmapCellOpacity(level),
170
185
  display: "inline-block",
171
186
  }}
172
187
  />
@@ -199,14 +214,16 @@ export default defineComponent({
199
214
  </div>
200
215
 
201
216
  <div class="hk-calendar-heatmap-cells">
202
- {/* Month markers, one slot per week column. A leading
203
- * partial month renders its empty slot (see
217
+ {/* Month markers, one slot per week column. A month is
218
+ * labeled on the column carrying its 1st; a partial head
219
+ * month therefore renders an empty slot (see
204
220
  * monthMarkers). */}
205
221
  <div
206
222
  class="hk-calendar-heatmap-markers"
207
223
  style={{
208
224
  gridTemplateColumns: columns.value,
209
225
  columnGap: `${props.cellGap}px`,
226
+ paddingInlineEnd: markersTrailingPad.value,
210
227
  }}
211
228
  >
212
229
  {markers.value.map((m, i) => (