@celestia-island/hikari 0.55.285 → 0.55.288

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.285",
3
+ "version": "0.55.288",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -1,11 +1,18 @@
1
1
  /**
2
- * HkCornerBadge — the generic corner mark (dot / count / label) pinned
3
- * to one corner of the default-slot anchor (see HkCornerBadge.tsx for
4
- * the contract). Geometry follows the house pattern the trigger dots
5
- * and the Rust family's .hk-badge-wrapper established: the anchor is a
6
- * relative inline-flex wrapper (the badge escapes nothing — hosts wrap
7
- * ANY child), the mark centers ON the corner via translate(50%,-50%)
8
- * with an optional --hk-corner-badge-offset clearance.
2
+ * HkCornerBadge — the generic anchored mark (dot / count / label) for the
3
+ * default-slot anchor (see HkCornerBadge.tsx for the contract). Geometry
4
+ * has TWO placement families, selected by `data-placement`:
5
+ *
6
+ * corners (bottom-right / …) OVERLAY the anchor: the anchor is a
7
+ * relative inline-flex wrapper (the badge escapes nothing), the mark
8
+ * centers ON the corner via translate(±50%) with an optional
9
+ * --hk-corner-badge-offset clearance — the subscript mark;
10
+ *
11
+ * sides (`right` / `left`) put the mark in the wrapper's FLOW beside the
12
+ * anchor, vertically centered, so the anchor's own box (and text) is
13
+ * never covered. The wrapper is already a row; the mark spaces ITSELF
14
+ * (margin = --hk-corner-badge-gap + --hk-corner-badge-offset, default
15
+ * 6px + 0), so a multi-child anchor's own layout is never touched.
9
16
  */
10
17
 
