@celestia-island/hikari 0.55.300 → 0.55.301
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/HkIconButton.test.tsx +30 -0
- package/src/components/HkIconButton.tsx +8 -0
- package/src/components/HkPillToggleGroup.scss +34 -0
- package/src/components/HkPillToggleGroup.test.tsx +97 -0
- package/src/components/HkPillToggleGroup.tsx +10 -0
- package/src/components/HkRangeRibbon.scss +31 -25
- package/src/components/HkRangeRibbon.test.tsx +42 -2
- package/src/components/HkRangeRibbon.tsx +14 -5
- package/src/components/range-ribbon-reset.contract.test.ts +52 -0
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -122,6 +122,36 @@ describe("HkIconButton", () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
|
|
126
|
+
describe("HkIconButton button semantics", () => {
|
|
127
|
+
it("renders type=button so a form never treats the chrome as a submitter", () => {
|
|
128
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
|
|
129
|
+
const btn = c.querySelector<HTMLButtonElement>("button")!;
|
|
130
|
+
expect(btn.getAttribute("type")).toBe("button");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("never overwrites an explicit type", () => {
|
|
134
|
+
for (const explicit of ["submit", "reset"] as const) {
|
|
135
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: explicit } as never));
|
|
136
|
+
expect(c.querySelector<HTMLButtonElement>("button")!.getAttribute("type")).toBe(explicit);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the default when the attribute is explicitly undefined or empty", () => {
|
|
141
|
+
// The discriminating cases: a pre-fix ordering (default first, attrs
|
|
142
|
+
// after) elides the attribute for `undefined` and passes an empty
|
|
143
|
+
// string through — the first is an implicit submitter, the second an
|
|
144
|
+
// invalid value that ALSO defaults to submit.
|
|
145
|
+
for (const value of [undefined, null, ""] as const) {
|
|
146
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: value } as never));
|
|
147
|
+
expect(
|
|
148
|
+
c.querySelector<HTMLButtonElement>("button")!.getAttribute("type"),
|
|
149
|
+
`type=${JSON.stringify(value)} must fall back to button`,
|
|
150
|
+
).toBe("button");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
125
155
|
/**
|
|
126
156
|
* Static pin for the header-chrome glyph step (user report 2026-09-29:
|
|
127
157
|
* the search / refresh / filter glyphs in the top-right clusters read
|
|
@@ -61,6 +61,14 @@ export default defineComponent({
|
|
|
61
61
|
disabled={props.disabled}
|
|
62
62
|
onClick={(e: MouseEvent) => emit("click", e)}
|
|
63
63
|
{...attrs}
|
|
64
|
+
// A rendered <button> defaults to type=submit, which silently turns
|
|
65
|
+
// every icon-only control inside a <form> into a submitter. The
|
|
66
|
+
// family is chrome, never a submitter: default it here, once, so
|
|
67
|
+
// call sites do not each spread an attribute the component does not
|
|
68
|
+
// declare. Placed AFTER the attrs spread and guarded with `||` so an
|
|
69
|
+
// explicit `type` still wins while an explicitly `undefined` one
|
|
70
|
+
// cannot silently defeat the default (HkButton's idiom).
|
|
71
|
+
type={((attrs.type as string) || "button") as "button" | "reset" | "submit"}
|
|
64
72
|
>
|
|
65
73
|
<span class="hk-icon-button-icon">
|
|
66
74
|
{slots.icon
|
|
@@ -95,6 +95,40 @@
|
|
|
95
95
|
background: rgb(var(--color-primary) / 12%);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/* Tone channels (2026-11-06): a pill can carry the hue identity of the
|
|
99
|
+
* thing it filters (the trace chips tint with their lane's token so a
|
|
100
|
+
* chip, its ribbon segments and its rows share one identity). A tone
|
|
101
|
+
* re-tints the ACTIVE state only — inactive pills stay muted regardless
|
|
102
|
+
* of tone. The four hue tones share one alpha (16%); the "muted" tone
|
|
103
|
+
* rides 18% because it has to read as a neutral wash rather than a hue.
|
|
104
|
+
* The attribute pair wins on SPECIFICITY ((0,3,0) vs (0,2,0)) — order
|
|
105
|
+
* independent; they are also written after the plain rule so the source
|
|
106
|
+
* order agrees with what specificity already decides. */
|
|
107
|
+
&[data-tone="success"][data-active] {
|
|
108
|
+
color: rgb(var(--color-success, 14 184 64));
|
|
109
|
+
background: rgb(var(--color-success, 14 184 64) / 16%);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&[data-tone="secondary"][data-active] {
|
|
113
|
+
color: rgb(var(--color-secondary, 81 154 115));
|
|
114
|
+
background: rgb(var(--color-secondary, 81 154 115) / 16%);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&[data-tone="warning"][data-active] {
|
|
118
|
+
color: rgb(var(--color-warning, 238 214 119));
|
|
119
|
+
background: rgb(var(--color-warning, 238 214 119) / 16%);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&[data-tone="error"][data-active] {
|
|
123
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
124
|
+
background: rgb(var(--color-error, 247 118 142) / 16%);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&[data-tone="muted"][data-active] {
|
|
128
|
+
color: rgb(var(--color-text, 30 30 30));
|
|
129
|
+
background: rgb(var(--color-muted, 108 108 108) / 18%);
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
&:focus-visible {
|
|
99
133
|
outline: 2px solid rgb(var(--color-primary));
|
|
100
134
|
outline-offset: -2px;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { compile } from "sass";
|
|
3
7
|
|
|
4
8
|
import HkPillToggleGroup from "./HkPillToggleGroup";
|
|
5
9
|
|
|
@@ -175,3 +179,96 @@ describe("HkPillToggleGroup", () => {
|
|
|
175
179
|
expect(toggled).toEqual([OPTIONS[1].value]);
|
|
176
180
|
});
|
|
177
181
|
});
|
|
182
|
+
|
|
183
|
+
// ── Tone channel ─────────────────────────────────────────────────────────
|
|
184
|
+
describe("HkPillToggleGroup tone channel", () => {
|
|
185
|
+
const TONED_OPTIONS = [
|
|
186
|
+
{ value: "input", label: "Input", tone: "success" },
|
|
187
|
+
{ value: "text", label: "Text", tone: "secondary" },
|
|
188
|
+
{ value: "tool", label: "Tool", tone: "warning" },
|
|
189
|
+
{ value: "plain", label: "Plain" },
|
|
190
|
+
{ value: "primary", label: "Primary", tone: "primary" },
|
|
191
|
+
] as const;
|
|
192
|
+
|
|
193
|
+
it("carries data-tone for non-primary tones and omits it for default/primary", () => {
|
|
194
|
+
const c = mount(
|
|
195
|
+
h(HkPillToggleGroup, { label: "Kinds", options: [...TONED_OPTIONS], selected: new Set<string>() }),
|
|
196
|
+
);
|
|
197
|
+
const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
198
|
+
expect(pills[0].getAttribute("data-tone")).toBe("success");
|
|
199
|
+
expect(pills[1].getAttribute("data-tone")).toBe("secondary");
|
|
200
|
+
expect(pills[2].getAttribute("data-tone")).toBe("warning");
|
|
201
|
+
expect(pills[3].hasAttribute("data-tone"), "no tone → no attribute").toBe(false);
|
|
202
|
+
expect(pills[4].hasAttribute("data-tone"), "primary is the default → no attribute").toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("renders the option label alongside the tone attribute", () => {
|
|
206
|
+
const c = mount(
|
|
207
|
+
h(HkPillToggleGroup, {
|
|
208
|
+
label: "Kinds",
|
|
209
|
+
options: [{ value: "tool", label: "Tool", tone: "warning" }],
|
|
210
|
+
selected: new Set<string>(["tool"]),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const pill = c.querySelector<HTMLButtonElement>(".hk-pill-group-pill")!;
|
|
214
|
+
expect(pill.textContent?.trim()).toBe("Tool");
|
|
215
|
+
expect(pill.hasAttribute("data-active")).toBe(true);
|
|
216
|
+
// Without this the whole channel could be reverted and the test stays green.
|
|
217
|
+
expect(pill.getAttribute("data-tone")).toBe("warning");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("stylesheet contract: every tone re-tints the ACTIVE state only, after the plain rule", () => {
|
|
221
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
222
|
+
const css = compile(join(here, "HkPillToggleGroup.scss"), { style: "expanded" }).css;
|
|
223
|
+
const tones = ["success", "secondary", "warning", "error", "muted"] as const;
|
|
224
|
+
// Same triplets styles/theme/channels.scss defines (light base).
|
|
225
|
+
const FALLBACKS: Record<(typeof tones)[number], string> = {
|
|
226
|
+
success: "14 184 64",
|
|
227
|
+
secondary: "81 154 115",
|
|
228
|
+
warning: "238 214 119",
|
|
229
|
+
error: "247 118 142",
|
|
230
|
+
muted: "108 108 108",
|
|
231
|
+
};
|
|
232
|
+
const plainActive = css.indexOf(".hk-pill-group-pill[data-active]");
|
|
233
|
+
expect(plainActive, "the plain active rule exists").toBeGreaterThan(-1);
|
|
234
|
+
for (const tone of tones) {
|
|
235
|
+
// Sass drops quotes around attribute values when expanding
|
|
236
|
+
// (`[data-status=pending]` — the HkRangeRibbon sheet contract hits
|
|
237
|
+
// the same formatting), so match the compiled shape.
|
|
238
|
+
const selector = `.hk-pill-group-pill[data-tone=${tone}][data-active]`;
|
|
239
|
+
const at = css.indexOf(selector);
|
|
240
|
+
expect(at, `${selector} compiles`).toBeGreaterThan(-1);
|
|
241
|
+
// Every tone rule must come AFTER the plain rule: the attribute pair
|
|
242
|
+
// outranks it, and the source order keeps that dominance explicit.
|
|
243
|
+
expect(at, `${tone} rule rides after the plain active rule`).toBeGreaterThan(plainActive);
|
|
244
|
+
// Tone must never repaint an INACTIVE pill: every tone selector has to
|
|
245
|
+
// carry the attribute pair. A bare `[data-tone=…]` rule would pass the
|
|
246
|
+
// assertions above while quietly tinting muted pills.
|
|
247
|
+
expect(css, "no tone rule without [data-active]").not.toMatch(
|
|
248
|
+
new RegExp(`\\[data-tone=${tone}\\]\\s*\\{`),
|
|
249
|
+
);
|
|
250
|
+
const body = css.slice(at, css.indexOf("}", at));
|
|
251
|
+
expect(body, `${tone} re-tints the ink`).toContain("color:");
|
|
252
|
+
expect(body, `${tone} re-tints the fill`).toContain("background:");
|
|
253
|
+
expect(body, `${tone} tint uses its theme token`).toContain(`var(--color-${tone}`);
|
|
254
|
+
// Presence alone is not enough: the token appears twice (ink + fill),
|
|
255
|
+
// so swapping one for another hue kept this green (R3 M9/M9b). Pin the
|
|
256
|
+
// ink and the fill to their own channels — muted is the documented
|
|
257
|
+
// exception (text ink, muted wash).
|
|
258
|
+
expect(body, `${tone} INK uses its own channel`).toContain(
|
|
259
|
+
`color: rgb(var(--color-${tone === "muted" ? "text" : tone}`,
|
|
260
|
+
);
|
|
261
|
+
expect(body, `${tone} FILL uses its own channel`).toContain(
|
|
262
|
+
`background: rgb(var(--color-${tone}`,
|
|
263
|
+
);
|
|
264
|
+
// The canonical fallback is what keeps the Rust-side mirror rendering
|
|
265
|
+
// the same hue without the theme layer — pin the exact triplet.
|
|
266
|
+
expect(body, `${tone} carries its canonical fallback`).toContain(FALLBACKS[tone]);
|
|
267
|
+
// Sass's expanded output drops the space before the alpha slash:
|
|
268
|
+
// `rgb(var(--color-x, t t t)/16%)`.
|
|
269
|
+
expect(body, `${tone} keeps the house alpha`).toMatch(
|
|
270
|
+
tone === "muted" ? /\/\s*18%/ : /\/\s*16%/,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -3,6 +3,13 @@ import { defineComponent, h, type PropType, type VNode } from "vue";
|
|
|
3
3
|
import { useOptionStrip } from "../composables/useOptionStrip";
|
|
4
4
|
import "./HkPillToggleGroup.scss";
|
|
5
5
|
|
|
6
|
+
/** Semantic tone channel for a pill's ACTIVE tint — the same vocabulary
|
|
7
|
+
* the HkRangeRibbon segment family speaks (a filter bar can keep its
|
|
8
|
+
* lane-hue identity while riding the shared pill chrome). Omit (or
|
|
9
|
+
* "primary") = the default primary tint. A tone recolors the ACTIVE
|
|
10
|
+
* state only; inactive pills stay muted regardless of tone. */
|
|
11
|
+
export type HkPillTone = "primary" | "success" | "secondary" | "warning" | "error" | "muted";
|
|
12
|
+
|
|
6
13
|
/** One toggleable pill of the group. */
|
|
7
14
|
export interface HkPillToggleGroupOption {
|
|
8
15
|
/** Stable identity — the value emitted back through `toggle`. */
|
|
@@ -14,6 +21,8 @@ export interface HkPillToggleGroupOption {
|
|
|
14
21
|
* (never stringified: a raw function child renders as source text,
|
|
15
22
|
* which is exactly the landmine this normalization defuses). */
|
|
16
23
|
icon?: unknown;
|
|
24
|
+
/** Active-tint hue channel (see {@link HkPillTone}). */
|
|
25
|
+
tone?: HkPillTone;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
/** Normalize an option icon to a renderable vnode. A component (function
|
|
@@ -94,6 +103,7 @@ export default defineComponent({
|
|
|
94
103
|
type="button"
|
|
95
104
|
key={o.value}
|
|
96
105
|
class="hk-pill-group-pill"
|
|
106
|
+
data-tone={o.tone && o.tone !== "primary" ? o.tone : undefined}
|
|
97
107
|
data-active={props.selected.has(o.value) || undefined}
|
|
98
108
|
aria-pressed={props.selected.has(o.value) ? "true" : "false"}
|
|
99
109
|
onClick={() => emit("toggle", o.value)}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
the strip is 32px tall, a handle is an 8px half-overlapping grip
|
|
10
10
|
(translateX(-50%) so `left: 20%` centers it on the window edge), a
|
|
11
11
|
clipped segment keeps a 2px minimum so edge slivers stay visible, and
|
|
12
|
-
the reset
|
|
13
|
-
half-width, so a handle at 0% / 100% is never
|
|
12
|
+
the reset button (a standard HkIconButton) clears the edge handle
|
|
13
|
+
(right: 4px = the handle's half-width, so a handle at 0% / 100% is never
|
|
14
|
+
covered). The 24px touch-floor seat costs the strip's top-right brush
|
|
15
|
+
band — the free band below the seat is what remains.
|
|
14
16
|
|
|
15
17
|
Colours and spacing come from hikari theme tokens; a few geometry values
|
|
16
18
|
(the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
|
|
@@ -152,7 +154,12 @@
|
|
|
152
154
|
position: absolute;
|
|
153
155
|
top: 3px;
|
|
154
156
|
bottom: 3px;
|
|
155
|
-
z-index:
|
|
157
|
+
// ABOVE the reset seat (z-index 3): a 24px seat reaches ~10-13% into the
|
|
158
|
+
// strip's right edge, so at z-index 2 a window whose end lands in that
|
|
159
|
+
// band had both grips covered by the reset's box and unreachable by
|
|
160
|
+
// pointer (the seat is opaque and paints later). The grip wins the band;
|
|
161
|
+
// the reset keeps every other pixel of its area.
|
|
162
|
+
z-index: 4;
|
|
156
163
|
width: 8px;
|
|
157
164
|
transform: translateX(-50%);
|
|
158
165
|
border: 1px solid rgb(var(--color-surface));
|
|
@@ -174,32 +181,31 @@
|
|
|
174
181
|
outline-offset: 2px;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
// The reset affordance is a STANDARD HkIconButton (size 24, ghost) — the
|
|
185
|
+
// icon-button sheet owns box, glyph size, hover, press and focus chrome.
|
|
186
|
+
// 24 is the touch floor the family uses for dense chrome (the same floor the
|
|
187
|
+
// consumers' icon-size guards enforce); the 32px strip still seats it with a
|
|
188
|
+
// 4px inset top and bottom.
|
|
189
|
+
// This rule only pins the seat on the overview field: right = the edge
|
|
190
|
+
// handle's half-width, so a handle at 0% / 100% is never covered.
|
|
191
|
+
// The seat rides the descendant key on purpose: `.hk-icon-button` declares
|
|
192
|
+
// `position: relative` at the SAME (0,1,0) specificity, so a bare
|
|
193
|
+
// `.hk-range-ribbon-reset` rule would only win while HkIconButton.scss
|
|
194
|
+
// happens to come first in the bundle (the exact failure the lightbox close
|
|
195
|
+
// hit in 2026-09-11 — its seat rule is `.hk-image-lightbox-close.hk-icon-button`
|
|
196
|
+
// for this reason). (0,2,0) makes the seat order-independent.
|
|
197
|
+
// The surface backing rides it for the same reason: the `--hi-icon-button-*`
|
|
198
|
+
// properties also live on `.hk-icon-button` (HkIconButtonVars). The ghost
|
|
199
|
+
// variant is transparent by default — a control floating on the strip's own
|
|
200
|
+
// paint keeps a surface tint to stay legible over segments and scrims.
|
|
201
|
+
.hk-range-ribbon .hk-range-ribbon-reset {
|
|
178
202
|
position: absolute;
|
|
179
203
|
top: 4px;
|
|
180
204
|
right: 4px;
|
|
181
205
|
z-index: 3;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
width: 18px;
|
|
186
|
-
height: 18px;
|
|
187
|
-
padding: 0;
|
|
188
|
-
border: 1px solid rgb(var(--color-border));
|
|
189
|
-
border-radius: var(--radius-sm);
|
|
190
|
-
background: rgb(var(--color-surface) / 85%);
|
|
191
|
-
color: rgb(var(--color-text));
|
|
192
|
-
cursor: pointer;
|
|
193
|
-
transition: background-color var(--duration-fast, 0.15s) ease;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.hk-range-ribbon-reset:hover {
|
|
197
|
-
background: rgb(var(--color-surface));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.hk-range-ribbon-reset:focus-visible {
|
|
201
|
-
outline: 2px solid rgb(var(--color-primary));
|
|
202
|
-
outline-offset: 1px;
|
|
206
|
+
--hi-icon-button-bg: rgb(var(--color-surface) / 85%);
|
|
207
|
+
--hi-icon-button-bg-hover: rgb(var(--color-surface));
|
|
208
|
+
--hi-icon-button-bg-active: rgb(var(--color-surface));
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
// Visually hidden, still read by AT: labels the keyboard contract the
|
|
@@ -771,6 +771,18 @@ describe("HkRangeRibbon reset", () => {
|
|
|
771
771
|
const host = mountHost([0.2, 0.6]);
|
|
772
772
|
const button = host.root.querySelector<HTMLElement>(".hk-range-ribbon-reset");
|
|
773
773
|
expect(button, "reset button is shown while a window exists").toBeTruthy();
|
|
774
|
+
// The reset affordance rides the STANDARD icon button (user direction
|
|
775
|
+
// 2026-11-06: no hand-rolled chip chrome), carrying its lucide glyph
|
|
776
|
+
// through the house-normal slot — pin the lucide class the slot must
|
|
777
|
+
// produce so a glyph swap cannot silently vanish.
|
|
778
|
+
expect(button!.className, "reset is an HkIconButton, not a bespoke chip").toContain(
|
|
779
|
+
"hk-icon-button",
|
|
780
|
+
);
|
|
781
|
+
expect(
|
|
782
|
+
button!.querySelector("svg.lucide-rotate-ccw"),
|
|
783
|
+
"reset glyph renders through the icon slot",
|
|
784
|
+
).not.toBeNull();
|
|
785
|
+
expect(button!.getAttribute("type"), "reset never becomes a submit button").toBe("button");
|
|
774
786
|
button!.click();
|
|
775
787
|
await nextTick();
|
|
776
788
|
expect(host.windowChange).toHaveBeenLastCalledWith(null);
|
|
@@ -1405,8 +1417,36 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1405
1417
|
// A clipped 2px sliver must still be visible.
|
|
1406
1418
|
expect(declaration(ruleBody(css, ".hk-range-ribbon-segment"), "min-width")).toBe("2px");
|
|
1407
1419
|
expect(declaration(ruleBody(css, ".hk-range-ribbon-lane-track"), "height")).toBe("20px");
|
|
1408
|
-
// The reset
|
|
1409
|
-
|
|
1420
|
+
// The reset seat clears the edge handle (half its 8px width). The rule
|
|
1421
|
+
// must sit on the descendant key (0,2,0): `.hk-icon-button` declares
|
|
1422
|
+
// `position: relative` at (0,1,0), so a bare-class seat would depend on
|
|
1423
|
+
// bundle order.
|
|
1424
|
+
const resetSeat = ruleBody(css, ".hk-range-ribbon .hk-range-ribbon-reset");
|
|
1425
|
+
expect(declaration(resetSeat, "right")).toBe("4px");
|
|
1426
|
+
expect(declaration(resetSeat, "position"), "the seat must out-rank .hk-icon-button").toBe(
|
|
1427
|
+
"absolute",
|
|
1428
|
+
);
|
|
1429
|
+
expect(declaration(resetSeat, "top"), "the seat clears the strip edge").toBe("4px");
|
|
1430
|
+
// The grip must paint ABOVE the seat: a right-side window puts its end
|
|
1431
|
+
// handle inside the seat's box, and an opaque seat below it would make
|
|
1432
|
+
// the grip unreachable by pointer.
|
|
1433
|
+
const gripZ = Number(declaration(ruleBody(css, ".hk-range-ribbon-handle"), "z-index"));
|
|
1434
|
+
const seatZ = Number(declaration(resetSeat, "z-index"));
|
|
1435
|
+
expect(gripZ, "the edge grip outranks the reset seat").toBeGreaterThan(seatZ);
|
|
1436
|
+
// The surface backing is what keeps the control legible over the strip's
|
|
1437
|
+
// segments and scrims: ghost carries NO background (HkIconButtonVars),
|
|
1438
|
+
// so without these three the reset degrades to a bare glyph floating on
|
|
1439
|
+
// the paint. Removing them alone left the whole suite green (R3 M5b) —
|
|
1440
|
+
// pin the paint, not just the geometry.
|
|
1441
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg"), "resting surface tint").toContain(
|
|
1442
|
+
"var(--color-surface)",
|
|
1443
|
+
);
|
|
1444
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg-hover"), "hover surface tint").toContain(
|
|
1445
|
+
"var(--color-surface)",
|
|
1446
|
+
);
|
|
1447
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg-active"), "press surface tint").toContain(
|
|
1448
|
+
"var(--color-surface)",
|
|
1449
|
+
);
|
|
1410
1450
|
expect(ruleBody(css, ".hk-range-ribbon-window")).toContain("var(--color-primary)");
|
|
1411
1451
|
expect(ruleBody(css, ".hk-range-ribbon-scrim")).toContain("var(--color-background)");
|
|
1412
1452
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "vue";
|
|
12
12
|
|
|
13
13
|
import { useI18n } from "../i18n/context";
|
|
14
|
+
import HIconButton from "./HkIconButton";
|
|
14
15
|
import "./HkRangeRibbon.scss";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -744,15 +745,23 @@ export default defineComponent({
|
|
|
744
745
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
|
|
745
746
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
|
|
746
747
|
/>
|
|
747
|
-
|
|
748
|
-
|
|
748
|
+
{/* The reset affordance is the STANDARD icon button (user
|
|
749
|
+
direction 2026-11-06: no hand-rolled chip chrome) —
|
|
750
|
+
HkIconButton owns box, glyph size, hover, press and
|
|
751
|
+
focus; this sheet only pins the seat (position) and a
|
|
752
|
+
surface backing via its override vars. The glyph slots
|
|
753
|
+
as a lucide component (the house-normal integration:
|
|
754
|
+
every in-tree HkIconButton slots its glyph). */}
|
|
755
|
+
<HIconButton
|
|
749
756
|
class="hk-range-ribbon-reset"
|
|
757
|
+
size={24}
|
|
758
|
+
variant="ghost"
|
|
750
759
|
aria-label={t("hikari::ribbon.reset", "Reset range")}
|
|
751
|
-
title
|
|
760
|
+
{...{ title: t("hikari::ribbon.reset", "Reset range") }}
|
|
752
761
|
onClick={resetWindow}
|
|
753
762
|
>
|
|
754
|
-
<RotateCcw
|
|
755
|
-
</
|
|
763
|
+
{{ icon: () => <RotateCcw aria-hidden="true" /> }}
|
|
764
|
+
</HIconButton>
|
|
756
765
|
</>
|
|
757
766
|
)}
|
|
758
767
|
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the range-ribbon reset affordance (user direction
|
|
3
|
+
* 2026-11-06: the waterfall minimap's reset control must be the STANDARD
|
|
4
|
+
* icon button, not a per-component hand-rolled `<button>` chip).
|
|
5
|
+
*
|
|
6
|
+
* HkRangeRibbon renders its reset through HkIconButton (the shared icon
|
|
7
|
+
* button: one box ladder, one glyph channel, one hover/press/focus chrome)
|
|
8
|
+
* carrying the a directly-imported `RotateCcw` glyph in the icon slot — the same "named-icon contract" the unified window-close wave
|
|
9
|
+
* pinned (window-close.contract.test.ts). The positioning class
|
|
10
|
+
* `hk-range-ribbon-reset` survives ONLY as the seat hook the component
|
|
11
|
+
* sheet pins (it must keep clearing the edge handle).
|
|
12
|
+
*
|
|
13
|
+
* Pinned here so a refactor cannot silently re-sprout a bespoke chip —
|
|
14
|
+
* the same "looks unified but drifted" failure class the window-close
|
|
15
|
+
* contract guards against.
|
|
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 ribbon = readFileSync(join(here, "HkRangeRibbon.tsx"), "utf-8");
|
|
24
|
+
|
|
25
|
+
describe("range-ribbon reset control contract", () => {
|
|
26
|
+
it("renders the reset affordance through the standard HkIconButton", () => {
|
|
27
|
+
expect(ribbon, "the reset must ride HkIconButton chrome").toContain("HIconButton");
|
|
28
|
+
expect(ribbon, "the seat class survives as the positioning hook").toContain(
|
|
29
|
+
'class="hk-range-ribbon-reset"',
|
|
30
|
+
);
|
|
31
|
+
expect(
|
|
32
|
+
ribbon,
|
|
33
|
+
"the glyph rides the house-normal lucide slot (every in-tree HkIconButton slots its glyph)",
|
|
34
|
+
).toContain("<RotateCcw");
|
|
35
|
+
expect(ribbon, "no hand-rolled <button> may carry the reset class").not.toMatch(
|
|
36
|
+
/<button[^>]*hk-range-ribbon-reset/,
|
|
37
|
+
);
|
|
38
|
+
// The three things a partial revert silently drops while still passing
|
|
39
|
+
// the substring checks above.
|
|
40
|
+
expect(ribbon, "the reset keeps its touch-floor size").toMatch(
|
|
41
|
+
/<HIconButton[\s\S]{0,200}?size=\{24\}/,
|
|
42
|
+
);
|
|
43
|
+
// The non-submit guarantee moved upstream into HkIconButton (one
|
|
44
|
+
// implementation, not a per-call-site spread); it is pinned in
|
|
45
|
+
// HkIconButton.test.tsx and asserted on the rendered DOM in
|
|
46
|
+
// HkRangeRibbon.test.tsx.
|
|
47
|
+
expect(ribbon, "the reset passes the standard variant explicitly").toContain('variant="ghost"');
|
|
48
|
+
expect(ribbon, "the glyph rides the slot form, not the icon prop").toMatch(
|
|
49
|
+
/\{\{\s*icon:\s*\(\)\s*=>\s*<RotateCcw/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -978,7 +978,7 @@ export type {
|
|
|
978
978
|
} from "./composables/useInfinitePages";
|
|
979
979
|
export { default as HkFilterChip } from "./components/HkFilterChip";
|
|
980
980
|
export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
|
|
981
|
-
export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
|
|
981
|
+
export type { HkPillToggleGroupOption, HkPillTone } from "./components/HkPillToggleGroup";
|
|
982
982
|
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
983
983
|
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
984
984
|
export { HkCardList, HkListRow } from "./components/HkCardList";
|