@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 +1 -1
- package/src/components/HkBoard.scss +11 -3
- package/src/components/HkMinimap.scss +7 -0
- package/src/components/HkNodeCanvas.scss +10 -0
- package/src/components/HkNodeCanvas.tsx +5 -1
- package/src/components/HkTable.interactions.test.tsx +102 -5
- package/src/components/HkTable.scss +20 -14
- package/src/components/HkTable.tsx +2 -2
- package/src/components/minimapDock.styles.test.ts +82 -0
- package/src/components/sheetfamily-morph-invariant.test.ts +19 -6
- package/src/styles/selectionSheet.test.ts +47 -30
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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 `
|
|
475
|
-
clicks run the shared clipboard-with-toast path (HkTable.tsx).
|
|
476
|
-
halves scope under `data-copy-cells`, so a consumer that opts
|
|
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 `
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
153
|
-
// 2026-11-07 user direction:
|
|
154
|
-
//
|
|
155
|
-
// background click copies nothing.
|
|
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
|
|
160
|
+
// rule can pass.
|
|
158
161
|
const css = compile("../components/HkTable.scss");
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
-
|
|
190
|
-
|
|
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
|
});
|