@celestia-island/hikari 0.55.10 → 0.55.12

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.10",
3
+ "version": "0.55.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -537,13 +537,14 @@ export default defineComponent({
537
537
  <h2>HEmptyState</h2>
538
538
  <div class="col">
539
539
  <HEmptyState
540
+ boxed
540
541
  title="No items"
541
542
  description="Nothing to show here yet."
542
543
  v-slots={{ action: () => <HButton size="sm">Create item</HButton> }}
543
544
  />
544
545
  <HEmptyState
545
546
  title="Nothing found"
546
- description="Search returned no matches — try a different query."
547
+ description="Bare by default — the host (card/modal/panel) provides the surface; pass boxed when the empty state stands on the page alone."
547
548
  icon={PackageOpen}
548
549
  />
549
550
  <HEmptyState
@@ -53,7 +53,7 @@ export const HkAdminTablePage = defineComponent({
53
53
  ) : props.loading && !props.rows.length ? (
54
54
  <HSpinner center />
55
55
  ) : !props.rows.length ? (
56
- <HEmptyState title={emptyTitle} description={props.emptyDescription} />
56
+ <HEmptyState boxed title={emptyTitle} description={props.emptyDescription} />
57
57
  ) : (
58
58
  <HCard padded={false}>
59
59
  <HTable columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
@@ -5,11 +5,17 @@
5
5
  justify-content: center;
6
6
  padding: var(--space-48, 3rem) var(--space-16, 1rem);
7
7
  text-align: center;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ /* The component's own surface. Opt-in (`boxed`): the host — card, modal,
12
+ panel — usually already provides a surface, and an always-on box turned
13
+ every in-card empty state into a nested placeholder layer. */
14
+ .hk-empty-state--boxed {
8
15
  background: var(--hk-empty-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
9
16
  backdrop-filter: blur(var(--blur-sm, 8px));
10
17
  border: 1px solid var(--hi-color-border, rgba(100,100,100,0.06));
11
18
  border-radius: var(--radius-md, 0.5rem);
12
- box-sizing: border-box;
13
19
  }
14
20
 
15
21
  .hk-empty-icon {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Source contract: the empty state's surface is OPT-IN (`boxed`). The
3
+ * base `.hk-empty-state` class must stay a bare layout block — the four
4
+ * surface declarations (background, blur, border, radius) live only in
5
+ * `.hk-empty-state--boxed`. Pinned because every in-card empty state in
6
+ * the family reads as a second nested placeholder layer the moment the
7
+ * surface creeps back into the base class (hikari #567); a class-name
8
+ * test alone cannot catch that, the compiled contract lives here.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+
17
+ const SURFACE_PROPERTIES = [
18
+ "background:",
19
+ "backdrop-filter:",
20
+ "border:",
21
+ "border-radius:",
22
+ ] as const;
23
+
24
+ /** Extract one top-level rule block's body (`{ ... }`) by selector. */
25
+ function ruleBody(css: string, selector: string): string {
26
+ const start = css.indexOf(`${selector} {`);
27
+ expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
28
+ const open = css.indexOf("{", start);
29
+ const close = css.indexOf("}", open);
30
+ return css.slice(open + 1, close);
31
+ }
32
+
33
+ describe("HkEmptyState surface contract", () => {
34
+ const css = readFileSync(join(here, "HkEmptyState.scss"), "utf-8");
35
+
36
+ it("keeps the base class free of surface declarations", () => {
37
+ const base = ruleBody(css, ".hk-empty-state");
38
+ for (const property of SURFACE_PROPERTIES) {
39
+ expect(base).not.toContain(property);
40
+ }
41
+ });
42
+
43
+ it("declares the full surface on the opt-in --boxed rule only", () => {
44
+ const boxed = ruleBody(css, ".hk-empty-state--boxed");
45
+ for (const property of SURFACE_PROPERTIES) {
46
+ expect(boxed).toContain(property);
47
+ }
48
+ // The surface is the ONLY rule allowed to paint: with the --boxed rule
49
+ // (and comments, whose prose may name the properties) removed, no
50
+ // declaration of a surface property may remain anywhere in the sheet.
51
+ // (The sr-only reset's `border: 0` and box-sizing's `border-box` are
52
+ // not surfaces — the assertions match the painted forms.)
53
+ const commentFree = css.replace(/\/\*[\s\S]*?\*\//g, "");
54
+ const withoutBoxed = commentFree.replace(
55
+ /\.hk-empty-state--boxed\s*\{[^}]*\}/g,
56
+ "",
57
+ );
58
+ expect(withoutBoxed).not.toContain("background:");
59
+ expect(withoutBoxed).not.toContain("backdrop-filter:");
60
+ expect(withoutBoxed).not.toContain("border: 1px");
61
+ expect(withoutBoxed).not.toContain("border-radius:");
62
+ });
63
+ });
@@ -135,4 +135,16 @@ describe("HkEmptyState", () => {
135
135
  expect(cls).toContain("hk-empty-state--fill");
136
136
  expect(cls).not.toContain("hk-empty-state--page");
137
137
  });
138
+
139
+ it("does not draw its own surface by default (the host provides it)", () => {
140
+ const c = mount(h(HkEmptyState, { title: "x" }));
141
+ const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
142
+ expect(cls).not.toContain("hk-empty-state--boxed");
143
+ });
144
+
145
+ it("opt-in boxed restores the surface modifier", () => {
146
+ const c = mount(h(HkEmptyState, { title: "x", boxed: true }));
147
+ const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
148
+ expect(cls).toContain("hk-empty-state--boxed");
149
+ });
138
150
  });
@@ -19,6 +19,12 @@ export default defineComponent({
19
19
  type: String as PropType<"page" | "fill">,
20
20
  default: "page",
21
21
  },
22
+ /** Draw the component's own surface (background, border, blur). Off by
23
+ * default: most hosts (cards, modals, panels) already provide a
24
+ * surface, and a boxed empty state inside them reads as a second
25
+ * nested placeholder layer. Set this only when the empty state stands
26
+ * directly on the page with no outer frame of its own. */
27
+ boxed: { type: Boolean, default: false },
22
28
  },
23
29
  setup(props, { slots }) {
24
30
  const { t } = useI18n();
@@ -28,6 +34,7 @@ export default defineComponent({
28
34
  "hk-empty-state",
29
35
  props.fit === "fill" ? "hk-empty-state--fill" : "hk-empty-state--page",
30
36
  props.loading && "hk-empty-state--loading",
37
+ props.boxed && "hk-empty-state--boxed",
31
38
  ]}
32
39
  role={props.loading ? "status" : undefined}
33
40
  aria-busy={props.loading ? "true" : undefined}
@@ -417,4 +417,3 @@
417
417
  transition: none;
418
418
  }
419
419
  }
420
- }
@@ -213,6 +213,6 @@
213
213
  height: 1px;
214
214
  margin: var(--hk-menu-item-pad-y, 6px) auto;
215
215
  width: calc(100% - 2 * var(--hk-menu-item-pad-x, 12px));
216
- background: rgb(var(--color-border, 120 120 120) / 18%);
216
+ background: rgb(var(--color-border, 100 100 100) / 18%);
217
217
  border: none;
218
218
  }
