@celestia-island/hikari 0.55.84 → 0.55.86

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.84",
3
+ "version": "0.55.86",
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",
@@ -66,6 +66,10 @@ export function isCssAnimationsEnabled(): boolean {
66
66
  registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
67
67
  // styles/admin-tokens.scss — auth card entrance (plays once).
68
68
  registerCssAnimation("s-auth-card-in");
69
+ // styles/admin-tokens.scss — status-bar recovery flash (plays once):
70
+ // the traffic light's green background blink when a dropped connection
71
+ // comes back.
72
+ registerCssAnimation("s-status-bar-recover-flash");
69
73
  // components/HkStatusPill.scss — live-dot pulse (loops forever).
70
74
  registerCssAnimation("hk-status-pill-pulse", { infinite: true });
71
75
  // components/HkRollingNumber.scss — digit roll-up (plays once, forwards).
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, h, nextTick } from "vue";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
3
 
4
4
  import { HkStatusBar } from "./HkStatusBar";
5
5
  import type { HkConnectionInfo } from "./HkConnectionInfo";
@@ -15,6 +15,31 @@ function mountBar(props: Record<string, unknown> = {}) {
15
15
  return container;
16
16
  }
17
17
 
18
+ /** Mount with a reactive connectionStatus the test can drive through
19
+ * state transitions (the fixed-props mountBar cannot re-render). */
20
+ type BarStatus = "connected" | "disconnected" | "connecting" | "reconnecting";
21
+
22
+ function mountReactiveStatus(initial: BarStatus) {
23
+ const status = ref(initial);
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const app = createApp({
27
+ render: () =>
28
+ h(HkStatusBar, {
29
+ version: "1.2.3",
30
+ connectionStatus: status.value,
31
+ connectionInfo: INFO,
32
+ }),
33
+ });
34
+ app.mount(container);
35
+ mounts.push({ app, container });
36
+ return { container, status };
37
+ }
38
+
39
+ function tagOf(container: HTMLElement) {
40
+ return container.querySelector<HTMLElement>(".s-status-bar-tag")!;
41
+ }
42
+
18
43
  const INFO: HkConnectionInfo = {
19
44
  state: "connected",
20
45
  tier: "poll",
@@ -221,4 +246,80 @@ describe("HkStatusBar", () => {
221
246
  expect(onRetry, "flick is not a tap").not.toHaveBeenCalled();
222
247
  expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
223
248
  });
249
+
250
+ it("stays quiet on the first connect: no recovery class on open", async () => {
251
+ vi.useFakeTimers();
252
+ try {
253
+ // The page-open sequence: connecting → connected for the first
254
+ // time in this component's life. No flash — the pill goes green
255
+ // without announcing itself (hosts used to toast here).
256
+ const { container, status } = mountReactiveStatus("connecting");
257
+ await nextTick();
258
+ expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
259
+ status.value = "connected";
260
+ await nextTick();
261
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
262
+ vi.advanceTimersByTime(1500);
263
+ await nextTick();
264
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
265
+ } finally {
266
+ vi.useRealTimers();
267
+ }
268
+ });
269
+
270
+ it("flashes the background once when a dropped connection recovers", async () => {
271
+ vi.useFakeTimers();
272
+ try {
273
+ // Mounted while connected (established), drops, then recovers:
274
+ // the recovering class rides the pill for the flash window and
275
+ // is gone after it.
276
+ const { container, status } = mountReactiveStatus("connected");
277
+ await nextTick();
278
+ status.value = "reconnecting";
279
+ await nextTick();
280
+ expect(tagOf(container).className).toContain("s-status-bar-tag-reconnecting");
281
+ status.value = "connected";
282
+ await nextTick();
283
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
284
+ // Mid-window (t=600ms): the class must still ride the pill — the JS
285
+ // window (1200ms) has to outlive the 1.1s CSS animation, or the class
286
+ // would truncate the decay's tail and snap to the resting color.
287
+ vi.advanceTimersByTime(600);
288
+ await nextTick();
289
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
290
+ vi.advanceTimersByTime(700);
291
+ await nextTick();
292
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
293
+ } finally {
294
+ vi.useRealTimers();
295
+ }
296
+ });
297
+
298
+ it("re-arms the flash on a later drop/recover cycle after the window closes", async () => {
299
+ vi.useFakeTimers();
300
+ try {
301
+ const { container, status } = mountReactiveStatus("connected");
302
+ await nextTick();
303
+ status.value = "disconnected";
304
+ await nextTick();
305
+ status.value = "connected";
306
+ await nextTick();
307
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
308
+ vi.advanceTimersByTime(1300);
309
+ await nextTick();
310
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
311
+ // Second cycle AFTER the window closed: the class must come back —
312
+ // a one-shot sticky flag or a dead timer would leave it dark.
313
+ status.value = "reconnecting";
314
+ await nextTick();
315
+ status.value = "connected";
316
+ await nextTick();
317
+ expect(tagOf(container).className).toContain("s-status-bar-tag-recovered");
318
+ vi.advanceTimersByTime(1300);
319
+ await nextTick();
320
+ expect(tagOf(container).className).not.toContain("s-status-bar-tag-recovered");
321
+ } finally {
322
+ vi.useRealTimers();
323
+ }
324
+ });
224
325
  });
