@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 +1 -1
- package/src/components/HkAlert.scss +5 -0
- package/src/components/HkBlockingToast.scss +7 -0
- package/src/components/HkConfirmDialog.scss +4 -0
- package/src/components/HkDiffViewer.scss +4 -0
- package/src/components/HkJsonTree.scss +5 -0
- package/src/components/HkLogWindow.scss +6 -0
- package/src/components/HkMarkdownRenderer.scss +5 -0
- package/src/components/HkMessageBox.scss +5 -0
- package/src/components/HkScrollContainer.fade.contract.test.ts +66 -0
- package/src/components/HkScrollContainer.scss +98 -46
- package/src/components/HkTable.scss +11 -0
- package/src/components/HkToast.scss +25 -11
- package/src/res/boot-chrome.js +5 -4
- package/src/res/bootChrome.test.ts +5 -4
- package/src/styles/index.scss +4 -0
- package/src/styles/selection.scss +59 -0
- package/src/styles/selectionSheet.test.ts +134 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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"]
|
|
78
|
-
-
|
|
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"]
|
|
83
|
-
-
|
|
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
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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-
|
|
139
|
-
border: 1px solid
|
|
140
|
-
border-radius: var(--radius-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
border-color:
|
|
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 {
|
package/src/res/boot-chrome.js
CHANGED
|
@@ -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
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
|
|
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
|
|
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
|
-
//
|
|
407
|
-
//
|
|
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
|
});
|
package/src/styles/index.scss
CHANGED
|
@@ -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
|
+
});
|