@celestia-island/hikari 0.55.319 → 0.55.321

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.319",
3
+ "version": "0.55.321",
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",
@@ -113,8 +113,16 @@
113
113
  inset-inline-end: var(--space-10);
114
114
  bottom: var(--space-10);
115
115
  z-index: 6;
116
- // Defensive re-enable (the inner HkMinimap root asserts it too,
117
- // HkMinimap.scss:15): keeps the dock alive if a host ever parks the
118
- // board under an ancestor `pointer-events: none`.
116
+ // Defensive re-enable (the inner HkMinimap root asserts it too, in
117
+ // HkMinimap.scss's root rule): keeps the dock alive if a host ever
118
+ // parks the board under an ancestor `pointer-events: none`.
119
119
  pointer-events: auto;
120
120
  }
121
+
122
+ // The dock is the ONE anchor (same contract as HkNodeCanvas's): the
123
+ // docked card goes static so the dock's own corner inset is the only
124
+ // one applied — without this the card stacked its self-docking offset
125
+ // on top and sat twice as far from the corner as designed.
126
+ .hk-board-minimap > .hk-minimap {
127
+ position: static;
128
+ }
@@ -1,6 +1,13 @@
1
1
  @use "../styles/polyfill" as *;
2
2
 
3
3
  .hk-minimap {
4
+ // SELF-DOCKING form (position: absolute + corner insets): for hosts
5
+ // that mount the card DIRECTLY on a positioned surface (HkTreemapStage,
6
+ // HkBoard3D's minimap3d). Hosts that wrap the card in a positioned DOCK
7
+ // (HkNodeCanvas's `.hk-node-canvas-minimap`, HkBoard's
8
+ // `.hk-board-minimap`) reset the card to `position: static` in their
9
+ // own sheet — the dock owns the corner and the card must not stack a
10
+ // second absolute offset on top of it (the doubled inset bug).
4
11
  position: absolute;
5
12
  bottom: var(--space-12);
6
13
  // Corner overlay anchor mirrors in RTL like the sibling HkZoomToolbar
@@ -67,6 +67,16 @@
67
67
  inset-inline-start: var(--space-12);
68
68
  }
69
69
 
70
+ /* The wrapper is the ONE anchor: the docked card goes static so it sizes
71
+ the wrapper's shrink-to-fit box instead of stacking its own absolute
72
+ corner offset on top of the wrapper's (the doubled inset read as the
73
+ map floating oddly far and lopsided from its corner — one inset came
74
+ from here, a second from `.hk-minimap`'s self-docking rule). Works for
75
+ every placement: the wrapper's own data-placement carries the corner. */
76
+ .hk-node-canvas-minimap > .hk-minimap {
77
+ position: static;
78
+ }
79
+
70
80
  /* ── Interactive edges ───────────────────────────────────────────────
71
81
  The halo paths are always in the DOM when `interactiveEdges` is on but
72
82
  invisible until their edge is hovered or selected, so highlighting
@@ -217,7 +217,11 @@ export default defineComponent({
217
217
  * out with `false`. Needs `contentBounds` to know the hull — without
218
218
  * it there is nothing to clamp against and this is a no-op. */
219
219
  clampToBounds: { type: Boolean, default: true },
220
- /** Mount the minimap in the corner. */
220
+ /** Mount the minimap in the corner. The slot's card must be a
221
+ * DIRECT child of the dock wrapper (`.hk-node-canvas-minimap >
222
+ * .hk-minimap` is reset to static — the dock owns the corner inset;
223
+ * an intermediate wrapper around the card would re-stack the card's
224
+ * own self-docking offset on top of the dock's). */
221
225
  minimap: { type: Boolean, default: true },
222
226
  /** Which corner the minimap takes. */
223
227
  minimapPlacement: {
@@ -1,9 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h } from "vue";
3
3
  import { readFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
+ import * as sass from "sass";
8
+
7
9
  import HTable from "./HkTable";
8
10
  import { useToast } from "../runtime/useToast";
9
11
 
@@ -23,10 +25,11 @@ import { useToast } from "../runtime/useToast";
23
25
  * row-action affordance)
