@celestia-island/hikari 0.55.223 → 0.55.225

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.223",
3
+ "version": "0.55.225",
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",
@@ -93,6 +93,11 @@
93
93
  }
94
94
 
95
95
  .hk-alert-text {
96
+ /* Alert body = the same copy-this-error-detail content class as the
97
+ * toast message and message-box text (L2 selection policy re-enables,
98
+ * 2026-10-29); the title above stays chrome. */
99
+ -webkit-user-select: text;
100
+ user-select: text;
96
101
  margin: 0;
97
102
  line-height: 1.5;
98
103
  color: color-mix(in srgb, var(--hi-color-text-secondary, #666666) 85%, transparent);
@@ -58,10 +58,17 @@
58
58
  }
59
59
 
60
60
  .hk-blocking-toast-title {
61
+ /* Content, not chrome — copy-worthy detail under the L2 selection
62
+ * policy (2026-10-29): the card shell denies, these two text blocks
63
+ * select again. */
64
+ -webkit-user-select: text;
65
+ user-select: text;
61
66
  font-weight: 700;
62
67
  }
63
68
 
64
69
  .hk-blocking-toast-message {
70
+ -webkit-user-select: text;
71
+ user-select: text;
65
72
  margin: 0;
66
73
  display: -webkit-box;
67
74
  -webkit-line-clamp: 5;
@@ -15,6 +15,10 @@
15
15
  }
16
16
 
17
17
  .hk-confirm-dialog-message {
18
+ /* Dialog message = copy-worthy content, same class as the message-box
19
+ * text (L2 selection policy re-enables, 2026-10-29). */
20
+ -webkit-user-select: text;
21
+ user-select: text;
18
22
  margin: 0;
19
23
  color: color-mix(in srgb, var(--hi-color-text-secondary) 85%, transparent);
20
24
  font-size: var(--text-sm, 0.8125rem);
@@ -67,6 +67,10 @@
67
67
  }
68
68
 
69
69
  .hk-diff-viewer__code {
70
+ /* The diff body is content (review text users copy into replies);
71
+ * the line-number gutter above and the sign column stay denied. */
72
+ -webkit-user-select: text;
73
+ user-select: text;
70
74
  padding-inline-end: 1ch;
71
75
  min-width: 0;
72
76
  }
@@ -45,6 +45,11 @@
45
45
  }
46
46
 
47
47
  .s-jt-row {
48
+ /* Tree rows are content — values users copy out of a payload — while
49
+ * the pane chrome above stays denied (L2 selection policy, 2026-10-29;
50
+ * the copy-tree affordance remains the bulk path). */
51
+ -webkit-user-select: text;
52
+ user-select: text;
48
53
  /* Baseline alignment keeps the small type hints (boolean/number/string)
49
54
  on the value's text baseline instead of floating above it. */
50
55
  display: flex;
@@ -117,6 +117,12 @@
117
117
  }
118
118
 
119
119
  .s-log-entry {
120
+ /* Log lines are the canonical copy-me content (diagnostics users
121
+ * select per-fragment, not just via the toolbar copy-all) — re-enable
122
+ * under the L2 selection policy (2026-10-29); the toolbar chrome
123
+ * around them stays denied. */
124
+ -webkit-user-select: text;
125
+ user-select: text;
120
126
  padding: var(--space-2, 0.125rem) var(--space-4, 0.25rem);
121
127
  font-family: var(--font-mono, vars.$hikari-font-family-mono);
122
128
  font-size: var(--text-xs, 0.75rem);
@@ -2,6 +2,11 @@
2
2
  @use "./hikari-vars" as vars;
3
3
 
4
4
  .hk-markdown {
5
+ /* Rendered prose is content by definition — the whole subtree selects
6
+ * again under the L2 selection policy (2026-10-29); its code blocks
7
+ * are covered twice over (whitelist tier 3). */
8
+ -webkit-user-select: text;
9
+ user-select: text;
5
10
  font-size: var(--text-base);
6
11
  line-height: 1.7;
7
12
  color: var(--hi-color-text-primary, #1e1e1e);
@@ -25,6 +25,11 @@
25
25
  }
26
26
 
27
27
  .hk-message-box-text {
28
+ /* Dialog message = content the user may need to copy (error detail),
29
+ * not chrome — whitelisted under the L2 selection policy (2026-10-29).
30
+ * The prompt's field below is covered by the editable-controls tier. */
31
+ -webkit-user-select: text;
32
+ user-select: text;
28
33
  margin: 0;
29
34
  color: rgb(var(--color-text));
30
35
  font-size: var(--text-base, 15px);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Source contract for the scroll-container edge fades (the 2026-10
3
+ * revision): each edge owns a CSS var and the viewport mask INTERSECTS a
4
+ * vertical and a horizontal gradient, so a two-axis container fades on all
5
+ * four sides and the corners fall off naturally.
6
+ *
7
+ * The regression this pins (user report: "the light effect is clipped at
8
+ * the two sides"): the old sheet wrote ONE `mask-image` per axis, so the
9
+ * last matching rule won, the other axis never faded, and its band ended in
10
+ * a hard cut. Vertical fades were missing entirely. Hence:
11
+ * - exactly ONE mask declaration pair lives in the sheet (the composed
12
+ * block on the viewport) — a new per-axis `mask-image` rule is drift;
13
+ * - all four edge vars are declared (top/right/bottom/left, 0px base);
14
+ * - start/end/both rules exist for BOTH axes;
15
+ * - RTL swaps the horizontal vars instead of rewriting the mask.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const sheet = readFileSync(join(here, "HkScrollContainer.scss"), "utf-8");
24
+
25
+ describe("scroll fade composition contract", () => {
26
+ it("declares one composed mask on the viewport (no per-axis overrides)", () => {
27
+ const declarations = sheet.match(/^\s*-?(?:webkit-)?mask-image:/gm) ?? [];
28
+ // The composed block owns exactly one pair: -webkit-mask-image + mask-image.
29
+ expect(declarations.length).toBe(2);
30
+ expect(sheet).toContain("mask-composite: intersect");
31
+ expect(sheet).toContain("-webkit-mask-composite: source-in");
32
+ });
33
+
34
+ it("declares the four edge vars with zero defaults", () => {
35
+ for (const edge of ["top", "right", "bottom", "left"]) {
36
+ expect(sheet).toContain(`--hk-scroll-fade-${edge}: 0px;`);
37
+ }
38
+ });
39
+
40
+ it("fades both axes from the sensed overflow", () => {
41
+ for (const axis of ["v", "h"]) {
42
+ for (const state of ["start", "end", "both"]) {
43
+ expect(sheet).toContain(
44
+ `[data-${axis}-overflow="${state}"]`,
45
+ );
46
+ }
47
+ }
48
+ // The vertical half is what the old sheet was missing entirely.
49
+ expect(sheet).toContain('--hk-scroll-fade-top: var(--hk-scroll-fade-size)');
50
+ expect(sheet).toContain('--hk-scroll-fade-bottom: var(--hk-scroll-fade-size)');
51
+ });
52
+
53
+ it("swaps horizontal sides under RTL instead of rewriting the mask", () => {
54
+ expect(sheet).toContain(
55
+ '[dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="start"]',
56
+ );
57
+ expect(sheet).toContain(
58
+ '[dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="end"]',
59
+ );
60
+ });
61
+
62
+ it("routes the dock fades through the same edge vars", () => {
63
+ expect(sheet).toContain('--hk-scroll-fade-bottom: var(--hk-scroll-dock-fade, 2rem)');
64
+ expect(sheet).toContain('--hk-scroll-fade-top: var(--hk-scroll-dock-fade, 2rem)');
65
+ });
66
+ });
@@ -39,18 +39,104 @@
39
39
  margin-inline: auto;
40
40
  }
41
41
 
42
- /* ── Optional edge fades (fade=true) ──────────────────────────────
43
- Pure CSS driven by the sensed overflow state the component mirrors
44
- onto the root (data-h-overflow). Only the sides with hidden
45
- content fade. The fade size custom property is declared on the
46
- root so consumers can override it from the container element. */
42
+ /* ── Optional edge fades (fade=true / dock fades) ─────────────────
43
+ FOUR-EDGE composition (2026-10 revision): each edge owns a CSS var, and
44
+ the viewport's mask INTERSECTS a vertical and a horizontal gradient, so
45
+ the fade reaches zero on every side that hides content and the corners
46
+ fall off naturally in all four directions. The previous per-axis rules
47
+ overwrote one `mask-image` property, so a two-axis container only faded
48
+ one axis AND the band ended in a hard cut at the other axis's edges
49
+ (user report: the light effect is clipped at the two sides). Vertical
50
+ fades were missing entirely. Dock fades ride the same vars, which also
51
+ removes their old mask-override conflict. */
52
+ .hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
53
+ --hk-scroll-fade-top: 0px;
54
+ --hk-scroll-fade-right: 0px;
55
+ --hk-scroll-fade-bottom: 0px;
56
+ --hk-scroll-fade-left: 0px;
57
+ -webkit-mask-image:
58
+ linear-gradient(
59
+ to bottom,
60
+ transparent 0,
61
+ #000 var(--hk-scroll-fade-top),
62
+ #000 calc(100% - var(--hk-scroll-fade-bottom)),
63
+ transparent 100%
64
+ ),
65
+ linear-gradient(
66
+ to right,
67
+ transparent 0,
68
+ #000 var(--hk-scroll-fade-left),
69
+ #000 calc(100% - var(--hk-scroll-fade-right)),
70
+ transparent 100%
71
+ );
72
+ -webkit-mask-composite: source-in;
73
+ -webkit-mask-repeat: no-repeat;
74
+ mask-image:
75
+ linear-gradient(
76
+ to bottom,
77
+ transparent 0,
78
+ #000 var(--hk-scroll-fade-top),
79
+ #000 calc(100% - var(--hk-scroll-fade-bottom)),
80
+ transparent 100%
81
+ ),
82
+ linear-gradient(
83
+ to right,
84
+ transparent 0,
85
+ #000 var(--hk-scroll-fade-left),
86
+ #000 calc(100% - var(--hk-scroll-fade-right)),
87
+ transparent 100%
88
+ );
89
+ mask-composite: intersect;
90
+ mask-repeat: no-repeat;
91
+ }
92
+
47
93
  .hk-scroll-container[data-fade="true"] {
48
94
  --hk-scroll-fade-size: var(--space-24);
49
95
  }
50
96
 
51
- .hk-scroll-container[data-fade="true"][data-h-overflow="end"] > .hk-scroll-container-viewport {
52
- -webkit-mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
53
- mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
97
+ /* Vertical axis — the half the old implementation never had. */
98
+ .hk-scroll-container[data-fade="true"][data-v-overflow="start"] {
99
+ --hk-scroll-fade-top: var(--hk-scroll-fade-size);
100
+ }
101
+
102
+ .hk-scroll-container[data-fade="true"][data-v-overflow="end"] {
103
+ --hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
104
+ }
105
+
106
+ .hk-scroll-container[data-fade="true"][data-v-overflow="both"] {
107
+ --hk-scroll-fade-top: var(--hk-scroll-fade-size);
108
+ --hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
109
+ }
110
+
111
+ /* Horizontal axis (LTR: inline start = left). */
112
+ .hk-scroll-container[data-fade="true"][data-h-overflow="start"] {
113
+ --hk-scroll-fade-left: var(--hk-scroll-fade-size);
114
+ }
115
+
116
+ .hk-scroll-container[data-fade="true"][data-h-overflow="end"] {
117
+ --hk-scroll-fade-right: var(--hk-scroll-fade-size);
118
+ }
119
+
120
+ .hk-scroll-container[data-fade="true"][data-h-overflow="both"] {
121
+ --hk-scroll-fade-left: var(--hk-scroll-fade-size);
122
+ --hk-scroll-fade-right: var(--hk-scroll-fade-size);
123
+ }
124
+
125
+ /* RTL: the inline start edge is the right side, so the horizontal vars
126
+ swap instead of the mask (the mask itself is direction-neutral). */
127
+ [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="start"] {
128
+ --hk-scroll-fade-left: 0px;
129
+ --hk-scroll-fade-right: var(--hk-scroll-fade-size);
130
+ }
131
+
132
+ [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="end"] {
133
+ --hk-scroll-fade-right: 0px;
134
+ --hk-scroll-fade-left: var(--hk-scroll-fade-size);
135
+ }
136
+
137
+ [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="both"] {
138
+ --hk-scroll-fade-left: var(--hk-scroll-fade-size);
139
+ --hk-scroll-fade-right: var(--hk-scroll-fade-size);
54
140
  }
55
141
 
56
142
  /* ── Anchored docks (dockTop / dockBottom slots) ─────────────────
@@ -74,14 +160,12 @@
74
160
  instead of a hard clip against the dock's top edge. Content never
75
161
  sits under the dock, so this fades the viewport's last few pixels,
76
162
  not the dock itself. */
77
- .hk-scroll-container[data-dock-fade="bottom"] > .hk-scroll-container-viewport {
78
- -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
79
- mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
163
+ .hk-scroll-container[data-dock-fade="bottom"] {
164
+ --hk-scroll-fade-bottom: var(--hk-scroll-dock-fade, 2rem);
80
165
  }
81
166
 
82
- .hk-scroll-container[data-dock-fade="top"] > .hk-scroll-container-viewport {
83
- -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
84
- mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
167
+ .hk-scroll-container[data-dock-fade="top"] {
168
+ --hk-scroll-fade-top: var(--hk-scroll-dock-fade, 2rem);
85
169
  }
86
170
 
87
171
  /* ── Floating-dock tail clearance (dockTailClearance opt-in) ──────
@@ -135,39 +219,7 @@
135
219
  }
136
220
 
137
221
 
138
- .hk-scroll-container[data-fade="true"][data-h-overflow="start"] > .hk-scroll-container-viewport {
139
- -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
140
- mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
141
- }
142
-
143
- /* RTL: the horizontal scroll start/end edges swap sides, so the
144
- * directional fades flip (the "both" mask is end-symmetric — no flip). */
145
- [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="end"] > .hk-scroll-container-viewport {
146
- -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
147
- mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
148
- }
149
222
 
150
- [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="start"] > .hk-scroll-container-viewport {
151
- -webkit-mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
152
- mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
153
- }
154
-
155
- .hk-scroll-container[data-fade="true"][data-h-overflow="both"] > .hk-scroll-container-viewport {
156
- -webkit-mask-image: linear-gradient(
157
- to right,
158
- transparent 0,
159
- #000 var(--hk-scroll-fade-size),
160
- #000 calc(100% - var(--hk-scroll-fade-size)),
161
- transparent 100%
162
- );
163
- mask-image: linear-gradient(
164
- to right,
165
- transparent 0,
166
- #000 var(--hk-scroll-fade-size),
167
- #000 calc(100% - var(--hk-scroll-fade-size)),
168
- transparent 100%
169
- );
170
- }
171
223
 
172
224
  /* autoFollow content is bottom-anchored while it fits the viewport: the
173
225
  newest row hugs the bottom edge (a tail-following console, e.g. the
@@ -420,6 +420,17 @@
420
420
  }
421
421
  }
422
422
 
423
+ /* Opt-out consumers get plain selectable cells back — explicit because
424
+ * the L2 selection policy (2026-10-29) denies at the root now: absence
425
+ * of the copy-cells deny rule no longer means selectable. Same shape as
426
+ * the copy layer, so "wholesale" is a per-cell mirror of the default. */
427
+ .hk-table:not([data-copy-cells]) {
428
+ .hk-table-cell {
429
+ -webkit-user-select: text;
430
+ user-select: text;
431
+ }
432
+ }
433
+
423
434
  /* The standard trailing row-actions column (HkRowActions trigger):
424
435
  fixed narrow track, centered, never wrapping. */
425
436
  .hk-table-manage-cell {
@@ -55,6 +55,12 @@
55
55
  }
56
56
 
57
57
  .hk-toast-message {
58
+ /* The message text is content — error detail a user copies out of a
59
+ * failing app — not chrome. Re-enable inside the denied toast shell
60
+ * (L2 selection policy, 2026-10-29): the shell keeps its none, the
61
+ * message subtree selects again. */
62
+ -webkit-user-select: text;
63
+ user-select: text;
58
64
  flex: 1;
59
65
  min-width: 0;
60
66
  display: -webkit-box;
@@ -128,17 +134,21 @@
128
134
  to { transform: rotate(360deg); }
129
135
  }
130
136
 
131
- /* Inline action (e.g. Undo): a compact text pill in the toast chrome,
132
- * between the body and the icon buttons. Sits in the same ghost-button
133
- * family as copy/close, but reads as an affordance. */
137
+ /* Inline action (e.g. Undo / open analysis): a GLASS PILL on the toast's
138
+ * solid band. The old ghost outline sat flat on the accent fill — "a grey
139
+ * outlined transparent button", user report 2026-10 — because it borrowed
140
+ * the page-surface border tokens on a filled surface. A currentColor tint
141
+ * follows every variant (white on the coloured bands, near-black on the
142
+ * amber warning band) and reads as a real affordance without introducing
143
+ * a second accent colour. */
134
144
  .hk-toast-action {
135
145
  flex: none;
136
146
  align-self: center;
137
147
  margin-inline-start: var(--space-4, 4px);
138
- padding: var(--space-2, 2px) var(--space-8, 8px);
139
- border: 1px solid rgb(var(--color-border, 100 100 100) / 30%);
140
- border-radius: var(--radius-sm, 4px);
141
- background: transparent;
148
+ padding: var(--space-4, 4px) var(--space-10, 10px);
149
+ border: 1px solid color-mix(in srgb, currentColor 38%, transparent);
150
+ border-radius: var(--radius-full, 9999px);
151
+ background: color-mix(in srgb, currentColor 14%, transparent);
142
152
  color: inherit;
143
153
  font: inherit;
144
154
  font-size: var(--text-sm, 0.8125rem);
@@ -146,14 +156,18 @@
146
156
  line-height: 1.4;
147
157
  cursor: pointer;
148
158
  white-space: nowrap;
149
- opacity: 0.92;
150
- transition: opacity var(--duration-fast, 0.15s) ease,
159
+ transition:
160
+ background var(--duration-fast, 0.15s) ease,
151
161
  border-color var(--duration-fast, 0.15s) ease;
152
162
  }
153
163
 
154
164
  .hk-toast-action:hover {
155
- opacity: 1;
156
- border-color: rgb(var(--color-border, 100 100 100) / 55%);
165
+ background: color-mix(in srgb, currentColor 24%, transparent);
166
+ border-color: color-mix(in srgb, currentColor 55%, transparent);
167
+ }
168
+
169
+ .hk-toast-action:active {
170
+ background: color-mix(in srgb, currentColor 32%, transparent);
157
171
  }
158
172
 
159
173
  .hk-toast-action:focus-visible {
@@ -720,10 +720,11 @@
720
720
  // stays enabled even when the window controls degraded.
721
721
  button.disabled = false;
722
722
  button.removeAttribute("aria-disabled");
723
- // Right-hand cluster, immediately LEFT of close: closing keeps the frame
724
- // corner (the familiar target), so a user reaching for it on a hung window
725
- // cannot hit reload by mistake.
726
- actions.insertBefore(button, closeBtn);
723
+ // Reload LEADS the cluster (2026-10-29 user direction): the affordance
724
+ // sits ahead of the window controls — [reload][minimize][maximize][close]
725
+ // — so the three caption buttons (and the close frame corner) keep their
726
+ // familiar trailing positions.
727
+ actions.insertBefore(button, minimizeBtn);
727
728
  reloadBtn = button;
728
729
  return button;
729
730
  }
@@ -396,19 +396,20 @@ describe("boot-chrome.js handoff and watchdog", () => {
396
396
  expect(control("close").disabled).toBe(false);
397
397
  });
398
398
 
399
- it("puts the reload affordance at the right edge, next to close (S3-5)", () => {
399
+ it("puts the reload affordance ahead of the window controls (2026-10-29 user direction)", () => {
400
400
  useWindowApi(fakeWindowApi());
401
401
  setGlobal("__TAURI_INTERNALS__", { invoke: vi.fn() });
402
402
  runBootChrome({ hangMs: 1000 });
403
403
 
404
404
  vi.advanceTimersByTime(1000);
405
405
 
406
- // Right-hand cluster, immediately left of close: closing keeps the frame
407
- // corner, so a mis-aimed click on a hung window cannot reload it.
406
+ // Reload LEADS the cluster — [reload][min][max][close] — so the three
407
+ // caption buttons (and the close frame corner) keep their familiar
408
+ // trailing positions instead of the reload splitting max from close.
408
409
  expect(controls().map((button) => button.getAttribute("data-act"))).toEqual([
410
+ "reload",
409
411
  "minimize",
410
412
  "maximize",
411
- "reload",
412
413
  "close",
413
414
  ]);
414
415
  });
@@ -6,6 +6,9 @@
6
6
  // 1. theme/channels.scss — the static seed: --color-* channel palette,
7
7
  // --hi-color-* derivations, --c-*/--shadow-*/--border-* aliases and
8
8
  // the icon-button bridge (vendored from src/tokens.scss).
9
+ // 1b. selection.scss — the selection policy: deny-by-default at the
10
+ // root (chrome text never takes a drag selection), whitelisted back
11
+ // for editable controls and the .hk-selectable content hook.
9
12
  // 2. theme/scale.scss — the L2 scale tokens: spacing, type, radius,
10
13
  // blur, opacity, durations, easings, z bands, fonts (vendored from
11
14
  // src/scale.scss).
@@ -28,6 +31,7 @@
28
31
  // version of this file re-exported packages/theme + packages/components
29
32
  // through paths that escape the npm package and did not compile.
30
33
  @use "./reset.scss";
34
+ @use "./selection.scss";
31
35
  @use "./theme/channels.scss";
32
36
  @use "./theme/scale.scss";
33
37
  @use "../overlay/overlayRoot.scss";
@@ -0,0 +1,59 @@
1
+ /* ── Selection policy (L2) — deny by default, whitelist back ───────────
2
+ * 2026-10-29 user direction: UI chrome text (workspace menus, buttons,
3
+ * tabs, labels, rails, caption bars) must not take a drag selection.
4
+ * The old model was a blacklist — every component sheet opted out
5
+ * piecemeal, and any surface a sheet forgot shipped selectable until a
6
+ * screenshot complained. This sheet flips the default: the root denies,
7
+ * and only whitelisted subtrees select again.
8
+ *
9
+ * How one rule covers everything: `user-select` is not inherited, but
10
+ * its initial value `auto` RESOLVES against the parent's used value.
11
+ * A single `none` on `html` therefore determines every descendant
12
+ * (shadow trees included), and a whitelisted subtree declaring a
13
+ * concrete value re-enables its own descendants the same way — no
14
+ * `*` sweep, and component/host rules keep winning by specificity or
15
+ * order wherever they speak.
16
+ *
17
+ * The whitelist:
18
+ * 1. Editable controls — input, textarea, select, contenteditable:
19
+ * the user authored this text; selecting it is the point.
20
+ * 2. `.hk-selectable` — the opt-in utility for hosts and components:
21
+ * put it on any subtree whose text is CONTENT (chat bodies, logs,
22
+ * error details, IDs, prose, standalone code blocks) rather than
23
+ * chrome. This is the contract that replaces the old per-component
24
+ * blacklist.
25
+ *
26
+ * Deliberately NOT whitelisted: bare `pre` / `code` / `kbd` / `samp`.
27
+ * A concrete value on an element BEATS the ancestor's none-propagation,
28
+ * so an element-type whitelist would punch through chrome that embeds
29
+ * those elements — HkButton's HkKbd shortcut chip (denied `.hk-btn` on
30
+ * the button itself), or a `<code>` slotted into a click-to-copy table
31
+ * cell (the 2026-10-18 copy-cells contract). Content containers own
32
+ * their re-enables instead: hikari's prose/code components (markdown
33
+ * body, diff code, json-tree rows, log entries) self-declare
34
+ * `user-select: text` in their sheets, so vendored copies carry their
35
+ * own contract, and hosts mark standalone code/log surfaces with
36
+ * `.hk-selectable`.
37
+ *
38
+ * Order: SECOND in the canonical composition (index.scss), right after
39
+ * the reset — the same "only lowers defaults; component sheets and host
40
+ * styles override by specificity/order" discipline applies. A host that
41
+ * truly wants select-everything back sets `user-select: text` on its own
42
+ * root; marking content with `.hk-selectable` is the supported route.
43
+ */
44
+
45
+ html {
46
+ -webkit-user-select: none;
47
+ user-select: none;
48
+ }
49
+
50
+ input,
51
+ textarea,
52
+ select,
53
+ /* `i` flag: HTML enumerated attribute values are ASCII case-insensitive,
54
+ * so contenteditable="FALSE" (valid, means false) must be excluded too. */
55
+ [contenteditable]:not([contenteditable='false' i]),
56
+ .hk-selectable {
57
+ -webkit-user-select: text;
58
+ user-select: text;
59
+ }
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import * as sass from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Selection policy guard (2026-10-29 user direction).
10
+ *
11
+ * `selection.scss` flips the selection model from a blacklist (component
12
+ * sheets opting out piecemeal; anything a sheet forgot shipped selectable)
13
+ * to a whitelist: the root denies, editable controls / the `.hk-selectable`
14
+ * content hook / pre-code conventions select again, and hikari's own
15
+ * content-bearing components self-declare their re-enables in their sheets
16
+ * (toast message, markdown body, diff code, json-tree rows, table opt-out).
17
+ *
18
+ * The contract under test, per half:
19
+ * - the canonical composition loads the sheet SECOND (right after the
20
+ * reset, before any theme/component sheet can override);
21
+ * - the compiled policy carries the root deny and every whitelist tier
22
+ * (assertions run on the COMPILED sheet — compressed drops both
23
+ * comment styles, so raw-text matching cannot pass on a commented-out
24
+ * rule);
25
+ * - the component re-enables really exist in the compiled component
26
+ * sheets, with the chrome halves still denied.
27
+ */
28
+
29
+ const stylesDir = resolve(dirname(fileURLToPath(import.meta.url)));
30
+ const srcDir = resolve(stylesDir, "..");
31
+
32
+ function read(path: string): string {
33
+ return readFileSync(resolve(stylesDir, path), "utf8").replace(/\r\n/g, "\n");
34
+ }
35
+
36
+ function compile(path: string): string {
37
+ return sass.compile(resolve(stylesDir, path), {
38
+ loadPaths: [srcDir, join(srcDir, "components"), join(srcDir, "styles"), join(srcDir, "styles", "theme")],
39
+ style: "compressed",
40
+ silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
41
+ }).css;
42
+ }
43
+
44
+ describe("selection policy sheet", () => {
45
+ it("loads second in the canonical composition (right after the reset)", () => {
46
+ const uses = read("index.scss")
47
+ .split("\n")
48
+ .filter((line) => line.startsWith('@use "./'))
49
+ .map((line) => line.match(/^@use "([^"]+)"/)?.[1]);
50
+ expect(uses[0]).toBe("./reset.scss");
51
+ expect(uses[1]).toBe("./selection.scss");
52
+ });
53
+
54
+ it("denies selection at the root", () => {
55
+ const css = compile("selection.scss");
56
+ expect(css).toMatch(/html\s*\{[^}]*?-webkit-user-select:\s*none/);
57
+ expect(css).toMatch(/html\s*\{[^}]*?user-select:\s*none/);
58
+ });
59
+
60
+ it("whitelists exactly the editable controls and the content hook", () => {
61
+ const css = compile("selection.scss");
62
+ // Exact, comma-anchored selector list — substring matching would pass
63
+ // vacuously ("select" occurs inside every user-select declaration).
64
+ // Compressed output drops unnecessary attribute-quote characters.
65
+ const group = css.match(
66
+ /((?:input|textarea|select|\[[^\]]+\]|\.hk-selectable)[^{}]*)\{-webkit-user-select:\s*text;user-select:\s*text\}/,
67
+ );
68
+ expect(group).toBeTruthy();
69
+ expect(group![1].split(",").map((s) => s.trim())).toEqual([
70
+ "input",
71
+ "textarea",
72
+ "select",
73
+ "[contenteditable]:not([contenteditable=false i])",
74
+ ".hk-selectable",
75
+ ]);
76
+ // Element-type content conventions (pre/code/kbd/samp) stay OUT: a
77
+ // concrete value beats ancestor chrome denies, so a bare kbd/code
78
+ // whitelist punched through HkButton's denied shortcut chip and
79
+ // click-to-copy table cells (2026-10-29 review, S1).
80
+ expect(css).not.toMatch(/(^|[,\s])(pre|code|kbd|samp)([,\s]*\{|,)/);
81
+ });
82
+
83
+ it("re-enables the toast family message text (content, not chrome)", () => {
84
+ const css = compile("../components/HkToast.scss");
85
+ expect(css).toMatch(/\.hk-toast-message[^{]*\{[^}]*?-webkit-user-select:\s*text/);
86
+ // The shell stays denied — only the message subtree selects.
87
+ expect(css).toMatch(/\.hk-toast-item\s*\{[^}]*?user-select:\s*none/);
88
+ const cssBlocking = compile("../components/HkBlockingToast.scss");
89
+ expect(cssBlocking).toMatch(/\.hk-blocking-toast-title[^{]*\{[^}]*?user-select:\s*text/);
90
+ expect(cssBlocking).toMatch(/\.hk-blocking-toast-message[^{]*\{[^}]*?user-select:\s*text/);
91
+ const cssBox = compile("../components/HkMessageBox.scss");
92
+ expect(cssBox).toMatch(/\.hk-message-box-text[^{]*\{[^}]*?user-select:\s*text/);
93
+ });
94
+
95
+ it("re-enables the markdown body and the diff code, keeping the diff gutter denied", () => {
96
+ const css = compile("../components/HkMarkdownRenderer.scss");
97
+ expect(css).toMatch(/\.hk-markdown\s*\{[^}]*?user-select:\s*text/);
98
+ const cssDiff = compile("../components/HkDiffViewer.scss");
99
+ expect(cssDiff).toMatch(/\.hk-diff-viewer__code[^{]*\{[^}]*?user-select:\s*text/);
100
+ expect(cssDiff).toMatch(/\.hk-diff-viewer__no[^{]*\{[^}]*?user-select:\s*none/);
101
+ expect(cssDiff).toMatch(/\.hk-diff-viewer__sign[^{]*\{[^}]*?user-select:\s*none/);
102
+ });
103
+
104
+ it("re-enables json-tree rows inside the denied pane chrome", () => {
105
+ const css = compile("../components/HkJsonTree.scss");
106
+ expect(css).toMatch(/\.s-jt-row[^{]*\{[^}]*?user-select:\s*text/);
107
+ expect(css).toMatch(/\.s-tool-json-tree[^{]*\{[^}]*?user-select:\s*none/);
108
+ });
109
+
110
+ it("re-enables the alert body and the confirm-dialog message", () => {
111
+ // Same copy-this-error-detail content class as the toast/message-box
112
+ // surfaces (2026-10-29 review, S2: the parallel surfaces were missed
113
+ // in the first cut).
114
+ const cssAlert = compile("../components/HkAlert.scss");
115
+ expect(cssAlert).toMatch(/\.hk-alert-text[^{]*\{[^}]*?user-select:\s*text/);
116
+ const cssConfirm = compile("../components/HkConfirmDialog.scss");
117
+ expect(cssConfirm).toMatch(
118
+ /\.hk-confirm-dialog-message[^{]*\{[^}]*?user-select:\s*text/,
119
+ );
120
+ const cssLog = compile("../components/HkLogWindow.scss");
121
+ expect(cssLog).toMatch(/\.s-log-entry[^{]*\{[^}]*?user-select:\s*text/);
122
+ });
123
+
124
+ it("keeps the table contract two-sided under the deny-by-default root", () => {
125
+ // 2026-10-18 contract: copy-cells (default) denies cells with the
126
+ // click-to-copy cursor; the opt-out now needs an explicit mirror —
127
+ // absence of a deny no longer means selectable once the root denies.
128
+ const css = compile("../components/HkTable.scss");
129
+ expect(css).toMatch(/\.hk-table\[data-copy-cells\] \.hk-table-cell\{[^}]*?user-select:\s*none/);
130
+ expect(css).toMatch(
131
+ /\.hk-table:not\(\[data-copy-cells\]\) \.hk-table-cell\{[^}]*?user-select:\s*text/,
132
+ );
133
+ });
134
+ });