@celestia-island/hikari 0.55.109 → 0.55.111

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.109",
3
+ "version": "0.55.111",
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",
@@ -56,11 +56,14 @@
56
56
  "vue": "^*"
57
57
  },
58
58
  "dependencies": {
59
+ "@xterm/addon-fit": "^0.*",
60
+ "@xterm/addon-image": "^0.10.0-beta.301",
61
+ "@xterm/xterm": "^6.1.0-beta.304",
59
62
  "dompurify": "^*",
60
63
  "echarts": "^*",
64
+ "highlight.js": "^*",
61
65
  "lucide-vue-next": "^*",
62
- "marked": "^*",
63
- "highlight.js": "^*"
66
+ "marked": "^*"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@types/three": "^*",
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vitest } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkFab from "./HkFab";
5
+ import { POPUP_Z_BANDS } from "../runtime/usePopupManager";
5
6
 
6
7
  const mounts: ReturnType<typeof createApp>[] = [];
7
8
  const containers: HTMLElement[] = [];
@@ -41,8 +42,14 @@ function mountFab(props: Record<string, unknown> = {}): Harness {
41
42
  mounts.push(app);
42
43
  app.mount(container);
43
44
 
44
- const root = container.querySelector<HTMLElement>(".hk-fab");
45
- const button = container.querySelector<HTMLButtonElement>(".hk-fab-button");
45
+ // A layer="top" fab teleports into the floating layer on <body>;
46
+ // fall back to the document scope so both modes resolve.
47
+ const root =
48
+ container.querySelector<HTMLElement>(".hk-fab") ??
49
+ document.body.querySelector<HTMLElement>(".hk-fab");
50
+ const button =
51
+ container.querySelector<HTMLButtonElement>(".hk-fab-button") ??
52
+ document.body.querySelector<HTMLButtonElement>(".hk-fab-button");
46
53
  if (!root || !button) throw new Error("HkFab did not render its shell");
47
54
  harness.root = root;
48
55
  harness.button = button;
@@ -215,3 +222,28 @@ describe("HkFab speed dial", () => {
215
222
  expect(toggles).toEqual([true, false, true, false]);
216
223
  });
217
224
  });
225
+
226
+ describe("HkFab layer", () => {
227
+ it("host (default) keeps the fab anchored in the host tree", () => {
228
+ const m = mountFab({ ariaLabel: "Host", positioning: "fixed" });
229
+ expect(document.body.querySelector(".hk-floating-layer")).toBeNull();
230
+ expect(m.root.getAttribute("data-positioning")).toBe("fixed");
231
+ });
232
+
233
+ it("top wraps the fab in an HkFloatingLayer above the window band", () => {
234
+ const m = mountFab({ ariaLabel: "Float", layer: "top", corner: "bottom-right" });
235
+ const layer = document.body.querySelector<HTMLElement>(".hk-floating-layer");
236
+ expect(layer).not.toBeNull();
237
+ expect(layer!.contains(m.root)).toBe(true);
238
+ // Geometry is owned by the layer alone once floated.
239
+ expect(m.root.getAttribute("data-positioning")).toBeNull();
240
+ expect(m.root.getAttribute("data-corner")).toBeNull();
241
+ // Offset vars hand off to the layer as well — the fab keeps none.
242
+ expect(m.root.style.getPropertyValue("--hk-fab-offset-x")).toBe("");
243
+ expect(m.root.style.getPropertyValue("--hk-fab-offset-y")).toBe("");
244
+ expect(layer!.getAttribute("data-corner")).toBe("bottom-right");
245
+ const z = Number(layer!.style.getPropertyValue("--hk-float-z"));
246
+ expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
247
+ expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
248
+ });
249
+ });
@@ -6,6 +6,7 @@ import {
6
6
  } from "vue";
7
7
 
8
8
  import { iconByName } from "../composables/iconRegistry";
9
+ import HkFloatingLayer from "./HkFloatingLayer";
9
10
  import "./HkFab.scss";
10
11
 
11
12
  /** One entry of the HkFab speed dial. */
@@ -21,6 +22,7 @@ type FabCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
21
22
  type FabSize = "sm" | "md" | "lg";
22
23
  type FabVariant = "primary" | "glass";
23
24
  type FabExpandDirection = "up" | "down" | "left" | "right";
25
+ type FabLayer = "host" | "top";
24
26
 
25
27
  /**
26
28
  * Material-design-style floating action button with an optional
@@ -46,6 +48,13 @@ export default defineComponent({
46
48
  disabled: { type: Boolean, default: false },
47
49
  actions: { type: Array as PropType<HFabAction[]>, default: undefined },
48
50
  expandDirection: { type: String as PropType<FabExpandDirection>, default: "up" },
51
+ /** "top" floats the fab above every window (HkFloatingLayer — the
52
+ * popup manager's tooltip band, teleported to <body>): dials stay
53
+ * reachable no matter which modal/sheet opens over them. The layer
54
+ * owns the geometry when floated — `positioning` is ignored, the
55
+ * corner/offset props hand off to it. "host" keeps the classic
56
+ * in-tree anchoring (the historical default, byte-identical). */
57
+ layer: { type: String as PropType<FabLayer>, default: "host" },
49
58
  },
