@celestia-island/hikari 0.45.9 → 0.46.0

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.
@@ -0,0 +1,182 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkIconButtonGroup from "./HkIconButtonGroup";
5
+
6
+ /**
7
+ * HkIconButtonGroup contract tests:
8
+ * - "buttons" mode: no selection state, select fires on every click
9
+ * - "single" mode: radiogroup semantics — v-model update + data-active,
10
+ * no re-fire on the already-active key
11
+ * - "multiple" mode: toggle set in v-model, aria-pressed mirrors state
12
+ * - tooltips: every labeled item rides an HkTooltip wrapper (label text),
13
+ * an explicit `tooltip: ""` suppresses it
14
+ * - disabled items / disabled group stay dead
15
+ * - extra attrs (aria-label) fall through onto the root
16
+ *
17
+ * House style: no @vue/test-utils dependency — raw createApp mounts on a
18
+ * shared container list torn down after each case.
19
+ */
20
+
21
+ const mounts: Array<ReturnType<typeof createApp>> = [];
22
+
23
+ function mountComp(render: () => ReturnType<typeof h>): HTMLElement {
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const app = createApp(defineComponent({ setup: () => render }));
27
+ mounts.push(app);
28
+ app.mount(container);
29
+ return container;
30
+ }
31
+
32
+ afterEach(() => {
33
+ for (const app of mounts.splice(0)) app.unmount();
34
+ for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
35
+ });
36
+
37
+ const options = [
38
+ { key: "totp", label: "验证器 App" },
39
+ { key: "passkey", label: "Passkey" },
40
+ ];
41
+
42
+ describe("HkIconButtonGroup", () => {
43
+ it("renders plain action buttons in buttons mode and emits select on every click", async () => {
44
+ const picked: string[] = [];
45
+ const c = mountComp(() =>
46
+ h(HkIconButtonGroup, { options, onSelect: (k: string) => picked.push(k) }),
47
+ );
48
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
49
+ expect(group.getAttribute("role")).toBe("group");
50
+ expect(group.classList.contains("hk-icon-group-buttons")).toBe(true);
51
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
52
+ expect(buttons.length).toBe(2);
53
+ expect(group.querySelectorAll(".hk-tooltip-wrapper").length).toBe(2);
54
+ buttons[0]!.click();
55
+ await Promise.resolve();
56
+ buttons[0]!.click();
57
+ await Promise.resolve();
58
+ expect(picked).toEqual(["totp", "totp"]);
59
+ // No selection state exists in buttons mode.
60
+ expect(c.querySelectorAll("[data-active]").length).toBe(0);
61
+ });
62
+
63
+ it("updates v-model and marks the active item in single mode", async () => {
64
+ const model = ref<string | null>(null);
65
+ const c = mountComp(() =>
66
+ h(HkIconButtonGroup, {
67
+ options,
68
+ mode: "single",
69
+ modelValue: model.value,
70
+ "onUpdate:modelValue": (v: string | string[]) => {
71
+ model.value = v as string;
72
+ },
73
+ }),
74
+ );
75
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
76
+ expect(group.getAttribute("role")).toBe("radiogroup");
77
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
78
+ expect(buttons[0]!.getAttribute("role")).toBe("radio");
79
+ expect(buttons[0]!.getAttribute("aria-checked")).toBe("false");
80
+ buttons[0]!.click();
81
+ await nextTick();
82
+ expect(model.value).toBe("totp");
83
+ expect(buttons[0]!.dataset.active).toBe("true");
84
+ expect(buttons[0]!.getAttribute("aria-checked")).toBe("true");
85
+ });
86
+
87
+ it("does not re-fire when the active key is re-clicked in single mode", async () => {
88
+ const events: string[] = [];
89
+ const c = mountComp(() =>
90
+ h(HkIconButtonGroup, {
91
+ options,
92
+ mode: "single",
93
+ modelValue: "totp",
94
+ "onUpdate:modelValue": () => events.push("update"),
95
+ onSelect: (k: string) => events.push(`select:${k}`),
96
+ }),
97
+ );
98
+ const button = c.querySelector<HTMLButtonElement>(".hk-icon-group-item")!;
99
+ expect(button.dataset.active).toBe("true");
100
+ button.click();
101
+ await Promise.resolve();
102
+ expect(events).toEqual([]);
103
+ });
104
+
105
+ it("toggles keys into and out of the v-model array in multiple mode", async () => {
106
+ const seen: Array<string | string[]> = [];
107
+ const c = mountComp(() =>
108
+ h(HkIconButtonGroup, {
109
+ options,
110
+ mode: "multiple",
111
+ modelValue: ["passkey"],
112
+ "onUpdate:modelValue": (v: string | string[]) => seen.push(v),
113
+ }),
114
+ );
115
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
116
+ expect(group.getAttribute("role")).toBe("group");
117
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
118
+ expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
119
+ buttons[0]!.click();
120
+ buttons[1]!.click();
121
+ await Promise.resolve();
122
+ expect(seen).toEqual([["passkey", "totp"], []]);
123
+ });
124
+
125
+ it("suppresses the tooltip with an explicit empty override and keeps the label as the a11y name", () => {
126
+ const c = mountComp(() =>
127
+ h(HkIconButtonGroup, {
128
+ options: [
129
+ { key: "a", label: "Alpha" },
130
+ { key: "b", label: "Beta", tooltip: "" },
131
+ ],
132
+ }),
133
+ );
134
+ expect(c.querySelectorAll(".hk-tooltip-wrapper").length).toBe(1);
135
+ const beta = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")[1]!;
136
+ expect(beta.getAttribute("aria-label")).toBe("Beta");
137
+ // The HkTooltip popup owns the hover text — the button carries no title.
138
+ expect(beta.getAttribute("title")).toBeNull();
139
+ });
140
+
141
+ it("renders the given icon vnode and falls back to the label initial without one", () => {
142
+ const c = mountComp(() =>
143
+ h(HkIconButtonGroup, {
144
+ options: [
145
+ { key: "a", label: "Alpha", icon: h("span", { class: "glyph" }) },
146
+ { key: "b", label: "Beta" },
147
+ ],
148
+ }),
149
+ );
150
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
151
+ expect(buttons[0]!.querySelector(".glyph")).toBeTruthy();
152
+ expect(buttons[1]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("B");
153
+ });
154
+
155
+ it("keeps disabled items and the group-level disable inert", async () => {
156
+ const picked: string[] = [];
157
+ const c = mountComp(() =>
158
+ h(HkIconButtonGroup, {
159
+ options: [
160
+ { key: "a", label: "Alpha", disabled: true },
161
+ { key: "b", label: "Beta" },
162
+ ],
163
+ disabled: true,
164
+ onSelect: (k: string) => picked.push(k),
165
+ }),
166
+ );
167
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
168
+ expect(buttons.every((b) => b.disabled)).toBe(true);
169
+ buttons[1]!.click();
170
+ await Promise.resolve();
171
+ expect(picked).toEqual([]);
172
+ });
173
+
174
+ it("falls extra attrs through onto the group root", () => {
175
+ const c = mountComp(() =>
176
+ h(HkIconButtonGroup, { options, "aria-label": "验证方式", "data-test": "probe" }),
177
+ );
178
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
179
+ expect(group.getAttribute("aria-label")).toBe("验证方式");
180
+ expect(group.getAttribute("data-test")).toBe("probe");
181
+ });
182
+ });
@@ -0,0 +1,152 @@
1
+ import { computed, defineComponent, type PropType } from "vue";
2
+ import HkTooltip from "./HkTooltip";
3
+ import type { TooltipPlacement } from "../runtime/tooltipPosition";
4
+ import "./HkIconButtonGroup.scss";
5
+
6
+ /** One selectable icon in the group. */
7
+ export interface HkIconButtonGroupOption {
8
+ /** Stable identity — the value surfaced through v-model / select. */
9
+ key: string;
10
+ /** Accessible name AND the default tooltip text. Required on purpose:
11
+ * an icon-only control without a label is an a11y hole. */
12
+ label: string;
13
+ /** Icon-only content: a prebuilt vnode (brand SVG, lucide component,
14
+ * <img>) or anything renderable. Typed loose on purpose (same as
15
+ * HkAuthMethodList entries): hosts materialize hikari against their
16
+ * own vue minor, and a hard VNode type breaks typecheck whenever the
17
+ * host's vue differs. Missing icons fall back to the label's initial. */
18
+ icon?: unknown;
19
+ disabled?: boolean;
20
+ /** Tooltip text override; an EMPTY string suppresses the tooltip. */
21
+ tooltip?: string;
22
+ }
23
+
24
+ /**
25
+ * HkIconButtonGroup — the icon-only variant of the button-group family
26
+ * (the same centered strip grammar as HkTabs, minus the text).
27
+ *
28
+ * Three working modes:
29
+ * - "buttons" a plain action group (no selection state) — the auth
30
+ * card's OAuth provider row
31
+ * - "single" a radiogroup: one active key (v-model), re-clicking the
32
+ * active key is a no-op — radio semantics
33
+ * - "multiple" a toggle group: v-model is a string[] of active keys,
34
+ * every item carries aria-pressed
35
+ *
36
+ * Every item is icon-only and rides one size taller than a standard
37
+ * icon button (md = 44px vs the 32px icon button / 40px text button) —
38
+ * the Windows/macOS-style login chooser. Because an icon alone is
39
+ * unlabelable at a glance, each item wraps itself in an HkTooltip
40
+ * (text = the option's label unless overridden), so the hover reveal is
41
+ * built in rather than left to every consumer.
42
+ *
43
+ * Known a11y limitation (documented 2026-09-13): single mode uses a
44
+ * radiogroup role with every item tabbable, not the full WAI-APG roving
45
+ * tabindex. All-items-tabbable keeps keyboard reachability identical
46
+ * across the three modes; roving focus is a follow-up if a host needs
47
+ * strict APG conformance.
48
+ */
49
+ export default defineComponent({
50
+ name: "HkIconButtonGroup",
51
+ inheritAttrs: false,
52
+ props: {
53
+ options: { type: Array as unknown as () => HkIconButtonGroupOption[], required: true },
54
+ /** Active key ("single") or keys ("multiple"); null when nothing is
55
+ * active — required/ignored by "buttons" mode. */
56
+ modelValue: { type: [String, Array] as PropType<string | string[] | null>, default: null },
57
+ mode: { type: String as PropType<"buttons" | "single" | "multiple">, default: "buttons" },
58
+ size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
59
+ disabled: { type: Boolean, default: false },
60
+ tooltipPlacement: { type: String as PropType<TooltipPlacement>, default: "top" },
61
+ tooltipDelay: { type: Number, default: 300 },
62
+ },
63
+ emits: {
64
+ /** v-model for "single" (string) and "multiple" (string[]). */
65
+ "update:modelValue": (_value: string | string[]) => true,
66
+ /** An item was activated (or toggled). Never fires for a no-op
67
+ * re-click of the already-active key in "single" mode. */
68
+ select: (_key: string) => true,
69
+ },
70
+ setup(props, { emit, attrs }) {
71
+ const cls = computed(() => [
72
+ "hk-icon-group",
73
+ `hk-icon-group-${props.mode}`,
74
+ `hk-icon-group-${props.size}`,
75
+ ]);
76
+
77
+ const groupRole = computed(() => (props.mode === "single" ? "radiogroup" : "group"));
78
+
79
+ function isActive(key: string): boolean {
80
+ if (props.mode === "multiple") {
81
+ return Array.isArray(props.modelValue) && props.modelValue.includes(key);
82
+ }
83
+ if (props.mode === "single") {
84
+ return props.modelValue === key;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function onSelect(option: HkIconButtonGroupOption) {
90
+ if (props.disabled || option.disabled) return;
91
+ if (props.mode === "single") {
92
+ if (isActive(option.key)) return; // radio semantics: no re-fire
93
+ emit("update:modelValue", option.key);
94
+ } else if (props.mode === "multiple") {
95
+ const current = Array.isArray(props.modelValue) ? props.modelValue : [];
96
+ const next = current.includes(option.key)
97
+ ? current.filter((k) => k !== option.key)
98
+ : [...current, option.key];
99
+ emit("update:modelValue", next);
100
+ }
101
+ emit("select", option.key);
102
+ }
103
+
104
+ return () => (
105
+ <div class={cls.value} role={groupRole.value} {...attrs}>
106
+ {props.options.map((option) => {
107
+ const tooltip = option.tooltip !== undefined ? option.tooltip : option.label;
108
+ const active = isActive(option.key);
109
+ const button = (
110
+ <button
111
+ key={option.key}
112
+ type="button"
113
+ class="hk-icon-group-item"
114
+ data-key={option.key}
115
+ data-active={active || undefined}
116
+ role={props.mode === "single" ? "radio" : undefined}
117
+ aria-checked={props.mode === "single" ? active : undefined}
118
+ aria-pressed={props.mode === "multiple" ? active : undefined}
119
+ aria-label={option.label}
120
+ title={undefined} // the HkTooltip popup owns the hover text
121
+ disabled={props.disabled || option.disabled}
122
+ onClick={() => onSelect(option)}
123
+ >
124
+ <span class="hk-icon-group-item-icon">
125
+ {option.icon != null
126
+ ? option.icon
127
+ : (
128
+ <span class="hk-icon-group-item-initial" aria-hidden="true">
129
+ {option.label.charAt(0).toUpperCase()}
130
+ </span>
131
+ )}
132
+ </span>
133
+ </button>
134
+ );
135
+ // An empty tooltip override suppresses the wrapper entirely.
136
+ return tooltip
137
+ ? (
138
+ <HkTooltip
139
+ key={option.key}
140
+ text={tooltip}
141
+ placement={props.tooltipPlacement}
142
+ delay={props.tooltipDelay}
143
+ >
144
+ {button}
145
+ </HkTooltip>
146
+ )
147
+ : button;
148
+ })}
149
+ </div>
150
+ );
151
+ },
152
+ });
@@ -1,13 +1,13 @@
1
1
  import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
2
2
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
3
- import { ancestorZoom } from "../runtime/cssZoom";
3
+ import { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
4
4
  import "./HkTooltip.scss";
5
5
 
6
6
  export default defineComponent({
7
7
  name: "HkTooltip",
8
8
  props: {
9
9
  text: { type: String, required: true },
10
- placement: { type: String as PropType<"top" | "bottom" | "left" | "right">, default: "top" },
10
+ placement: { type: String as PropType<TooltipPlacement>, default: "top" },
11
11
  delay: { type: Number, default: 300 },
12
12
  maxWidth: { type: String, default: undefined },
13
13
  },
@@ -33,46 +33,11 @@ export default defineComponent({
33
33
  function updatePosition() {
34
34
  if (!wrapperRef.value) return;
35
35
  const rect = wrapperRef.value.getBoundingClientRect();
36
- const gap = 8;
37
- // The tooltip teleports to <body> — inside any root CSS zoom
38
- // subtree — while `rect` is already in the root visual space
39
- // (standardized zoom reports ancestor zoom applied). The browser
40
- // scales the tooltip's local px back up at paint, so the visual
41
- // rect values must be written divided by the cumulative zoom or
42
- // the tooltip drifts zoom× off its trigger (chest's root-level
43
- // manual DPI scale).
44
- const z = ancestorZoom(document.body);
45
- const local = (v: number) => `${v / z}px`;
46
- const style: CSSProperties = {};
47
-
48
- if (props.maxWidth) {
49
- style.maxWidth = props.maxWidth;
50
- }
51
-
52
- switch (props.placement) {
53
- case "top":
54
- style.top = local(rect.top - gap);
55
- style.left = local(rect.left + rect.width / 2);
56
- style.transform = "translate(-50%, -100%)";
57
- break;
58
- case "bottom":
59
- style.top = local(rect.bottom + gap);
60
- style.left = local(rect.left + rect.width / 2);
61
- style.transform = "translate(-50%, 0)";
62
- break;
63
- case "left":
64
- style.top = local(rect.top + rect.height / 2);
65
- style.left = local(rect.left - gap);
66
- style.transform = "translate(-100%, -50%)";
67
- break;
68
- case "right":
69
- style.top = local(rect.top + rect.height / 2);
70
- style.left = local(rect.right + gap);
71
- style.transform = "translate(0, -50%)";
72
- break;
73
- }
74
-
75
- tooltipStyle.value = style;
36
+ // Placement geometry lives in the shared runtime helper (it also
37
+ // serves the document-level tooltip bridge) — including the
38
+ // ancestor-zoom division that keeps teleported popups pinned to
39
+ // their trigger inside scaled roots.
40
+ tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
76
41
  }
77
42
 
78
43
  function show() {
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export { default as HExpansionPanel } from "./components/HkExpansionPanel";
24
24
  export { default as HFab, type HFabAction } from "./components/HkFab";
25
25
  export { default as HIcon } from "./components/HkIcon";
26
26
  export { default as HIconButton } from "./components/HkIconButton";
27
+ export { default as HIconButtonGroup, type HkIconButtonGroupOption } from "./components/HkIconButtonGroup";
27
28
  export { default as HImageLightbox } from "./components/HkImageLightbox";
28
29
  export { default as HImagePreview, type ImagePreviewObjectFit } from "./components/HkImagePreview";
29
30
  export { default as HInput } from "./components/HkInput";
@@ -239,6 +240,10 @@ export {
239
240
  type SafeAreaInsets,
240
241
  type BackGuard,
241
242
  type BackGuardOptions,
243
+ installHkTooltipBridge,
244
+ type HkTooltipBridgeOptions,
245
+ installHkImageFallback,
246
+ type HkImageFallbackOptions,
242
247
  } from "./runtime";
243
248
 
244
249
  // i18n
@@ -0,0 +1,13 @@
1
+ /* installHkImageFallback — the `.hk-img-broken` treatment.
2
+ *
3
+ * The bundled placeholder SVG carries its own muted tile + glyph; this
4
+ * sheet only keeps the img box polite while it shows the placeholder
5
+ * (never stretches it, faint surface behind transparent areas) and
6
+ * neutralizes any fancy borders a consumer put on the healthy image. */
7
+
8
+ img.hk-img-broken {
9
+ object-fit: contain;
10
+ background: rgb(var(--color-surface, 255 255 255) / 30%);
11
+ border-radius: var(--radius-sm, 4px);
12
+ box-shadow: none;
13
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+
3
+ import { installHkImageFallback } from "./imageFallback";
4
+
5
+ /**
6
+ * installHkImageFallback contract tests:
7
+ * - a broken <img> (capture-phase error) gains `.hk-img-broken` and its
8
+ * src swaps to the bundled inline-SVG placeholder, exactly once
9
+ * (data flag — a failing fallback can never loop)
10
+ * - a load of REAL content after a breakage clears the state; the load
11
+ * OF the placeholder keeps it
12
+ * - escape hatches: [data-hk-img-native], selector/exclude options
13
+ * - custom fallbackSrc override; `""` marks without swapping
14
+ * - idempotent install; uninstall stops further marking
15
+ */
16
+
17
+ const uninstalls: Array<() => void> = [];
18
+
19
+ function install(options?: Parameters<typeof installHkImageFallback>[0]) {
20
+ const uninstall = installHkImageFallback(options);
21
+ uninstalls.push(uninstall);
22
+ return uninstall;
23
+ }
24
+
25
+ function img(src = "https://example.invalid/pic.png"): HTMLImageElement {
26
+ const el = document.createElement("img");
27
+ el.src = src;
28
+ document.body.appendChild(el);
29
+ return el;
30
+ }
31
+
32
+ function fail(el: HTMLImageElement) {
33
+ el.dispatchEvent(new Event("error"));
34
+ }
35
+
36
+ function load(el: HTMLImageElement) {
37
+ el.dispatchEvent(new Event("load"));
38
+ }
39
+
40
+ afterEach(() => {
41
+ for (const u of uninstalls.splice(0)) u();
42
+ });
43
+
44
+ describe("installHkImageFallback", () => {
45
+ it("marks a broken image and swaps in the placeholder once", () => {
46
+ install();
47
+ const el = img();
48
+ fail(el);
49
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
50
+ expect(el.dataset.hkImgFallback).toBe("1");
51
+ expect(el.getAttribute("src")?.startsWith("data:image/svg+xml,")).toBe(true);
52
+ const swapped = el.getAttribute("src")!;
53
+ // The placeholder's own failure must not loop.
54
+ fail(el);
55
+ expect(el.getAttribute("src")).toBe(swapped);
56
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
57
+ });
58
+
59
+ it("clears the broken state when real content loads later", () => {
60
+ install();
61
+ const el = img();
62
+ fail(el);
63
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
64
+ // A repaired src loads: recovery clears class AND the one-shot flag.
65
+ el.setAttribute("src", "https://example.invalid/fixed.png");
66
+ load(el);
67
+ expect(el.classList.contains("hk-img-broken")).toBe(false);
68
+ expect(el.dataset.hkImgFallback).toBeUndefined();
69
+ // So a SECOND breakage marks again.
70
+ fail(el);
71
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
72
+ });
73
+
74
+ it("keeps the treatment when the placeholder itself loads", () => {
75
+ install();
76
+ const el = img();
77
+ fail(el);
78
+ const placeholder = el.getAttribute("src")!;
79
+ load(el);
80
+ expect(el.getAttribute("src")).toBe(placeholder);
81
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
82
+ });
83
+
84
+ it("leaves native-escape-hatch images alone", () => {
85
+ install();
86
+ const el = img();
87
+ el.setAttribute("data-hk-img-native", "");
88
+ fail(el);
89
+ expect(el.classList.contains("hk-img-broken")).toBe(false);
90
+ expect(el.getAttribute("src")).toBe("https://example.invalid/pic.png");
91
+ });
92
+
93
+ it("honors selector and exclude options", () => {
94
+ install({ selector: "img.content", exclude: ".keep" });
95
+ const wrong = img();
96
+ fail(wrong);
97
+ expect(wrong.classList.contains("hk-img-broken")).toBe(false);
98
+ const kept = img();
99
+ kept.className = "content keep";
100
+ fail(kept);
101
+ expect(kept.classList.contains("hk-img-broken")).toBe(false);
102
+ const covered = img();
103
+ covered.className = "content";
104
+ fail(covered);
105
+ expect(covered.classList.contains("hk-img-broken")).toBe(true);
106
+ });
107
+
108
+ it("supports a custom fallbackSrc override", () => {
109
+ install({ fallbackSrc: "data:image/svg+xml,custom" });
110
+ const custom = img();
111
+ fail(custom);
112
+ expect(custom.getAttribute("src")).toBe("data:image/svg+xml,custom");
113
+ });
114
+
115
+ it("supports an empty fallbackSrc that marks without swapping", () => {
116
+ install({ fallbackSrc: "" });
117
+ const marked = img();
118
+ fail(marked);
119
+ expect(marked.classList.contains("hk-img-broken")).toBe(true);
120
+ expect(marked.getAttribute("src")).toBe("https://example.invalid/pic.png");
121
+ });
122
+
123
+ it("keeps the one-shot marker across repeat errors even without a src swap", () => {
124
+ // Mark-only mode: no src to dedupe against, so the dataset flag is the
125
+ // only once-guard — pin it (repeat errors must stay marked, and the
126
+ // flag must exist for the recovery path to clear).
127
+ install({ fallbackSrc: "" });
128
+ const el = img();
129
+ fail(el);
130
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
131
+ expect(el.dataset.hkImgFallback).toBe("1");
132
+ fail(el);
133
+ fail(el);
134
+ expect(el.dataset.hkImgFallback).toBe("1");
135
+ expect(el.classList.contains("hk-img-broken")).toBe(true);
136
+ });
137
+
138
+ it("is idempotent per document and uninstallable", () => {
139
+ install();
140
+ install(); // duplicate: silently reuses the first bridge
141
+ const el = img();
142
+ fail(el);
143
+ expect(el.dataset.hkImgFallback).toBe("1");
144
+ uninstalls.splice(0)[0]!();
145
+ const after = img();
146
+ fail(after);
147
+ expect(after.classList.contains("hk-img-broken")).toBe(false);
148
+ });
149
+ });