@@ -1,4 +1,4 @@
1
- import { defineComponent, ref, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
2
2
  import { Wifi, WifiOff, Globe, Cable, Monitor, Cog, Plug } from "lucide-vue-next";
3
3
 
4
4
  import { useI18n } from "../i18n/context";
@@ -133,6 +133,38 @@ export const HkStatusBar = defineComponent({
133
133
  disconnected: "rgb(var(--color-error))",
134
134
  };
135
135
 
136
+ // ── Recovery flash ─────────────────────────────────────────────
137
+ // One soft green background blink when the light RETURNS to green
138
+ // after a drop (reconnecting/connecting/disconnected → connected).
139
+ // The FIRST connect of the component's life stays quiet: the flash
140
+ // acknowledges a connection the user saw go down, it is not an
141
+ // open-time announcement (hosts used to fire a toast for exactly
142
+ // this moment; the light now owns that feedback, quietly).
143
+ // `seenConnected` seeds from the initial prop so a component that
144
+ // mounts while already connected still counts as established and a
145
+ // LATER drop/recover pair flashes.
146
+ const RECOVER_FLASH_MS = 1200;
147
+ const seenConnected = ref(props.connectionStatus === "connected");
148
+ const recovering = ref(false);
149
+ let recoverTimer: ReturnType<typeof setTimeout> | null = null;
150
+
151
+ watch(() => props.connectionStatus, (next) => {
152
+ if (next !== "connected") return;
153
+ const wasEstablished = seenConnected.value;
154
+ seenConnected.value = true;
155
+ if (!wasEstablished) return;
156
+ if (recoverTimer) clearTimeout(recoverTimer);
157
+ recovering.value = true;
158
+ recoverTimer = setTimeout(() => {
159
+ recovering.value = false;
160
+ recoverTimer = null;
161
+ }, RECOVER_FLASH_MS);
162
+ });
163
+
164
+ onBeforeUnmount(() => {
165
+ if (recoverTimer) clearTimeout(recoverTimer);
166
+ });
167
+
136
168
  function onTagEnter() {
137
169
  if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
138
170
  popupOpen.value = true;
@@ -244,9 +276,11 @@ export const HkStatusBar = defineComponent({
244
276
  // + value column), so both rows share one true left edge — no
245
277
  // separator, no mid-token wrapping, at any footer width.
246
278
 
247
- const tagClass = connecting
248
- ? "s-status-bar-tag s-status-bar-tag-reconnecting"
249
- : "s-status-bar-tag";
279
+ const tagClass = [
280
+ "s-status-bar-tag",
281
+ connecting ? "s-status-bar-tag-reconnecting" : "",
282
+ recovering.value ? "s-status-bar-tag-recovered" : "",
283
+ ].filter(Boolean).join(" ");
250
284
 
251
285
  const inner = (
252
286
  <>
@@ -0,0 +1,38 @@
1
+ /* HkThirdPartySignInCard — the passwordless login card body: the
2
+ * third-party method list as the card's ONLY content (no credentials
3
+ * form, no submit button). Everything about the divider, the tile row
4
+ * and the tiles themselves is owned by HkAuthMethodList.scss and
5
+ * admin-tokens.scss; this file only re-positions the reused methods
6
+ * container inside the card body and promotes the tiles one size step.
7
+ */
8
+
9
+ /* The body wrapper reuses the `.s-auth-methods` container class — it owns
10
+ the block's flex-column rhythm, and through the `.s-auth-methods`
11
+ scope the tile row's centered layout and the tooltip wrappers' flex
12
+ sizing resolve too. But here the block sits INSIDE the card's
13
+ `.s-auth-form` body instead of under a credentials form, so the
14
+ banding that position needs (24px top margin + 8px/2rem padding, put
15
+ there to separate the divider from a submit button above) collapses
16
+ away: the body container already owns the padding. The (0,2,0)
17
+ selector deterministically beats both the container's own (0,1,0)
18
+ rules (including the ≤480px re-declaration) and the body's generic
19
+ `.s-auth-form > *` child gap, whatever the stylesheet order is. */
20
+ .s-auth-form .s-auth-third-party-body {
21
+ margin-top: 0;
22
+ padding: 0;
23
+ gap: var(--space-8, 0.5rem);
24
+ /* Center the block vertically should a consumer give the body more
25
+ height than the content needs (the card itself still hugs its
26
+ content); horizontal centering is the tile row's own behavior. */
27
+ justify-content: center;
28
+ }
29
+
30
+ /* Primary-flow presence: with the tiles as the card's ONLY content, the
31
+ standard 44px (2.75rem) alternative-row tile reads undersized next to
32
+ the card header. One context-scoped step up to 52px — the frame, hover
33
+ wash, focus ring and icon fit all come from the unchanged tile rules
34
+ (the icon box is max-100% and follows the frame). */
35
+ .s-auth-third-party-body .s-auth-methods-tile {
36
+ width: 3.25rem;
37
+ height: 3.25rem;
38
+ }
@@ -0,0 +1,178 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { createApp, h, nextTick } from "vue";
7
+
8
+ import { HkThirdPartySignInCard } from "./HkThirdPartySignInCard";
9
+
10
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
11
+
12
+ function mount(node: ReturnType<typeof h>) {
13
+ const container = document.createElement("div");
14
+ document.body.appendChild(container);
15
+ const app = createApp({ render: () => node });
16
+ app.mount(container);
17
+ mounts.push({ app, container });
18
+ return container;
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const { app, container } of mounts.splice(0)) {
23
+ app.unmount();
24
+ container.remove();
25
+ }
26
+ for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
27
+ });
28
+
29
+ const methods = [
30
+ { key: "feishu", label: "Feishu" },
31
+ { key: "github", label: "GitHub" },
32
+ ];
33
+
34
+ describe("HkThirdPartySignInCard", () => {
35
+ it("renders the auth-card shell with the tiles as the only body content", () => {
36
+ const c = mount(h(HkThirdPartySignInCard, { title: "Sign in", subtitle: "Welcome back", methods }));
37
+ expect(c.querySelector(".s-auth-card")).toBeTruthy();
38
+ expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
39
+ expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
40
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
41
+ expect(tiles.length).toBe(2);
42
+ // Primary and ONLY content: no credentials form, no submit button.
43
+ expect(c.querySelector("form")).toBeNull();
44
+ expect(c.querySelector("button[type='submit']")).toBeNull();
45
+ expect(c.querySelector(".hk-btn")).toBeNull();
46
+ // The methods block renders as the card BODY (inside the form-body
47
+ // container), not as the under-a-form `methods` slot block.
48
+ const blocks = c.querySelectorAll(".s-auth-methods");
49
+ expect(blocks.length).toBe(1);
50
+ expect(blocks[0]!.closest(".s-auth-form")).toBeTruthy();
51
+ });
52
+
53
+ it("forwards the divider text to the method list", () => {
54
+ const c = mount(
55
+ h(HkThirdPartySignInCard, { title: "T", divider: "选择登录方式", methods }),
56
+ );
57
+ expect(c.querySelector(".s-auth-methods-divider")?.textContent).toContain("选择登录方式");
58
+ });
59
+
60
+ it("emits select with the method key on click", async () => {
61
+ const picked: string[] = [];
62
+ const c = mount(
63
+ h(HkThirdPartySignInCard, {
64
+ title: "T",
65
+ methods,
66
+ onSelect: (key: string) => {
67
+ picked.push(key);
68
+ },
69
+ }),
70
+ );
71
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
72
+ tiles[1]!.click();
73
+ await nextTick();
74
+ // Both tiles are asserted so a constant-literal emit cannot ride an
75
+ // accidental fixture coincidence.
76
+ tiles[0]!.click();
77
+ await nextTick();
78
+ expect(picked).toEqual(["github", "feishu"]);
79
+ });
80
+
81
+ it("disables every tile while loading and swallows clicks", async () => {
82
+ const picked: string[] = [];
83
+ const c = mount(
84
+ h(HkThirdPartySignInCard, {
85
+ title: "T",
86
+ methods,
87
+ loading: true,
88
+ onSelect: (key: string) => {
89
+ picked.push(key);
90
+ },
91
+ }),
92
+ );
93
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
94
+ expect(tiles.length).toBe(2);
95
+ for (const tile of tiles) {
96
+ expect(tile.disabled).toBe(true);
97
+ tile.click();
98
+ }
99
+ await nextTick();
100
+ expect(picked).toEqual([]);
101
+ });
102
+
103
+ it("renders no tiles for an empty methods list without losing the shell", () => {
104
+ const c = mount(h(HkThirdPartySignInCard, { title: "T", methods: [] }));
105
+ expect(c.querySelector(".s-auth-card")).toBeTruthy();
106
+ expect(c.querySelectorAll(".s-auth-methods-tile").length).toBe(0);
107
+ });
108
+
109
+ it("renders the header logo from logoSrc", () => {
110
+ const c = mount(h(HkThirdPartySignInCard, { title: "T", logoSrc: "/logo.webp", methods }));
111
+ const img = c.querySelector(".s-auth-header img") as HTMLImageElement;
112
+ expect(img.getAttribute("src")).toBe("/logo.webp");
113
+ });
114
+
115
+ it("forwards the top and footer slots", () => {
116
+ const c = mount(
117
+ h(HkThirdPartySignInCard, { title: "T", methods }, {
118
+ top: () => h("div", { id: "top-slot" }, "tabs"),
119
+ footer: () => h("div", { class: "footer-probe" }, "footer"),
120
+ }),
121
+ );
122
+ const top = c.querySelector("#top-slot") as HTMLElement;
123
+ expect(top).toBeTruthy();
124
+ expect(c.querySelector(".s-auth-footer .footer-probe")).toBeTruthy();
125
+ // The top slot sits above the tiles in the card body.
126
+ const tiles = c.querySelector(".s-auth-methods-tiles") as HTMLElement;
127
+ expect(
128
+ top.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING,
129
+ ).toBeTruthy();
130
+ });
131
+ });
132
+
133
+ // SCSS contract (house pattern, cf. the HkAuthCard methods-block spacing
134
+ // contract): happy-dom does not lay out, so the body re-positioning and
135
+ // the primary-flow tile bump are asserted on the sheet text. Block
136
+ // comments are stripped first so a commented-out declaration cannot
137
+ // satisfy the guard.
138
+ describe("HkThirdPartySignInCard SCSS contract", () => {
139
+ const here = dirname(fileURLToPath(import.meta.url));
140
+ const scss = readFileSync(join(here, "./HkThirdPartySignInCard.scss"), "utf-8")
141
+ .replace(/\/\*[\s\S]*?\*\//g, "");
142
+
143
+ it("re-positions the reused methods container as the card body", () => {
144
+ const rule = scss.match(/\.s-auth-form \.s-auth-third-party-body\s*{[^}]*}/)?.[0] ?? "";
145
+ expect(rule, "body re-positioning rule must exist").toBeTruthy();
146
+ // The under-a-form banding collapses (the body container owns the
147
+ // padding) and the block centers when the body is taller.
148
+ expect(rule).toContain("margin-top: 0");
149
+ expect(rule).toContain("padding: 0");
150
+ expect(rule).toContain("justify-content: center");
151
+ expect(rule).toContain("gap: var(--space-8, 0.5rem)");
152
+ });
153
+
154
+ it("promotes the tiles one size step in the primary-flow context", () => {
155
+ const rule = scss.match(/\.s-auth-third-party-body \.s-auth-methods-tile\s*{[^}]*}/)?.[0] ?? "";
156
+ expect(rule, "tile bump rule must exist").toBeTruthy();
157
+ expect(rule).toContain("width: 3.25rem");
158
+ expect(rule).toContain("height: 3.25rem");
159
+ });
160
+ });
161
+
162
+ describe("HkThirdPartySignInCard public export surface", () => {
163
+ // 2026-09-28 R1 finding: removing either spelling from index.ts passed
164
+ // every gate — nothing guarded the barrel. Importing the full barrel in a
165
+ // test hangs (750+ transitive modules), so the guard is source-pinned:
166
+ // both export lines must exist verbatim. A dropped line goes red here.
167
+ it("exports both the canonical Hk* and legacy H* spellings from the barrel", async () => {
168
+ const { readFile } = await import("node:fs/promises");
169
+ const { resolve } = await import("node:path");
170
+ const index = await readFile(resolve(__dirname, "../index.ts"), "utf8");
171
+ expect(index).toContain(
172
+ 'export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";',
173
+ );
174
+ expect(index).toContain(
175
+ 'export { HkThirdPartySignInCard as HThirdPartySignInCard } from "./components/HkThirdPartySignInCard";',
176
+ );
177
+ });
178
+ });
@@ -0,0 +1,130 @@
1
+ import { defineComponent } from "vue";
2
+
3
+ import { HkAuthCard } from "./HkAuthCard";
4
+ import HkAuthMethodList from "./HkAuthMethodList";
5
+
6
+ import "./HkThirdPartySignInCard.scss";
7
+
8
+ /**
9
+ * HkThirdPartySignInCard — the passwordless sign-in card: the auth shell
10
+ * with third-party method tiles as its PRIMARY and ONLY content.
11
+ *
12
+ * For deployments where password login is disabled (the ERP login page in
13
+ * Feishu-only mode, shittim-chest's org channel), the login UI must show
14
+ * centered provider buttons and NOTHING else — no username/password
15
+ * fields, no submit button, no "alternative ways" framing. This card is
16
+ * that composition: `HkAuthCard` (title/subtitle/logo header) with an
17
+ * `HkAuthMethodList` rendered into the card's form-body slot, promoted to
18
+ * primary-flow presence through a context-scoped tile modifier.
19
+ *
20
+ * NOTE on `methods`: it is a PROP (the provider array). Unlike HkSignInCard
21
+ * there is NO `#methods` slot on this card — a slot of that name is silently
22
+ * ignored; pass the array through the prop.
23
+ *
24
+ * Control contract (same split as HkSignInCard): the card owns no state
25
+ * and never talks to a backend. The consumer passes the provider
26
+ * `methods` (same item shape as HkAuthMethodList) and binds the OAuth
27
+ * redirect/popup to `select(key)`; the in-flight state feeds back through
28
+ * `loading`, which disables every tile for the duration.
29
+ *
30
+ * Layout contract: the list renders into the card BODY (`.s-auth-form`
31
+ * container), NOT the `methods` slot — that slot's top margin and banding
32
+ * exist to separate "alternative" buttons from a credentials form above,
33
+ * and would read as dead space with nothing above them. The body wrapper
34
+ * (`.s-auth-third-party-body`) restores the methods block's own
35
+ * divider→tile rhythm and centers the row; its scss also scales the tiles
36
+ * up one step, because the standard 44px "alternative row" tile reads
37
+ * undersized when it is the whole card.
38
+ *
39
+ * Slots: `top` (content between the header and the tiles — channel tabs
40
+ * and similar; there is no `<form>` to keep it out of), `footer`
41
+ * (remember-me / protocol rows, forwarded to HkAuthCard's centered
42
+ * footer group) and `logo` (replaces the `logoSrc` image).
43
+ *
44
+ * i18n: hikari ships no dictionary for consumer copy — `title`,
45
+ * `subtitle` and the `divider` text are all consumer-localized on
46
+ * purpose, exactly like HkAuthMethodList's divider.
47
+ *
48
+ * ```tsx
49
+ * <HThirdPartySignInCard
50
+ * title="Sign in"
51
+ * subtitle="Continue with Feishu"
52
+ * :logo-src="logo"
53
+ * :loading="pending"
54
+ * :methods="[{ key: 'feishu', label: 'Feishu', icon: feishuIcon }]"
55
+ * @select="(key) => startOAuth(key)"
56
+ * />
57
+ * ```
58
+ */
59
+ export const HkThirdPartySignInCard = defineComponent({
60
+ name: "HkThirdPartySignInCard",
61
+ props: {
62
+ title: { type: String, required: true },
63
+ subtitle: { type: String, default: "" },
64
+ /** Optional logo image URL for the card header. */
65
+ logoSrc: { type: String, default: undefined },
66
+ /** External in-flight state; disables every tile while true. */
67
+ loading: { type: Boolean, default: false },
68
+ /** Optional divider text above the tiles, forwarded to
69
+ * HkAuthMethodList (consumer-localized on purpose). */
70
+ divider: { type: String, default: "" },
71
+ /** Provider entries, in render order — same item shape as
72
+ * HkAuthMethodList's `methods`. */
73
+ methods: {
74
+ type: Array as unknown as () => Array<{
75
+ key: string;
76
+ /** Accessible name + tooltip text (the hover reveal). */
77
+ label: string;
78
+ /** Prebuilt icon vnode rendered inside the framed tile; a
79
+ * missing icon falls back to the label's initial. */
80
+ icon?: unknown;
81
+ disabled?: boolean;
82
+ }>,
83
+ required: true,
84
+ },
85
+ },
86
+ emits: {
87
+ /** A provider tile was clicked; carries the method `key`. */
88
+ select: (_key: string) => true,
89
+ },
90
+ setup(props, { emit, slots }) {
91
+ return () => (
92
+ <HkAuthCard title={props.title} subtitle={props.subtitle}>
93
+ {{
94
+ logo: () =>
95
+ slots.logo ? (
96
+ slots.logo()
97
+ ) : props.logoSrc ? (
98
+ <img class="hk-logo-img" src={props.logoSrc} alt="" style={{ width: "3.5rem", height: "3.5rem" }} />
99
+ ) : null,
100
+ default: () => (
101
+ <>
102
+ {slots.top?.()}
103
+ {/* The wrapper carries BOTH classes: `s-auth-methods` is
104
+ the container HkAuthMethodList's scoped rules resolve
105
+ against (tile-row centering, tooltip-wrapper sizing),
106
+ `s-auth-third-party-body` re-positions it as the card
107
+ body — see the scss. */}
108
+ <div class="s-auth-methods s-auth-third-party-body">
109
+ {/* `loading` maps onto the per-entry disabled flag so the
110
+ list itself stays untouched: HkAuthMethodList already
111
+ renders disabled tiles dead and click-swallowing. An
112
+ entry's own disabled bit survives (OR, not replace). */}
113
+ <HkAuthMethodList
114
+ divider={props.divider}
115
+ methods={
116
+ props.loading
117
+ ? props.methods.map((method) => ({ ...method, disabled: true }))
118
+ : props.methods
119
+ }
120
+ onSelect={(key: string) => emit("select", key)}
121
+ />
122
+ </div>
123
+ </>
124
+ ),
125
+ footer: () => slots.footer?.(),
126
+ }}
127
+ </HkAuthCard>
128
+ );
129
+ },
130
+ });
package/src/index.ts CHANGED
@@ -472,6 +472,7 @@ export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components
472
472
  export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
473
473
  export { default as HAuthMethodList } from "./components/HkAuthMethodList";
474
474
  export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
475
+ export { HkThirdPartySignInCard as HThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
475
476
  export { default as HAuthSubmitButton } from "./components/HkAuthSubmitButton";
476
477
  export {
477
478
  HkMfaVerifyCard as HMfaVerifyCard,
@@ -745,6 +746,7 @@ export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
745
746
  export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
746
747
  export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
747
748
  export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
749
+ export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
748
750
  export { HkMfaVerifyCard as HkMfaVerifyCard } from "./components/HkMfaVerifyCard";
749
751
  export { type HkMfaFactor as HkMfaFactor } from "./components/HkMfaVerifyCard";
750
752
  export { HkLocalePicker as HkLocalePicker } from "./components/HkLocalePicker";
@@ -225,6 +225,37 @@
225
225
  transition: background var(--duration-short), width var(--duration-short) var(--ease-standard);
226
226
  }
227
227
 
228
+ /* One soft green blink when the light recovers: the pill background
229
+ pulses from a success tint down to the resting surface colour
230
+ exactly once, then hands the property back. The transient peak sits
231
+ slightly above the reconnecting tint's resting intensity so both
232
+ states read as one family; first connects never get it (see
233
+ HkStatusBar's seenConnected guard). Reduced-motion users get the
234
+ plain state switch instead. */
235
+ .s-status-bar-tag-recovered {
236
+ animation: s-status-bar-recover-flash 1.1s var(--ease-standard) 1;
237
+ }
238
+
239
+ @keyframes s-status-bar-recover-flash {
240
+ 0% {
241
+ background: rgb(var(--color-success) / 22%);
242
+ }
243
+
244
+ 55% {
245
+ background: rgb(var(--color-success) / 10%);
246
+ }
247
+
248
+ 100% {
249
+ background: rgb(var(--color-surface));
250
+ }
251
+ }
252
+
253
+ @media (prefers-reduced-motion: reduce) {
254
+ .s-status-bar-tag-recovered {
255
+ animation: none;
256
+ }
257
+ }
258
+
228
259
  .s-status-bar-retry {
229
260
  display: inline-flex;
230
261
  align-items: center;