@@ -15,34 +15,34 @@
15
15
  * loads before this sheet. Only plana-specific tokens remain below. */
16
16
 
17
17
  /* ── Shared color aliases ─ */
18
- --c-primary: rgb(var(--color-primary, 255 107 157));
19
- --c-primary-light: rgb(var(--color-primary, 255 107 157) / 15%);
20
- --c-primary-subtle: rgb(var(--color-primary, 255 107 157) / 8%);
21
- --c-primary-overlay: rgb(var(--color-primary, 255 107 157) / 15%);
18
+ --c-primary: rgb(var(--color-primary, 122 162 247));
19
+ --c-primary-light: rgb(var(--color-primary, 122 162 247) / 15%);
20
+ --c-primary-subtle: rgb(var(--color-primary, 122 162 247) / 8%);
21
+ --c-primary-overlay: rgb(var(--color-primary, 122 162 247) / 15%);
22
22
 
23
23
  /* ── Hikari color bridge ─ */
24
- --hi-color-primary: rgb(var(--color-primary, 255 107 157));
25
- --hi-color-primary-rgb: var(--color-primary, 255 107 157);
26
- --hi-color-surface: rgb(var(--color-surface, 255 255 255));
24
+ --hi-color-primary: rgb(var(--color-primary, 122 162 247));
25
+ --hi-color-primary-rgb: var(--color-primary, 122 162 247);
26
+ --hi-color-surface: rgb(var(--color-surface, 240 244 248));
27
27
  --hi-color-border: rgb(var(--color-border, 100 100 100) / 15%);
