@celestia-island/hikari 0.55.336 → 0.55.338
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/HkKanban.scss +9 -3
- package/src/components/HkKanban.tsx +14 -4
- package/src/components/HkScrollContainer.dock.test.tsx +6 -1
- package/src/components/HkScrollContainer.dockRide.test.tsx +195 -6
- package/src/components/HkScrollContainer.scss +29 -0
- package/src/components/HkScrollContainer.tsx +23 -3
- package/src/components/HkTreemap.test.tsx +87 -23
- package/src/components/HkTreemap.tsx +64 -17
package/package.json
CHANGED
|
@@ -37,9 +37,15 @@
|
|
|
37
37
|
|
|
38
38
|
Ride only, by the root hook HkKanban emits for a ride host: a pin board
|
|
39
39
|
must keep the full-height lanes box it always had, so this rule never
|
|
40
|
-
reaches an existing board.
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
reaches an existing board. DIRECT-CHILD chain (2026-11-10 follow-up,
|
|
41
|
+
same shape as chest's consumer-side override): the earlier descendant
|
|
42
|
+
form ALSO released the lanes box of a board NESTED inside a card — an
|
|
43
|
+
inner `.hk-kanban` is not the ride host and must keep its own
|
|
44
|
+
full-height lanes (its strip viewport scrolls its own cards; the
|
|
45
|
+
release would cut its lane chrome short). Specificity (0,5,0) beats the
|
|
46
|
+
base rule above; a host that wants its own rule can override it the
|
|
47
|
+
same way. */
|
|
48
|
+
.hk-kanban[data-dock-mode="ride"] > .hk-kanban-strip > .hk-scroll-container-viewport > .hk-kanban-lanes {
|
|
43
49
|
min-height: 0;
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -122,8 +122,9 @@ export default defineComponent({
|
|
|
122
122
|
* window's leftover (measured on a real board: 275.8px static vs the
|
|
123
123
|
* 139px dock, i.e. zero improvement over the pin board). The
|
|
124
124
|
* attribute is emitted only while a ride dock is genuinely mounted (not
|
|
125
|
-
* for pin, not for a horizontal board, not for an empty
|
|
126
|
-
*
|
|
125
|
+
* for pin, not for a horizontal board, not for an empty board — with no
|
|
126
|
+
* lanes the whole strip including the dock is not rendered — and not
|
|
127
|
+
* for an empty dock slot), so every existing board is untouched. */
|
|
127
128
|
dockMode: { type: String as PropType<"pin" | "ride">, default: "pin" },
|
|
128
129
|
},
|
|
129
130
|
emits: ["move"],
|
|
@@ -219,8 +220,17 @@ export default defineComponent({
|
|
|
219
220
|
// always had, so no existing board changes. Gated on the dock actually
|
|
220
221
|
// being mounted too (S2-2): a requested-but-empty slot is legal, and
|
|
221
222
|
// releasing the lanes box for it would only shrink lane chrome on a
|
|
222
|
-
// short board while no dock is there to justify it.
|
|
223
|
-
|
|
223
|
+
// short board while no dock is there to justify it. Gated on a
|
|
224
|
+
// non-empty board as well (2026-11-10 follow-up): with
|
|
225
|
+
// `lanes.length === 0` the whole strip is replaced by the empty slot,
|
|
226
|
+
// so the container — and any dock in it — is not rendered at all; the
|
|
227
|
+
// hook means "a ride dock is mounted", and on an empty board none
|
|
228
|
+
// ever is.
|
|
229
|
+
const rideBoard =
|
|
230
|
+
docksOnTail &&
|
|
231
|
+
props.dockMode === "ride" &&
|
|
232
|
+
!!slots.dockBottom &&
|
|
233
|
+
props.lanes.length > 0;
|
|
224
234
|
return (
|
|
225
235
|
<div
|
|
226
236
|
class="hk-kanban"
|
|
@@ -163,10 +163,15 @@ describe("HkScrollContainer floating-dock tail clearance", () => {
|
|
|
163
163
|
// hidden vertical axis (measured: scrollHeight +spacer, and a
|
|
164
164
|
// programmatic scrollTop shifts content up behind
|
|
165
165
|
// overflow-y:hidden with no wheel path back).
|
|
166
|
+
// Comments stripped (block + line, house two-step form — see
|
|
167
|
+
// HkScrollContainer.dockRide.test.ts): a rule commented out must go
|
|
168
|
+
// red, not hide in the text the regex matches.
|
|
166
169
|
const scss = readFileSync(
|
|
167
170
|
join(dirname(fileURLToPath(import.meta.url)), "HkScrollContainer.scss"),
|
|
168
171
|
"utf8",
|
|
169
|
-
)
|
|
172
|
+
)
|
|
173
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
174
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
170
175
|
const spacer = scss.match(
|
|
171
176
|
/\.hk-scroll-container\[data-dock-tail="true"\]:not\(\[data-axis="horizontal"\]\) > \.hk-scroll-container-viewport::after\s*\{[^}]*\}/,
|
|
172
177
|
);
|
|
@@ -164,6 +164,25 @@ function stubGeometry(
|
|
|
164
164
|
Object.defineProperties(el, desc);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/** Stub one element's drawn vertical band (house pattern,
|
|
168
|
+
* HkScrollContainer.selectionHint.test.ts): the selection hint reads
|
|
169
|
+
* `getBoundingClientRect` on the viewport AND on each selected row, and
|
|
170
|
+
* happy-dom reports zeros for both. */
|
|
171
|
+
function stubBand(el: HTMLElement, top: number, bottom: number) {
|
|
172
|
+
const rect = {
|
|
173
|
+
x: 0,
|
|
174
|
+
y: top,
|
|
175
|
+
top,
|
|
176
|
+
bottom,
|
|
177
|
+
left: 0,
|
|
178
|
+
right: 100,
|
|
179
|
+
width: 100,
|
|
180
|
+
height: bottom - top,
|
|
181
|
+
toJSON: () => ({}),
|
|
182
|
+
};
|
|
183
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
|
|
184
|
+
}
|
|
185
|
+
|
|
167
186
|
/** The physical edges the shared <HkEdgeGlow> is painting right now. */
|
|
168
187
|
function bandsOf(host: HTMLElement): (string | null)[] {
|
|
169
188
|
return Array.from(host.querySelectorAll<HTMLElement>(".hk-edge-glow-band"), (band) =>
|
|
@@ -231,17 +250,30 @@ const MOUNT_SLOTS = {
|
|
|
231
250
|
dockBottom: () => h("div", { class: "the-dock" }, "dock"),
|
|
232
251
|
};
|
|
233
252
|
|
|
253
|
+
/** The sheets with comments STRIPPED (block + line comments, house
|
|
254
|
+
* two-step form, tokenInventory.test.ts): every source-contract match
|
|
255
|
+
* below judges the LIVE rules. A rule commented out must go red — a bare
|
|
256
|
+
* regex happily matches commented text, which is exactly how the
|
|
257
|
+
* 2026-11-10 mutation round's comment-out survivor stayed green (and how
|
|
258
|
+
* the round-1 review of a73a9f99 caught the same hole re-created by the
|
|
259
|
+
* newer assertions). Both steps are needed: the sheets carry block
|
|
260
|
+
* comments as well as line comments. */
|
|
234
261
|
const scss = readFileSync(
|
|
235
262
|
join(dirname(fileURLToPath(import.meta.url)), "HkScrollContainer.scss"),
|
|
236
263
|
"utf8",
|
|
237
|
-
)
|
|
264
|
+
)
|
|
265
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
266
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
238
267
|
|
|
239
268
|
/** The board's own sheet — the ride board's content-height switch lives
|
|
240
|
-
* there (see the S0 block in the ride contract tests).
|
|
269
|
+
* there (see the S0 block in the ride contract tests). Stripped like
|
|
270
|
+
* `scss` above, for the same reason. */
|
|
241
271
|
const kanbanScss = readFileSync(
|
|
242
272
|
join(dirname(fileURLToPath(import.meta.url)), "HkKanban.scss"),
|
|
243
273
|
"utf8",
|
|
244
|
-
)
|
|
274
|
+
)
|
|
275
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
276
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
245
277
|
|
|
246
278
|
afterEach(() => {
|
|
247
279
|
restoreRect?.();
|
|
@@ -351,6 +383,46 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
351
383
|
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
352
384
|
});
|
|
353
385
|
|
|
386
|
+
it("zeroes the published heights when a pin dock slot disappears at runtime", async () => {
|
|
387
|
+
// 2026-11-10 follow-up: the onUpdated re-point path is
|
|
388
|
+
// placement-agnostic and the ride case above guards it — this pin
|
|
389
|
+
// sibling pins the SAME contract there, where the published vars feed
|
|
390
|
+
// consumer end-padding instead of ride geometry: a dock removed at
|
|
391
|
+
// runtime must republish the inert 0px on BOTH vars, else a consumer
|
|
392
|
+
// keeps padding clear of a dock that no longer exists.
|
|
393
|
+
stubMeasuredHeight(".hk-scroll-dock", 139);
|
|
394
|
+
const container = document.createElement("div");
|
|
395
|
+
document.body.appendChild(container);
|
|
396
|
+
containers.push(container);
|
|
397
|
+
const show = ref(true);
|
|
398
|
+
const app = createApp(defineComponent({
|
|
399
|
+
setup() {
|
|
400
|
+
return () => {
|
|
401
|
+
const slots: Record<string, () => VNodeChild> = {
|
|
402
|
+
default: () => h("div", { class: "content" }, "rows"),
|
|
403
|
+
};
|
|
404
|
+
if (show.value) slots.dockBottom = () => h("div", { class: "the-dock" }, "dock");
|
|
405
|
+
// dockMode unset — the pin placement, the pre-ride default.
|
|
406
|
+
return h(HkScrollContainer, {}, slots);
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
}));
|
|
410
|
+
mounts.push(app);
|
|
411
|
+
const host = (app.mount(container) as unknown as { $el: HTMLElement }).$el;
|
|
412
|
+
expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeTruthy();
|
|
413
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("139px");
|
|
414
|
+
|
|
415
|
+
show.value = false;
|
|
416
|
+
await nextTick();
|
|
417
|
+
expect(host.querySelector(".hk-scroll-dock")).toBeNull();
|
|
418
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("0px");
|
|
419
|
+
expect(host.style.getPropertyValue("--hk-scroll-dock-top")).toBe("0px");
|
|
420
|
+
// The pin container keeps its pre-ride attribute set: no ride hook and,
|
|
421
|
+
// with the slot gone, no dock-edge fade either.
|
|
422
|
+
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
423
|
+
expect(host.getAttribute("data-dock-fade")).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
|
|
354
426
|
it("drops the bottom glow band under a mounted ride dock (pin keeps it)", async () => {
|
|
355
427
|
// S2-1: the shared <HkEdgeGlow> painter lives on the container root at
|
|
356
428
|
// z-index 2, i.e. ABOVE the viewport — with a ride dock inside that
|
|
@@ -375,6 +447,38 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
375
447
|
expect(bandsOf(pin.host)).toEqual(["top", "bottom"]);
|
|
376
448
|
});
|
|
377
449
|
|
|
450
|
+
it("drops the bottom band from the selection hint under a mounted ride dock (pin keeps it)", async () => {
|
|
451
|
+
// 2026-11-10 follow-up: the selection hint's painter rides the SAME
|
|
452
|
+
// `painterEdges` filter as the overflow glow (same function, same
|
|
453
|
+
// bottom drop — the band would paint over the ride dock's lower limb
|
|
454
|
+
// exactly like the glow's). Until now only the glow call site had a
|
|
455
|
+
// test; the review's mutation g (bypassing the filter at the hint call
|
|
456
|
+
// site) survived the whole suite green. The verdict attribute — the
|
|
457
|
+
// documented consumer contract — is untouched: only the BAND is
|
|
458
|
+
// dropped.
|
|
459
|
+
const slots = {
|
|
460
|
+
default: () => h("div", { class: "is-active" }, "row"),
|
|
461
|
+
dockBottom: () => h("div", { class: "the-dock" }, "dock"),
|
|
462
|
+
};
|
|
463
|
+
const ride = mountWithApi(slots, { dockMode: "ride", selectionHint: true });
|
|
464
|
+
const rideViewport = ride.host.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
465
|
+
stubBand(rideViewport, 0, 400);
|
|
466
|
+
stubBand(rideViewport.querySelector<HTMLElement>(".is-active")!, 500, 560);
|
|
467
|
+
ride.api.value!.refresh();
|
|
468
|
+
await nextTick();
|
|
469
|
+
expect(ride.host.getAttribute("data-selection-beyond")).toBe("bottom");
|
|
470
|
+
expect(bandsOf(ride.host)).toEqual([]);
|
|
471
|
+
|
|
472
|
+
const pin = mountWithApi(slots, { selectionHint: true });
|
|
473
|
+
const pinViewport = pin.host.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
474
|
+
stubBand(pinViewport, 0, 400);
|
|
475
|
+
stubBand(pinViewport.querySelector<HTMLElement>(".is-active")!, 500, 560);
|
|
476
|
+
pin.api.value!.refresh();
|
|
477
|
+
await nextTick();
|
|
478
|
+
expect(pin.host.getAttribute("data-selection-beyond")).toBe("bottom");
|
|
479
|
+
expect(bandsOf(pin.host)).toEqual(["bottom"]);
|
|
480
|
+
});
|
|
481
|
+
|
|
378
482
|
it("warns once in dev when autoFollow is combined with a ride dock", async () => {
|
|
379
483
|
// S2-3: the combination reproduces the full-height-content trap that
|
|
380
484
|
// S0 fixed (autoFollow bottom-anchors a min-height:100% box), so it is
|
|
@@ -405,6 +509,25 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
405
509
|
expect((pin.$el as HTMLElement).getAttribute("data-dock-fade")).toBe("bottom");
|
|
406
510
|
});
|
|
407
511
|
|
|
512
|
+
it("publishes the dock-edge fade for a top dock even in ride mode", () => {
|
|
513
|
+
// 2026-11-10 follow-up: the ride suppression concerns the BOTTOM dock
|
|
514
|
+
// only — that is the one living inside the masked viewport band. A
|
|
515
|
+
// `dockTop` dock is not on that edge, so the container publishes the
|
|
516
|
+
// dock-edge fade for it as usual, flipping the attribute to "top"
|
|
517
|
+
// (and, with no bottom slot, emitting no ride hook at all).
|
|
518
|
+
const vm = mountDock(
|
|
519
|
+
{
|
|
520
|
+
default: () => h("div", { class: "content" }, "rows"),
|
|
521
|
+
dockTop: () => h("div", { class: "top-chrome" }, "top"),
|
|
522
|
+
},
|
|
523
|
+
{ dockMode: "ride" },
|
|
524
|
+
);
|
|
525
|
+
const host = vm.$el as HTMLElement;
|
|
526
|
+
expect(host.getAttribute("data-dock-fade")).toBe("top");
|
|
527
|
+
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
528
|
+
expect(host.querySelector(".hk-scroll-dock-ride")).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
|
|
408
531
|
it("rides when the axis is both", () => {
|
|
409
532
|
const vm = mountDock(MOUNT_SLOTS, { dockMode: "ride", axis: "both" });
|
|
410
533
|
const host = vm.$el as HTMLElement;
|
|
@@ -443,7 +566,7 @@ describe("HkScrollContainer ride-mode bottom dock", () => {
|
|
|
443
566
|
expect(host.getAttribute("data-dock-mode")).toBeNull();
|
|
444
567
|
});
|
|
445
568
|
|
|
446
|
-
it("leaves the pin placement
|
|
569
|
+
it("leaves the pin placement structurally compatible", () => {
|
|
447
570
|
// dockMode is additive: the default must keep today's DOM exactly —
|
|
448
571
|
// sibling dock, no spacer, no ride class, dock fade published, both
|
|
449
572
|
// measured-height vars on the host.
|
|
@@ -501,6 +624,10 @@ describe("HkScrollContainer ride placement style contract", () => {
|
|
|
501
624
|
// lands it UNDER the dock. `scroll-padding-bottom` belongs on the
|
|
502
625
|
// scrollport (the viewport) and is exactly this band; keyed on the ride
|
|
503
626
|
// hook, which is emitted only while the dock is mounted.
|
|
627
|
+
//
|
|
628
|
+
// `scss` is the comment-stripped sheet (see the module const): the
|
|
629
|
+
// 2026-11-10 mutation check found that commenting the rule out left
|
|
630
|
+
// this test green — the regex happily matched the commented text.
|
|
504
631
|
const rule = scss.match(
|
|
505
632
|
/\.hk-scroll-container\[data-dock-mode="ride"\] > \.hk-scroll-container-viewport \{[^}]*\}/,
|
|
506
633
|
);
|
|
@@ -510,6 +637,40 @@ describe("HkScrollContainer ride placement style contract", () => {
|
|
|
510
637
|
);
|
|
511
638
|
});
|
|
512
639
|
|
|
640
|
+
it("suppresses the dockTailClearance spacer while a ride dock is mounted", () => {
|
|
641
|
+
// 2026-11-10 follow-up S3 (flagged by two review rounds): with
|
|
642
|
+
// `dockTailClearance` on AND a ride dock mounted, the tail spacer's
|
|
643
|
+
// `::after` lined up BEHIND the ride dock in the content flow — two
|
|
644
|
+
// stacked tail reservations for one dock. Ride wins: the sheet drops
|
|
645
|
+
// the spacer box under the same two root hooks.
|
|
646
|
+
//
|
|
647
|
+
// happy-dom computes no CSS, so this is pinned the suite's usual way —
|
|
648
|
+
// the runtime precondition (both hooks on the same root) plus the rule
|
|
649
|
+
// itself as a source contract.
|
|
650
|
+
const vm = mountDock(MOUNT_SLOTS, { dockMode: "ride", dockTailClearance: true });
|
|
651
|
+
const host = vm.$el as HTMLElement;
|
|
652
|
+
expect(host.getAttribute("data-dock-mode")).toBe("ride");
|
|
653
|
+
expect(host.getAttribute("data-dock-tail")).toBe("true");
|
|
654
|
+
|
|
655
|
+
const generation = scss.match(
|
|
656
|
+
/\.hk-scroll-container\[data-dock-tail="true"\]:not\(\[data-axis="horizontal"\]\) > \.hk-scroll-container-viewport::after \{[^}]*\}/,
|
|
657
|
+
);
|
|
658
|
+
expect(generation, "the tail spacer generation rule exists").not.toBeNull();
|
|
659
|
+
const guard = scss.match(
|
|
660
|
+
/\.hk-scroll-container\[data-dock-mode="ride"\]\[data-dock-tail\] > \.hk-scroll-container-viewport::after \{[^}]*\}/,
|
|
661
|
+
);
|
|
662
|
+
expect(guard, "ride suppresses the tail spacer").not.toBeNull();
|
|
663
|
+
// `content: none` removes the box outright — a zero-height box could
|
|
664
|
+
// still perturb the layout.
|
|
665
|
+
expect(guard![0]).toMatch(/content:\s*none/);
|
|
666
|
+
// Equal specificity (0,4,0) with the generation rule, so source order
|
|
667
|
+
// is what makes the suppression win.
|
|
668
|
+
expect(
|
|
669
|
+
scss.indexOf(guard![0]),
|
|
670
|
+
"the suppression wins on source order, not on specificity",
|
|
671
|
+
).toBeGreaterThan(scss.indexOf(generation![0]));
|
|
672
|
+
});
|
|
673
|
+
|
|
513
674
|
it("zeroes the container's own bottom fade band in ride mode", () => {
|
|
514
675
|
// S2-c: the ride dock lives INSIDE the viewport at its trailing edge,
|
|
515
676
|
// so the container's own fade mask dissolves the dock's lower limb
|
|
@@ -717,6 +878,24 @@ describe("HkKanban ride-dock forwarding", () => {
|
|
|
717
878
|
expect(vm.$el.querySelector(".hk-scroll-dock-ride")).toBeNull();
|
|
718
879
|
});
|
|
719
880
|
|
|
881
|
+
it("emits no ride hook for an empty board (the strip is not mounted at all)", () => {
|
|
882
|
+
// 2026-11-10 follow-up: with `lanes.length === 0` the whole strip —
|
|
883
|
+
// container, dock slot and all — is replaced by the empty slot, so no
|
|
884
|
+
// ride dock is ever mounted and the root must keep its pre-ride
|
|
885
|
+
// attribute set. The hook means "a ride dock is mounted", and here
|
|
886
|
+
// none is: the previously ungated emission contradicted the prop doc.
|
|
887
|
+
const vm = mountKanban(
|
|
888
|
+
{ axis: "vertical", dockMode: "ride", lanes: [] },
|
|
889
|
+
{
|
|
890
|
+
dockBottom: () => h("div", { class: "board-dock" }, "dock"),
|
|
891
|
+
empty: () => h("div", { class: "board-empty" }, "empty"),
|
|
892
|
+
},
|
|
893
|
+
);
|
|
894
|
+
expect(vm.$el.getAttribute("data-dock-mode")).toBeNull();
|
|
895
|
+
expect(vm.$el.querySelector(".hk-kanban-strip")).toBeNull();
|
|
896
|
+
expect(vm.$el.querySelector(".board-empty")).toBeTruthy();
|
|
897
|
+
});
|
|
898
|
+
|
|
720
899
|
it("ignores the slot on a horizontal board (the cross-axis tail)", () => {
|
|
721
900
|
// The default axis: pre-2026-11-10 shape — no forwarded dock slot and
|
|
722
901
|
// no ride chrome, because a horizontal strip's tail is its cross axis.
|
|
@@ -734,7 +913,7 @@ describe("HkKanban ride-dock forwarding", () => {
|
|
|
734
913
|
});
|
|
735
914
|
|
|
736
915
|
describe("HkKanban ride board content-height contract", () => {
|
|
737
|
-
it("releases the full-height lanes box under the ride hook only", () => {
|
|
916
|
+
it("releases the full-height lanes box under the ride hook only, via the direct-child chain", () => {
|
|
738
917
|
// S0, measured on a real board: in a column-flex strip viewport
|
|
739
918
|
// `min-height: 100%` makes the lanes BOX as tall as the viewport, so
|
|
740
919
|
// the in-flow spacer + sticky dock always sit below the fold — the dock
|
|
@@ -742,11 +921,21 @@ describe("HkKanban ride board content-height contract", () => {
|
|
|
742
921
|
// window's leftover (275.8px static / 563.8px scrolled against a 139px
|
|
743
922
|
// dock). Releasing it is the half of the contract that makes the air
|
|
744
923
|
// follow the CONTENT end.
|
|
924
|
+
//
|
|
925
|
+
// 2026-11-10 follow-up: the release selector is the DIRECT-CHILD chain
|
|
926
|
+
// (same shape as chest's consumer-side override). The earlier
|
|
927
|
+
// descendant form also released the lanes box of a board NESTED inside
|
|
928
|
+
// a card of the outer ride board — the inner board is not the ride
|
|
929
|
+
// host, and its strip viewport scrolls its own cards, so cutting its
|
|
930
|
+
// lanes short broke its lane chrome.
|
|
745
931
|
const release = kanbanScss.match(
|
|
746
|
-
/\.hk-kanban\[data-dock-mode="ride"\] \.hk-kanban-lanes \{[^}]*\}/,
|
|
932
|
+
/\.hk-kanban\[data-dock-mode="ride"\] > \.hk-kanban-strip > \.hk-scroll-container-viewport > \.hk-kanban-lanes \{[^}]*\}/,
|
|
747
933
|
);
|
|
748
934
|
expect(release, "the ride boards release their lanes min-height").not.toBeNull();
|
|
749
935
|
expect(release![0]).toMatch(/min-height:\s*0/);
|
|
936
|
+
// And the descendant spelling stays gone: reintroducing it (the
|
|
937
|
+
// mutation) would leak the release into nested boards again.
|
|
938
|
+
expect(kanbanScss).not.toMatch(/\.hk-kanban\[data-dock-mode="ride"\] \.hk-kanban-lanes/);
|
|
750
939
|
// The base rule keeps the fill for every other mode (pin, horizontal
|
|
751
940
|
// boards): a short board still reaches the strip's floor there.
|
|
752
941
|
const base = kanbanScss.match(/\.hk-kanban-lanes \{[^}]*\}/);
|
|
@@ -342,6 +342,14 @@
|
|
|
342
342
|
geometry is unchanged by this line (measured). */
|
|
343
343
|
inset-inline-start: 0;
|
|
344
344
|
flex: none;
|
|
345
|
+
/* WEAK STACKING GUARANTEE (2026-11-10 follow-up): 1 stays a literal, not
|
|
346
|
+
* a `--hk-scroll-dock-ride-z` variable. It only lifts the dock above the
|
|
347
|
+
* PLAIN in-flow content scrolling under it; a descendant of the viewport
|
|
348
|
+
* that is itself positioned with z-index >= 2 (a floating toolbar, a drag
|
|
349
|
+
* layer) paints OVER the pinned dock. No shipped consumer stacks one
|
|
350
|
+
* inside the dock band, and a variable would add an override surface
|
|
351
|
+
* before a second consumer needs it (additive-chrome discipline) —
|
|
352
|
+
* promote it to a variable if one appears. */
|
|
345
353
|
z-index: 1;
|
|
346
354
|
/* Transparent band: the dock surface inside opts back in (see the
|
|
347
355
|
* pointer contract above). */
|
|
@@ -429,6 +437,27 @@
|
|
|
429
437
|
height: var(--hk-scroll-tail-bottom, 0px);
|
|
430
438
|
}
|
|
431
439
|
|
|
440
|
+
/* ── Ride wins over the tail spacer (mutual exclusion) ─────────────
|
|
441
|
+
2026-11-10 follow-up S3, flagged by two review rounds: with
|
|
442
|
+
`dockTailClearance` on AND a ride dock mounted, the spacer above lined
|
|
443
|
+
up BEHIND the ride dock (in flow after it) and the tail got TWO
|
|
444
|
+
stacked reservations — the shell-published `--hk-scroll-tail-bottom`
|
|
445
|
+
band plus the dock's own measured box. The two props answer the same
|
|
446
|
+
question for two different dock geometries: the clearance spacer is
|
|
447
|
+
for a dock FLOATING OVER the scroll area from outside the flow, ride
|
|
448
|
+
mounts the dock INSIDE it and spends its measured box as the air. So
|
|
449
|
+
while a ride dock is actually mounted (the root carries BOTH
|
|
450
|
+
`data-dock-mode="ride"` and `data-dock-tail`) the spacer box is not
|
|
451
|
+
generated at all. Same specificity (0,4,0) as the generation rule
|
|
452
|
+
above and declared after it, so source order decides; `content: none`
|
|
453
|
+
removes the box outright rather than shrinking it (a zero-height box
|
|
454
|
+
could still perturb the layout). The shrink pin above stays: it is
|
|
455
|
+
harmless under ride and still load-bearing while the prop serves a
|
|
456
|
+
floating dock (no ride hook present). */
|
|
457
|
+
.hk-scroll-container[data-dock-mode="ride"][data-dock-tail] > .hk-scroll-container-viewport::after {
|
|
458
|
+
content: none;
|
|
459
|
+
}
|
|
460
|
+
|
|
432
461
|
|
|
433
462
|
/* ── Selection edge hint (selectionHint opt-in) ────────────────────
|
|
434
463
|
A list taller than its viewport hides its SELECTED row as soon as it
|
|
@@ -281,7 +281,19 @@ export default defineComponent({
|
|
|
281
281
|
* accepted cost: a child that legitimately needs main-axis shrink
|
|
282
282
|
* inside an opted-in container (a nested pane meant to fit the
|
|
283
283
|
* viewport and scroll internally) cannot shrink; consumers escape
|
|
284
|
-
* with a >=(0,2,0) override loaded after the library CSS.
|
|
284
|
+
* with a >=(0,2,0) override loaded after the library CSS.
|
|
285
|
+
*
|
|
286
|
+
* MUTUALLY EXCLUSIVE WITH `dockMode="ride"` (2026-11-10 follow-up):
|
|
287
|
+
* the two props answer the same tail question for two different dock
|
|
288
|
+
* geometries — this spacer reserves a band for a dock FLOATING OVER
|
|
289
|
+
* the scroll area from outside the flow, ride mounts the dock inside
|
|
290
|
+
* it and spends its measured box as the air. With both armed and a
|
|
291
|
+
* ride dock actually mounted, the tail spacer is NOT generated (the
|
|
292
|
+
* sheet drops the viewport's `::after` under
|
|
293
|
+
* `[data-dock-mode="ride"][data-dock-tail]`) — ride wins, the two
|
|
294
|
+
* reservations would stack into a double-clear band. The shrink pin
|
|
295
|
+
* above stays: harmless under ride, still load-bearing while this
|
|
296
|
+
* prop serves a floating dock. */
|
|
285
297
|
dockTailClearance: { type: Boolean, default: false },
|
|
286
298
|
/** Where a mounted `dockBottom` dock LIVES (2026-11-10 user ruling):
|
|
287
299
|
*
|
|
@@ -326,7 +338,8 @@ export default defineComponent({
|
|
|
326
338
|
* its only effect (measured: identical to the pin board's 184px,
|
|
327
339
|
* and worse at the scroll end). HkKanban releases its own
|
|
328
340
|
* `.hk-kanban-lanes` box for a ride host
|
|
329
|
-
* (`.hk-kanban[data-dock-mode="ride"] .hk-kanban-
|
|
341
|
+
* (`.hk-kanban[data-dock-mode="ride"] > .hk-kanban-strip >
|
|
342
|
+
* .hk-scroll-container-viewport > .hk-kanban-lanes
|
|
330
343
|
* { min-height: 0 }`); a custom host has to do the same.
|
|
331
344
|
* - `autoFollow` IS NOT SUPPORTED TOGETHER WITH RIDE (documented,
|
|
332
345
|
* not policed beyond a dev warning): autoFollow bottom-anchors a
|
|
@@ -381,6 +394,13 @@ export default defineComponent({
|
|
|
381
394
|
* SLOT CONTENT THAT MUST CATCH EVENTS HAS TO DECLARE
|
|
382
395
|
* `pointer-events: auto` ON THE PART THAT CATCHES THEM — the
|
|
383
396
|
* wrapper does not hand interactivity to a generic slot child.
|
|
397
|
+
* - MUTUALLY EXCLUSIVE WITH `dockTailClearance` (2026-11-10
|
|
398
|
+
* follow-up): that prop reserves a tail band for a dock FLOATING
|
|
399
|
+
* OVER the scroll area from outside the flow; ride mounts the
|
|
400
|
+
* dock inside it. With both set and a ride dock mounted, the
|
|
401
|
+
* tail spacer is not generated (the sheet drops the viewport's
|
|
402
|
+
* `::after` under the same two root hooks) and ride wins — the
|
|
403
|
+
* stacked reservations would double-clear the same tail.
|
|
384
404
|
*
|
|
385
405
|
* Ignored (falls back to `pin`) on `axis="horizontal"` containers:
|
|
386
406
|
* there the content tail is the cross axis and an in-flow block
|
|
@@ -1372,7 +1392,7 @@ export default defineComponent({
|
|
|
1372
1392
|
// owns the viewport's bottom edge — so the sheet must keep fading that
|
|
1373
1393
|
// edge and HkKanban must keep its full-height lanes box (S2-2).
|
|
1374
1394
|
const rideDockMounted = !!dockBottomSlot && rideBottom;
|
|
1375
|
-
if (import.meta.env
|
|
1395
|
+
if (import.meta.env?.DEV && !warnedAutoFollowRide && rideDockMounted && props.autoFollow) {
|
|
1376
1396
|
warnedAutoFollowRide = true;
|
|
1377
1397
|
// eslint-disable-next-line no-console -- dev-only unsupported-combination alarm
|
|
1378
1398
|
console.warn(
|
|
@@ -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 =
|
|
313
|
+
const plate = luminanceOf(hexChannels(color));
|
|
276
314
|
const ink = contrastTextColor(color);
|
|
277
|
-
expect(["#fff", "#0b1220"]).toContain(ink);
|
|
278
|
-
expect(
|
|
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 =
|
|
282
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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?\((
|
|
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 =
|
|
124
|
-
const saturation =
|
|
125
|
-
const lightness =
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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",
|