24
26
  * - `Column.copyable: false` and the table-wide `copy-cells=false` opt
25
27
  * out wholesale
26
- * - the `copy` cursor and the hover hint ride `[data-copy-cell]` — the
27
- * default text cells, in a copyable column, whose value still renders
28
- * text — never every cell, so the pointer describes what a background
29
- * click really does. Slot cells' inner leaves still copy, so their
28
+ * - the click cursor (`pointer`; the copy SEMANTICS live in the `title`
29
+ * hint) and the hover hint ride `[data-copy-cell]` — the default text
30
+ * cells, in a copyable column, whose value still renders text — never
31
+ * every cell, so the cursor describes what a background click really
32
+ * does. Slot cells' inner leaves still copy, so their
30
33
  * plain arrow is a deliberate under-promise (2026-11-07 user
31
34
  * direction)
32
35
  * - `manageable` renders the trailing ⋯ column (HkRowActions) and
@@ -77,6 +80,34 @@ const COLS = [
77
80
  { key: "endpoint", title: "Endpoint" },
78
81
  ];
79
82
 
83
+ // The compiled sheet, injected into the document so `getComputedStyle` sees
84
+ // the REAL cascade. Selector-text assertions cannot catch a rule that reaches
85
+ // an unmarked cell through inheritance, an element selector (`td`) or a media
86
+ // block — the cascade can.
87
+ //
88
+ // `style: "expanded"` is load-bearing: the compressed writer emits
89
+ // `@media(min-width:600px)` (no space after `@media`) and happy-dom then
90
+ // DROPS the whole block (cssRules stays empty), which would make a
91
+ // media-wrapped widening rule invisible to this suite.
92
+ const HERE = dirname(fileURLToPath(import.meta.url));
93
+ const SRC = resolve(HERE, "..");
94
+ const SHEET_STYLE = "expanded" as const;
95
+ const SHEET_CSS = sass.compile(resolve(HERE, "HkTable.scss"), {
96
+ loadPaths: [SRC, join(SRC, "components"), join(SRC, "styles"), join(SRC, "styles", "theme")],
97
+ style: SHEET_STYLE,
98
+ silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
99
+ }).css;
100
+ // The parser probe below is compiled with the SAME style on purpose: if a
101
+ // future edit flips SHEET_STYLE back to "compressed", the probe loses its
102
+ // media block too and this suite goes red. That keeps the "fixture must stay
103
+ // `expanded`" premise under its own tripwire instead of silently disarming
104
+ // the cascade assertion.
105
+ const PROBE_CSS = sass.compileString("@media (min-width: 600px) { .hk-probe { color: red } }", {
106
+ style: SHEET_STYLE,
107
+ }).css;
108
+ // happy-dom reports an unset cursor as "" (a browser reports "auto").
109
+ const isAuto = (value: string): boolean => value === "" || value === "auto";
110
+
80
111
  async function settle() {
81
112
  await Promise.resolve();
82
113
  await Promise.resolve();
@@ -416,3 +447,69 @@ describe("HkTable interaction stylesheet", () => {
416
447
  expect(block.indexOf("cursor: default")).toBeGreaterThan(-1);
417
448
  });
418
449
  });
