@celestia-island/hikari 0.55.90 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.90",
3
+ "version": "0.55.92",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Source contract: the inline landing sits FLUSH under its context. The
3
+ * `.is-inline` variant must drop the viewport takeover's block-axis
4
+ * padding entirely (the host pane already pads and a sibling header sits
5
+ * immediately above — the old 1.25rem top padding floated the card a
6
+ * full padding-frame below its context) and slim the card's page-grade
7
+ * top padding (2.25rem desktop / 1.75rem mobile) to one space step. The
8
+ * horizontal inset stays so a narrow pane never seats the card
9
+ * edge-to-edge. A class-name test cannot see this; the compiled contract
10
+ * lives here.
11
+ */
12
+ import { 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
+
19
+ /** Extract one rule's body with nesting-aware brace matching. */
20
+ function ruleBody(css: string, selector: string): string {
21
+ const start = css.indexOf(`${selector} {`);
22
+ expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
23
+ const open = css.indexOf("{", start);
24
+ let depth = 0;
25
+ for (let i = open; i < css.length; i++) {
26
+ if (css[i] === "{") depth++;
27
+ else if (css[i] === "}") {
28
+ depth--;
29
+ if (depth === 0) return css.slice(open + 1, i);
30
+ }
31
+ }
32
+ throw new Error(`unbalanced braces in ${selector}`);
33
+ }
34
+
35
+ describe("HkErrorLanding inline contract", () => {
36
+ const css = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
37
+ const inline = ruleBody(css, ".hk-error-landing.is-inline");
38
+
39
+ it("drops the block-axis padding entirely and keeps only the horizontal inset", () => {
40
+ expect(inline).toContain("min-height: 0");
41
+ expect(inline).toContain("padding: 0 var(--space-12, 0.75rem)");
42
+ // The takeover padding must not survive in any form.
43
+ expect(inline).not.toContain("1.25rem");
44
+ });
45
+
46
+ it("slims the card's top padding below the page-grade value", () => {
47
+ const card = ruleBody(inline, ".hk-error-landing__card");
48
+ // The shorthand's block-start value IS the slim step, not the
49
+ // page-grade 2.25rem (desktop) / 1.75rem (≤480px media) top.
50
+ expect(card).toContain("padding: var(--space-16, 1rem) ");
51
+ expect(card).not.toContain("2.25rem");
52
+ });
53
+ });
@@ -260,11 +260,26 @@
260
260
 
261
261
  // Inline variant: the same card without the viewport takeover — used when
262
262
  // HkErrorBoundary captures a crash inside a pane and swaps in the landing
263
- // in-place instead of covering the whole screen.
263
+ // in-place instead of covering the whole screen, or when a host view
264
+ // renders it as its failed content region right under a sibling header.
264
265
  .hk-error-landing.is-inline {
265
266
  min-height: 0;
266
- padding: var(--space-20, 1.25rem) var(--space-12, 0.75rem);
267
+ // In-pane, the host region already pads and a sibling header usually
268
+ // sits immediately above: the takeover's block-axis padding floated
269
+ // the card a full padding-frame below its context. The vertical
270
+ // padding is dropped entirely (the card's own slimmer top padding
271
+ // below supplies the breathing room); the horizontal inset stays so a
272
+ // narrow pane never seats the card edge-to-edge.
273
+ padding: 0 var(--space-12, 0.75rem);
267
274
  background: transparent;
275
+
276
+ // The card's page-grade top padding (2.25rem desktop / 1.75rem mobile)
277
+ // reads as a detached float inside a pane — slim it to one space step.
278
+ // Specificity (0,3,0) outranks the ≤480px media rule (0,1,0), so this
279
+ // holds on phones too.
280
+ .hk-error-landing__card {
281
+ padding: var(--space-16, 1rem) var(--space-28, 1.75rem) var(--space-24, 1.5rem);
282
+ }
268
283
  }
269
284
 
270
285
  @media (max-width: 480px) {
@@ -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 buttons are
13
- // 28px boxes with 16px glyphs, so a header action that must read as
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 buttons
80
- * (28px boxes with direct 16px lucide glyphs). The xs mapping (12px)
81
- * rendered those glyphs visibly smaller than the sun beside them
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 — 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.
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 src = readFileSync(join(here, "HkThemeToggle.scss"), "utf-8");
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 ghost trigger chrome contract", () => {
23
+ describe("HkThemeToggle trigger chrome contract", () => {
21
24
  let block = "";
22
25
  beforeAll(() => {
23
- block = src.match(/\.s-theme-toggle-btn\s*{[^}]*}/)?.[0] ?? "";
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("paints no border on the trigger (border: none)", () => {
27
- expect(block).toContain("border: none;");
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("keeps the rest state transparent (no surface box)", () => {
31
- expect(block).toContain("background: transparent;");
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("removes the per-theme border var entirely", () => {
35
- expect(src).not.toContain("--hk-theme-toggle-btn-border");
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("retains a visible focus ring via :focus-visible", () => {
39
- expect(src).toMatch(/\.s-theme-toggle-btn:focus-visible\s*{[^}]*}/);
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
- .s-theme-toggle-btn {
10
- display: inline-flex;
11
- align-items: center;
12
- justify-content: center;
13
- height: 1.75rem;
14
- min-width: 1.75rem;
15
- padding: 0 var(--space-6, 0.375rem);
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))));
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
- const arrow = container.querySelector(
64
- '.s-theme-toggle-btn[data-variant="arrow"]',
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
- arrow!.click();
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, ChevronDown, Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
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. A main button
44
- * cycles light/dark (auto keeps a Monitor glyph); the popover offers the
45
- * color-mode group and preset/custom theme selection (custom themes are
46
- * removable), and opens HColorSchemeDialog to create a new custom scheme.
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, toggleMode, allThemeList, addCustomTheme, removeCustomTheme, customThemes } = useTheme();
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
- <button
233
- type="button"
234
- class="s-theme-toggle-btn"
235
- data-variant="main"
236
- onClick={toggleMode}
237
- title={modeLabel.value}
238
- aria-label={t("hikari::theme.mode")}
239
- >
240
- {currentMode.value === "system" ? (
241
- <Monitor size={16} />
242
- ) : effectiveMode.value === "dark" ? (
243
- <MoonStar size={16} />
244
- ) : (
245
- <Sun size={16} />
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
- data-variant="arrow"
252
- onClick={() => { menuOpen.value = !menuOpen.value; }}
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
- <ChevronDown size={12} />
256
- </button>
252
+ {{ icon: () => <Palette size={16} /> }}
253
+ </HkIconButton>
257
254
 
258
255
  <HPopover
259
256
  modelValue={menuOpen.value}