50
59
  emits: {
51
60
  click: (_e: MouseEvent) => true,
@@ -116,6 +125,8 @@ export default defineComponent({
116
125
  }
117
126
 
118
127
  return () => {
128
+ const floated = props.layer === "top";
129
+
119
130
  // Icon precedence: slot > registry > inline chevron-down fallback.
120
131
  // `as any` matches HkIcon — the registry returns a loose Component.
121
132
  const RegistryIcon = props.icon ? (iconByName(props.icon) as any) : null;
@@ -144,19 +155,19 @@ export default defineComponent({
144
155
  </span>
145
156
  );
146
157
 
147
- return (
158
+ const fab = (
148
159
  <div
149
160
  ref={rootRef}
150
161
  class="hk-fab"
151
- data-positioning={props.positioning}
152
- data-corner={props.corner}
162
+ data-positioning={floated ? undefined : props.positioning}
163
+ data-corner={floated ? undefined : props.corner}
153
164
  data-size={props.size}
154
165
  data-variant={props.variant}
155
166
  data-expand={props.expandDirection}
156
167
  data-expanded={isOpen.value ? "true" : undefined}
157
168
  style={{
158
- "--hk-fab-offset-x": props.offsetX,
159
- "--hk-fab-offset-y": props.offsetY,
169
+ "--hk-fab-offset-x": floated ? undefined : props.offsetX,
170
+ "--hk-fab-offset-y": floated ? undefined : props.offsetY,
160
171
  }}
161
172
  >
162
173
  {(props.actions?.length ?? 0) > 0 && (
@@ -196,6 +207,23 @@ export default defineComponent({
196
207
  </button>
197
208
  </div>
198
209
  );
210
+
211
+ // Floated: the HkFloatingLayer owns viewport anchoring (corner +
212
+ // safe-area + tooltip-band z); the fab itself renders bare inside
213
+ // it (data-positioning/corner stripped above so the geometry is
214
+ // owned by exactly one layer).
215
+ if (floated) {
216
+ return (
217
+ <HkFloatingLayer
218
+ corner={props.corner}
219
+ offsetX={props.offsetX}
220
+ offsetY={props.offsetY}
221
+ >
222
+ {fab}
223
+ </HkFloatingLayer>
224
+ );
225
+ }
226
+ return fab;
199
227
  };
200
228
  },
201
229
  });
@@ -0,0 +1,58 @@
1
+ // HkFloatingLayer.scss — top-layer floating chrome.
2
+ //
3
+ // Anchoring mirrors HkFab's safe-area-aware corner insets, extended per
4
+ // instance by --hk-float-offset-*. The z rides the popup manager's
5
+ // tooltip band via --hk-float-z (inline from the component once the
6
+ // registration resolves); the fallback must stay in sync with
7
+ // POPUP_Z_BANDS.tooltip in runtime/usePopupManager.ts.
8
+ .hk-floating-layer {
9
+ position: fixed;
10
+ z-index: var(--hk-float-z, 3000);
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: var(--space-8, 0.5rem);
14
+ pointer-events: none;
15
+
16
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
17
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
18
+
19
+ &[data-corner$="-right"] {
20
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-right, 0px));
21
+ right: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
22
+ }
23
+
24
+ &[data-corner$="-left"] {
25
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
26
+ left: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
27
+ }
28
+
29
+ &[data-corner^="top"] {
30
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-top, 0px));
31
+ top: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
32
+ }
33
+
34
+ &[data-corner^="bottom"] {
35
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
36
+ bottom: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
37
+ }
38
+
39
+ // The layer never eats gestures; the widgets inside do. NOTE: for a
40
+ // floated HkFab this rule must LOSE to HkFab's own
41
+ // `.hk-fab { pointer-events: none }` (equal specificity — later sheet
42
+ // wins — and HkFab.tsx imports this component before its own sheet),
43
+ // so the fab root stays transparent while its button re-enables.
44
+ // Do not strengthen this selector or reorder those imports: the
45
+ // layer's gap boxes would start eating clicks.
46
+ > * {
47
+ pointer-events: auto;
48
+ }
49
+
50
+ // A floated HkFab (layer="top") hands viewport anchoring to this
51
+ // layer entirely: drop its own positioning so its corner lands in
52
+ // the layer's flow instead of double-resolving the insets.
53
+ > .hk-fab {
54
+ position: static;
55
+ inset: auto;
56
+ z-index: auto;
57
+ }
58
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet contract for HkFloatingLayer. The layer IS its
10
+ * CSS (fixed positioning, gesture transparency, the > .hk-fab
11
+ * positioning hand-off), none of which the runtime suite can see —
12
+ * vitest stubs CSS — so the sheet is compiled and asserted here.
13
+ */
14
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
15
+
16
+ describe("HkFloatingLayer stylesheet contract", () => {
17
+ const tsx = readFileSync(resolve(componentDir, "HkFloatingLayer.tsx"), "utf8");
18
+ const css = compile(resolve(componentDir, "HkFloatingLayer.scss"), {
19
+ style: "expanded",
20
+ }).css;
21
+
22
+ it("compiles the fixed, gesture-transparent top-layer shell", () => {
23
+ expect(css).toContain(".hk-floating-layer {");
24
+ expect(css).toContain("position: fixed;");
25
+ expect(css).toContain("z-index: var(--hk-float-z, 3000);");
26
+ // The layer never eats gestures; the widgets inside do.
27
+ expect(css).toContain("pointer-events: none;");
28
+ expect(css).toContain(".hk-floating-layer > * {");
29
+ expect(css).toContain("pointer-events: auto;");
30
+ });
31
+
32
+ it("anchors all four corners with safe-area-aware insets", () => {
33
+ expect(css).toContain('[data-corner$=-right]');
34
+ expect(css).toContain('[data-corner$=-left]');
35
+ expect(css).toContain('[data-corner^=top]');
36
+ expect(css).toContain('[data-corner^=bottom]');
37
+ for (const side of ["right: calc", "left: calc", "top: calc", "bottom: calc"]) {
38
+ expect(css).toContain(side);
39
+ }
40
+ expect(css).toContain("env(safe-area-inset-left, 0px)");
41
+ expect(css).toContain("env(safe-area-inset-right, 0px)");
42
+ expect(css).toContain("env(safe-area-inset-top, 0px)");
43
+ expect(css).toContain("env(safe-area-inset-bottom, 0px)");
44
+ });
45
+
46
+ it("takes over a floated HkFab's positioning entirely", () => {
47
+ expect(css).toContain(".hk-floating-layer > .hk-fab {");
48
+ expect(css).toContain("position: static;");
49
+ expect(css).toContain("inset: auto;");
50
+ expect(css).toContain("z-index: auto;");
51
+ });
52
+
53
+ it("has no dead selectors: every rule the sheet emits is rendered", () => {
54
+ // The TSX must reference every state the sheet styles.
55
+ expect(tsx).toContain('class="hk-floating-layer"');
56
+ expect(tsx).toContain("data-corner={props.corner}");
57
+ });
58
+ });
@@ -0,0 +1,145 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkFloatingLayer from "./HkFloatingLayer";
5
+ import { POPUP_Z_BANDS, usePopupManager } from "../runtime/usePopupManager";
6
+
7
+ const mounts: ReturnType<typeof createApp>[] = [];
8
+ const containers: HTMLElement[] = [];
9
+
10
+ /** Mount an HkFloatingLayer through a wrapper app (mirrors the
11
+ * HkFab.test.tsx harness). The layer teleports its node to <body>,
12
+ * so queries go through document.body, not the host container. */
13
+ function mountLayer(props: Record<string, unknown> = {}, slot?: () => unknown) {
14
+ const container = document.createElement("div");
15
+ document.body.appendChild(container);
16
+ containers.push(container);
17
+
18
+ const Wrapper = defineComponent({
19
+ setup() {
20
+ return () => h(HkFloatingLayer, props, slot ? { default: slot } : undefined);
21
+ },
22
+ });
23
+ const app = createApp(Wrapper);
24
+ mounts.push(app);
25
+ app.mount(container);
26
+
27
+ return {
28
+ app,
29
+ root: () => document.body.querySelector<HTMLElement>(".hk-floating-layer"),
30
+ };
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const app of mounts.splice(0)) app.unmount();
35
+ for (const el of containers.splice(0)) el.remove();
36
+ document.body.innerHTML = "";
37
+ });
38
+
39
+ describe("HkFloatingLayer", () => {
40
+ it("teleports the corner layer to body with a tooltip-band z slot", () => {
41
+ const m = mountLayer({ ariaLabel: "Date dial" });
42
+ const root = m.root();
43
+ expect(root).not.toBeNull();
44
+ // Teleported: outside the wrapper's host subtree.
45
+ expect(containers[0].contains(root!)).toBe(false);
46
+ expect(root!.getAttribute("data-corner")).toBe("bottom-right");
47
+ expect(root!.getAttribute("role")).toBe("group");
48
+ expect(root!.getAttribute("aria-label")).toBe("Date dial");
49
+ // Inline z from the popup manager registration — inside the
50
+ // tooltip band, strictly below the toast band.
51
+ const z = Number(root!.style.getPropertyValue("--hk-float-z"));
52
+ expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
53
+ expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
54
+ });
55
+
56
+ it("open=false renders nothing at all", () => {
57
+ const m = mountLayer({ open: false });
58
+ expect(m.root()).toBeNull();
59
+ });
60
+
61
+ it("registers exactly one tooltip entry and releases it on unmount", () => {
62
+ const manager = usePopupManager();
63
+ const tooltipCount = () => {
64
+ let n = 0;
65
+ for (const entry of manager.registry.value.values()) {
66
+ if (entry.kind === "tooltip") n++;
67
+ }
68
+ return n;
69
+ };
70
+ const before = tooltipCount();
71
+ // Non-blocking, untitled chrome must stay silent (a future
72
+ // warnUntitled change folding tooltip into the naming rule would
73
+ // dev-warn on every mount — that regression goes red here).
74
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
75
+ const m = mountLayer();
76
+ expect(tooltipCount()).toBe(before + 1);
77
+ m.app.unmount();
78
+ mounts.splice(mounts.indexOf(m.app), 1);
79
+ expect(tooltipCount()).toBe(before);
80
+ expect(warn).not.toHaveBeenCalled();
81
+ warn.mockRestore();
82
+ });
83
+
84
+ it("hide releases the slot; re-show re-registers with a fresh band z", async () => {
85
+ const manager = usePopupManager();
86
+ const tooltipCount = () => {
87
+ let n = 0;
88
+ for (const entry of manager.registry.value.values()) {
89
+ if (entry.kind === "tooltip") n++;
90
+ }
91
+ return n;
92
+ };
93
+ const container = document.createElement("div");
94
+ document.body.appendChild(container);
95
+ containers.push(container);
96
+
97
+ const visible = ref(true);
98
+ const Wrapper = defineComponent({
99
+ setup() {
100
+ return () => h(HkFloatingLayer, { open: visible.value });
101
+ },
102
+ });
103
+ const app = createApp(Wrapper);
104
+ mounts.push(app);
105
+ app.mount(container);
106
+
107
+ const layer = () => document.body.querySelector<HTMLElement>(".hk-floating-layer");
108
+ const before = tooltipCount();
109
+ expect(before).toBeGreaterThanOrEqual(1);
110
+ const z1 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
111
+ expect(z1).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
112
+
113
+ // Hide: the node leaves AND the band slot is released — the
114
+ // count test above cannot catch this branch on its own (unmount
115
+ // cleanup would mask it).
116
+ visible.value = false;
117
+ await nextTick();
118
+ expect(tooltipCount()).toBe(before - 1);
119
+ expect(layer()).toBeNull();
120
+
121
+ // Re-show: registered again, first paint already carries the z.
122
+ visible.value = true;
123
+ await nextTick();
124
+ expect(tooltipCount()).toBe(before);
125
+ const z2 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
126
+ expect(z2).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
127
+
128
+ app.unmount();
129
+ mounts.splice(mounts.indexOf(app), 1);
130
+ expect(tooltipCount()).toBe(before - 1);
131
+ });
132
+
133
+ it("honours the corner and offset props", () => {
134
+ const m = mountLayer({ corner: "top-left", offsetX: "1rem", offsetY: "2rem" });
135
+ const root = m.root()!;
136
+ expect(root.getAttribute("data-corner")).toBe("top-left");
137
+ expect(root.style.getPropertyValue("--hk-float-offset-x")).toBe("1rem");
138
+ expect(root.style.getPropertyValue("--hk-float-offset-y")).toBe("2rem");
139
+ });
140
+
141
+ it("keeps slot content mounted inside the layer", () => {
142
+ const m = mountLayer({}, () => h("button", { class: "probe" }, "go"));
143
+ expect(m.root()!.querySelector(".probe")).not.toBeNull();
144
+ });
145
+ });
@@ -0,0 +1,112 @@
1
+ import {
2
+ defineComponent,
3
+ onBeforeUnmount,
4
+ ref,
5
+ Teleport,
6
+ watch,
7
+ type PropType,
8
+ } from "vue";
9
+
10
+ import { usePopupManager } from "../runtime/usePopupManager";
11
+ import "./HkFloatingLayer.scss";
12
+
13
+ type FloatCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
14
+
15
+ /**
16
+ * HkFloatingLayer — viewport-fixed chrome that floats at the TOP layer.
17
+ *
18
+ * The dial/FAB problem this solves: always-reachable chrome anchored to
19
+ * a page (or a window) dies the moment a real window opens over it —
20
+ * either it is buried under the modal overlay (page pads), or it gets
21
+ * re-rendered as an in-window copy that no longer shares the page's
22
+ * geometry (the per-modal dial copies this layer replaces). Floating at
23
+ * the popup manager's tooltip band makes the chrome independent of the
24
+ * window stack, exactly like a tooltip: above every modal/drawer
25
+ * overlay, below toasts, present on its own merits.
26
+ *
27
+ * Mechanics:
28
+ * - Teleported to <body>, so no transformed ancestor (morph tabs,
29
+ * modal content frames) can re-anchor or clip a `position: fixed`
30
+ * child, and the layer resolves against the viewport full stop.
31
+ * - Registered with the popup manager (kind "tooltip", non-blocking,
32
+ * no scroll lock, no breadcrumb title): the z slot derives from the
33
+ * live registry instead of a magic constant, and the layer shows up
34
+ * in `readHkRuntime("popupManager")` like every other surface.
35
+ * - The layer itself is gesture-transparent (pointer-events: none);
36
+ * the widgets inside catch their own pointers.
37
+ * - Corner insets mirror HkFab's safe-area-aware math, extended per
38
+ * instance by the offset props.
39
+ *
40
+ * - Within the tooltip band, stacking is open-order (the manager's
41
+ * rule): a tooltip that registered later paints above this chrome
42
+ * near the corner, and a layer mounted after a live tooltip paints
43
+ * above it — transient vs persistent chrome trades locally. Do not
44
+ * "fix" this with a magic z. Two layers at one corner overlap;
45
+ * pass offsets to stack them deliberately.
46
+ * - While a modal is open, the modal's focus trap keeps this chrome
47
+ * pointer-reachable but NOT keyboard-reachable — the standing
48
+ * tradeoff for floating chrome; a focus relay is out of scope.
49
+ *
50
+ * Consumers: the shell's quick-action pad, a modal's detached date dial
51
+ * (via `HkFab layer="top"` or direct use) — anything that must survive
52
+ * the next window opening on top of it.
53
+ */
54
+ export default defineComponent({
55
+ name: "HkFloatingLayer",
56
+ props: {
57
+ /** False unmounts the teleported layer and releases its band slot
58
+ * (true = always shown). House `open` convention, like every other
59
+ * hikari surface. */
60
+ open: { type: Boolean, default: true },
61
+ corner: { type: String as PropType<FloatCorner>, default: "bottom-right" },
62
+ offsetX: { type: String, default: undefined },
63
+ offsetY: { type: String, default: undefined },
64
+ /** Accessible group name for the chrome (e.g. "Date dial"). */
65
+ ariaLabel: { type: String, default: "" },
66
+ },
67
+ setup(props, { slots }) {
68
+ const { register, unregister } = usePopupManager();
69
+ const handle = ref<{ id: string; zIndex: number } | null>(null);
70
+
71
+ // Annotation-band chrome, not a breadcrumb level: non-blocking,
72
+ // untitled, no scroll lock, no outside-dismiss channel. Registered
73
+ // exactly while shown (immediate, so the first paint already
74
+ // carries the band z); a hidden layer holds no slot.
75
+ watch(
76
+ () => props.open,
77
+ (show) => {
78
+ if (show && !handle.value) handle.value = register("tooltip", false);
79
+ else if (!show && handle.value) {
80
+ unregister(handle.value.id);
81
+ handle.value = null;
82
+ }
83
+ },
84
+ { immediate: true },
85
+ );
86
+ onBeforeUnmount(() => {
87
+ if (handle.value) unregister(handle.value.id);
88
+ handle.value = null;
89
+ });
90
+
91
+ return () => {
92
+ if (!props.open) return null;
93
+ return (
94
+ <Teleport to="body">
95
+ <div
96
+ class="hk-floating-layer"
97
+ data-corner={props.corner}
98
+ role={props.ariaLabel ? "group" : undefined}
99
+ aria-label={props.ariaLabel || undefined}
100
+ style={{
101
+ "--hk-float-z": handle.value ? String(handle.value.zIndex) : undefined,
102
+ "--hk-float-offset-x": props.offsetX,
103
+ "--hk-float-offset-y": props.offsetY,
104
+ }}
105
+ >
106
+ {slots.default?.()}
107
+ </div>
108
+ </Teleport>
109
+ );
110
+ };
111
+ },
112
+ });
@@ -0,0 +1,27 @@
1
+ // Use theme variables with namespace to avoid conflicts
2
+ @use "./hikari-vars" as vars;
3
+
4
+ .hk-terminal {
5
+ position: relative;
6
+ display: block;
7
+ width: 100%;
8
+ height: 100%;
9
+ min-height: 8rem;
10
+ padding: 0.35rem 0.45rem;
11
+ background: rgb(var(--color-background, 20 22 26));
12
+ border: 1px solid rgb(var(--color-border, 128 128 128) / 15%);
13
+ border-radius: vars.$hikari-radius-fui-md;
14
+ box-sizing: border-box;
15
+ overflow: hidden;
16
+
17
+ /* xterm fills the host box */
18
+ .terminal,
19
+ .xterm {
20
+ height: 100%;
21
+ }
22
+
23
+ /* The viewport scrollbar inherits the shared chrome shape. */
24
+ .xterm-viewport {
25
+ border-radius: vars.$hikari-radius-fui-sm;
26
+ }
27
+ }
@@ -0,0 +1,299 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ /**
5
+ * HkTerminal contract tests.
6
+ *
7
+ * The xterm CORE is mocked (happy-dom has no layout engine — xterm's
8
+ * WidthCache needs real glyph measurement); the mock records the exact
9
+ * calls so the component contract is pinned without executing the
10
+ * renderer:
11
+ *
12
+ * 1. props reach the Terminal options
13
+ * 2. `write` is a binary-safe passthrough (Uint8Array handed over
14
+ * AS-IS — a string round-trip would corrupt kitty/SIXEL payloads)
15
+ * 3. `data` / `resize` events are wired to the consumer
16
+ * 4. the image addon loads with kitty/SIXEL/IIP enabled, and does not
17
+ * load when `enableImages` is false
18
+ * 5. the terminal is disposed on unmount and observers disconnected
19
+ * 6. the theme derivation turns channel triplets into hex colors
20
+ */
21
+
22
+ const writeCalls: Array<string | Uint8Array> = [];
23
+ const loadedAddons: Array<{ ctor: string; opts?: Record<string, unknown> }> = [];
24
+ const dataHandlers = new Set<(text: string) => void>();
25
+ const resizeHandlers = new Set<(d: { cols: number; rows: number }) => void>();
26
+ let disposed = 0;
27
+ let lastOptions: Record<string, unknown> | null = null;
28
+ let resizeCalls: Array<{ cols: number; rows: number }> = [];
29
+
30
+ vi.mock("@xterm/xterm", () => ({
31
+ Terminal: class {
32
+ options: Record<string, unknown>;
33
+ cols: number;
34
+ rows: number;
35
+ constructor(opts: Record<string, unknown>) {
36
+ this.options = { ...opts };
37
+ this.cols = (opts.cols as number) ?? 80;
38
+ this.rows = (opts.rows as number) ?? 24;
39
+ lastOptions = this.options;
40
+ }
41
+ loadAddon(addon: { ctor?: string; opts?: Record<string, unknown> }) {
42
+ loadedAddons.push({
43
+ ctor: (addon as { ctor?: string }).ctor ?? "unknown",
44
+ opts: (addon as { opts?: Record<string, unknown> }).opts,
45
+ });
46
+ }
47
+ open() {}
48
+ onData(cb: (text: string) => void) {
49
+ dataHandlers.add(cb);
50
+ return () => dataHandlers.delete(cb);
51
+ }
52
+ onResize(cb: (d: { cols: number; rows: number }) => void) {
53
+ resizeHandlers.add(cb);
54
+ return () => resizeHandlers.delete(cb);
55
+ }
56
+ write(data: string | Uint8Array) {
57
+ writeCalls.push(data);
58
+ }
59
+ writeln(data: string | Uint8Array) {
60
+ writeCalls.push(data);
61
+ writeCalls.push("\r\n");
62
+ }
63
+ clear() {}
64
+ reset() {}
65
+ focus() {}
66
+ resize(cols: number, rows: number) {
67
+ this.cols = cols;
68
+ this.rows = rows;
69
+ resizeCalls.push({ cols, rows });
70
+ // Real xterm fires onResize from resize() — the mock must too, or
71
+ // double-emit bugs in the component hide behind it (R1 F2).
72
+ for (const cb of [...resizeHandlers]) cb({ cols, rows });
73
+ }
74
+ dispose() {
75
+ disposed += 1;
76
+ }
77
+ },
78
+ }));
79
+ vi.mock("@xterm/addon-fit", () => ({
80
+ FitAddon: class {
81
+ ctor = "fit";
82
+ fit() {}
83
+ },
84
+ }));
85
+ vi.mock("@xterm/addon-image", () => ({
86
+ ImageAddon: class {
87
+ ctor = "image";
88
+ opts: Record<string, unknown>;
89
+ constructor(opts: Record<string, unknown>) {
90
+ this.opts = opts;
91
+ }
92
+ },
93
+ }));
94
+ vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
95
+
96
+ import HkTerminal, { __testables } from "./HkTerminal";
97
+
98
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
99
+
100
+ interface TerminalHarness {
101
+ exposed: Record<string, unknown>;
102
+ unmount: () => void;
103
+ props: Record<string, unknown>;
104
+ }
105
+
106
+ function mountTerminal(props: Record<string, unknown> = {}): TerminalHarness {
107
+ const container = document.createElement("div");
108
+ document.body.appendChild(container);
109
+ const exposedRef = ref<Record<string, unknown> | null>(null);
110
+ const Host = defineComponent({
111
+ setup() {
112
+ return () =>
113
+ h(HkTerminal, {
114
+ ...props,
115
+ ref: (el: unknown) => {
116
+ exposedRef.value = el as Record<string, unknown>;
117
+ },
118
+ });
119
+ },
120
+ });
121
+ const app = createApp(Host);
122
+ app.mount(container);
123
+ mounts.push({ app, container });
124
+ return {
125
+ get exposed() {
126
+ return exposedRef.value ?? {};
127
+ },
128
+ unmount: () => app.unmount(),
129
+ props,
130
+ };
131
+ }
132
+
133
+ /** Read a sibling source file (for source-level contract pins). Vitest
134
+ * runs with cwd = packages/vue, which is the stable anchor here. */
135
+ function require_fs_read(name: string): string {
136
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
137
+ const fs = require("node:fs") as typeof import("node:fs");
138
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
139
+ const path = require("node:path") as typeof import("node:path");
140
+ return fs.readFileSync(path.resolve(process.cwd(), "src/components", name), "utf8");
141
+ }
142
+
143
+ beforeEach(() => {
144
+ writeCalls.length = 0;
145
+ loadedAddons.length = 0;
146
+ dataHandlers.clear();
147
+ resizeHandlers.clear();
148
+ disposed = 0;
149
+ lastOptions = null;
150
+ resizeCalls = [];
151
+ });
152
+
153
+ afterEach(() => {
154
+ while (mounts.length) {
155
+ const m = mounts.pop()!;
156
+ m.app.unmount();
157
+ m.container.remove();
158
+ }
159
+ });
160
+
161
+ describe("HkTerminal", () => {
162
+ it("passes the typography props into the Terminal options", async () => {
163
+ mountTerminal({ fontSize: 15, fontFamily: "MyMono", scrollback: 500, cursorBlink: false });
164
+ await nextTick();
165
+ expect(lastOptions).toMatchObject({
166
+ fontSize: 15,
167
+ fontFamily: "MyMono",
168
+ scrollback: 500,
169
+ cursorBlink: false,
170
+ cols: 80,
171
+ rows: 24,
172
+ });
173
+ });
174
+
175
+ it("writes bytes through untouched (binary safety)", async () => {
176
+ const t = mountTerminal();
177
+ await nextTick();
178
+ const bytes = new Uint8Array([0x1b, 0x5f, 0x47, 0xe4, 0xbd, 0xa0]);
179
+ (t.exposed.write as (d: Uint8Array) => void)(bytes);
180
+ expect(writeCalls).toHaveLength(1);
181
+ expect(writeCalls[0]).toBe(bytes); // SAME object — no string round-trip
182
+ });
183
+
184
+ it("forwards xterm data to the consumer event", async () => {
185
+ const seen: string[] = [];
186
+ const container = document.createElement("div");
187
+ document.body.appendChild(container);
188
+ const Host = defineComponent({
189
+ setup() {
190
+ return () =>
191
+ h(HkTerminal, {
192
+ onData: (text: string) => seen.push(text),
193
+ });
194
+ },
195
+ });
196
+ const app = createApp(Host);
197
+ app.mount(container);
198
+ mounts.push({ app, container });
199
+ await nextTick();
200
+ for (const cb of [...dataHandlers]) cb("ls -la\r");
201
+ expect(seen).toEqual(["ls -la\r"]);
202
+ });
203
+
204
+ it("loads the image addon with kitty/SIXEL/IIP support", async () => {
205
+ mountTerminal();
206
+ await nextTick();
207
+ const image = loadedAddons.find((a) => a.ctor === "image");
208
+ expect(image).toBeDefined();
209
+ expect(image!.opts).toMatchObject({
210
+ kittySupport: true,
211
+ sixelSupport: true,
212
+ iipSupport: true,
213
+ enableSizeReports: true,
214
+ });
215
+ });
216
+
217
+ it("skips the image addon when disabled", async () => {
218
+ mountTerminal({ enableImages: false });
219
+ await nextTick();
220
+ expect(loadedAddons.find((a) => a.ctor === "image")).toBeUndefined();
221
+ expect(loadedAddons.find((a) => a.ctor === "fit")).toBeDefined();
222
+ });
223
+
224
+ it("emits exactly one resize when the cols/rows props change", async () => {
225
+ const resizeEvents: Array<{ cols: number; rows: number }> = [];
226
+ const container = document.createElement("div");
227
+ document.body.appendChild(container);
228
+ const propsRef = ref<Record<string, unknown>>({ cols: 100, rows: 30 });
229
+ const Host = defineComponent({
230
+ setup() {
231
+ return () => h(HkTerminal, {
232
+ ...(propsRef.value as Record<string, unknown>),
233
+ onResize: (d: { cols: number; rows: number }) => resizeEvents.push(d),
234
+ });
235
+ },
236
+ });
237
+ const app = createApp(Host);
238
+ app.mount(container);
239
+ mounts.push({ app, container });
240
+ await nextTick();
241
+ resizeEvents.length = 0;
242
+ propsRef.value = { cols: 120, rows: 40 };
243
+ await nextTick();
244
+ await nextTick();
245
+ // xterm's own onResize already emitted — a manual second emit (the
246
+ // R1 F2 double-fire shape) would make this 2.
247
+ expect(resizeEvents).toEqual([{ cols: 120, rows: 40 }]);
248
+ });
249
+
250
+ it("imports xterm's core stylesheet (source pin)", () => {
251
+ // The component is only self-sufficient with xterm.css in place
252
+ // (R1 F1); bundlers prune nothing thanks to sideEffects. A source
253
+ // pin is the honest guard — the runtime import is invisible to the
254
+ // mocked tests by construction.
255
+ const src = require_fs_read("HkTerminal.tsx");
256
+ expect(src).toContain('@xterm/xterm/css/xterm.css');
257
+ });
258
+
259
+ it("disposes the terminal on unmount", async () => {
260
+ const t = mountTerminal();
261
+ await nextTick();
262
+ expect(disposed).toBe(0);
263
+ t.unmount();
264
+ expect(disposed).toBe(1);
265
+ });
266
+
267
+ it("resizes programmatically and reports the new grid", async () => {
268
+ const resizeEvents: Array<{ cols: number; rows: number }> = [];
269
+ const container = document.createElement("div");
270
+ document.body.appendChild(container);
271
+ const Host = defineComponent({
272
+ setup() {
273
+ return () =>
274
+ h(HkTerminal, {
275
+ cols: 100,
276
+ rows: 30,
277
+ onResize: (d: { cols: number; rows: number }) => resizeEvents.push(d),
278
+ });
279
+ },
280
+ });
281
+ const app = createApp(Host);
282
+ app.mount(container);
283
+ mounts.push({ app, container });
284
+ await nextTick();
285
+ // Simulate xterm's own onResize (what fit() triggers internally).
286
+ for (const cb of [...resizeHandlers]) cb({ cols: 120, rows: 40 });
287
+ expect(resizeEvents).toEqual([{ cols: 120, rows: 40 }]);
288
+ });
289
+ });
290
+
291
+ describe("channelToHex", () => {
292
+ it("turns r-g-b channel triplets into hex and keeps passthrough colors", () => {
293
+ const el = document.createElement("div");
294
+ const hex = __testables.channelToHex(el, "--color-background", "#000000");
295
+ // No channel value set in happy-dom → fallback path (unless the env
296
+ // defines one); both arms are valid, assert it returns a color.
297
+ expect(hex).toMatch(/^(#[0-9a-fA-F]{6}|rgb\(.*\))$/);
298
+ });
299
+ });
@@ -0,0 +1,241 @@
1
+ import {
2
+ defineComponent,
3
+ onBeforeUnmount,
4
+ onMounted,
5
+ ref,
6
+ watch,
7
+ } from "vue";
8
+
9
+ import { FitAddon } from "@xterm/addon-fit";
10
+ import { ImageAddon } from "@xterm/addon-image";
11
+ import { Terminal, type ITheme } from "@xterm/xterm";
12
+
13
+ // xterm's own core stylesheet (element layout, viewport scrolling, the
14
+ // hidden helper textarea) — the component is self-sufficient only with
15
+ // it in place; sideEffects in package.json keeps bundlers from pruning.
16
+ import "@xterm/xterm/css/xterm.css";
17
+
18
+ import "./HkTerminal.scss";
19
+
20
+ /**
21
+ * HkTerminal — the shared terminal rendering surface (a rich component in
22
+ * the HkMarkdownRenderer class, not a thin wrapper).
23
+ *
24
+ * Owns the whole xterm.js lifecycle: creation, fitting, image-protocol
25
+ * decoding, theming from the hikari theme channels, and disposal. The
26
+ * consumer owns the DATA — it feeds bytes in through `write` (Uint8Array
27
+ * passthrough keeps split UTF-8 and APC/SIXEL/kitty payloads intact) and
28
+ * listens to `data` / `resize` to drive its transport.
29
+ *
30
+ * Props
31
+ * - `cols` / `rows`: initial grid (autoFit re-fits to the host afterwards).
32
+ * - `autoFit`: keep the grid matched to the host box (ResizeObserver).
33
+ * (Mount-only: toggling after mount has no effect.)
34
+ * - `enableImages`: decode kitty graphics / SIXEL / iTerm2 IIP payloads
35
+ * in-band (remote-control sessions push screenshots through the PTY;
36
+ * mount-only).
37
+ * - `fontSize` / `fontFamily`: terminal typography (defaults to the
38
+ * hikari mono stack).
39
+ * - `scrollback`: scrollback buffer size.
40
+ * - `cursorBlink`: blinking caret.
41
+ *
42
+ * Events
43
+ * - `data(text)`: keystrokes and pastes (UTF-8 text, as xterm emits).
44
+ * - `resize({cols, rows})`: the grid changed (autoFit or option change).
45
+ *
46
+ * Exposed API
47
+ * - `write(data: string | Uint8Array)` — binary-safe feed.
48
+ * - `writeln`, `clear`, `reset`, `fit`, `focus`, `dimensions`.
49
+ *
50
+ * Theming: the xterm theme is derived from the hikari color channels
51
+ * (`--color-background` / `--color-text` / `--color-primary` / …) at
52
+ * mount, and re-derived when the document element's class or style
53
+ * changes (the light/dark switch mechanism consumers use).
54
+ */
55
+
56
+ /** Resolve an `r g b` triplet channel (with fallback) to `#rrggbb`. */
57
+ function channelToHex(host: HTMLElement, name: string, fallback: string): string {
58
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
59
+ const parts = raw.split(/\s+/).map((p) => Number.parseFloat(p));
60
+ if (parts.length === 3 && parts.every((n) => Number.isFinite(n) && n >= 0 && n <= 255)) {
61
+ return (
62
+ "#" +
63
+ parts
64
+ .map((n) => Math.round(n).toString(16).padStart(2, "0"))
65
+ .join("")
66
+ );
67
+ }
68
+ // Some channels are already full colors (rgb(...) / #hex) — pass through.
69
+ if (raw && !raw.includes(" ")) return raw;
70
+ return fallback;
71
+ }
72
+
73
+ function deriveXtermTheme(host: HTMLElement): ITheme {
74
+ return {
75
+ background: channelToHex(host, "--color-background", "#14161a"),
76
+ foreground: channelToHex(host, "--color-text", "#e6e6e6"),
77
+ cursor: channelToHex(host, "--color-primary", "#7aa2f7"),
78
+ cursorAccent: channelToHex(host, "--color-background", "#14161a"),
79
+ selectionBackground: channelToHex(host, "--color-selected-bg", "#3b4356") + "88",
80
+ };
81
+ }
82
+
83
+ const FAMILY_FALLBACK =
84
+ "ui-monospace, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
85
+
86
+ export default defineComponent({
87
+ name: "HkTerminal",
88
+ props: {
89
+ cols: { type: Number, default: 80 },
90
+ rows: { type: Number, default: 24 },
91
+ autoFit: { type: Boolean, default: true },
92
+ enableImages: { type: Boolean, default: true },
93
+ fontSize: { type: Number, default: 13 },
94
+ fontFamily: { type: String, default: FAMILY_FALLBACK },
95
+ scrollback: { type: Number, default: 2000 },
96
+ cursorBlink: { type: Boolean, default: true },
97
+ },
98
+ emits: {
99
+ data: (text: string) => typeof text === "string",
100
+ resize: (dims: { cols: number; rows: number }) =>
101
+ typeof dims?.cols === "number" && typeof dims?.rows === "number",
102
+ },
103
+ setup(props, { emit, expose }) {
104
+ const hostRef = ref<HTMLElement>();
105
+ let term: Terminal | null = null;
106
+ let fitAddon: FitAddon | null = null;
107
+ let observer: ResizeObserver | null = null;
108
+ let themeObserver: MutationObserver | null = null;
109
+
110
+ function emitResize(cols: number, rows: number) {
111
+ emit("resize", { cols, rows });
112
+ }
113
+
114
+ onMounted(() => {
115
+ const host = hostRef.value;
116
+ if (!host) return;
117
+ const t = new Terminal({
118
+ cursorBlink: props.cursorBlink,
119
+ fontSize: props.fontSize,
120
+ fontFamily: props.fontFamily,
121
+ cols: props.cols,
122
+ rows: props.rows,
123
+ scrollback: props.scrollback,
124
+ theme: deriveXtermTheme(host),
125
+ allowProposedApi: true,
126
+ });
127
+ const fa = new FitAddon();
128
+ t.loadAddon(fa);
129
+ if (props.enableImages) {
130
+ // Image protocols: kitty graphics (APC _G) + SIXEL + iTerm2 IIP —
131
+ // remote-control sessions push screenshots and plots through the
132
+ // same PTY stream; their escape sequences must render as images.
133
+ // 0.10.0-beta is currently the only kitty implementation for
134
+ // xterm.js (alpha upstream).
135
+ t.loadAddon(
136
+ new ImageAddon({
137
+ enableSizeReports: true,
138
+ sixelSupport: true,
139
+ iipSupport: true,
140
+ kittySupport: true,
141
+ sixelPaletteLimit: 256,
142
+ storageLimit: 256,
143
+ showPlaceholder: false,
144
+ }),
145
+ );
146
+ }
147
+ t.open(host);
148
+ t.onData((text) => emit("data", text));
149
+ t.onResize(({ cols, rows }) => emitResize(cols, rows));
150
+ term = t;
151
+ fitAddon = fa;
152
+ if (props.autoFit) {
153
+ fa.fit();
154
+ observer = new ResizeObserver(() => {
155
+ if (!fitAddon || !term) return;
156
+ fitAddon.fit();
157
+ });
158
+ observer.observe(host);
159
+ }
160
+ // Re-derive colors when the consumer flips the theme (host apps
161
+ // switch light/dark by mutating :root class or style).
162
+ const retheme = () => {
163
+ if (term && hostRef.value) term.options.theme = deriveXtermTheme(hostRef.value);
164
+ };
165
+ if (typeof MutationObserver !== "undefined") {
166
+ themeObserver = new MutationObserver(retheme);
167
+ themeObserver.observe(document.documentElement, {
168
+ attributes: true,
169
+ attributeFilter: ["class", "style", "data-theme", "data-mode"],
170
+ });
171
+ }
172
+ });
173
+
174
+ onBeforeUnmount(() => {
175
+ observer?.disconnect();
176
+ observer = null;
177
+ themeObserver?.disconnect();
178
+ themeObserver = null;
179
+ term?.dispose();
180
+ term = null;
181
+ fitAddon = null;
182
+ });
183
+
184
+ // Live option updates (recreate-free where xterm supports it).
185
+ watch(
186
+ () => [props.fontSize, props.fontFamily, props.scrollback, props.cursorBlink],
187
+ ([fontSize, fontFamily, scrollback, cursorBlink]) => {
188
+ if (!term) return;
189
+ term.options.fontSize = fontSize as number;
190
+ term.options.fontFamily = fontFamily as string;
191
+ term.options.scrollback = scrollback as number;
192
+ term.options.cursorBlink = cursorBlink as boolean;
193
+ fitAddon?.fit();
194
+ },
195
+ );
196
+ watch(
197
+ () => [props.cols, props.rows],
198
+ ([cols, rows]) => {
199
+ if (!term) return;
200
+ if (term.cols !== (cols as number) || term.rows !== (rows as number)) {
201
+ // term.resize fires xterm's own onResize, which emits to the
202
+ // consumer exactly once — emitting here as well would double
203
+ // every resize.
204
+ term.resize(cols as number, rows as number);
205
+ }
206
+ },
207
+ );
208
+
209
+ expose({
210
+ /** Binary-safe feed: Uint8Array goes to xterm's UTF-8 decoder, so
211
+ * multi-byte sequences split across writes survive. */
212
+ write(data: string | Uint8Array) {
213
+ term?.write(data);
214
+ },
215
+ writeln(data: string) {
216
+ term?.writeln(data);
217
+ },
218
+ clear() {
219
+ term?.clear();
220
+ },
221
+ reset() {
222
+ term?.reset();
223
+ },
224
+ fit() {
225
+ fitAddon?.fit();
226
+ },
227
+ focus() {
228
+ term?.focus();
229
+ },
230
+ /** Current grid size (null before mount). */
231
+ get dimensions(): { cols: number; rows: number } | null {
232
+ return term ? { cols: term.cols, rows: term.rows } : null;
233
+ },
234
+ });
235
+
236
+ return () => <div ref={hostRef} class="hk-terminal" />;
237
+ },
238
+ });
239
+
240
+ /** Test seam: pure helpers (no DOM dependency beyond getComputedStyle). */
241
+ export const __testables = { channelToHex };
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export { default as HLabel } from "./components/HkLabel";
41
41
  export { default as HListTransition } from "./components/HkListTransition";
42
42
  export { default as HLoadMore } from "./components/HkLoadMore";
43
43
  export { default as HMarkdownRenderer } from "./components/HkMarkdownRenderer";
44
+ export { default as HTerminal } from "./components/HkTerminal";
44
45
  export { default as HModal } from "./components/HkModal";
45
46
  export { default as HNavItem } from "./components/HkNavItem";
46
47
  export { default as HNumberInput } from "./components/HkNumberInput";
@@ -688,6 +689,7 @@ export { default as HkDrawer } from "./components/HkDrawer";
688
689
  export { default as HkEmptyState } from "./components/HkEmptyState";
689
690
  export { default as HkExpansionPanel } from "./components/HkExpansionPanel";
690
691
  export { default as HkFab } from "./components/HkFab";
692
+ export { default as HkFloatingLayer } from "./components/HkFloatingLayer";
691
693
  export { default as HkIcon } from "./components/HkIcon";
692
694
  export { default as HkIconButton } from "./components/HkIconButton";
693
695
  export { default as HkIconButtonGroup } from "./components/HkIconButtonGroup";
@@ -700,6 +702,7 @@ export { default as HkLabel } from "./components/HkLabel";
700
702
  export { default as HkListTransition } from "./components/HkListTransition";
701
703
  export { default as HkLoadMore } from "./components/HkLoadMore";
702
704
  export { default as HkMarkdownRenderer } from "./components/HkMarkdownRenderer";
705
+ export { default as HkTerminal } from "./components/HkTerminal";
703
706
  export { default as HkModal } from "./components/HkModal";
704
707
  export { default as HkNavItem } from "./components/HkNavItem";
705
708
  export { default as HkNumberInput } from "./components/HkNumberInput";