450
+
451
+ describe("HkTable click-cursor cascade", () => {
452
+ let sheet: HTMLStyleElement;
453
+ beforeEach(() => {
454
+ sheet = document.createElement("style");
455
+ sheet.textContent = SHEET_CSS;
456
+ document.head.appendChild(sheet);
457
+ });
458
+ afterEach(() => {
459
+ sheet.remove();
460
+ });
461
+
462
+ it("parses the injected sheet (a blind sheet would make this suite vacuous)", () => {
463
+ expect(sheet.sheet?.cssRules?.length ?? 0).toBeGreaterThan(0);
464
+ // Probe the PARSER, not this sheet: the next test can only catch a
465
+ // media-wrapped widening rule if a media block survives parsing. The
466
+ // probe CSS is synthetic (so unrelated sheet edits, e.g. dropping the
467
+ // reduced-motion block, can never turn this suite red) but compiled with
468
+ // the SAME style as the fixture (so a fixture-style regression does).
469
+ const probe = document.createElement("style");
470
+ probe.textContent = PROBE_CSS;
471
+ document.head.appendChild(probe);
472
+ try {
473
+ const rules = [...(probe.sheet?.cssRules ?? [])];
474
+ expect(rules.some((rule) => typeof (rule as { media?: unknown }).media !== "undefined")).toBe(
475
+ true,
476
+ );
477
+ } finally {
478
+ probe.remove();
479
+ }
480
+ });
481
+
482
+ it("reaches the marked cell only — computed cascade, not selector text", () => {
483
+ const c = mount(
484
+ h(
485
+ HTable,
486
+ {
487
+ columns: [...COLS, { key: "secret", title: "Secret", copyable: false }],
488
+ rows: [{ name: "alpha", endpoint: "https://x.example", secret: "nope-token" }],
489
+ draggable: true,
490
+ selectable: true,
491
+ manageable: true,
492
+ },
493
+ { "cell-endpoint": () => "pure-text-slot" },
494
+ ),
495
+ );
496
+ // drag grip, selection, name, endpoint (slot), secret (opted out), row actions
497
+ const tds = Array.from(c.querySelectorAll<HTMLElement>("tbody td"));
498
+ expect(tds).toHaveLength(6);
499
+ const cursor = (index: number): string => getComputedStyle(tds[index]!).cursor;
500
+ expect(cursor(2)).toBe("pointer");
501
+ for (const index of [0, 1, 3, 4]) {
502
+ expect(isAuto(cursor(index))).toBe(true);
503
+ }
504
+ expect(cursor(5)).toBe("default");
505
+ });
506
+
507
+ it("an opted-out table hands every cell the ordinary cursor back", () => {
508
+ const c = mount(
509
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "x" }], copyCells: false }),
510
+ );
511
+ for (const td of Array.from(c.querySelectorAll<HTMLElement>("tbody td"))) {
512
+ expect(isAuto(getComputedStyle(td).cursor)).toBe(true);
513
+ }
514
+ });
515
+ });
@@ -471,32 +471,38 @@
471
471
  Table content is not prose. While the copy layer is enabled
472
472
  (`copy-cells`, the default), body cells kill drag mass-selection
473
473
  (user-select: none) and the cells whose background click copies
474
- advertise the click-to-copy affordance with the `copy` cursor — text
475
- clicks run the shared clipboard-with-toast path (HkTable.tsx). Both
476
- halves scope under `data-copy-cells`, so a consumer that opts out
477
- gets plain selectable text back wholesale.
474
+ advertise the click-to-copy affordance with the `pointer` cursor —
475
+ text clicks run the shared clipboard-with-toast path (HkTable.tsx).
476
+ Both halves scope under `data-copy-cells`, so a consumer that opts
477
+ out gets plain selectable text back wholesale.
478
478
 
479
- The `copy` cursor rides `[data-copy-cell]`, NOT every cell
479
+ The `pointer` cursor rides `[data-copy-cell]`, NOT every cell
480
480
  (2026-11-07 user direction): the marker is the same predicate as the
481
481
  `title` hint and as a DEFAULT text cell's background click — a
482
482
  default-rendered cell, in a `copyable` column, whose value still
483
483
  renders text. Slot cells (chips/badges/layout wrappers) never take
484
484
  the click on their td SURFACE — only their inner text leaves copy —
485
485
  and `copyable: false` columns copy nothing at all, so both keep the