28
- --hi-color-text-primary: rgb(var(--color-text, 0 0 0));
28
+ --hi-color-text-primary: rgb(var(--color-text, 30 30 30));
29
29
  --hi-color-text-on-primary: rgb(var(--color-on-primary, 255 255 255)); /* deprecated in-tree: prefer the on-solid slots */
30
30
  --color-on-solid: 255 255 255;
31
31
  --color-on-solid-text: 255 255 255;
32
32
  --color-on-solid-icon: 255 255 255;
33
33
  --hi-color-text-on-solid: rgb(var(--color-on-solid-text, 255 255 255));
34
34
  --hi-color-icon-on-solid: rgb(var(--color-on-solid-icon, 255 255 255));
35
- --hi-color-text-secondary: color-mix(in srgb, rgb(var(--color-text, 0 0 0)) 60%, transparent);
36
- --hi-color-muted: rgb(var(--color-muted, 100 100 100));
37
- --hi-color-background: rgb(var(--color-background, 246 246 246));
38
- --hi-color-bg-canvas: rgb(var(--color-background, 246 246 246));
39
- --hi-color-bg-elevated: rgb(var(--color-surface, 255 255 255));
40
- --hi-color-bg-subtle: rgb(var(--color-surface, 255 255 255) / 0.5);
41
- --hi-secondary-bg: rgb(var(--color-surface, 255 255 255) / 0.5);
42
- --hi-color-danger: rgb(var(--color-error, 200 50 50));
43
- --hi-color-success: rgb(var(--color-success, 0 150 0));
44
- --hi-color-warning: rgb(var(--color-warning, 200 150 0));
45
- --hi-color-info: rgb(var(--color-info, 255 107 157));
35
+ --hi-color-text-secondary: color-mix(in srgb, rgb(var(--color-text, 30 30 30)) 60%, transparent);
36
+ --hi-color-muted: rgb(var(--color-muted, 108 108 108));
37
+ --hi-color-background: rgb(var(--color-background, 214 236 240));
38
+ --hi-color-bg-canvas: rgb(var(--color-background, 214 236 240));
39
+ --hi-color-bg-elevated: rgb(var(--color-surface, 240 244 248));
40
+ --hi-color-bg-subtle: rgb(var(--color-surface, 240 244 248) / 0.5);
41
+ --hi-secondary-bg: rgb(var(--color-surface, 240 244 248) / 0.5);
42
+ --hi-color-danger: rgb(var(--color-error, 247 118 142));
43
+ --hi-color-success: rgb(var(--color-success, 14 184 64));
44
+ --hi-color-warning: rgb(var(--color-warning, 238 214 119));
45
+ --hi-color-info: rgb(var(--color-info, 106 190 214));
46
46
  --hi-color-black-10: rgb(0 0 0 / 0.1);
47
47
  --hi-color-white-5: rgb(255 255 255 / 0.05);
48
48
  --hi-color-white-10: rgb(255 255 255 / 0.1);
@@ -58,8 +58,8 @@
58
58
  * by load order. (--hk-surface's only in-tree consumer — the retired
59
59
  * HTabs tail overlay — is gone; the token stays published for
60
60
  * external plana-ui readers.) */
61
- --hk-surface: var(--color-surface, 255 255 255);
62
- --hk-primary: var(--color-primary, 60 120 240);
61
+ --hk-surface: var(--color-surface, 240 244 248);
62
+ --hk-primary: var(--color-primary, 122 162 247);
63
63
  }
64
64
 
65
65
  /* Global reset (opt-in: import this explicitly) */
@@ -545,7 +545,7 @@
545
545
  height: 1px;
546
546
  margin: var(--hk-menu-item-pad-y, 6px) auto;
547
547
  width: calc(100% - 2 * var(--hk-menu-item-pad-x, 12px));
548
- background: rgb(var(--color-border, 120 120 120) / 18%);
548
+ background: rgb(var(--color-border, 100 100 100) / 18%);
549
549
  border: none;
550
550
  }
551
551
 
