@celestia-island/hikari 0.55.91 → 0.55.92
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.tsx +2 -2
- package/src/components/HkIconButtonVars.scss +3 -3
- package/src/components/HkThemeToggle.ghost-chrome.test.ts +32 -18
- package/src/components/HkThemeToggle.scss +9 -36
- package/src/components/HkThemeToggle.test.ts +59 -3
- package/src/components/HkThemeToggle.tsx +34 -37
package/package.json
CHANGED
|
@@ -9,8 +9,8 @@ export default defineComponent({
|
|
|
9
9
|
props: {
|
|
10
10
|
icon: { type: String, default: "" },
|
|
11
11
|
variant: { type: String as PropType<"ghost" | "primary" | "secondary" | "danger" | "success">, default: "ghost" },
|
|
12
|
-
// 28 is the header chrome step: HkThemeToggle's trigger
|
|
13
|
-
//
|
|
12
|
+
// 28 is the header chrome step: HkThemeToggle's trigger is a 28px box
|
|
13
|
+
// with a 16px glyph, so a header action that must read as
|
|
14
14
|
// "the same kind of button" (global search, the console pages'
|
|
15
15
|
// refresh) composes at 28 — 24 read visibly smaller beside the toggle
|
|
16
16
|
// (user report 2026-09-28: 前台全局搜索与主题切换按钮不同尺寸).
|
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
--hi-icon-button-size: 28px;
|
|
77
77
|
/* The header-chrome step carries the sm glyph (16px): its consumers are
|
|
78
78
|
* the header clusters — global search, the pages' refresh, the filter
|
|
79
|
-
* trigger — whose visual sibling is HkThemeToggle's own trigger
|
|
80
|
-
*
|
|
81
|
-
* rendered those glyphs visibly smaller than the
|
|
79
|
+
* trigger — whose visual sibling is HkThemeToggle's own trigger (one
|
|
80
|
+
* 28px box with a direct 16px lucide glyph). The xs mapping (12px)
|
|
81
|
+
* rendered those glyphs visibly smaller than the glyphs beside them
|
|
82
82
|
* (user report 2026-09-29: 不像一个尺寸的图标按钮) — the `.svg` rule
|
|
83
83
|
* in HkIconButton.scss forces ANY slotted glyph to this var, so the
|
|
84
84
|
* slot's size-16 attribute never reached the screen. */
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Source contract for the theme-toggle trigger chrome (2026-09-08 user
|
|
3
3
|
* report: the bordered surface box read as a mystery outline in the
|
|
4
|
-
* header and fought the host themes
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* header and fought the host themes). That round standardized the trigger
|
|
5
|
+
* onto ghost chrome via a bespoke scss block; 2026-09-30 (user direction)
|
|
6
|
+
* went one step further: the trigger IS an HkIconButton (ghost/28 — the
|
|
7
|
+
* header chrome step, user report 2026-09-28) now — the retired
|
|
8
|
+
* main/arrow button pair, whose primary hover wash disagreed with the
|
|
9
|
+
* ghost gray of the buttons beside it, is merged into ONE palette-glyph
|
|
10
|
+
* trigger, and every byte of trigger chrome comes from the shared
|
|
11
|
+
* HkIconButtonVars contract. Pinned here so neither the bespoke block
|
|
12
|
+
* nor the two-button pair can silently return.
|
|
11
13
|
*/
|
|
12
14
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
13
15
|
import { readFileSync } from "node:fs";
|
|
@@ -15,27 +17,39 @@ import { dirname, join } from "node:path";
|
|
|
15
17
|
import { fileURLToPath } from "node:url";
|
|
16
18
|
|
|
17
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const
|
|
20
|
+
const scss = readFileSync(join(here, "HkThemeToggle.scss"), "utf-8");
|
|
21
|
+
const tsx = readFileSync(join(here, "HkThemeToggle.tsx"), "utf-8");
|
|
19
22
|
|
|
20
|
-
describe("HkThemeToggle
|
|
23
|
+
describe("HkThemeToggle trigger chrome contract", () => {
|
|
21
24
|
let block = "";
|
|
22
25
|
beforeAll(() => {
|
|
23
|
-
|
|
26
|
+
// Line-anchored: prose comments may NAME the hook class — only a real
|
|
27
|
+
// top-level rule block for it counts as chrome coming back.
|
|
28
|
+
block = scss.match(/^\.s-theme-toggle-btn[^{]*\{[^}]*\}/m)?.[0] ?? "";
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
it("
|
|
27
|
-
|
|
31
|
+
it("carries no bespoke chrome block for the trigger", () => {
|
|
32
|
+
// The class survives ONLY as a stable hook on the HkIconButton —
|
|
33
|
+
// any rule painting it would fork the chrome away from the shared
|
|
34
|
+
// ghost contract again.
|
|
35
|
+
expect(block).toBe("");
|
|
28
36
|
});
|
|
29
37
|
|
|
30
|
-
it("
|
|
31
|
-
expect(
|
|
38
|
+
it("removes the per-theme border var entirely", () => {
|
|
39
|
+
expect(scss).not.toContain("--hk-theme-toggle-btn-border");
|
|
32
40
|
});
|
|
33
41
|
|
|
34
|
-
it("
|
|
35
|
-
expect(
|
|
42
|
+
it("renders the trigger through the standard HkIconButton", () => {
|
|
43
|
+
expect(tsx).toContain("HkIconButton");
|
|
44
|
+
expect(tsx).toMatch(/variant="ghost"/);
|
|
36
45
|
});
|
|
37
46
|
|
|
38
|
-
it("
|
|
39
|
-
|
|
47
|
+
it("merges the retired main/arrow pair into one trigger", () => {
|
|
48
|
+
// One s-theme-toggle-btn element, and the retired data hooks are gone.
|
|
49
|
+
const triggers = tsx.match(/class="s-theme-toggle-btn"/g) ?? [];
|
|
50
|
+
expect(triggers).toHaveLength(1);
|
|
51
|
+
expect(tsx).not.toContain('data-variant="arrow"');
|
|
52
|
+
expect(tsx).not.toContain('data-variant="main"');
|
|
53
|
+
expect(tsx).not.toContain("toggleMode");
|
|
40
54
|
});
|
|
41
55
|
});
|
|
@@ -3,35 +3,17 @@
|
|
|
3
3
|
.s-theme-toggle {
|
|
4
4
|
display: inline-flex;
|
|
5
5
|
align-items: center;
|
|
6
|
-
gap: var(--space-2, 2px);
|
|
7
6
|
}
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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))));
|
|
27
|
-
cursor: pointer;
|
|
28
|
-
transition: background 0.15s ease, color 0.15s ease;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.s-theme-toggle-btn:focus-visible {
|
|
32
|
-
outline: 2px solid rgb(var(--color-primary));
|
|
33
|
-
outline-offset: 1px;
|
|
34
|
-
}
|
|
8
|
+
/* The trigger carries NO css of its own (2026-09-30 user direction): the
|
|
9
|
+
* retired bespoke trigger block — whose primary hover wash disagreed with
|
|
10
|
+
* the ghost gray of the HkIconButtons beside it and made the header
|
|
11
|
+
* cluster read as two different button families — is gone. The trigger IS
|
|
12
|
+
* an HkIconButton (ghost/28, the header chrome step) now, so its
|
|
13
|
+
* rest/hover/press chrome, radius and focus ring come verbatim from the
|
|
14
|
+
* shared HkIconButtonVars contract; the s-theme-toggle-btn class survives
|
|
15
|
+
* only as a stable hook on that button (chest's chrome guard asserts
|
|
16
|
+
* chest never re-paints it). Do not reintroduce a chrome rule for it. */
|
|
35
17
|
|
|
36
18
|
/* Host strip under the mode group (mode-extra slot). */
|
|
37
19
|
.s-theme-mode-extra {
|
|
@@ -39,15 +21,6 @@
|
|
|
39
21
|
width: 100%;
|
|
40
22
|
}
|
|
41
23
|
|
|
42
|
-
.s-theme-toggle-btn:hover {
|
|
43
|
-
background: rgb(var(--color-primary) / 12%);
|
|
44
|
-
color: rgb(var(--color-text));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.s-theme-toggle-btn:active {
|
|
48
|
-
background: rgb(var(--color-primary) / 16%);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
24
|
.s-theme-menu {
|
|
52
25
|
display: flex;
|
|
53
26
|
flex-direction: column;
|
|
@@ -60,10 +60,18 @@ async function settle(): Promise<void> {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function openMenu(container: HTMLElement): void {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// The single merged trigger (user direction 2026-09-30): ONE ghost
|
|
64
|
+
// HkIconButton opens the menu — the retired main/arrow pair is gone.
|
|
65
|
+
const trigger = container.querySelector(
|
|
66
|
+
".s-theme-toggle-btn",
|
|
65
67
|
) as HTMLButtonElement | null;
|
|
66
|
-
|
|
68
|
+
trigger!.click();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function triggerButton(container: HTMLElement): HTMLButtonElement {
|
|
72
|
+
const trigger = container.querySelector<HTMLButtonElement>(".s-theme-toggle-btn");
|
|
73
|
+
expect(trigger).toBeTruthy();
|
|
74
|
+
return trigger!;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
function paletteButton(): HTMLButtonElement {
|
|
@@ -96,6 +104,54 @@ afterEach(() => {
|
|
|
96
104
|
vi.unstubAllGlobals();
|
|
97
105
|
});
|
|
98
106
|
|
|
107
|
+
describe("HkThemeToggle trigger", () => {
|
|
108
|
+
it("renders ONE standard ghost icon-button trigger and no retired pair", async () => {
|
|
109
|
+
const { container } = mountToggle(true);
|
|
110
|
+
await settle();
|
|
111
|
+
|
|
112
|
+
// Exactly one trigger, and it IS the shared standard part: the merged
|
|
113
|
+
// single button (user direction 2026-09-30) must be chrome-identical
|
|
114
|
+
// to the functional HkIconButtons hosts place beside it — the ghost
|
|
115
|
+
// variant's shared rest/hover/press paint, not a bespoke block. The
|
|
116
|
+
// 28 step is the header chrome step (user report 2026-09-28: the
|
|
117
|
+
// cluster reads as one row only at ghost/28, sm 16px glyph).
|
|
118
|
+
const triggers = container.querySelectorAll(".s-theme-toggle-btn");
|
|
119
|
+
expect(triggers).toHaveLength(1);
|
|
120
|
+
const trigger = triggers[0] as HTMLElement;
|
|
121
|
+
expect(trigger.classList.contains("hk-icon-button")).toBe(true);
|
|
122
|
+
expect(trigger.classList.contains("hk-icon-button-28")).toBe(true);
|
|
123
|
+
expect(trigger.classList.contains("hk-icon-button-ghost")).toBe(true);
|
|
124
|
+
// The retired main/arrow data hooks must never come back.
|
|
125
|
+
expect(trigger.hasAttribute("data-variant")).toBe(false);
|
|
126
|
+
// A palette glyph, not the retired mode glyphs (the mode state lives
|
|
127
|
+
// in the menu's segmented group now).
|
|
128
|
+
expect(trigger.querySelector("svg")).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("opens the theme menu from the single trigger with menu aria wiring", async () => {
|
|
132
|
+
const { container } = mountToggle(true);
|
|
133
|
+
await settle();
|
|
134
|
+
|
|
135
|
+
const trigger = triggerButton(container);
|
|
136
|
+
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
|
137
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
138
|
+
// A click opens the menu; no mode switch fires (the retired direct
|
|
139
|
+
// mode-cycle press is gone — mode switching lives in the strip).
|
|
140
|
+
const modeBefore = useTheme().currentMode.value;
|
|
141
|
+
trigger.click();
|
|
142
|
+
await settle();
|
|
143
|
+
expect(document.body.querySelector(".s-theme-menu")).toBeTruthy();
|
|
144
|
+
expect(useTheme().currentMode.value).toBe(modeBefore);
|
|
145
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
146
|
+
|
|
147
|
+
// Second press closes it again (plain toggle, same as before).
|
|
148
|
+
trigger.click();
|
|
149
|
+
await settle();
|
|
150
|
+
expect(document.body.querySelector(".s-theme-menu")).toBeNull();
|
|
151
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
99
155
|
describe("HkThemeToggle external customization", () => {
|
|
100
156
|
it("emits open-customize and keeps the dialog closed when externalCustomize is true", async () => {
|
|
101
157
|
const { container, openCustomize } = mountToggle(true);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
|
|
2
|
-
import { Check,
|
|
2
|
+
import { Check, Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
|
|
3
3
|
import {
|
|
4
4
|
HDivider,
|
|
5
5
|
HPopover,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@celestia-island/hikari";
|
|
15
15
|
|
|
16
16
|
import { HColorSchemeDialog, type HCustomTheme } from "./HkColorSchemeDialog";
|
|
17
|
+
import HkIconButton from "./HkIconButton";
|
|
17
18
|
import HTabs, { type TabItem } from "./HkTabs";
|
|
18
19
|
import "./HkThemeToggle.scss";
|
|
19
20
|
|
|
@@ -40,10 +41,14 @@ export interface ThemeItemScope {
|
|
|
40
41
|
* HkThemeToggle — light/dark/auto theme control over hikari's theme engine.
|
|
41
42
|
*
|
|
42
43
|
* Composes hikari's `useTheme()` (theme presets + custom themes + mode
|
|
43
|
-
* persistence); it does NOT reimplement the theme engine.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* persistence); it does NOT reimplement the theme engine. ONE trigger —
|
|
45
|
+
* a standard HkIconButton ghost/28 with a palette glyph, chrome-identical
|
|
46
|
+
* to the functional buttons hosts place beside it — opens the popover,
|
|
47
|
+
* which offers the color-mode group and preset/custom theme selection
|
|
48
|
+
* (custom themes are removable) and opens HColorSchemeDialog to create a
|
|
49
|
+
* new custom scheme. The retired main/arrow button pair cycled light/dark
|
|
50
|
+
* straight from the header; that press lives on only as the menu's
|
|
51
|
+
* altitude-strip resolution (auto) and the segmented group (manual).
|
|
47
52
|
*
|
|
48
53
|
* Theme rows are host-customizable through three scoped slots (all render
|
|
49
54
|
* in the popover AND the mobile bottom sheet — same DOM):
|
|
@@ -102,7 +107,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
102
107
|
},
|
|
103
108
|
setup(props, { emit, slots, expose }) {
|
|
104
109
|
const { t } = useI18n();
|
|
105
|
-
const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode,
|
|
110
|
+
const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode, allThemeList, addCustomTheme, removeCustomTheme, customThemes } = useTheme();
|
|
106
111
|
|
|
107
112
|
/** Resolve a row's full definition for the item slots: the user's
|
|
108
113
|
* stored custom schemes first, then the live preset table — the SAME
|
|
@@ -171,15 +176,6 @@ export const HkThemeToggle = defineComponent({
|
|
|
171
176
|
|
|
172
177
|
const isAutoMode = computed(() => currentMode.value === "system");
|
|
173
178
|
|
|
174
|
-
const modeLabel = computed(() => {
|
|
175
|
-
const map: Record<string, string> = {
|
|
176
|
-
system: t("hikari::theme.modeAuto"),
|
|
177
|
-
light: t("hikari::theme.modeLight"),
|
|
178
|
-
dark: t("hikari::theme.modeDark"),
|
|
179
|
-
};
|
|
180
|
-
return map[currentMode.value] ?? currentMode.value;
|
|
181
|
-
});
|
|
182
|
-
|
|
183
179
|
/**
|
|
184
180
|
* Fresh option objects (and fresh icon vnodes) on every call — never
|
|
185
181
|
* cache icon vnodes across renders, or closing/reopening the popover
|
|
@@ -229,31 +225,32 @@ export const HkThemeToggle = defineComponent({
|
|
|
229
225
|
|
|
230
226
|
return () => (
|
|
231
227
|
<div class="s-theme-toggle" ref={triggerRef}>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
</button>
|
|
248
|
-
<button
|
|
249
|
-
type="button"
|
|
228
|
+
{/* ONE trigger (user direction 2026-09-30): the old main/arrow pair
|
|
229
|
+
* read as two separate header buttons whose hover washes disagreed
|
|
230
|
+
* (bespoke primary wash here vs the ghost gray beside them). The
|
|
231
|
+
* trigger is now a STANDARD HkIconButton ghost/28 with a palette
|
|
232
|
+
* glyph — identical chrome to the functional buttons it sits
|
|
233
|
+
* beside (transparent rest, ghost wash on hover/press, shared
|
|
234
|
+
* focus ring) at the SAME header chrome step (user report
|
|
235
|
+
* 2026-09-28: the cluster reads as one row only at ghost/28 with
|
|
236
|
+
* the sm 16px glyph, which the 28 step maps). And it only opens
|
|
237
|
+
* the menu; mode switching lives in the menu's color-mode strip
|
|
238
|
+
* (auto altitude strip resolves auto to a manual side), which
|
|
239
|
+
* loses nothing the old direct mode-cycle press offered. The
|
|
240
|
+
* `s-theme-toggle-btn` class stays as a stable consumer/test
|
|
241
|
+
* hook; it carries NO css of its own. */}
|
|
242
|
+
<HkIconButton
|
|
250
243
|
class="s-theme-toggle-btn"
|
|
251
|
-
|
|
252
|
-
|
|
244
|
+
size={28}
|
|
245
|
+
variant="ghost"
|
|
246
|
+
aria-haspopup="menu"
|
|
247
|
+
aria-expanded={menuOpen.value ? "true" : "false"}
|
|
253
248
|
aria-label={t("hikari::theme.themes")}
|
|
249
|
+
{...{ title: t("hikari::theme.themes") }}
|
|
250
|
+
onClick={() => { menuOpen.value = !menuOpen.value; }}
|
|
254
251
|
>
|
|
255
|
-
<
|
|
256
|
-
</
|
|
252
|
+
{{ icon: () => <Palette size={16} /> }}
|
|
253
|
+
</HkIconButton>
|
|
257
254
|
|
|
258
255
|
<HPopover
|
|
259
256
|
modelValue={menuOpen.value}
|