@celestia-island/hikari 0.40.23 → 0.40.25
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/HkAffixPicker.scss +10 -0
- package/src/components/HkAffixPicker.sheetcap.test.ts +27 -0
- package/src/components/HkButton.scss +9 -1
- package/src/components/HkButton.transition.test.ts +39 -0
- package/src/components/HkDrawer.scss +55 -0
- package/src/components/HkDrawer.sheetfamily.test.ts +87 -0
- package/src/components/HkImageLightbox.scss +8 -1
- package/src/components/HkImageLightbox.width.test.ts +48 -0
- package/src/components/HkModal.scss +9 -2
- package/src/components/HkPopover.scss +20 -0
- package/src/components/HkPopover.sheet-maxwidth.test.ts +41 -0
- package/src/components/HkSelect.scss +10 -0
- package/src/components/HkThemeToggle.ghost-chrome.test.ts +41 -0
- package/src/components/HkThemeToggle.scss +16 -14
package/package.json
CHANGED
|
@@ -179,6 +179,16 @@
|
|
|
179
179
|
max-width: min(19rem, calc(100vw - 2rem));
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/* Mobile sheet context (#424 pattern): inside the full-width select sheet
|
|
183
|
+
* the popup's designed measure becomes a centered cap instead of a
|
|
184
|
+
* left-glued clamp — the list reads as one designed block inside the
|
|
185
|
+
* viewport-wide sheet rather than hugging its left edge. */
|
|
186
|
+
.hk-select-sheet-panel .hk-affix-scroll {
|
|
187
|
+
max-width: min(19rem, 100%);
|
|
188
|
+
margin-inline: auto;
|
|
189
|
+
width: 100%;
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
.hk-affix-list {
|
|
183
193
|
display: flex;
|
|
184
194
|
flex-direction: column;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the affix picker's measure inside the mobile select
|
|
3
|
+
* sheet (2026-09-08 scan wave 2, finding F2 — #424 pattern).
|
|
4
|
+
*
|
|
5
|
+
* The picker renders on phones inside the full-width `.hk-select-sheet-panel`;
|
|
6
|
+
* the base 19rem (304px) cap left-glued with ~92px dead space right. Pinned
|
|
7
|
+
* here so the sheet-context centering rule cannot be dropped in a refactor:
|
|
8
|
+
* the designed measure stays as a cap but the list reads as one centered
|
|
9
|
+
* block inside the viewport-wide sheet.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const src = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
|
|
18
|
+
|
|
19
|
+
describe("HkAffixPicker mobile sheet measure contract", () => {
|
|
20
|
+
it("caps and centers the list inside the full-width select sheet", () => {
|
|
21
|
+
const block =
|
|
22
|
+
src.match(/\.hk-select-sheet-panel \.hk-affix-scroll\s*{[^}]*}/)?.[0] ??
|
|
23
|
+
"";
|
|
24
|
+
expect(block).toContain("max-width: min(19rem, 100%)");
|
|
25
|
+
expect(block).toContain("margin-inline: auto");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
border: 1px solid transparent;
|
|
13
13
|
border-radius: var(--radius-sm);
|
|
14
14
|
cursor: pointer;
|
|
15
|
-
|
|
15
|
+
/* Longhand on purpose: the former comma-separated shorthand
|
|
16
|
+
* `transition: background-color, ..., filter var(--duration-normal) ...`
|
|
17
|
+
* declares one transition PER property where only the LAST carries the
|
|
18
|
+
* duration — every property but `filter` animated at 0s and hover states
|
|
19
|
+
* snapped (host overrides kept patching over it). One property LIST with
|
|
20
|
+
* one duration/easing animates them all. */
|
|
21
|
+
transition-property: background-color, border-color, color, box-shadow, opacity, transform, filter;
|
|
22
|
+
transition-duration: var(--duration-normal);
|
|
23
|
+
transition-timing-function: var(--ease-standard);
|
|
16
24
|
white-space: nowrap;
|
|
17
25
|
user-select: none;
|
|
18
26
|
outline: none;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the HkButton transition declaration (2026-09-08 scan
|
|
3
|
+
* wave 2, finding F5).
|
|
4
|
+
*
|
|
5
|
+
* The former comma-separated shorthand
|
|
6
|
+
* `transition: background-color, ..., filter var(--duration-normal) ...`
|
|
7
|
+
* declared one transition PER property where only the LAST carried the
|
|
8
|
+
* duration/easing — every property but `filter` animated at 0s and hover
|
|
9
|
+
* color/border/background snapped instead of fading (hosts kept patching
|
|
10
|
+
* over it; chest's deleted .hk-btn-ghost override was one). Pinned here so
|
|
11
|
+
* the malformed shorthand cannot silently return.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const src = readFileSync(join(here, "HkButton.scss"), "utf-8");
|
|
20
|
+
// Comments stripped before the negative assertion: the explanatory comment
|
|
21
|
+
// intentionally QUOTES the old malformed shorthand, and this contract is
|
|
22
|
+
// about live CSS, not prose.
|
|
23
|
+
const css = src
|
|
24
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
25
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
26
|
+
|
|
27
|
+
describe("HkButton transition contract", () => {
|
|
28
|
+
it("declares the transition in longhand with one shared duration/easing", () => {
|
|
29
|
+
expect(src).toContain(
|
|
30
|
+
"transition-property: background-color, border-color, color, box-shadow, opacity, transform, filter;"
|
|
31
|
+
);
|
|
32
|
+
expect(src).toContain("transition-duration: var(--duration-normal);");
|
|
33
|
+
expect(src).toContain("transition-timing-function: var(--ease-standard);");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not regress to the malformed comma-separated shorthand", () => {
|
|
37
|
+
expect(css).not.toMatch(/transition:\s*background-color,/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -165,3 +165,58 @@
|
|
|
165
165
|
border-top: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.06));
|
|
166
166
|
flex-shrink: 0;
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
// ------
|
|
170
|
+
// Mobile bottom drawer = the sheet family (2026-09-08 scan wave 2).
|
|
171
|
+
// HkAdaptiveDialog renders its mobile form as a bottom drawer, which
|
|
172
|
+
// made it the ONLY bottom-docked window outside the HkModal / HkPopover /
|
|
173
|
+
// HkSelectPanel sheet family contract: glued to `bottom: 0` (hosts lifting
|
|
174
|
+
// --hk-sheet-bottom-gap saw no effect), capped by the inline `size` 70vh
|
|
175
|
+
// with no top-inset reservation (a tall dialog covered the secondary-window
|
|
176
|
+
// breadcrumb strip), 8px corners against the family's 12px, a footer that
|
|
177
|
+
// ignored the home-bar safe area, and sm/md footer buttons without the
|
|
178
|
+
// family's 44px touch lift. Same geometry, same tokens, one family.
|
|
179
|
+
// ------
|
|
180
|
+
@media (max-width: 767px) {
|
|
181
|
+
.hk-drawer-bottom {
|
|
182
|
+
bottom: var(--hk-sheet-bottom-gap, 0px);
|
|
183
|
+
border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
184
|
+
var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
185
|
+
0 0;
|
|
186
|
+
// The inline maxHeight from the `size` prop must lose to the family cap
|
|
187
|
+
// (plain-vh fallback first for dvh-less engines, then dvh) — same
|
|
188
|
+
// inline-beating !important pattern as HkModal's mobile max-width.
|
|
189
|
+
max-height: calc(
|
|
190
|
+
100vh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
|
|
191
|
+
) !important;
|
|
192
|
+
max-height: calc(
|
|
193
|
+
100dvh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
|
|
194
|
+
) !important;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Scoped to BOTTOM drawers only: side drawers (admin panels etc.) keep
|
|
198
|
+
// their desktop footer contract on phones — the sheet family is the
|
|
199
|
+
// bottom-docked form factor. Three-value padding keeps the axes right:
|
|
200
|
+
// the --hk-drawer-footer-padding hook owns the TOP, 1rem the sides, and
|
|
201
|
+
// the home-bar safe area lands on the BOTTOM where it belongs (a 2-value
|
|
202
|
+
// form would put the safe-area calc on the horizontal axis — and for
|
|
203
|
+
// hosts that zero the var it would strip the bottom protection entirely,
|
|
204
|
+
// the exact opposite of the intent).
|
|
205
|
+
.hk-drawer-bottom .hk-drawer-footer {
|
|
206
|
+
// Family baseline: home-bar safe area stacked on the base inset (same
|
|
207
|
+
// stacking as HkModal's mobile footer), on top of the existing
|
|
208
|
+
// --hk-drawer-footer-padding hook for menu-panel hosts.
|
|
209
|
+
padding: var(--hk-drawer-footer-padding, 0.625rem 1rem) 1rem
|
|
210
|
+
calc(0.375rem + var(--hk-sheet-footer-gap, 0.5rem) + env(safe-area-inset-bottom, 0px));
|
|
211
|
+
|
|
212
|
+
// Footer actions are the drawer's primary touch targets on phones:
|
|
213
|
+
// lift sm/md buttons to the 44px class, exactly like HkModal's mobile
|
|
214
|
+
// footer (padding/min-height only; font-size stays --text-base).
|
|
215
|
+
.hk-btn-sm,
|
|
216
|
+
.hk-btn-md {
|
|
217
|
+
min-height: 2.75rem;
|
|
218
|
+
padding: var(--space-8) var(--space-16);
|
|
219
|
+
font-size: var(--text-base);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the mobile bottom-drawer sheet family membership
|
|
3
|
+
* (2026-09-08 scan wave 2, finding F1).
|
|
4
|
+
*
|
|
5
|
+
* HkAdaptiveDialog renders its mobile form as a bottom drawer, which made
|
|
6
|
+
* it the ONLY bottom-docked window outside the HkModal / HkPopover /
|
|
7
|
+
* HkSelectPanel sheet family contract. Pinned here so a refactor cannot
|
|
8
|
+
* silently regress to the pre-family geometry:
|
|
9
|
+
* - docks on the shared --hk-sheet-bottom-gap hook (was hard `bottom: 0`)
|
|
10
|
+
* - capped by the family formula (top inset + bottom gap), NOT the inline
|
|
11
|
+
* `size` 70vh — the inline maxHeight must lose via !important (same
|
|
12
|
+
* inline-beating pattern as HkModal's mobile `max-width: 100%
|
|
13
|
+
* !important`), with the plain-vh fallback ahead of the dvh twin
|
|
14
|
+
* - 12px family corners (was 8px)
|
|
15
|
+
* - footer stacks the home-bar safe area on the BOTTOM axis (3-value
|
|
16
|
+
* padding: hook top / 1rem sides / safe-area bottom — a 2-value form
|
|
17
|
+
* would put the safe-area calc on the horizontal axis and strip the
|
|
18
|
+
* bottom protection for hosts that zero the hook) and is scoped to
|
|
19
|
+
* BOTTOM drawers only (side drawers keep their desktop footer
|
|
20
|
+
* contract on phones); sm/md buttons lift to the 44px class, like
|
|
21
|
+
* HkModal's mobile footer
|
|
22
|
+
*/
|
|
23
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const src = readFileSync(join(here, "HkDrawer.scss"), "utf-8");
|
|
30
|
+
|
|
31
|
+
describe("HkDrawer mobile bottom sheet-family contract", () => {
|
|
32
|
+
let media = "";
|
|
33
|
+
let drawer = "";
|
|
34
|
+
let footer = "";
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
media = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
37
|
+
drawer = media.match(/\.hk-drawer-bottom\s*{[^}]*}/)?.[0] ?? "";
|
|
38
|
+
footer = media.match(/\.hk-drawer-bottom \.hk-drawer-footer\s*{[\s\S]*?^ }/m)?.[0] ?? "";
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("docks the bottom drawer on the shared --hk-sheet-bottom-gap hook", () => {
|
|
42
|
+
expect(drawer).toContain("bottom: var(--hk-sheet-bottom-gap");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("caps the sheet with the family top-inset formula in both vh and dvh", () => {
|
|
46
|
+
expect(drawer).toContain("100vh - var(--hk-sheet-top-inset");
|
|
47
|
+
expect(drawer).toContain("100dvh - var(--hk-sheet-top-inset");
|
|
48
|
+
// dvh-less engines (older Android WebView / Tauri) drop the whole dvh
|
|
49
|
+
// calc — the plain-vh fallback must stay ahead of it.
|
|
50
|
+
expect(drawer.indexOf("100vh - var(")).toBeLessThan(
|
|
51
|
+
drawer.indexOf("100dvh - var(")
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("beats the inline `size` maxHeight with !important on both caps", () => {
|
|
56
|
+
// HkDrawer applies `maxHeight: props.size` (default 70vh) inline for
|
|
57
|
+
// horizontal drawers — inline beats stylesheet, so the family cap must
|
|
58
|
+
// carry !important on the vh fallback AND the dvh twin.
|
|
59
|
+
expect(drawer.match(/\) !important;/g)).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("adopts the 12px family corner radius", () => {
|
|
63
|
+
expect(drawer).toContain("border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("stacks the home-bar safe area on the mobile footer's BOTTOM axis", () => {
|
|
67
|
+
expect(footer).toContain("env(safe-area-inset-bottom");
|
|
68
|
+
expect(footer).toContain("--hk-sheet-footer-gap");
|
|
69
|
+
// 3-value padding: hook top / 1rem sides / safe-area calc bottom. The
|
|
70
|
+
// safe-area calc must be the THIRD value, not the second (2-value form
|
|
71
|
+
// puts it on the horizontal axis and zeroes the bottom for hook=0 hosts).
|
|
72
|
+
expect(footer).toMatch(
|
|
73
|
+
/padding: var\(--hk-drawer-footer-padding,[^)]*\) 1rem\s*\n?\s*calc\(0\.375rem \+ var\(--hk-sheet-footer-gap/
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("scopes the mobile footer rules to bottom drawers only", () => {
|
|
78
|
+
// Side drawers (admin panels) keep the desktop footer contract on
|
|
79
|
+
// phones — the media block must not carry an unscoped .hk-drawer-footer.
|
|
80
|
+
expect(media).not.toMatch(/@media[\s\S]*?\n \.hk-drawer-footer\s*{/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("lifts sm/md footer buttons to the 44px touch-target class", () => {
|
|
84
|
+
expect(footer).toMatch(/\.hk-btn-sm,\s*\n\s*\.hk-btn-md/);
|
|
85
|
+
expect(footer).toContain("min-height: 2.75rem");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -102,9 +102,16 @@
|
|
|
102
102
|
top: 0;
|
|
103
103
|
bottom: auto;
|
|
104
104
|
left: 0;
|
|
105
|
-
width
|
|
105
|
+
// Full-bleed width from the containing block, NOT 100vw and NOT the
|
|
106
|
+
// desktop rule's inherited `min(96vw, 80rem)`: that unconditional
|
|
107
|
+
// same-specificity width would persist into mobile and, over-constrained
|
|
108
|
+
// against the inherited `left: 0; right: 0` pair, drop `right` under
|
|
109
|
+
// LTR — a persistent ~4vw dead strip on the right edge (the exact gap
|
|
110
|
+
// class this wave hunts). 100vw itself also measures the ICB including
|
|
111
|
+
// classic scrollbar gutters.
|
|
106
112
|
// Plain-vh fallback first for dvh-less engines (older Android
|
|
107
113
|
// WebView / Tauri), then the dynamic-viewport height.
|
|
114
|
+
width: 100%;
|
|
108
115
|
height: 100vh;
|
|
109
116
|
height: 100dvh;
|
|
110
117
|
max-height: none;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the mobile lightbox horizontal sizing (2026-09-08
|
|
3
|
+
* scan wave 2, finding F3 + round-3 fix).
|
|
4
|
+
*
|
|
5
|
+
* The fullscreen mobile block used to declare `width: 100vw`, but 100vw
|
|
6
|
+
* measures the ICB including classic scrollbar gutters — combined with the
|
|
7
|
+
* inherited `left: 0; right: 0` docking it over-constrained the box (LTR
|
|
8
|
+
* dropped `right`) and clipped the right edge under a classic scrollbar.
|
|
9
|
+
* Deleting the width alone was NOT enough (round-3 adversarial catch):
|
|
10
|
+
* the unconditional desktop rule `width: min(96vw, 80rem)` (same selector,
|
|
11
|
+
* same specificity) then persisted into mobile and, over-constrained
|
|
12
|
+
* against left/right, dropped `right` — a persistent ~4vw dead strip on
|
|
13
|
+
* the right edge. The mobile block must declare its OWN width: 100% so
|
|
14
|
+
* the containing block (not the viewport unit, not the desktop measure)
|
|
15
|
+
* owns the full-bleed. Pinned here so neither regression can return.
|
|
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 src = readFileSync(join(here, "HkImageLightbox.scss"), "utf-8");
|
|
24
|
+
|
|
25
|
+
describe("HkImageLightbox mobile width contract", () => {
|
|
26
|
+
let mobile = "";
|
|
27
|
+
it("declares its own full-bleed width in the mobile block", () => {
|
|
28
|
+
mobile = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
29
|
+
const panel = mobile.match(/\.hk-modal-content\.hk-image-lightbox\s*{[^}]*}/)?.[0] ?? "";
|
|
30
|
+
expect(panel).toContain("left: 0;");
|
|
31
|
+
expect(panel).toContain("width: 100%;");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps 100vw sizing out (scrollbar-gutter divergence)", () => {
|
|
35
|
+
expect(mobile).not.toContain("width: 100vw");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps the desktop 96vw measure out of the mobile block", () => {
|
|
39
|
+
// The unconditional desktop rule keeps min(96vw, 80rem) — only the
|
|
40
|
+
// mobile panel block's own width: 100% stops it from leaking in.
|
|
41
|
+
// Comments are stripped first: the rationale text legitimately quotes
|
|
42
|
+
// the desktop measure, and only live declarations are under contract.
|
|
43
|
+
const panel = (mobile.match(/\.hk-modal-content\.hk-image-lightbox\s*{[^}]*}/)?.[0] ?? "")
|
|
44
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
45
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
46
|
+
expect(panel).not.toContain("96vw");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -393,8 +393,15 @@
|
|
|
393
393
|
// sits off the very bottom edge (chest asked for a visibly narrower
|
|
394
394
|
// gap than body bottom + safe area on phones). Safe area is added ON
|
|
395
395
|
// TOP of the gap so home-bar devices never lose tappable chrome.
|
|
396
|
-
padding
|
|
397
|
-
|
|
396
|
+
// --hk-modal-footer-padding-mobile is the host-tunable mobile footer
|
|
397
|
+
// inset: a host with edge-to-edge footer content (chest's report
|
|
398
|
+
// reply bar) sets it to 0 instead of repainting the rule and
|
|
399
|
+
// duplicating the formula — desktop keeps --hk-modal-padding-footer.
|
|
400
|
+
padding: var(
|
|
401
|
+
--hk-modal-footer-padding-mobile,
|
|
402
|
+
0.625rem 1rem
|
|
403
|
+
calc(0.375rem + var(--hk-sheet-footer-gap, 0.5rem) + env(safe-area-inset-bottom, 0px))
|
|
404
|
+
);
|
|
398
405
|
|
|
399
406
|
// Footer actions are the sheet's primary touch targets: lift sm/md
|
|
400
407
|
// buttons one size up to the 44px class regardless of the caller's
|
|
@@ -109,6 +109,16 @@
|
|
|
109
109
|
* rule's max-content width would over-constrain it (left+right+width
|
|
110
110
|
* drops `right` in LTR) and dock it at content width instead. */
|
|
111
111
|
width: auto;
|
|
112
|
+
/* The base .hk-popover-panel max-width clamp (calc(100vw - 16px), the
|
|
113
|
+
* anchored panel's ratchet guard) must NOT leak into the sheet: with
|
|
114
|
+
* the inline left:0 + right:0 docking the clamp over-constrains the
|
|
115
|
+
* used width and LTR drops the `right` constraint — the sheet docks
|
|
116
|
+
* flush left but stops 16px short of the right viewport edge (2026-09-08
|
|
117
|
+
* user report: a mysterious right-hand gap on the secondary windows,
|
|
118
|
+
* present on every popover-family sheet and never on the HkModal /
|
|
119
|
+
* HkSelectPanel sheets, hence "some have it, some don't"). The inline
|
|
120
|
+
* left/right pair alone owns the sheet's horizontal geometry. */
|
|
121
|
+
max-width: none;
|
|
112
122
|
border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
113
123
|
var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
114
124
|
0 0;
|
|
@@ -122,6 +132,16 @@
|
|
|
122
132
|
var(--hk-sheet-bottom-gap, 0px)
|
|
123
133
|
)
|
|
124
134
|
);
|
|
135
|
+
/* Dynamic-viewport pairing (HkModal parity): the dvh twin follows the
|
|
136
|
+
* vh cap so a maxed sheet cannot extend under the URL bar on
|
|
137
|
+
* dynamic-toolbar browsers. */
|
|
138
|
+
max-height: min(
|
|
139
|
+
72vh,
|
|
140
|
+
calc(
|
|
141
|
+
100dvh - var(--hk-sheet-top-inset, 4.25rem) -
|
|
142
|
+
var(--hk-sheet-bottom-gap, 0px)
|
|
143
|
+
)
|
|
144
|
+
);
|
|
125
145
|
display: flex;
|
|
126
146
|
flex-direction: column;
|
|
127
147
|
overflow-y: auto;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the popover sheet viewport width (2026-09-08 user
|
|
3
|
+
* report: the mobile bottom sheets showed a mysterious right-hand gap —
|
|
4
|
+
* the sheet docked flush left but stopped 16px short of the right
|
|
5
|
+
* viewport edge, on every popover-family sheet and never on the HkModal
|
|
6
|
+
* / HkSelectPanel sheets, hence "some have it, some don't").
|
|
7
|
+
*
|
|
8
|
+
* Root cause: the base `.hk-popover-panel` rule sets
|
|
9
|
+
* `max-width: calc(100vw - 2 * 8px)` (the anchored panel's
|
|
10
|
+
* anti-ratchet clamp, #421). The mobile sheet branch overrides
|
|
11
|
+
* `width: auto` but inherited the max-width, over-constraining the
|
|
12
|
+
* inline `left: 0; right: 0` docking — the used width resolved to
|
|
13
|
+
* 100vw - 16px and LTR dropped the `right` constraint. Pinned here so
|
|
14
|
+
* a refactor cannot silently reintroduce the leak.
|
|
15
|
+
*/
|
|
16
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const scss = readFileSync(join(here, "HkPopover.scss"), "utf-8");
|
|
23
|
+
|
|
24
|
+
describe("HkPopover sheet viewport width contract", () => {
|
|
25
|
+
let block = "";
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
block = scss.match(/\.hk-popover-panel\.hk-is-sheet\s*{[\s\S]*?^\}/m)?.[0] ?? "";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("neutralizes the base max-width clamp inside the sheet block", () => {
|
|
31
|
+
expect(block).toContain("max-width: none;");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps the sheet width content-independent (width: auto)", () => {
|
|
35
|
+
expect(block).toContain("width: auto;");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("declares max-width: none after the width override (ordering sanity)", () => {
|
|
39
|
+
expect(block.indexOf("max-width: none")).toBeGreaterThan(block.indexOf("width: auto"));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -271,6 +271,16 @@
|
|
|
271
271
|
var(--hk-sheet-bottom-gap, 0px)
|
|
272
272
|
)
|
|
273
273
|
);
|
|
274
|
+
/* Dynamic-viewport pairing (HkModal parity): the dvh twin follows the
|
|
275
|
+
* vh cap so a maxed sheet cannot extend under the URL bar on
|
|
276
|
+
* dynamic-toolbar browsers. */
|
|
277
|
+
max-height: min(
|
|
278
|
+
72vh,
|
|
279
|
+
calc(
|
|
280
|
+
100dvh - var(--hk-sheet-top-inset, 4.25rem) -
|
|
281
|
+
var(--hk-sheet-bottom-gap, 0px)
|
|
282
|
+
)
|
|
283
|
+
);
|
|
274
284
|
background: rgb(var(--color-surface));
|
|
275
285
|
border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
276
286
|
var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the theme-toggle trigger chrome (2026-09-08 user
|
|
3
|
+
* report: the bordered surface box read as a mystery outline in the
|
|
4
|
+
* header and fought the host themes — the faint border, the host
|
|
5
|
+
* override layer and the `--hk-theme-toggle-btn-border` var never
|
|
6
|
+
* agreed). The trigger is now the STANDARD ghost icon-button chrome
|
|
7
|
+
* (same as `.hk-btn-ghost` / the HkIconButton ghost variant beside it):
|
|
8
|
+
* transparent at rest, primary wash on hover, no border, no surface,
|
|
9
|
+
* no shadow. Pinned here so the bordered-box chrome cannot silently
|
|
10
|
+
* return.
|
|
11
|
+
*/
|
|
12
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const src = readFileSync(join(here, "HkThemeToggle.scss"), "utf-8");
|
|
19
|
+
|
|
20
|
+
describe("HkThemeToggle ghost trigger chrome contract", () => {
|
|
21
|
+
let block = "";
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
block = src.match(/\.s-theme-toggle-btn\s*{[^}]*}/)?.[0] ?? "";
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("paints no border on the trigger (border: none)", () => {
|
|
27
|
+
expect(block).toContain("border: none;");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the rest state transparent (no surface box)", () => {
|
|
31
|
+
expect(block).toContain("background: transparent;");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("removes the per-theme border var entirely", () => {
|
|
35
|
+
expect(src).not.toContain("--hk-theme-toggle-btn-border");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("retains a visible focus ring via :focus-visible", () => {
|
|
39
|
+
expect(src).toMatch(/\.s-theme-toggle-btn:focus-visible\s*{[^}]*}/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -13,15 +13,26 @@
|
|
|
13
13
|
height: 1.75rem;
|
|
14
14
|
min-width: 1.75rem;
|
|
15
15
|
padding: 0 var(--space-6, 0.375rem);
|
|
16
|
-
/*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
/* Standard ghost icon-button chrome (2026-09-08 user report: the
|
|
17
|
+
* bordered surface box read as a mystery outline in the header and
|
|
18
|
+
* fought the host themes — the faint border, the host override layer
|
|
19
|
+
* and the border-var never agreed). The trigger now matches the
|
|
20
|
+
* standard ghost buttons beside it (same chrome as .hk-btn-ghost and
|
|
21
|
+
* the HkIconButton ghost variant): transparent at rest, primary wash
|
|
22
|
+
* on hover, no border, no surface, no shadow, no per-theme border var. */
|
|
23
|
+
border: none;
|
|
24
|
+
border-radius: var(--radius-sm, 6px);
|
|
25
|
+
background: transparent;
|
|
26
|
+
color: rgb(var(--color-text, var(--color-text-secondary, var(--color-muted))));
|
|
21
27
|
cursor: pointer;
|
|
22
28
|
transition: background 0.15s ease, color 0.15s ease;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
.s-theme-toggle-btn:focus-visible {
|
|
32
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
33
|
+
outline-offset: 1px;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/* Host strip under the mode group (mode-extra slot). */
|
|
26
37
|
.s-theme-mode-extra {
|
|
27
38
|
margin-top: var(--space-8, 0.5rem);
|
|
@@ -33,15 +44,6 @@
|
|
|
33
44
|
color: rgb(var(--color-text));
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
.s-theme-toggle-btn[data-variant="main"] {
|
|
37
|
-
border-radius: var(--radius-sm, 6px) 0 0 var(--radius-sm, 6px);
|
|
38
|
-
border-right: none;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
.s-theme-toggle-btn[data-variant="arrow"] {
|
|
42
|
-
border-radius: 0 var(--radius-sm, 6px) var(--radius-sm, 6px) 0;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
47
|
.s-theme-menu {
|
|
46
48
|
display: flex;
|
|
47
49
|
flex-direction: column;
|