486
- ordinary arrow; a `copy` cursor there (which Windows paints as the
487
- legacy arrow+plus glyph) promised an affordance the surface click
488
- does not deliver. Two deliberate under-promises follow: an inner
489
- leaf that copies still shows the arrow, and a slot that renders BARE
490
- text (no wrapper) makes the td's own surface copy — yet stays
491
- unmarked because the slot owns its hints. Under-promising is the
492
- point; over-promising is the bug this rule removes. */
486
+ ordinary arrow; a copy affordance there promised something the
487
+ surface click does not deliver. Two deliberate under-promises
488
+ follow: an inner leaf that copies still shows the arrow, and a slot
489
+ that renders BARE text (no wrapper) makes the td's own surface copy
490
+ — yet stays unmarked because the slot owns its hints. Under-
491
+ promising is the point; over-promising is the bug this rule removes.
492
+
493
+ Why `pointer`, not `copy` (2026-11-07 user direction, second turn):
494
+ the OS paints CSS `copy` with the legacy arrow+plus bitmap (no
495
+ themed cursor exists for it), which reads as a 90s artifact next to
496
+ the modern arrow. The COPY semantics live in the `title` hint
497
+ ("Click to copy"); the cursor only has to say "this text reacts to a
498
+ click", which is exactly what `pointer` says. */
493
499
  .hk-table[data-copy-cells] {
494
500
  .hk-table-cell {
495
501
  @include user-select(none);
496
502
  }
497
503
 
498
504
  .hk-table-cell[data-copy-cell] {
499
- cursor: copy;
505
+ cursor: pointer;
500
506
  }
501
507
 
502
508
  /* Defense in depth: the row-actions track is a control and never
@@ -504,7 +510,7 @@
504
510
  `.hk-table-cell` on purpose: that keeps its specificity equal to the
505
511
  marker rule above (`(0,4,0)`) and, declared after it, it still wins
506
512
  if a future widening ever hands the marker to the ⋯ trigger. A bare
507
- `.hk-table-manage-cell` (`(0,3,0)`) would now lose that tie. */
513
+ `.hk-table-manage-cell` (`(0,3,0)`) would lose that tie outright. */
508
514
  .hk-table-cell.hk-table-manage-cell {
509
515
  cursor: default;
510
516
  }
@@ -1076,7 +1076,7 @@ export default defineComponent({
1076
1076
  const isSlotCell = Boolean(slots[`cell-${col.key}`]);
1077
1077
  const value = row[col.key];
1078
1078
  // ONE predicate behind the hover hint AND the
1079
- // `copy` cursor (`data-copy-cell`): a DEFAULT text
1079
+ // copy-affordance cursor (`pointer`, `data-copy-cell`): a DEFAULT text
1080
1080
  // cell advertises the copy affordance only when its
1081
1081
  // background click really copies — the td is a text
1082
1082
  // leaf that still RENDERS text (see `renderedText`).
@@ -1085,7 +1085,7 @@ export default defineComponent({
1085
1085
  // copy) and `copyable: false` columns copy nothing,
1086
1086
  // so both keep the plain arrow — a deliberate
1087
1087
  // under-promise, never an over-promise (2026-11-07
1088
- // user direction: `cursor: copy` on every cell
1088
+ // user direction: a copy affordance on every cell
1089
1089
  // promised more than the handler does; badges carry
1090
1090
  // their own pointer/tooltip). A slot that renders
1091
1091
  // BARE text is the one case where the td surface
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet guard for the minimap DOCK contract: a positioned
10
+ * dock wrapper (HkNodeCanvas's `.hk-node-canvas-minimap`, HkBoard's
11
+ * `.hk-board-minimap`) owns the corner inset, and the docked card is
12
+ * reset to `position: static` so it sizes the dock's shrink-to-fit box.
13
+ * Without the reset the card stacked its own self-docking absolute
14
+ * offset (`.hk-minimap`'s bottom + inset-inline-end) on top of the
15
+ * dock's, and the map sat twice as far from its corner as designed —
16
+ * measurably lopsided against the undocked axes. Vitest stubs CSS, so
17
+ * the runtime suites can never catch a lost rule; this reads the
18
+ * compiled sheet.
19
+ */
20
+
21
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
22
+
23
+ function compileSheet(name: string): string {
24
+ return compile(resolve(componentDir, name), { style: "expanded" }).css;
25
+ }
26
+
27
+ const canvasCss = compileSheet("HkNodeCanvas.scss");
28
+ const boardCss = compileSheet("HkBoard.scss");
29
+
30
+ function ruleFor(css: string, selector: string): string {
31
+ const start = css.indexOf(selector);
32
+ expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
33
+ const open = css.indexOf("{", start);
34
+ const close = css.indexOf("}", open);
35
+ return css.slice(start, close + 1);
36
+ }
37
+
38
+ describe("minimap dock stylesheet contract", () => {
39
+ it("HkNodeCanvas's wrapper is the single anchor: the docked card is static", () => {
40
+ const rule = ruleFor(canvasCss, ".hk-node-canvas-minimap > .hk-minimap");
41
+ expect(rule).toContain("position: static");
42
+ });
43
+
44
+ it("HkBoard's dock is the single anchor: the docked card is static", () => {
45
+ const rule = ruleFor(boardCss, ".hk-board-minimap > .hk-minimap");
46
+ expect(rule).toContain("position: static");
47
+ });
48
+
49
+ it("the docks keep their own corner insets (the anchor that remains)", () => {
50
+ // The wrapper still carries the placement insets — the static card
51
+ // has not REPLACED the dock's geometry, only stopped doubling it.
52
+ // (Compiled attribute selectors lose their quotes: bottom-right.)
53
+ expect(canvasCss).toContain(".hk-node-canvas-minimap[data-placement=bottom-right]");
54
+ const dock = ruleFor(boardCss, ".hk-board-minimap");
55
+ expect(dock).toContain("position: absolute");
56
+ expect(dock).toContain("inset-inline-end");
57
+ expect(dock).toContain("bottom");
58
+ });
59
+
60
+ it("the card's self-docking form survives for direct mounts", () => {
61
+ // TreemapStage / HkBoard3D mount the card directly on a positioned
62
+ // surface with no dock wrapper — the root rule must keep its own
63
+ // absolute corner for them.
64
+ const minimapCss = compileSheet("HkMinimap.scss");
65
+ const root = ruleFor(minimapCss, ".hk-minimap {");
66
+ expect(root).toContain("position: absolute");
67
+ });
68
+
69
+ it("the static reset survives sheet read round-trips (no @extend loss)", () => {
70
+ // The selector is plain and direct (no nesting tricks), so a future
71
+ // Sass rewrite cannot silently drop it from the compiled output.
72
+ expect(canvasCss.match(/position: static/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
73
+ expect(boardCss.match(/position: static/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
74
+ });
75
+
76
+ it("board scss raw source still documents the single-anchor reason", () => {
77
+ // The WHY lives next to the rule (comments are stripped from the
78
+ // compiled output, so guard the source text).
79
+ const src = readFileSync(resolve(componentDir, "HkBoard.scss"), "utf8");
80
+ expect(src).toContain("ONE anchor");
81
+ });
82
+ });
@@ -76,10 +76,18 @@ function flaggedRules(source: string): Array<{ selector: string; body: string }>
76
76
  return out;
77
77
  }
78
78
 
79
- /** The block's own transition declaration (multi-line shorthands joined). */
80
- function transitionOf(block: string): string {
81
- const match = block.match(/transition:[^;]+;/);
82
- return match?.[0]?.replace(/\s+/g, " ").trim() ?? "";
79
+ /** Every transition declaration in the block (multi-line shorthands
80
+ * joined, in order). Reading ALL of them closes the coverage hole the
81
+ * mutation round proved (H1): a stray second `transition:` declaration
82
+ * silently overrides the first in real CSS (last wins), so leg checks
83
+ * scoped to the first would pass while the real grammar went rogue.
84
+ * The flagged-rule assertions below additionally require EXACTLY one
85
+ * declaration — two shorthands in one rule is an authoring hazard the
86
+ * mechanical contract refuses outright. */
87
+ function transitionOf(block: string): string[] {
88
+ return [...block.matchAll(/transition:[^;]+;/g)].map((m) =>
89
+ m[0].replace(/\s+/g, " ").trim(),
90
+ );
83
91
  }
84
92
 
85
93
  describe("sheet family morph-flag ↔ transition-grammar contract", () => {
@@ -94,10 +102,15 @@ describe("sheet family morph-flag ↔ transition-grammar contract", () => {
94
102
  it("every flagged rule carries a clip-path leg and no dead height/max-height legs", () => {
95
103
  for (const sheet of FAMILY_SHEETS) {
96
104
  for (const rule of flaggedRules(read(sheet))) {
97
- const tr = transitionOf(rule.body);
105
+ const declarations = transitionOf(rule.body);
106
+ expect(
107
+ declarations.length,
108
+ `${sheet} ${rule.selector} carries exactly one transition declaration (a stray second silently overrides the first in real CSS)`,
109
+ ).toBe(1);
110
+ const tr = declarations[0] ?? "";
98
111
  expect(tr, `${sheet} ${rule.selector} has a transition`).toContain("clip-path");
99
112
  expect(tr, `${sheet} ${rule.selector} height leg is dead in clip mode`).not.toMatch(
100
- /\bheight\b/,
113
+ /(?<!max-)\bheight\b/,
101
114
  );
102
115
  expect(
103
116
  tr,
@@ -139,7 +139,7 @@ describe("selection policy sheet", () => {
139
139
  // 2026-10-18 contract: copy-cells (default) denies drag selection on
140
140
  // every body cell; the opt-out now needs an explicit mirror — absence
141
141
  // of a deny no longer means selectable once the root denies. The
142
- // `copy` CURSOR is narrower than the selection deny since 2026-11-07
142
+ // click CURSOR is narrower than the selection deny since 2026-11-07
143
143
  // (marker-scoped, asserted by the next test): the deny still covers
144
144
  // the whole cell body, only the pointer narrowed.
145
145
  const css = compile("../components/HkTable.scss");
@@ -149,45 +149,62 @@ describe("selection policy sheet", () => {
149
149
  );
150
150
  });
151
151
 
152
- it("hands the copy cursor only to the marked cells, never to every cell", () => {
153
- // 2026-11-07 user direction: `.hk-table-cell { cursor: copy }` on the
154
- // whole body promised a copy on slot/control/empty cells whose
155
- // background click copies nothing. Asserted on the COMPILED sheet so
152
+ it("hands the clickable-text pointer only to the marked cells, never to every cell", () => {
153
+ // 2026-11-07 user direction: the copy affordance used to ride every
154
+ // `.hk-table-cell` (a copy cursor on slot/control/empty cells whose
155
+ // background click copies nothing). It now rides `[data-copy-cell]`
156
+ // only, and the cursor is the generic `pointer` — the OS paints CSS
157
+ // `copy` with the legacy arrow+plus bitmap, so the copy semantics
158
+ // live in the `title` hint instead. Asserted on the COMPILED sheet so
156
159
  // neither a commented-out rule nor a selector list that re-widens the
157
- // rule (`.hk-table-cell, .hk-table-cell[data-copy-cell]`) can pass.
160
+ // rule can pass.
158
161
  const css = compile("../components/HkTable.scss");
159
- const copyRules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((match) =>
160
- /cursor:\s*copy/.test(match[2]!),
162
+ // 1) The legacy `copy` glyph must not come back anywhere — not as the
163
+ // bare keyword, not as a `url(...)` fallback list, not in any case.
164
+ // Only the KEYWORD counts: `url(copy-icon.cur)` / `var(--copy-cursor)`
165
+ // merely spell "copy" without selecting the legacy glyph.
166
+ const legacyCopy = [...css.matchAll(/cursor\s*:([^;}]*)/gi)].filter((match) => {
167
+ const value = match[1]!.replace(/url\([^)]*\)|var\([^)]*\)/gi, " ");
168
+ return /\bcopy\b/i.test(value);
169
+ });
170
+ expect(legacyCopy).toHaveLength(0);
171
+ // 2) The marked cell IS the clickable-text surface.
172
+ expect(css).toMatch(
173
+ /\.hk-table\[data-copy-cells\] \.hk-table-cell\[data-copy-cell(?:="")?\]\{[^}]*?cursor:\s*pointer/,
174
+ );
175
+ // 3) No rule may hand an UNMARKED table cell the copy layer's pointer
176
+ // (that is exactly the re-widening this contract removed).
177
+ expect(css).not.toMatch(
178
+ /\.hk-table\[data-copy-cells\] \.hk-table-cell\{[^}]*?cursor:\s*pointer/,
161
179
  );
162
- expect(copyRules.length).toBeGreaterThan(0);
163
- for (const rule of copyRules) {
180
+ // 4) The marker must sit on the CELL compound, not on an ancestor:
181
+ // `.hk-table[data-copy-cell] .hk-table-cell` keeps the token in the
182
+ // selector yet can never match a real cell.
183
+ for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
184
+ if (!/cursor\s*:[^;}]*\bpointer\b/i.test(rule[2]!)) continue;
164
185
  for (const selector of rule[1]!.split(",")) {
165
- // Tolerate the empty-value spelling Vue emits
166
- // (`[data-copy-cell=""]`) but neither a prefix token nor a
167
- // selector carrying a different value.
168
- expect(selector).toMatch(/\[data-copy-cell(?:="")?\]/);
169
- // The marker must sit on the CELL compound: moving it to an
170
- // ancestor (`.hk-table[data-copy-cell] .hk-table-cell`) keeps the
171
- // token in the selector yet can never match a real cell.
172
186
  const lastCompound = selector.trim().split(/\s+/).pop() ?? "";
173
- expect(lastCompound).toMatch(/\.hk-table-cell/);
187
+ // Only the CELL compound itself is constrained: a legitimate
188
+ // descendant rule (`.hk-table-cell a { cursor: pointer }`) is a
189
+ // control inside the cell and keeps its own cursor.
190
+ if (!lastCompound.startsWith(".hk-table-cell")) continue;
174
191
  expect(lastCompound).toMatch(/\[data-copy-cell(?:="")?\]/);
175
192
  }
176
193
  }
177
- // The marked rule must also sit under the table-level opt-out gate:
178
- // moving it into a dead block (`.hk-table:not([data-copy-cells])`)
179
- // would keep a marker-bearing selector while the cursor never applies.
180
- expect(css).toMatch(
181
- /\.hk-table\[data-copy-cells\] \.hk-table-cell\[data-copy-cell(?:="")?\]\{[^}]*?cursor:\s*copy/,
182
- );
183
- // The control-track override repeats `.hk-table-cell` so its
184
- // specificity TIES the marker rule above and, declared after it, wins
185
- // the tie — the defense-in-depth promise needs that ordering.
194
+ // 5) The control-track override repeats `.hk-table-cell` so its
195
+ // specificity TIES the marker rule above and, declared after it,
196
+ // wins the tie — the defense-in-depth promise needs that ordering.
186
197
  expect(css).toMatch(
187
198
  /\.hk-table\[data-copy-cells\] \.hk-table-cell\.hk-table-manage-cell\{[^}]*?cursor:\s*default/,
188
199
  );
189
- expect(css.indexOf(".hk-table-cell.hk-table-manage-cell")).toBeGreaterThan(
190
- css.indexOf("[data-copy-cell]"),
200
+ // Anchor on the rule that DECLARES the cursor (not merely on the first
201
+ // selector that mentions the marker — a decoy rule carrying the selector
202
+ // without a cursor would neutralize the order pin).
203
+ const markedRule = css.match(
204
+ /\.hk-table\[data-copy-cells\] \.hk-table-cell\[data-copy-cell(?:="")?\]\{[^}]*?cursor:\s*pointer/,
191
205
  );
192
- });
206
+ expect(markedRule).not.toBeNull();
207
+ expect(css.indexOf(".hk-table-cell.hk-table-manage-cell")).toBeGreaterThan(
208
+ css.indexOf(markedRule![0]),
209
+ ); });
193
210
  });