11
18
  .hk-corner-badge-anchor {
@@ -15,41 +22,70 @@
15
22
 
16
23
  .hk-corner-badge {
17
24
  position: absolute;
18
- /* Centered on the corner; the corner data-attribute flips the axes
19
- * and the offset var adds clearance without re-deriving transforms. */
25
+ /* Corner placements center ON the corner; the side placement rules
26
+ * below return the mark to the flow (static, no transform). */
20
27
  transform: translate(var(--hk-corner-badge-tx, 50%), var(--hk-corner-badge-ty, -50%));
21
28
  pointer-events: none;
22
29
  white-space: nowrap;
23
30
  }
24
31
 
25
- .hk-corner-badge[data-corner="top-right"] {
32
+ .hk-corner-badge[data-placement="top-right"] {
26
33
  top: var(--hk-corner-badge-offset, 0px);
27
34
  right: var(--hk-corner-badge-offset, 0px);
28
35
  --hk-corner-badge-tx: 50%;
29
36
  --hk-corner-badge-ty: -50%;
30
37
  }
31
38
 
32
- .hk-corner-badge[data-corner="top-left"] {
39
+ .hk-corner-badge[data-placement="top-left"] {
33
40
  top: var(--hk-corner-badge-offset, 0px);
34
41
  left: var(--hk-corner-badge-offset, 0px);
35
42
  --hk-corner-badge-tx: -50%;
36
43
  --hk-corner-badge-ty: -50%;
37
44
  }
38
45
 
39
- .hk-corner-badge[data-corner="bottom-right"] {
46
+ .hk-corner-badge[data-placement="bottom-right"] {
40
47
  bottom: var(--hk-corner-badge-offset, 0px);
41
48
  right: var(--hk-corner-badge-offset, 0px);
42
49
  --hk-corner-badge-tx: 50%;
43
50
  --hk-corner-badge-ty: 50%;
44
51
  }
45
52
 
46
- .hk-corner-badge[data-corner="bottom-left"] {
53
+ .hk-corner-badge[data-placement="bottom-left"] {
47
54
  bottom: var(--hk-corner-badge-offset, 0px);
48
55
  left: var(--hk-corner-badge-offset, 0px);
49
56
  --hk-corner-badge-tx: -50%;
50
57
  --hk-corner-badge-ty: 50%;
51
58
  }
52
59
 
60
+ /* Side placements: the mark rides the wrapper's row. `right` stays after
61
+ * the anchor (DOM order), `left` flips ahead of it; `align-self` centers
62
+ * the mark on the row and the mark's own margin spaces it from the anchor,
63
+ * so the anchor's other children keep their default alignment. The flex
64
+ * row follows the writing direction, so an RTL host mirrors the side for
65
+ * free (the house flex-row convention, see HkNumberSteppers). The two
66
+ * sides are written out separately (rather than one shared rule) so each
67
+ * side's block stays a single, greppable declaration. */
68
+ .hk-corner-badge[data-placement="right"] {
69
+ position: static;
70
+ transform: none;
71
+ align-self: center;
72
+ margin-inline-start: calc(
73
+ var(--hk-corner-badge-gap, var(--space-6, 6px)) +
74
+ var(--hk-corner-badge-offset, 0px)
75
+ );
76
+ }
77
+
78
+ .hk-corner-badge[data-placement="left"] {
79
+ position: static;
80
+ transform: none;
81
+ align-self: center;
82
+ order: -1;
83
+ margin-inline-end: calc(
84
+ var(--hk-corner-badge-gap, var(--space-6, 6px)) +
85
+ var(--hk-corner-badge-offset, 0px)
86
+ );
87
+ }
88
+
53
89
  /* Dot form: a filled circle in the variant's tone (same token trio
54
90
  * HkBadge uses — variant classes below set all three channels). */
55
91
  .hk-corner-badge--dot {
@@ -7,9 +7,14 @@ import { describe, expect, it } from "vitest";
7
7
 
8
8
  /**
9
9
  * Compiled-stylesheet guard for HkCornerBadge — the anchoring contract
10
- * (2026-10-15): the anchor wrapper is a relative inline-flex host (any
11
- * child can carry the mark), the mark centers ON its corner via
12
- * translate(±50%,±50%), and each data-corner flips the right axes.
10
+ * (2026-10-15, two placement families since 2026-11-05): the anchor
11
+ * wrapper is a relative inline-flex host (any child can carry the mark);
12
+ * the four CORNER placements overlay it, centering ON the corner via
13
+ * translate(±50%,±50%) with each data-placement flipping the right axes;
14
+ * the SIDE placements (`right` / `left`) return the mark to the flow,
15
+ * centered on the wrapper's row by `align-self: center`, and the mark's
16
+ * own margin spaces it from the anchor (gap + offset), so the anchor's
17
+ * own box — and its other children's alignment — is never touched.
13
18
  * Vitest stubs CSS, so the runtime suite can never catch a lost rule.
14
19
  */
15
20
 
@@ -46,10 +51,23 @@ describe("HkCornerBadge stylesheet contract", () => {
46
51
  });
47
52
 
48
53
  it("each corner flips the translate axes", () => {
49
- expect(ruleFor('.hk-corner-badge[data-corner=top-right] {')).toContain("--hk-corner-badge-tx: 50%");
50
- expect(ruleFor('.hk-corner-badge[data-corner=top-left] {')).toContain("--hk-corner-badge-tx: -50%");
51
- expect(ruleFor('.hk-corner-badge[data-corner=bottom-right] {')).toContain("--hk-corner-badge-ty: 50%");
52
- expect(ruleFor('.hk-corner-badge[data-corner=bottom-left] {')).toContain("--hk-corner-badge-tx: -50%");
54
+ expect(ruleFor('.hk-corner-badge[data-placement=top-right] {')).toContain("--hk-corner-badge-tx: 50%");
55
+ expect(ruleFor('.hk-corner-badge[data-placement=top-left] {')).toContain("--hk-corner-badge-tx: -50%");
56
+ expect(ruleFor('.hk-corner-badge[data-placement=bottom-right] {')).toContain("--hk-corner-badge-ty: 50%");
57
+ expect(ruleFor('.hk-corner-badge[data-placement=bottom-left] {')).toContain("--hk-corner-badge-tx: -50%");
58
+ });
59
+
60
+ it("side placements return to the flow and space themselves from the anchor", () => {
61
+ const right = ruleFor('.hk-corner-badge[data-placement=right] {');
62
+ expect(right).toContain("position: static");
63
+ expect(right).toContain("transform: none");
64
+ expect(right).toContain("align-self: center");
65
+ expect(right).toContain("margin-inline-start: calc(var(--hk-corner-badge-gap");
66
+ const left = ruleFor('.hk-corner-badge[data-placement=left] {');
67
+ expect(left).toContain("position: static");
68
+ expect(left).toContain("align-self: center");
69
+ expect(left).toContain("order: -1");
70
+ expect(left).toContain("margin-inline-end: calc(var(--hk-corner-badge-gap");
53
71
  });
54
72
 
55
73
  it("the dot is round and the pill is a tabular-numbers chip", () => {
@@ -21,7 +21,7 @@ afterEach(() => {
21
21
  }
22
22
  });
23
23
 
24
- describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
24
+ describe("HkCornerBadge — the generic anchored mark (2026-10-15)", () => {
25
25
  it("anchors the default slot in a relative wrapper with no mark by default", () => {
26
26
  const c = mount(h(HkCornerBadge, null, () => h("button", { class: "probe" }, "x")));
27
27
  const anchor = c.querySelector(".hk-corner-badge-anchor")!;
@@ -32,19 +32,34 @@ describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
32
32
  expect(c.querySelector(".hk-corner-badge")).toBeNull();
33
33
  });
34
34
 
35
+ it("defaults to the subscript overlay (bottom-right) exactly like an avatar mark", () => {
36
+ const c = mount(h(HkCornerBadge, { count: 2 }, () => h("i")));
37
+ const anchor = c.querySelector(".hk-corner-badge-anchor")!;
38
+ expect(anchor.getAttribute("data-placement")).toBe("bottom-right");
39
+ expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("bottom-right");
40
+ });
41
+
35
42
  it("dot form: a variant-colored dot at the requested corner, decorative by default", () => {
36
43
  const c = mount(
37
- h(HkCornerBadge, { dot: true, variant: "success", corner: "bottom-right" }, () => h("i")),
44
+ h(HkCornerBadge, { dot: true, variant: "success", placement: "top-right" }, () => h("i")),
38
45
  );
39
46
  const badge = c.querySelector(".hk-corner-badge")!;
40
47
  expect(badge).toBeTruthy();
41
48
  expect(badge.className).toContain("hk-corner-badge--dot");
42
49
  expect(badge.className).toContain("hk-corner-badge-success");
43
- expect(badge.getAttribute("data-corner")).toBe("bottom-right");
50
+ expect(badge.getAttribute("data-placement")).toBe("top-right");
44
51
  expect(badge.getAttribute("aria-hidden")).toBe("true");
45
52
  expect(badge.getAttribute("role")).toBeNull();
46
53
  });
47
54
 
55
+ it("side placements carry the same data attribute on anchor and mark (button-group default)", () => {
56
+ const c = mount(
57
+ h(HkCornerBadge, { count: 4, placement: "right" }, () => h("span", "Active")),
58
+ );
59
+ expect(c.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
60
+ expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("right");
61
+ });
62
+
48
63
  it("count form: renders the number, caps past max, hides zero unless showZero", () => {
49
64
  const c = mount(h(HkCornerBadge, { count: 7 }, () => h("i")));
50
65
  expect(c.querySelector(".hk-corner-badge")!.textContent).toBe("7");
@@ -13,17 +13,41 @@ export type CornerBadgeVariant =
13
13
  | "info"
14
14
  | "muted";
15
15
 
16
- /** Which corner of the anchor the mark hangs on. Exported so hosts that
17
- * embed the shell (HkTabs' trigger badge) share the one vocabulary
18
- * instead of re-spelling the union; HkIconButton's dot borrows the tone
19
- * channels but is an inset span of its own, not a corner mark. */
20
- export type CornerBadgeCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
16
+ /**
17
+ * Where the mark sits relative to the anchor — TWO families, not just
18
+ * corners (user direction 2026-11-05: a corner overlay on every anchor
19
+ * was wrong; "(n)" replaced by a badge must not cover the trigger's own
20
+ * label).
21
+ *
22
+ * - `top-right` / `top-left` / `bottom-right` / `bottom-left` OVERLAY the
23
+ * anchor: the mark centers ON the anchor's edge (half of it straddles
24
+ * the corner) — the classic subscript / notification mark for a fixed
25
+ * box (an avatar, an icon button).
26
+ * - `right` / `left` place the mark BESIDE the anchor in the flow,
27
+ * vertically centered, so the anchor's own box (and text) is never
28
+ * covered; the wrapper grows to fit both — the button-group / tab
29
+ * trigger default.
30
+ *
31
+ * Scenario defaults follow the same line: an anchor that can grow (a
32
+ * trigger inside a button group / tab strip) wants a side placement, a
33
+ * fixed-box anchor (an avatar) wants the bottom-right overlay. The
34
+ * component default is `bottom-right` (the subscript mark); hosts whose
35
+ * scenario is a button group pass `right` as their own default (HkTabs).
36
+ */
37
+ export type CornerBadgePlacement =
38
+ | "top-right"
39
+ | "top-left"
40
+ | "bottom-right"
41
+ | "bottom-left"
42
+ | "right"
43
+ | "left";
21
44
 
22
45
  /**
23
- * HkCornerBadge — the generic corner mark for ANY anchor (user
46
+ * HkCornerBadge — the generic anchored mark for ANY anchor (user
24
47
  * direction 2026-10-15): a green/red/traffic dot, a notification count
25
- * ("9+"), or a custom text label pinned to one corner of whatever sits
26
- * in the default slot. The Vue package had every consumer hand-rolling
48
+ * ("9+"), or a custom text label pinned to a corner of — or beside —
49
+ * whatever sits in the default slot (see CornerBadgePlacement). The Vue
50
+ * package had every consumer hand-rolling
27
51
  * its own absolute span (the filter/search trigger dots, the icon-group
28
52
  * marker, chest's dock time-range pill); this is the one component they
29
53
  * converge on — API parity with the Rust family's badge.rs (dot / count
@@ -48,13 +72,18 @@ export default defineComponent({
48
72
  * ANCHOR, never the label — overloading it made every anchored
49
73
  * child's own text leak into the badge. */
50
74
  text: { type: String, default: undefined },
51
- /** Which corner of the anchor. */
52
- corner: {
53
- type: String as PropType<CornerBadgeCorner>,
54
- default: "top-right",
75
+ /** Where the mark hangs (see CornerBadgePlacement). The four corners
76
+ * overlay the anchor; `right` / `left` sit beside it, vertically
77
+ * centered and never covering it. Default `bottom-right` = the
78
+ * subscript mark (avatar / icon). Button-group hosts (HkTabs)
79
+ * default to `right` instead. */
80
+ placement: {
81
+ type: String as PropType<CornerBadgePlacement>,
82
+ default: "bottom-right",
55
83
  },
56
- /** Extra clearance from the corner in px (0 = sit ON the corner,
57
- * the translate(50%,-50%) centering). */
84
+ /** Extra clearance in px. Corner placements: clearance from the
85
+ * corner (0 = the mark centers ON it via translate(±50%,±50%));
86
+ * side placements: the gap beyond the shared --hk-corner-badge-gap. */
58
87
  offset: { type: Number, default: 0 },
59
88
  /** Accessible name for the mark (without it the badge is
60
89
  * aria-hidden decoration). */
@@ -73,7 +102,7 @@ export default defineComponent({
73
102
  const show = computed(() => props.dot || text.value !== null);
74
103
 
75
104
  return () => (
76
- <span class="hk-corner-badge-anchor">
105
+ <span class="hk-corner-badge-anchor" data-placement={props.placement}>
77
106
  {slots.default?.()}
78
107
  {show.value && (
79
108
  <span
@@ -82,7 +111,7 @@ export default defineComponent({
82
111
  `hk-corner-badge-${props.variant}`,
83
112
  props.dot ? "hk-corner-badge--dot" : "hk-corner-badge--pill",
84
113
  ]}
85
- data-corner={props.corner}
114
+ data-placement={props.placement}
86
115
  style={props.offset ? { "--hk-corner-badge-offset": `${props.offset}px` } : undefined}
87
116
  aria-hidden={props.label ? undefined : "true"}
88
117
  role={props.label ? "img" : undefined}
@@ -68,7 +68,7 @@ export default defineComponent({
68
68
  const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
69
69
  return (
70
70
  <span ref={anchorRef} class="hk-filter-trigger">
71
- <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
71
+ <HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
72
72
  <HkIconButton
73
73
  size={chromeSize.value}
74
74
  variant="ghost"
@@ -96,7 +96,7 @@ const HkSearchTrigger = defineComponent({
96
96
  {/* The active-keyword marker rides the generic corner badge —
97
97
  * the same green bottom-right dot the filter trigger wears
98
98
  * (2026-10-15: was a hand-rolled primary dot, top-right). */}
99
- <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
99
+ <HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
100
100
  <HkIconButton
101
101
  size={chromeSize.value}
102
102
  variant="ghost"
@@ -15,6 +15,10 @@
15
15
  align-items: center;
16
16
  gap: var(--space-8, 0.5rem);
17
17
  min-width: 0;
18
+ /* The row's cross size is the icon-button square (28px), so the summary
19
+ * line and the action share ONE geometric center independent of the
20
+ * host's line-height. */
21
+ min-height: var(--space-28, 1.75rem);
18
22
  }
19
23
 
20
24
  .hk-solar-summary {
@@ -25,6 +29,13 @@
25
29
  flex: 1 1 auto;
26
30
  color: rgb(var(--color-text-secondary, var(--color-muted)));
27
31
  font-size: var(--text-sm);
32
+ /* The text clips at the ellipsis (`overflow: hidden` below), so the
33
+ * line box must cover the FONT'S OWN metrics: a fixed 1em/1.2em value
34
+ * cut descenders in tall fallback faces (Latin 1.17em, Arabic 1.36em,
35
+ * Noto CJK 1.46em). `normal` makes the box equal the resolved font's
36
+ * ascent+descent by construction; the row's 28px min-height and flex
37
+ * centering own the geometry around it. */
38
+ line-height: normal;
28
39
  }
29
40
 
30
41
  .hk-solar-summary-icon {
@@ -32,6 +43,7 @@
32
43
  align-items: center;
33
44
  justify-content: center;
34
45
  flex-shrink: 0;
46
+ line-height: 0;
35
47
  color: rgb(var(--color-muted));
36
48
  }
37
49
 
@@ -45,6 +57,15 @@
45
57
  white-space: nowrap;
46
58
  }
47
59
 
60
+ /* The icon-only action's tooltip host: the button itself is disabled in
61
+ * manual modes and therefore loses pointer events (native `disabled`), so
62
+ * the title has to live on this wrapper. */
63
+ .hk-solar-edit-wrap {
64
+ display: inline-flex;
65
+ flex: none;
66
+ align-items: center;
67
+ }
68
+
48
69
  .hk-solar-edit {
49
70
  flex-shrink: 0;
50
71
  }
@@ -18,6 +18,7 @@ import {
18
18
  type SolarLocationStrategy,
19
19
  } from "../theme/useSolarTime";
20
20
  import { cityKey, sectorForZone, sectorNameKey, standardOffsetHours } from "../theme/solarTimeZones";
21
+ import { useTheme } from "../theme/useTheme";
21
22
 
22
23
  // The locale dictionaries, loaded the same way the locales-coverage suite
23
24
  // reads them (import.meta.glob; readFileSync resolves wrong under vitest's
@@ -68,6 +69,9 @@ function resetEngineState(): void {
68
69
  setSolarLocationStrategy("timezone");
69
70
  setSolarManualLocation(null);
70
71
  resetSolarIpInsight();
72
+ // The card's Edit gate reads the singleton theme mode; every case starts
73
+ // in AUTO ("system") so the action is enabled by default.
74
+ useTheme().setMode("system");
71
75
  }
72
76
 
73
77
  function mountSetting(): HTMLElement {
@@ -229,12 +233,66 @@ describe("HkSolarLocationSetting card", () => {
229
233
  expect(summary.textContent).not.toContain(systemTimeZoneId());
230
234
  expect(summary.querySelector("svg"), "strategy glyph renders").toBeTruthy();
231
235
 
236
+ // The Edit affordance is an ICON-ONLY HkIconButton (ghost/28) — the
237
+ // square matches the row's 28px cross size; the accessible name and
238
+ // the tooltip carry the action (the wrapper owns the title because a
239
+ // disabled button loses pointer events).
232
240
  const btn = editButton(container);
233
- expect(btn.textContent?.trim()).toBe("Edit");
241
+ expect(btn.classList.contains("hk-icon-button")).toBe(true);
242
+ expect(btn.classList.contains("hk-icon-button-28")).toBe(true);
243
+ expect(btn.classList.contains("hk-icon-button-ghost")).toBe(true);
244
+ expect(
245
+ btn.querySelector(".hk-icon-button-icon svg.lucide-pencil"),
246
+ "the Pencil glyph (not IconButton's fallback circle) renders",
247
+ ).toBeTruthy();
248
+ expect(btn.textContent?.trim(), "no text label in the icon button").toBe("");
249
+ expect(btn.getAttribute("aria-label")).toBe("Edit");
250
+ expect(btn.disabled, "auto mode enables the editor").toBe(false);
251
+ expect(btn.closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe("Edit");
234
252
  // No editor in the DOM before the first open.
235
253
  expect(document.body.querySelector(".hk-solar-editor")).toBeNull();
236
254
  });
237
255
 
256
+ it("grays the edit action outside auto mode and re-enables it in auto", async () => {
257
+ const { setMode } = useTheme();
258
+ // Mount in AUTO first, then drive the live mode transitions a host's
259
+ // own switch performs — this is the user path and it keeps the test
260
+ // independent of which mode the singleton happened to carry at mount.
261
+ const container = mountSetting();
262
+ await settle();
263
+ expect(editButton(container).disabled, "auto mode enables the editor").toBe(false);
264
+ expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
265
+ "Edit",
266
+ );
267
+
268
+ setMode("light");
269
+ await settle();
270
+ const btn = editButton(container);
271
+ expect(btn.disabled, "manual modes disable the solar editor").toBe(true);
272
+ expect(btn.getAttribute("aria-label")).toBe("Edit");
273
+ expect(btn.closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
274
+ "Switch to Auto mode to change the daylight location",
275
+ );
276
+
277
+ // A disabled native button cannot open the editor.
278
+ btn.click();
279
+ await settle();
280
+ expect(modalHeader(), "disabled edit never opens the modal").toBeNull();
281
+
282
+ // Back to auto: the SAME mounted card re-enables on the live mode ref.
283
+ setMode("system");
284
+ await settle();
285
+ expect(editButton(container).disabled).toBe(false);
286
+ expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
287
+ "Edit",
288
+ );
289
+
290
+ // Dark is just another manual side of the same gate.
291
+ setMode("dark");
292
+ await settle();
293
+ expect(editButton(container).disabled).toBe(true);
294
+ });
295
+
238
296
  it("is a standard hikari card: localized default title, overridable, hideable", async () => {
239
297
  const container = mountSetting();
240
298
  await settle();
@@ -573,7 +631,9 @@ describe("HkSolarLocationSetting i18n", () => {
573
631
  expect(card?.querySelector(".hk-card-title")?.textContent?.trim()).toBe("日照位置");
574
632
  const summary = rowSummary(container);
575
633
  expect(summary.textContent).toContain("自动 · 基于时区");
576
- expect(editButton(container).textContent?.trim()).toBe("编辑");
634
+ // The icon button localizes its accessible name/tooltip, not a text label.
635
+ expect(editButton(container).getAttribute("aria-label")).toBe("编辑");
636
+ expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe("编辑");
577
637
 
578
638
  await openEditor(container);
579
639
  expect(modalHeader()!.textContent).toContain("太阳钟定位");
@@ -1,9 +1,9 @@
1
1
  // AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
2
2
  import { computed, defineAsyncComponent, defineComponent, ref, watch } from "vue";
3
- import { Clock, Globe2, MapPin } from "lucide-vue-next";
3
+ import { Clock, Globe2, MapPin, Pencil } from "lucide-vue-next";
4
4
 
5
5
  import { useI18n } from "../i18n/context";
6
- import { refreshThemeClock } from "../theme/useTheme";
6
+ import { refreshThemeClock, useTheme } from "../theme/useTheme";
7
7
  import {
8
8
  hydrateSolarLocationState,
9
9
  manualLocationOf,
@@ -25,6 +25,7 @@ import {
25
25
  standardOffsetHours,
26
26
  } from "../theme/solarTimeZones";
27
27
  import HkButton from "./HkButton";
28
+ import HkIconButton from "./HkIconButton";
28
29
  import HkCard from "./HkCard";
29
30
  import HkCopyBadge from "./HkCopyBadge";
30
31
  import HkModal, { type ModalAction } from "./HkModal";
@@ -136,9 +137,10 @@ export function solarTimezoneDetectedRows(
136
137
  * - the card is a standard HkCard titled "Daylight location" (the
137
138
  * `title` prop overrides or, with an empty string, hides the header
138
139
  * for hosts that already label their section), its body carrying the
139
- * live strategy summary and an Edit button — the summary names the
140
- * POLICY in a fixed shape ("Auto · By time zone" / "Auto · IP
141
- * location" / "Manual · {zone} · {hemisphere}");
140
+ * live strategy summary and an ICON-ONLY Edit button (HkIconButton
141
+ * ghost/28, the row's own cross size) — the summary names the POLICY
142
+ * in a fixed shape ("Auto · By time zone" / "Auto · IP location" /
143
+ * "Manual · {zone} · {hemisphere}");
142
144
  * - the modal drafts a strategy (segmented Clock/Globe2/MapPin tabs).
143
145
  * The first line under the tabs states the strategy's PRINCIPLE; for
144
146
  * the non-manual strategies a detected-value readout follows, one
@@ -154,6 +156,14 @@ export function solarTimezoneDetectedRows(
154
156
  * setSolarLocationStrategy → refreshThemeClock() (the setters drop
155
157
  * the engine's geo memo but do not re-resolve on their own), and the
156
158
  * card's summary reflects the new state immediately.
159
+ *
160
+ * The Edit affordance is AUTO-MODE ONLY (2026-11-05 user direction): the
161
+ * solar clock only drives the theme in `currentMode === "system"`; in a
162
+ * manual light/dark mode the button is disabled (gray) and its tooltip
163
+ * says so. The component reads the singleton theme state itself, so a
164
+ * host that drops it under its own mode switch gets the behavior without
165
+ * wiring anything. The Rust mirror takes the same state as an
166
+ * `editor_disabled` prop (it has no theme engine).
157
167
  */
158
168
  export const HkSolarLocationSetting = defineComponent({
159
169
  name: "HkSolarLocationSetting",
@@ -168,6 +178,9 @@ export const HkSolarLocationSetting = defineComponent({
168
178
  },
169
179
  setup(props, { emit }) {
170
180
  const { t } = useI18n();
181
+ // AUTO-mode gate: the sun clock only drives the theme in "system".
182
+ const { currentMode } = useTheme();
183
+ const editorDisabled = computed(() => currentMode.value !== "system");
171
184
  // The engine hydrates lazily (storage-silent imports): a host that
172
185
  // mounts this card before initTheme() still sees the stored strategy.
173
186
  hydrateSolarLocationState();
@@ -433,14 +446,29 @@ export const HkSolarLocationSetting = defineComponent({
433
446
  <span class="hk-solar-summary-icon" aria-hidden="true">{summaryIcon()}</span>
434
447
  <span class="hk-solar-summary-text">{summaryText.value}</span>
435
448
  </div>
436
- <HkButton
437
- size="sm"
438
- variant="outline"
439
- class="hk-solar-edit"
440
- onClick={openEditor}
449
+ {/* Icon-only Edit (2026-11-05): the row's action is the standard
450
+ ghost icon-button square, not a text pill. Disabled outside
451
+ auto mode; the wrapper owns the tooltip because a disabled
452
+ button drops pointer events. */}
453
+ <span
454
+ class="hk-solar-edit-wrap"
455
+ title={
456
+ editorDisabled.value
457
+ ? t("hikari::theme.solar.editAutoOnly")
458
+ : t("hikari::theme.solar.edit")
459
+ }
441
460
  >
442
- {t("hikari::theme.solar.edit")}
443
- </HkButton>
461
+ <HkIconButton
462
+ size={28}
463
+ variant="ghost"
464
+ class="hk-solar-edit"
465
+ aria-label={t("hikari::theme.solar.edit")}
466
+ disabled={editorDisabled.value}
467
+ onClick={openEditor}
468
+ >
469
+ {{ icon: () => <Pencil size={16} /> }}
470
+ </HkIconButton>
471
+ </span>
444
472
  </div>
445
473
 
446
474
  <HkModal
@@ -691,24 +691,47 @@ describe("HkTabs trigger corner badge", () => {
691
691
  return { container };
692
692
  }
693
693
 
694
- it("renders the count through HkCornerBadge inside the trigger (no hand-rolled suffix)", () => {
694
+ it("renders the count through HkCornerBadge BESIDE the label (side default, no hand-rolled suffix)", () => {
695
695
  const { container } = mountBadge({ count: 3, label: "3 items" });
696
696
  const trig = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!;
697
- // The mark is the family's corner-badge pill, anchored bottom-right
698
- // (the subscript corner) of the label, not a "(3)" text suffix.
697
+ // The mark is the family's pill, placed on the BUTTON-GROUP default:
698
+ // `right` — beside the label, vertically centered, never covering
699
+ // it (user direction 2026-11-05: the corner overlay read as "歪了/
700
+ // 盖住了" on a tab trigger). Not a "(3)" text suffix either.
699
701
  const mark = trig.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
700
702
  expect(mark).not.toBeNull();
701
703
  expect(mark.textContent).toBe("3");
702
- expect(mark.dataset.corner).toBe("bottom-right");
704
+ expect(mark.dataset.placement).toBe("right");
705
+ expect(trig.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
703
706
  expect(trig.textContent).not.toContain("(3)");
704
- // Default clearance: the straddling pill must stay inside the
705
- // scroller's 6px vertical clearance below the label (see
706
- // TabItem.badge.offset) — the offset var pins that contract.
707
- expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
707
+ // Side placement needs no corner clearance: the wrapper gap is the
708
+ // spacing, so the tab default writes no offset var.
709
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("");
710
+ // Side flow order: the label comes first, the mark after it.
711
+ const anchor = trig.querySelector(".hk-corner-badge-anchor")!;
712
+ expect(anchor.firstElementChild!.textContent).toBe("Alpha");
713
+ expect(anchor.lastElementChild).toBe(mark);
708
714
  // The sibling trigger stays badge-free.
709
715
  expect(container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[1]!.querySelector(".hk-corner-badge")).toBeNull();
710
716
  });
711
717
 
718
+ it("keeps the explicit corner override, with the corner clearance default", () => {
719
+ // A host that opts back into the overlay must still get the 4px
720
+ // clearance the scroller viewport needs (the placement-aware default),
721
+ // without restating it.
722
+ const { container } = mountBadge({ count: 3, label: "3 items", placement: "bottom-right" });
723
+ const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
724
+ expect(mark.dataset.placement).toBe("bottom-right");
725
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
726
+ });
727
+
728
+ it("honours an explicit offset in either placement family", () => {
729
+ const side = mountBadge({ count: 3, label: "3 items", offset: 2 });
730
+ const mark = side.container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
731
+ expect(mark.dataset.placement).toBe("right");
732
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("2px");
733
+ });
734
+
712
735
  it("keeps zero visible (presence count, not notification noise)", () => {
713
736
  const { container } = mountBadge({ count: 0, label: "0 items" });
714
737
  const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;