@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 +1 -1
- package/src/components/HkCornerBadge.scss +49 -13
- package/src/components/HkCornerBadge.styles.test.ts +25 -7
- package/src/components/HkCornerBadge.test.tsx +18 -3
- package/src/components/HkCornerBadge.tsx +45 -16
- package/src/components/HkFilterTrigger.tsx +1 -1
- package/src/components/HkSearchTrigger.tsx +1 -1
- package/src/components/HkSolarLocationSetting.scss +21 -0
- package/src/components/HkSolarLocationSetting.test.tsx +62 -2
- package/src/components/HkSolarLocationSetting.tsx +40 -12
- package/src/components/HkTabs.test.ts +31 -8
- package/src/components/HkTabs.tsx +39 -30
- package/src/i18n/locales/ar/platform.json +1 -0
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/index.ts +1 -1
- package/src/scene3d/holographicFleetBody.test.ts +149 -30
- package/src/scene3d/holographicFleetBody.ts +140 -36
- package/src/scene3d/holographicFleetScene.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HkCornerBadge — the generic
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* with an optional
|
|
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
|
-
/*
|
|
19
|
-
*
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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-
|
|
50
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
51
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
52
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
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
|
|
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",
|
|
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-
|
|
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
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
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
|
|
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
|
|
26
|
-
* in the default slot. The Vue
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
57
|
-
* the translate(50
|
|
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-
|
|
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"
|
|
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"
|
|
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.
|
|
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
|
-
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
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
|
|
698
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
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")!;
|