@@ -754,7 +754,7 @@
754
754
  depend on the host having loaded the reset sheet (without it a 1px
755
755
  border pushes the box 2px past its max-width and off-centre). */
756
756
  box-sizing: border-box;
757
- background: color-mix(in srgb, rgb(var(--color-surface, 255 255 255)) 70%, transparent);
757
+ background: color-mix(in srgb, rgb(var(--color-surface, 240 244 248)) 70%, transparent);
758
758
  border: 1px solid color-mix(in srgb, rgb(var(--color-text, 30 30 30)) 12%, transparent);
759
759
  border-radius: var(--hi-radius-sm, 4px);
760
760
  box-shadow: 0 2px 16px rgb(0 0 0 / 8%);
@@ -879,8 +879,8 @@
879
879
  gap: var(--space-8, 0.5rem);
880
880
  font-size: var(--text-xs, 0.75rem);
881
881
  color: var(--hi-color-danger, #f85149);
882
- background: color-mix(in srgb, var(--hi-color-danger, #ef4444) 10%, transparent);
883
- border: 1px solid color-mix(in srgb, var(--hi-color-danger, #ef4444) 20%, transparent);
882
+ background: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 10%, transparent);
883
+ border: 1px solid color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
884
884
  border-radius: 6px;
885
885
  padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
886
886
  }
@@ -967,8 +967,8 @@
967
967
 
968
968
  .s-auth-invite-banner.expired {
969
969
  color: var(--hi-color-danger, #f85149);
970
- background: color-mix(in srgb, var(--hi-color-danger, #ef4444) 10%, transparent);
971
- border-color: color-mix(in srgb, var(--hi-color-danger, #ef4444) 20%, transparent);
970
+ background: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 10%, transparent);
971
+ border-color: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
972
972
  }
973
973
 
974
974
  @media (max-width: 480px) {
@@ -992,7 +992,7 @@
992
992
  }
993
993
 
994
994
  .a-text-danger {
995
- color: var(--hi-color-danger, #ef4444);
995
+ color: var(--hi-color-danger, rgb(247 118 142));
996
996
  }
997
997
 
998
998
  .a-icon-inline {
@@ -1000,11 +1000,11 @@
1000
1000
  }
1001
1001
 
1002
1002
  .a-icon-success {
1003
- color: var(--color-success, #22c55e);
1003
+ color: var(--color-success, rgb(14 184 64));
1004
1004
  }
1005
1005
 
1006
1006
  .a-icon-warning {
1007
- color: var(--color-warning, #f59e0b);
1007
+ color: var(--color-warning, rgb(238 214 119));
1008
1008
  }
1009
1009
 
1010
1010
  .a-bg-secondary {
@@ -1022,7 +1022,7 @@
1022
1022
 
1023
1023
  .a-error-banner {
1024
1024
  background: var(--hi-color-danger, rgba(239, 68, 68, 0.1));
1025
- border: 1px solid color-mix(in srgb, var(--hi-color-danger, #ef4444) 20%, transparent);
1025
+ border: 1px solid color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
1026
1026
  }
1027
1027
 
1028
1028
  .a-tabular-nums {
@@ -0,0 +1,190 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Channel-fallback drift guard (2026-09-16).
9
+ *
10
+ * The sheets carry two layers of colour: the STATIC SEED
11
+ * (`styles/theme/channels.scss`, a byte-identical copy of `src/tokens.scss`
12
+ * — see vendoredSync.test.ts) which "renders correctly even without
13
+ * initTheme()", and the alias fallbacks in `styles/admin-tokens.scss`,
14
+ * `styles/reset.scss`, `components/_menu-item.scss` plus the package-local
15
+ * ladders in `packages/theme/styles`, which apply when a host never themes
16
+ * — or, in the legacy ladders, as plain values.
17
+ *
18
+ * Failure class: a fallback is a SECOND COPY of a palette decision, and
19
+ * nothing kept it in step with the seed. Six of them still named
20
+ * synthwave84's pink (`rgb(255 107 157)`) long after the seed had moved to
21
+ * its own blue, so a consumer resolving the alias sheet without the seed got
22
+ * the retired default — a downstream panel's standalone error page shipped
23
+ * exactly that pink, the legacy focus ring glowed pink, and the button
24
+ * ladders still carried the pink/salmon trio. A fallback must resolve to
25
+ * what the seeded composition already declares, and no shipped sheet may
26
+ * carry the retired family at all.
27
+ *
28
+ * Slots with no seed are listed in UNSEEDED rather than skipped silently:
29
+ * `--color-bg-secondary` / `--color-bg-tertiary` are retired spellings
30
+ * (their `#18181b` / `#27272a` fallbacks always win) and `--color-danger` is
31
+ * defined nowhere, so there is no seed value to compare against — recorded
32
+ * debt, not a passing check.
33
+ */
34
+
35
+ const stylesDir = resolve(dirname(fileURLToPath(import.meta.url)));
36
+ const packageDir = resolve(stylesDir, "..", "..");
37
+ const legacyDir = resolve(packageDir, "..", "theme", "styles");
38
+
39
+ /** Slots this guard cannot hold to the seed, with the reason above. */
40
+ const UNSEEDED = ["--color-bg-secondary", "--color-bg-tertiary", "--color-danger"];
41
+
42
+ function read(path: string): string {
43
+ return readFileSync(path, "utf8");
44
+ }
45
+
46
+ /** `--color-SLOT: R G B;` declarations of the static seed. */
47
+ function seedTriplets(source: string): Record<string, string> {
48
+ const out: Record<string, string> = {};
49
+ for (const match of source.matchAll(/--color-([a-z-]+):\s*(\d+ \d+ \d+);/g)) {
50
+ out[match[1]] = match[2];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Normalize a colour literal to `R G B`: bare/space/comma triplets,
56
+ * `rgb()`/`rgba()` wrappers, an alpha term, or hex — every spelling the
57
+ * sheets actually use. */
58
+ function triplet(value: string): string | null {
59
+ const v = value
60
+ .trim()
61
+ .replace(/^(?:rgba?)\(/i, "")
62
+ .replace(/\)$/, "")
63
+ .replace(/\/[^/]*$/, "")
64
+ .trim();
65
+ const parts = v.split(/[\s,]+/).filter(Boolean);
66
+ if (parts.length >= 3 && parts.slice(0, 3).every((p) => /^\d+$/.test(p))) {
67
+ return parts.slice(0, 3).join(" ");
68
+ }
69
+ const hex = /^#([0-9a-f]{6})$/i.exec(v);
70
+ if (hex) {
71
+ const n = Number.parseInt(hex[1], 16);
72
+ return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Typed fallbacks, whitespace-tolerant: `var( --color-x, v )` must not slip
78
+ * past the scan (a single occurrence of that spelling was proven to hide a
79
+ * drifted value while every counter stayed green). */
80
+ function fallbacks(source: string): { slot: string; value: string; line: number }[] {
81
+ const out: { slot: string; value: string; line: number }[] = [];
82
+ const re = /var\(\s*--color-([a-z-]+)\s*,\s*((?:[^()]|\([^()]*\))+?)\s*\)/g;
83
+ let match: RegExpExecArray | null;
84
+ while ((match = re.exec(source)) !== null) {
85
+ out.push({
86
+ slot: match[1],
87
+ value: match[2],
88
+ line: source.slice(0, match.index).split("\n").length,
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** The retired pink/salmon family in EVERY spelling: flatten whitespace and
95
+ * commas so `rgba(238, 162, 164, 0.8)` is caught next to `238 162 164` (the
96
+ * space-only needles let the comma form through, proven by mutation). */
97
+ function retiredHits(source: string): string[] {
98
+ const flat = source.toLowerCase().replace(/[\s,]+/g, "");
99
+ return [
100
+ // synthwave84's pink pair
101
+ "#ff6b9d",
102
+ "#d63384",
103
+ "255107157",
104
+ "21451132",
105
+ // the Gen-1 peony brand and its tints/salmon form
106
+ "#eea2a4",
107
+ "#f4c4c5",
108
+ "#d88b8e",
109
+ "238162164",
110
+ "244196197",
111
+ "216139142",
112
+ // NOT listed: the Gen-1 vermilion danger family (#FF4C00 / #FF7A33 /
113
+ // #CC3D00 / rgba(255, 76, 0, …) — 26 sites across the component sheets
114
+ // and base.scss's danger slot). That is a deliberate Gen-1 palette, not
115
+ // a leftover of the pink brand line, so moving it is a design decision
116
+ // of its own; recorded in the PR instead of smuggled into this guard.
117
+ ].filter((needle) => flat.includes(needle));
118
+ }
119
+
120
+ describe("channel fallbacks mirror the static seed", () => {
121
+ const seedSource = read(resolve(packageDir, "src", "tokens.scss"));
122
+ const seed = seedTriplets(seedSource);
123
+
124
+ /** Sheets that carry typed `var(--color-SLOT, …)` fallbacks. */
125
+ const FALLBACK_SHEETS: [string, string][] = [
126
+ ["src/tokens.scss", seedSource],
127
+ ["src/styles/theme/channels.scss", read(resolve(stylesDir, "theme", "channels.scss"))],
128
+ ["src/styles/admin-tokens.scss", read(resolve(stylesDir, "admin-tokens.scss"))],
129
+ ["src/styles/reset.scss", read(resolve(stylesDir, "reset.scss"))],
130
+ ["src/components/_menu-item.scss", read(resolve(packageDir, "src", "components", "_menu-item.scss"))],
131
+ ];
132
+
133
+ /** Every shipped sheet, for the retired-family sweep. */
134
+ const ALL_SHEETS: [string, string][] = [
135
+ ...FALLBACK_SHEETS,
136
+ ["src/styles/theme/scale.scss", read(resolve(stylesDir, "theme", "scale.scss"))],
137
+ ["src/styles/theme/scrollbar.scss", read(resolve(stylesDir, "theme", "scrollbar.scss"))],
138
+ ...[
139
+ "_tokens.scss",
140
+ "base.scss",
141
+ "foundation.scss",
142
+ "themes.scss",
143
+ "variables.scss",
144
+ "_layout.scss",
145
+ "_glass.scss",
146
+ "mixins.scss",
147
+ "_scrollbar.scss",
148
+ ].map((name) => [`packages/theme/styles/${name}`, read(resolve(legacyDir, name))] as [string, string]),
149
+ ];
150
+
151
+ it("finds both the seed and the fallbacks (positive control)", () => {
152
+ // An empty or mis-pathed read would make every check below pass
153
+ // vacuously, so each scan must prove it saw real content.
154
+ expect(Object.keys(seed).length).toBeGreaterThanOrEqual(15);
155
+ const typed = FALLBACK_SHEETS.flatMap(([, source]) => fallbacks(source));
156
+ expect(typed.length).toBeGreaterThanOrEqual(20);
157
+ for (const [label, source] of ALL_SHEETS) {
158
+ expect(source.length, `${label} read empty`).toBeGreaterThan(200);
159
+ }
160
+ });
161
+
162
+ it("declares every typed fallback with the seed value for its slot", () => {
163
+ const checked: string[] = [];
164
+ for (const [label, source] of FALLBACK_SHEETS) {
165
+ for (const row of fallbacks(source)) {
166
+ const slot = `--color-${row.slot}`;
167
+ if (UNSEEDED.includes(slot)) continue;
168
+ expect(seed[row.slot], `${label}:${row.line} ${slot} has no seed slot`).toBeDefined();
169
+ checked.push(`${label}:${row.line}`);
170
+ expect(
171
+ triplet(row.value),
172
+ `${label}:${row.line} var(${slot}, ${row.value})`,
173
+ ).toBe(seed[row.slot]);
174
+ }
175
+ }
176
+ // The scan must actually reach the sheets (guards against a regex that
177
+ // silently matches nothing after an edit).
178
+ expect(checked.length).toBeGreaterThanOrEqual(25);
179
+ });
180
+
181
+ it("keeps the retired synthwave pink family out of every shipped sheet", () => {
182
+ for (const [label, source] of ALL_SHEETS) {
183
+ expect(retiredHits(source), `${label} reintroduced a retired colour`).toEqual([]);
184
+ }
185
+ // Positive control: the detector must fire on the known-bad spellings.
186
+ expect(retiredHits("--x: rgba(238, 162, 164, 0.8);")).toContain("238162164");
187
+ expect(retiredHits("--x: #F4C4C5;")).toContain("#f4c4c5");
188
+ expect(retiredHits("--x: rgb(255 107 157 / 12%);")).toContain("255107157");
189
+ });
190
+ });
@@ -20,7 +20,7 @@
20
20
  box-sizing: border-box;
21
21
  border-width: 0;
22
22
  border-style: solid;
23
- border-color: rgb(var(--color-border, 120 120 120) / 40%);
23
+ border-color: rgb(var(--color-border, 100 100 100) / 40%);
24
24
  }
25
25
 
26
26
  html {
@@ -0,0 +1,74 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import * as sass from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Every shipped sheet must compile (2026-09-17).
10
+ *
11
+ * Regression: hikari #564 ("Highlight the timeline step on hover instead of
12
+ * scaling it") left a stray `}` at the end of `HkTimeline.scss`. The sheet
13
+ * was uncompilable from the moment it merged and nothing noticed, because
14
+ * · the entry sheet (`styles/index.scss`) does not `@use` component sheets,
15
+ * · component tests import the TSX, never the SCSS,
16
+ * · and the one contract that DOES compile sheets
17
+ * (`components/textClip.contract.test.ts`) only compiles the ones it
18
+ * reviews — HkTimeline is not among them.
19
+ * So the first thing to compile it would have been a downstream app.
20
+ *
21
+ * This guard compiles every `.scss` under `src/` with the same sass a
22
+ * consumer uses (144 sheets, ~1s), which is cheap enough to run always.
23
+ * A failure names the file and sass's own message.
24
+ */
25
+
26
+ const srcDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ function sheetsIn(dir: string): string[] {
29
+ const out: string[] = [];
30
+ for (const entry of readdirSync(dir)) {
31
+ if (entry === "node_modules") continue;
32
+ const path = join(dir, entry);
33
+ if (statSync(path).isDirectory()) out.push(...sheetsIn(path));
34
+ else if (entry.endsWith(".scss")) out.push(path);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function compile(file: string): string | null {
40
+ try {
41
+ sass.compile(file, {
42
+ loadPaths: [srcDir, join(srcDir, "styles"), join(srcDir, "styles", "theme")],
43
+ // `@import` and the JS-API bridge are deprecated, not errors: the
44
+ // legacy Gen-1 sheets still use them and that is not this guard's job.
45
+ silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
46
+ });
47
+ return null;
48
+ } catch (error) {
49
+ return String((error as Error).message).split("\n")[0];
50
+ }
51
+ }
52
+
53
+ describe("every shipped stylesheet compiles", () => {
54
+ const sheets = sheetsIn(srcDir);
55
+
56
+ it("walks the whole source tree (positive control)", () => {
57
+ // A mis-rooted walk would make the sweep below pass vacuously.
58
+ expect(sheets.length, "sheets found under src/").toBeGreaterThan(100);
59
+ });
60
+
61
+ it("reports failures (instrument self-test)", () => {
62
+ expect(compile(resolve(srcDir, "styles", "index.scss")), "the entry sheet compiles").toBeNull();
63
+ // The instrument must be able to see the defect class it guards: sass
64
+ // rejects an unbalanced block, and this is exactly the error #564 shipped.
65
+ expect(() => sass.compileString(".hk-probe { color: red;\n}\n}")).toThrow(/unmatched/i);
66
+ });
67
+
68
+ it("compiles every sheet, naming the ones that fail", () => {
69
+ const failures = sheets
70
+ .map((file) => ({ file: relative(srcDir, file), error: compile(file) }))
71
+ .filter((row): row is { file: string; error: string } => row.error !== null);
72
+ expect(failures, failures.map((f) => `${f.file}: ${f.error}`).join("\n")).toEqual([]);
73
+ });
74
+ });
@@ -69,8 +69,8 @@
69
69
  --hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
70
70
  --hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
71
71
  --hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
72
- --hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 200 50 50)) 82%, #000);
73
- --hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 0 150 0)) 82%, #000);
72
+ --hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 247 118 142)) 82%, #000);
73
+ --hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 14 184 64)) 82%, #000);
74
74
  --hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
75
75
  --hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
76
76
  --hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));
package/src/tokens.scss CHANGED
@@ -62,8 +62,8 @@
62
62
  --hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
63
63
  --hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
64
64
  --hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
65
- --hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 200 50 50)) 82%, #000);
66
- --hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 0 150 0)) 82%, #000);
65
+ --hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 247 118 142)) 82%, #000);
66
+ --hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 14 184 64)) 82%, #000);
67
67
  --hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
68
68
  --hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
69
69
  --hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));