@celestia-island/hikari 0.55.336 → 0.55.337

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.336",
3
+ "version": "0.55.337",
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",
@@ -139,6 +139,30 @@ afterEach(() => {
139
139
  globalThis.ResizeObserver = originalResizeObserver;
140
140
  });
141
141
 
142
+ /** WCAG helpers for the ink assertions — an independent re-implementation of
143
+ * the requirement (AA contrast), deliberately not the component's own code. */
144
+ const srgbLinear = (channel: number) => {
145
+ const value = channel / 255;
146
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
147
+ };
148
+ const hexChannels = (hex: string): [number, number, number] => {
149
+ const raw = hex.length === 4
150
+ ? hex.slice(1).split("").map((char) => char + char).join("")
151
+ : hex.slice(1);
152
+ return [
153
+ Number.parseInt(raw.slice(0, 2), 16),
154
+ Number.parseInt(raw.slice(2, 4), 16),
155
+ Number.parseInt(raw.slice(4, 6), 16),
156
+ ];
157
+ };
158
+ const luminanceOf = (rgb: readonly number[]) =>
159
+ 0.2126 * srgbLinear(rgb[0]!) + 0.7152 * srgbLinear(rgb[1]!) + 0.0722 * srgbLinear(rgb[2]!);
160
+ const contrastAgainst = (backgroundLuminance: number, ink: string) => {
161
+ const text = luminanceOf(hexChannels(ink));
162
+ return (Math.max(backgroundLuminance, text) + 0.05)
163
+ / (Math.min(backgroundLuminance, text) + 0.05);
164
+ };
165
+
142
166
  describe("HkTreemap pure layout input", () => {
143
167
  it("sums nested groups from leaves and ignores caller-provided parent values", () => {
144
168
  const layout = buildHkTreemapLayout(NODES);
@@ -251,38 +275,78 @@ describe("HkTreemap pure layout input", () => {
251
275
  expect(contrastTextColor("rgb(78 121 167 / 40%)")).toBe("rgb(var(--color-text, 30 30 30))");
252
276
  expect(contrastTextColor("#4e79a780")).toBe("rgb(var(--color-text, 30 30 30))");
253
277
  expect(contrastTextColor("not-a-color")).toBe("rgb(var(--color-text, 30 30 30))");
278
+ // Parse strictness is part of the mirror contract: trailing garbage,
279
+ // inf/NaN spellings and broken hues are NOT truncated numbers — both
280
+ // runtimes must answer "no solid answer" identically.
281
+ expect(contrastTextColor("rgb(10x 20 30)")).toBe("rgb(var(--color-text, 30 30 30))");
282
+ expect(contrastTextColor("rgb(inf 0 0)")).toBe("rgb(var(--color-text, 30 30 30))");
283
+ expect(contrastTextColor("rgb(50x% 0 0)")).toBe("rgb(var(--color-text, 30 30 30))");
284
+ expect(contrastTextColor("hsl(10x, 50%, 50%)")).toBe("rgb(var(--color-text, 30 30 30))");
285
+ // The deg suffix still parses (the Rust angle channel strips it too) —
286
+ // in ANY case, because functional forms match on the lowercased value.
287
+ expect(contrastTextColor("hsl(120deg, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
288
+ expect(contrastTextColor("hsl(120DEG, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
289
+ // Uppercase NONE channels are filtered exactly like lowercase ones, and
290
+ // a BOM is whitespace on both sides of the mirror.
291
+ expect(contrastTextColor("rgb(NONE 1 2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
292
+ expect(contrastTextColor("rgb(1 2 3 / NONE)")).toBe(contrastTextColor("rgb(1 2 3)"));
293
+ expect(contrastTextColor("\uFEFF#fff")).toBe(contrastTextColor("#fff"));
294
+ expect(contrastTextColor("rgb(1\uFEFF2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
295
+ // The whitespace MODEL is pinned too, not just these shapes: an
296
+ // interleaved BOM between real spaces still trims, and U+0085 (NEL) is
297
+ // a control character — NOT JS whitespace — so it never parses.
298
+ expect(contrastTextColor("\uFEFF \uFEFF#fff")).toBe(contrastTextColor("#fff"));
299
+ expect(contrastTextColor("#fff\uFEFF \uFEFF")).toBe(contrastTextColor("#fff"));
300
+ expect(contrastTextColor("\u0085#fff")).toBe("rgb(var(--color-text, 30 30 30))");
301
+ expect(contrastTextColor("#fff\u0085")).toBe("rgb(var(--color-text, 30 30 30))");
302
+ expect(contrastTextColor("rgb(1\u00852 3)")).toBe("rgb(var(--color-text, 30 30 30))");
303
+ // A line terminator inside the envelope parses (the Rust mirror's
304
+ // strip_prefix/strip_suffix never sees it as a terminator).
305
+ expect(contrastTextColor("rgb(1\n2 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
306
+ expect(contrastTextColor("rgb(1\u20282 3)")).toBe(contrastTextColor("rgb(1 2 3)"));
307
+ expect(contrastTextColor("rgb(\t1\n2\r3)")).toBe(contrastTextColor("rgb(1 2 3)"));
308
+ expect(contrastTextColor("hsl(120\u2028, 50%, 50%)")).toBe(contrastTextColor("hsl(120, 50%, 50%)"));
254
309
  });
255
310
 
256
311
  it("keeps every default palette cell AND its group band at WCAG AA", () => {
257
- const linear = (channel: number) => {
258
- const value = channel / 255;
259
- return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
260
- };
261
- const channels = (hex: string) => {
262
- const raw = hex.length === 4
263
- ? hex.slice(1).split("").map((char) => char + char).join("")
264
- : hex.slice(1);
265
- return [0, 2, 4].map((at) => Number.parseInt(raw.slice(at, at + 2), 16));
266
- };
267
- const luminance = (rgb: number[]) => 0.2126 * linear(rgb[0]!)
268
- + 0.7152 * linear(rgb[1]!)
269
- + 0.0722 * linear(rgb[2]!);
270
- const ratio = (plate: number, ink: string) => {
271
- const text = luminance(channels(ink));
272
- return (Math.max(plate, text) + 0.05) / (Math.min(plate, text) + 0.05);
273
- };
274
312
  for (const color of HK_TREEMAP_DEFAULT_COLORS) {
275
- const plate = luminance(channels(color));
313
+ const plate = luminanceOf(hexChannels(color));
276
314
  const ink = contrastTextColor(color);
277
- expect(["#fff", "#0b1220"]).toContain(ink);
278
- expect(ratio(plate, ink)).toBeGreaterThanOrEqual(4.5);
315
+ expect(["#fff", "#0b1220", "#000"]).toContain(ink);
316
+ expect(contrastAgainst(plate, ink)).toBeGreaterThanOrEqual(4.5);
279
317
  // The band the group label actually sits on is the scrimmed plate —
280
318
  // pin it too, the worst palette case (#59a14f) sits at 4.506:1.
281
- const band = luminance(
282
- channels(color).map((channel) => channel * (1 - HK_TREEMAP_HEAD_SCRIM)),
319
+ const band = luminanceOf(
320
+ hexChannels(color).map((channel) => channel * (1 - HK_TREEMAP_HEAD_SCRIM)),
283
321
  );
284
322
  const bandInk = contrastTextColor(color, HK_TREEMAP_HEAD_SCRIM);
285
- expect(ratio(band, bandInk)).toBeGreaterThanOrEqual(4.5);
323
+ expect(contrastAgainst(band, bandInk)).toBeGreaterThanOrEqual(4.5);
324
+ }
325
+ });
326
+
327
+ it("never leaves a solid plate under AA — black covers the mid-tone dead zone", () => {
328
+ // The white/near-black pair cannot reach 4.5:1 for plate luminances in
329
+ // (0.1833, 0.2024); pure black is the escape hatch and must appear there.
330
+ const deadZoneGrey = "rgb(120 120 130)";
331
+ expect(contrastAgainst(luminanceOf(hexChannels("#787882")), "#fff")).toBeLessThan(4.5);
332
+ expect(contrastAgainst(luminanceOf(hexChannels("#787882")), "#0b1220")).toBeLessThan(4.5);
333
+ expect(contrastTextColor(deadZoneGrey)).toBe("#000");
334
+ // …and only there: no default palette member is ever black.
335
+ for (const color of HK_TREEMAP_DEFAULT_COLORS) {
336
+ expect(contrastTextColor(color)).not.toBe("#000");
337
+ }
338
+ // The same grey's header band is light enough for white.
339
+ expect(contrastTextColor(deadZoneGrey, HK_TREEMAP_HEAD_SCRIM)).toBe("#fff");
340
+ // The band itself can land in the dead zone: #63a04d's plate keeps the
341
+ // near-black ink (5.94:1), but its scrimmed band cannot clear AA with
342
+ // either softer ink (white 4.49, near-black 4.17) -> black (4.68).
343
+ expect(contrastTextColor("#63a04d")).toBe("#0b1220");
344
+ expect(contrastTextColor("#63a04d", HK_TREEMAP_HEAD_SCRIM)).toBe("#000");
345
+ // Property: every grey step clears AA with the ink it picks.
346
+ for (let value = 0; value <= 255; value += 1) {
347
+ const color = `rgb(${value} ${value} ${value})`;
348
+ const plate = luminanceOf([value, value, value]);
349
+ expect(contrastAgainst(plate, contrastTextColor(color))).toBeGreaterThanOrEqual(4.5);
286
350
  }
287
351
  });
288
352
 
@@ -65,15 +65,37 @@ function parseHexColor(raw: string): RgbaChannels | null {
65
65
  ];
66
66
  }
67
67
 
68
+ /** Strict decimal/scientific numeric parse — the WHOLE string must be one
69
+ * number, mirroring the Rust mirror's `str::parse::<f64>()` + `is_finite()`
70
+ * gate: trailing garbage ("10x") and inf/NaN spellings are rejected, not
71
+ * truncated. Returns NaN for anything that is not one number. */
72
+ const NUMERIC_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
73
+ function parseStrictNumber(raw: string): number {
74
+ const value = raw.trim();
75
+ return NUMERIC_PATTERN.test(value) ? Number(value) : Number.NaN;
76
+ }
77
+
68
78
  function numericChannel(raw: string): number | null {
69
79
  if (raw.endsWith("%")) {
70
- const percent = Number.parseFloat(raw.slice(0, -1));
80
+ const percent = parseStrictNumber(raw.slice(0, -1));
71
81
  return Number.isFinite(percent) ? Math.min(255, Math.max(0, (percent / 100) * 255)) : null;
72
82
  }
73
- const value = Number.parseFloat(raw);
83
+ const value = parseStrictNumber(raw);
74
84
  return Number.isFinite(value) ? Math.min(255, Math.max(0, value)) : null;
75
85
  }
76
86
 
87
+ /** An hsl hue: a strict number with the optional `deg` suffix (all trailing
88
+ * repeats stripped, like the Rust mirror's `trim_end_matches("deg")`). */
89
+ function angleChannel(raw: string): number {
90
+ return parseStrictNumber(raw.replace(/(?:deg)+$/, ""));
91
+ }
92
+
93
+ /** An hsl saturation/lightness: a strict number with an optional `%` suffix
94
+ * (CSS requires the percent, but both forms appear in the wild). */
95
+ function percentChannel(raw: string): number {
96
+ return parseStrictNumber(raw.replace(/%+$/, ""));
97
+ }
98
+
77
99
  function hueToRgb(hue: number, saturation: number, lightness: number): [number, number, number] {
78
100
  const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
79
101
  const sector = ((hue % 360) + 360) % 360 / 60;
@@ -96,17 +118,25 @@ function hueToRgb(hue: number, saturation: number, lightness: number): [number,
96
118
  function alphaComponent(raw: string | undefined): number | null {
97
119
  if (raw == null || raw === "") return 1;
98
120
  if (raw.endsWith("%")) {
99
- const percent = Number.parseFloat(raw.slice(0, -1));
121
+ const percent = parseStrictNumber(raw.slice(0, -1));
100
122
  return Number.isFinite(percent) ? Math.min(1, Math.max(0, percent / 100)) : null;
101
123
  }
102
- const value = Number.parseFloat(raw);
124
+ const value = parseStrictNumber(raw);
103
125
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null;
104
126
  }
105
127
 
106
128
  function parseCssColor(raw: string): RgbaChannels | null {
107
- const value = raw.trim();
129
+ // JS trim() strips U+FEFF; the Rust mirror is taught the same character so
130
+ // both runtimes accept or reject a BOM-prefixed colour identically.
131
+ const value = raw.trim().replace(/^\uFEFF+/, "").replace(/\uFEFF+$/, "").trim();
108
132
  if (value.startsWith("#")) return parseHexColor(value);
109
- const rgb = /^rgba?\((.*)\)$/i.exec(value);
133
+ // Functional forms are matched on the LOWERCASED value — exactly the Rust
134
+ // mirror's to_ascii_lowercase() — so case-insensitive CSS units (`DEG`,
135
+ // `NONE` channels) parse or reject identically on both sides.
136
+ const lower = value.toLowerCase();
137
+ // [\s\S] (not `.`): a line terminator inside the envelope is legal for the
138
+ // Rust mirror's strip_prefix/strip_suffix, so it must be legal here too.
139
+ const rgb = /^rgba?\(([\s\S]*)\)$/.exec(lower);
110
140
  if (rgb) {
111
141
  const parts = rgb[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
112
142
  if (parts.length < 3) return null;
@@ -116,13 +146,13 @@ function parseCssColor(raw: string): RgbaChannels | null {
116
146
  if (alpha === null) return null;
117
147
  return [...(channels as [number, number, number]), alpha];
118
148
  }
119
- const hsl = /^hsla?\((.*)\)$/i.exec(value);
149
+ const hsl = /^hsla?\(([\s\S]*)\)$/.exec(lower);
120
150
  if (hsl) {
121
151
  const parts = hsl[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
122
152
  if (parts.length < 3) return null;
123
- const hue = Number.parseFloat(parts[0]!);
124
- const saturation = Number.parseFloat(parts[1]!);
125
- const lightness = Number.parseFloat(parts[2]!);
153
+ const hue = angleChannel(parts[0]!);
154
+ const saturation = percentChannel(parts[1]!);
155
+ const lightness = percentChannel(parts[2]!);
126
156
  if (![hue, saturation, lightness].every(Number.isFinite)) return null;
127
157
  const alpha = alphaComponent(parts[3]);
128
158
  if (alpha === null) return null;
@@ -164,13 +194,25 @@ function contrastRatio(a: number, b: number): number {
164
194
  return (lighter + 0.05) / (darker + 0.05);
165
195
  }
166
196
 
167
- /** The two inks a SOLID fill can carry. The dark end is the family's
197
+ /** The two inks a SOLID fill normally carries. The dark end is the family's
168
198
  * near-black (a hair of blue keeps it from reading as dead #000 on the
169
199
  * pale palette members). */
170
200
  const SOLID_LIGHT_INK = "#fff";
171
201
  const SOLID_DARK_INK = "#0b1220";
172
202
 
173
- /** Relative luminances of the two candidate inks (white is the reference 1). */
203
+ /** Last-resort ink: the softer pair leaves a narrow mid-tone dead zone
204
+ * (plate luminance in the open interval (0.1833, 0.2024)) where neither clears AA; there pure black
205
+ * is the higher-contrast choice that can reach the floor. Outside that band
206
+ * the softer pair is SELECTED even though black would beat the near-black on
207
+ * raw ratio everywhere — black stays a floor escape, not the normal ink. */
208
+ const SOLID_NEUTRAL_INK = "#000";
209
+
210
+ /** WCAG AA floor for the fully-opaque cell label; the meta line and the
211
+ * italic unknown label keep their pre-existing 0.85 / 0.9 opacity (a
212
+ * deliberate de-emphasis, not part of this contract). */
213
+ const AA_CONTRAST = 4.5;
214
+
215
+ /** Relative luminances of the candidate inks (white is the reference 1). */
174
216
  const SOLID_DARK_INK_LUMINANCE = relativeLuminance(parseHexColor(SOLID_DARK_INK)!);
175
217
  const SOLID_LIGHT_INK_LUMINANCE = 1;
176
218
 
@@ -209,10 +251,13 @@ export function contrastTextColor(background: string, scrim = 0): string {
209
251
  1,
210
252
  ];
211
253
  const luminance = relativeLuminance(effective);
212
- return contrastRatio(luminance, SOLID_DARK_INK_LUMINANCE)
213
- >= contrastRatio(luminance, SOLID_LIGHT_INK_LUMINANCE)
214
- ? SOLID_DARK_INK
215
- : SOLID_LIGHT_INK;
254
+ const dark = contrastRatio(luminance, SOLID_DARK_INK_LUMINANCE);
255
+ const light = contrastRatio(luminance, SOLID_LIGHT_INK_LUMINANCE);
256
+ const best = Math.max(dark, light);
257
+ // Neither softer ink clears the floor -> pure black, which is strictly the
258
+ // higher-contrast choice inside that band (see SOLID_NEUTRAL_INK).
259
+ if (best < AA_CONTRAST) return SOLID_NEUTRAL_INK;
260
+ return dark >= light ? SOLID_DARK_INK : SOLID_LIGHT_INK;
216
261
  }
217
262
 
218
263
  export const HK_TREEMAP_DEFAULT_COLORS = [
@@ -433,7 +478,9 @@ export function createHkTreemapSummary(
433
478
  *
434
479
  * Cell ink is derived from each SOLID plate's own luminance (WCAG
435
480
  * contrast), because a big opaque block is the background that matters;
436
- * only a transparent/unparseable fill defers to the theme text token.
481
+ * only a transparent/unparseable fill defers to the theme text token. The
482
+ * mid-tone band where neither the white nor the family near-black clears AA
483
+ * falls back to pure black.
437
484
  */
438
485
  export default defineComponent({
439
486
  name: "HkTreemap",