@celestia-island/hikari 0.39.0 → 0.40.1

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.39.0",
3
+ "version": "0.40.1",
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",
@@ -38,7 +38,7 @@ import { useBreakpoint } from "../runtime/useBreakpoint";
38
38
  import "./HkAdaptiveDialog.scss";
39
39
  import HButton from "./HkButton";
40
40
  import HDrawer from "./HkDrawer";
41
- import HModal, { type ModalAction } from "./HkModal";
41
+ import HModal, { type ModalAction, type ModalWidth } from "./HkModal";
42
42
 
43
43
  export default defineComponent({
44
44
  name: "HkAdaptiveDialog",
@@ -47,7 +47,7 @@ export default defineComponent({
47
47
  title: { type: String, default: undefined },
48
48
  closable: { type: Boolean, default: true },
49
49
  /** Desktop modal width (HkModal `width`). */
50
- width: { type: String, default: "32rem" },
50
+ width: { type: String as PropType<ModalWidth>, default: "32rem" },
51
51
  /** Mobile drawer height (HkDrawer `size`). HkDrawer's bottom-side CSS
52
52
  * clamps panels at maxHeight 70vh, so the default equals the cap;
53
53
  * pass `panelClass` with a custom max-height for taller sheets. */
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, type PropType } from "vue";
2
- import { HModal } from "@celestia-island/hikari";
2
+ import { HModal, type ModalWidth } from "@celestia-island/hikari";
3
3
 
4
4
  import { useI18n } from "../i18n/context";
5
5
 
@@ -23,7 +23,7 @@ export const HkCaptchaModal = defineComponent({
23
23
  scriptUrl: { type: String, default: undefined },
24
24
  attempt: { type: Number, default: 0 },
25
25
  title: { type: String, default: undefined },
26
- width: { type: String, default: "30rem" },
26
+ width: { type: String as PropType<ModalWidth>, default: "30rem" },
27
27
  },
28
28
  emits: {
29
29
  "update:modelValue": (_v: boolean) => true,
@@ -1,6 +1,6 @@
1
1
  import { computed, defineComponent, nextTick, ref, watch, type PropType } from "vue";
2
2
  import { Copy, Eraser, Pause, Play, ScrollText } from "lucide-vue-next";
3
- import { HModal, HScrollContainer, useClipboard } from "@celestia-island/hikari";
3
+ import { HModal, HScrollContainer, useClipboard, type ModalWidth } from "@celestia-island/hikari";
4
4
 
5
5
  import { useI18n } from "../i18n/context";
6
6
 
@@ -47,7 +47,7 @@ export const HkLogWindow = defineComponent({
47
47
  /** Height of the log body (CSS length, e.g. "55vh"). */
48
48
  height: { type: String, default: "55vh" },
49
49
  title: { type: String, default: undefined },
50
- width: { type: String, default: "60rem" },
50
+ width: { type: String as PropType<ModalWidth>, default: "60rem" },
51
51
  },
52
52
  emits: {
53
53
  "update:modelValue": (_v: boolean) => true,
@@ -34,6 +34,84 @@ export interface ModalAction {
34
34
  onClick?: () => void;
35
35
  }
36
36
 
37
+ /**
38
+ * Named width presets for the `width` prop. Callers kept reaching for
39
+ * semantic sizes (`width="sm"`) which, passed through as raw CSS
40
+ * `max-width`, are silently dropped by the browser — the frame fell back
41
+ * to `width: 100%` and spanned the whole viewport (chest #662-era change
42
+ * password modal rendered 2048px wide). Named values now resolve here;
43
+ * anything else keeps passing through as a CSS max-width value.
44
+ */
45
+ export const MODAL_WIDTH_PRESETS = {
46
+ xs: "20rem",
47
+ sm: "32rem",
48
+ md: "40rem",
49
+ lg: "56rem",
50
+ xl: "72rem",
51
+ } as const;
52
+
53
+ export type ModalWidthPreset = keyof typeof MODAL_WIDTH_PRESETS;
54
+
55
+ /**
56
+ * `width` accepts a named preset or any CSS max-width value. The
57
+ * `(string & {})` arm keeps preset autocomplete in TSX while still
58
+ * allowing arbitrary lengths ("32rem", "560px", "50%").
59
+ */
60
+ export type ModalWidth = ModalWidthPreset | (string & {});
61
+
62
+ const warnedWidths = new Set<string>();
63
+ /** Dev-only warn-once memory cap: a session feeding dynamically generated
64
+ * garbage widths must not grow the set forever. Prod never reaches this
65
+ * code (DEV is statically false there). */
66
+ const MAX_WARNED_WIDTHS = 50;
67
+
68
+ /** Valid digit-free CSS max-width keywords: the browser honors these, so
69
+ * the "would span the viewport" dev warn must stay silent for them. */
70
+ const WIDTH_KEYWORDS = new Set([
71
+ "auto",
72
+ "none",
73
+ "min-content",
74
+ "max-content",
75
+ "fit-content",
76
+ ]);
77
+
78
+ /**
79
+ * Resolve the `width` prop to a concrete CSS max-width value: presets map
80
+ * through `MODAL_WIDTH_PRESETS`, everything else passes through untouched.
81
+ * In dev, a value that is neither a preset, a keyword, nor a length (no
82
+ * digit anywhere — e.g. a stray token like `"wide"`) warns once: as a raw
83
+ * max-width it would be dropped by the browser and the modal would span
84
+ * the viewport.
85
+ */
86
+ export function resolveModalWidth(width: string): string {
87
+ // Coerce defensively: a JS consumer passing width={560} skips the prop
88
+ // type check at runtime and Vue would have rendered the number as px.
89
+ const value = String(width);
90
+ // hasOwnProperty.call instead of Object.hasOwn (ES2022): the package
91
+ // ships TS source, so consumers' bundlers will not polyfill new
92
+ // built-ins — this must not raise the runtime floor. The own-property
93
+ // guard is what keeps inherited members ("constructor", "toString")
94
+ // from resolving as presets past the dev warn.
95
+ const key = value.trim();
96
+ const preset = Object.prototype.hasOwnProperty.call(MODAL_WIDTH_PRESETS, key)
97
+ ? MODAL_WIDTH_PRESETS[key as ModalWidthPreset]
98
+ : undefined;
99
+ if (preset !== undefined) return preset;
100
+ if (
101
+ import.meta.env?.DEV &&
102
+ !/\d/.test(value) &&
103
+ !WIDTH_KEYWORDS.has(key) &&
104
+ warnedWidths.size < MAX_WARNED_WIDTHS &&
105
+ !warnedWidths.has(value)
106
+ ) {
107
+ warnedWidths.add(value);
108
+ console.warn(
109
+ `[HkModal] width "${value}" is neither a named preset (${Object.keys(MODAL_WIDTH_PRESETS).join("/")}) nor a CSS length — the browser would drop it and the modal would span the viewport.`,
110
+ );
111
+ }
112
+ return value;
113
+ }
114
+
37
115
  const FOCUSABLE_SELECTOR =
38
116
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
39
117
 
@@ -44,7 +122,8 @@ export default defineComponent({
44
122
  modelValue: { type: Boolean, required: true },
45
123
  title: { type: String, default: undefined },
46
124
  closable: { type: Boolean, default: true },
47
- width: { type: String, default: "32rem" },
125
+ /** Named preset ("xs"…"xl") or any CSS max-width value (see ModalWidth). */
126
+ width: { type: String as PropType<ModalWidth>, default: "32rem" },
48
127
  /**
49
128
  * Escape hatch: extra class appended to the .hk-modal-content frame
50
129
  * so hosts can restyle the surface without forking the modal (e.g.
@@ -114,7 +193,7 @@ export default defineComponent({
114
193
 
115
194
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
116
195
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
117
- const resolvedWidth = computed(() => props.width);
196
+ const resolvedWidth = computed(() => resolveModalWidth(props.width));
118
197
  // Layer/dialog name: the explicit surface name wins over the header
119
198
  // title (which header-less surfaces never pass).
120
199
  const resolvedSurfaceName = computed(() => props.surfaceTitle ?? props.title);
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal, { MODAL_WIDTH_PRESETS, resolveModalWidth } from "./HkModal";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(async () => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe("resolveModalWidth", () => {
16
+ it("maps every named preset to its CSS max-width value", () => {
17
+ for (const [name, css] of Object.entries(MODAL_WIDTH_PRESETS)) {
18
+ expect(resolveModalWidth(name)).toBe(css);
19
+ }
20
+ });
21
+
22
+ it("keeps the default-sized presets bounded and ordered", () => {
23
+ // Sanity on the scale itself: xs < sm(default) < md < lg < xl.
24
+ expect(MODAL_WIDTH_PRESETS.xs).toBe("20rem");
25
+ expect(MODAL_WIDTH_PRESETS.sm).toBe("32rem");
26
+ expect(MODAL_WIDTH_PRESETS.md).toBe("40rem");
27
+ expect(MODAL_WIDTH_PRESETS.lg).toBe("56rem");
28
+ expect(MODAL_WIDTH_PRESETS.xl).toBe("72rem");
29
+ });
30
+
31
+ it("passes arbitrary CSS max-width values through untouched", () => {
32
+ expect(resolveModalWidth("32rem")).toBe("32rem");
33
+ expect(resolveModalWidth("560px")).toBe("560px");
34
+ expect(resolveModalWidth("50%")).toBe("50%");
35
+ expect(resolveModalWidth("min(90vw, 48rem)")).toBe("min(90vw, 48rem)");
36
+ });
37
+
38
+ it("passes digit-free max-width keywords through without warning", () => {
39
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
40
+ for (const keyword of ["auto", "none", "min-content", "max-content", "fit-content"]) {
41
+ expect(resolveModalWidth(keyword)).toBe(keyword);
42
+ }
43
+ expect(warn).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("coerces a numeric width from JS consumers instead of throwing", () => {
47
+ expect(resolveModalWidth(560 as unknown as string)).toBe("560");
48
+ });
49
+
50
+ it("does not inherit Object.prototype members as presets", () => {
51
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
52
+ // A bare index lookup would resolve "constructor"/"toString" to
53
+ // inherited functions and early-return them past the warn — the
54
+ // guard must treat them as unknown values instead.
55
+ for (const hostile of ["constructor", "toString", "valueOf"]) {
56
+ expect(resolveModalWidth(hostile)).toBe(hostile);
57
+ }
58
+ expect(warn).toHaveBeenCalledTimes(3);
59
+ });
60
+
61
+ it("trims surrounding whitespace before the preset lookup", () => {
62
+ expect(resolveModalWidth(" sm ")).toBe("32rem");
63
+ expect(resolveModalWidth(" 560px ")).toBe(" 560px ");
64
+ });
65
+
66
+ it("warns once in dev for a value that is neither a preset nor a length", () => {
67
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
68
+ // A unique digit-free token per run: the warn-once set is module
69
+ // state, and any digit would classify the value as a length.
70
+ const stray = `wide-${Math.random().toString(36).slice(2).replace(/\d/g, "") || "wide"}`;
71
+ expect(resolveModalWidth(stray)).toBe(stray);
72
+ expect(resolveModalWidth(stray)).toBe(stray);
73
+ expect(warn).toHaveBeenCalledTimes(1);
74
+ expect(warn.mock.calls[0]?.[0]).toContain(stray);
75
+ });
76
+ });
77
+
78
+ describe("HkModal width rendering", () => {
79
+ async function mountModal(width: string): Promise<HTMLElement> {
80
+ const container = document.createElement("div");
81
+ document.body.appendChild(container);
82
+ containers.push(container);
83
+
84
+ const open = ref(true);
85
+ const Wrapper = defineComponent({
86
+ setup() {
87
+ return () =>
88
+ h(HkModal, {
89
+ modelValue: open.value,
90
+ width,
91
+ }, { default: () => h("div", "content") });
92
+ },
93
+ });
94
+ const app = createApp(Wrapper);
95
+ mounts.push(app);
96
+ app.mount(container);
97
+ await nextTick();
98
+
99
+ // HkModal Teleports to document.body — the mount container stays empty.
100
+ const content = document.body.querySelector<HTMLElement>(".hk-modal-content");
101
+ expect(content).toBeTruthy();
102
+ return content!;
103
+ }
104
+
105
+ it("renders a named preset as the frame's max-width", async () => {
106
+ const content = await mountModal("sm");
107
+ expect(content.style.maxWidth).toBe("32rem");
108
+ });
109
+
110
+ it("renders an arbitrary CSS length as the frame's max-width", async () => {
111
+ const content = await mountModal("560px");
112
+ expect(content.style.maxWidth).toBe("560px");
113
+ });
114
+ });
@@ -1,5 +1,5 @@
1
- import { computed, defineComponent, onBeforeUnmount, ref, watch } from "vue";
2
- import { HMarkdownRenderer, HModal, useClipboard, type ModalAction } from "@celestia-island/hikari";
1
+ import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
2
+ import { HMarkdownRenderer, HModal, useClipboard, type ModalAction, type ModalWidth } from "@celestia-island/hikari";
3
3
 
4
4
  import { useI18n } from "../i18n/context";
5
5
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
@@ -29,7 +29,7 @@ export const HkProtocolModal = defineComponent({
29
29
  declineLabel: { type: String, default: undefined },
30
30
  /** Allow dismissing without a decision (overlay/ESC/X). Default true. */
31
31
  closable: { type: Boolean, default: true },
32
- width: { type: String, default: "48rem" },
32
+ width: { type: String as PropType<ModalWidth>, default: "48rem" },
33
33
  /** Cap the scroll body height (e.g. "60vh"). */
34
34
  bodyHeight: { type: String, default: undefined },
35
35
  },
@@ -0,0 +1,128 @@
1
+ // HkTitleBar — hybrid-shell caption bar.
2
+ //
3
+ // Colors ride the theme channels (--color-*) so the bar follows whatever
4
+ // preset/mode the host app is in. Drag is pure CSS app-region (Tauri and
5
+ // Electron both honor it); interactive children opt out with no-drag.
6
+ // Text selection is chrome-disabled across the whole bar.
7
+
8
+ .hk-titlebar {
9
+ --hk-tb-height: 32px;
10
+ --hk-tb-bg: color-mix(in srgb, rgb(var(--color-surface, 240 244 248)) 80%, transparent);
11
+ --hk-tb-border: rgb(var(--color-border, 100 100 100) / 0.25);
12
+ --hk-tb-fg: rgb(var(--color-text-secondary, 102 102 102));
13
+ --hk-tb-fg-strong: rgb(var(--color-text, 30 30 30));
14
+ --hk-tb-hover: rgb(var(--color-primary, 122 162 247) / 0.12);
15
+ --hk-tb-active: rgb(var(--color-primary, 122 162 247) / 0.2);
16
+ --hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
17
+ --hk-tb-close-hover: #e81123;
18
+ --hk-tb-close-active: #f1707a;
19
+ --hk-tb-radius-close: 0 8px 0 0;
20
+
21
+ position: fixed;
22
+ top: 0;
23
+ left: 0;
24
+ right: 0;
25
+ height: var(--hk-tb-height);
26
+ display: flex;
27
+ align-items: center;
28
+ z-index: var(--z-titlebar, 100001);
29
+ user-select: none;
30
+ -webkit-user-select: none;
31
+ background: var(--hk-tb-bg);
32
+ backdrop-filter: blur(16px);
33
+ border-bottom: 1px solid var(--hk-tb-border);
34
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
35
+ -webkit-app-region: drag;
36
+ app-region: drag;
37
+ }
38
+
39
+ .hk-titlebar-title {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 8px;
43
+ padding-left: 12px;
44
+ white-space: nowrap;
45
+ cursor: default;
46
+ }
47
+
48
+ .hk-titlebar-icon {
49
+ width: 16px;
50
+ height: 16px;
51
+ display: block;
52
+ pointer-events: none;
53
+ }
54
+
55
+ .hk-titlebar-title-text {
56
+ font-size: 11px;
57
+ font-weight: 600;
58
+ letter-spacing: 0.04em;
59
+ color: var(--hk-tb-fg-strong);
60
+ }
61
+
62
+ .hk-titlebar-subtitle {
63
+ font-size: 10px;
64
+ color: var(--hk-tb-fg);
65
+ opacity: 0.7;
66
+ padding-left: 8px;
67
+ white-space: nowrap;
68
+ }
69
+
70
+ .hk-titlebar-spacer {
71
+ flex: 1;
72
+ }
73
+
74
+ .hk-titlebar-actions {
75
+ -webkit-app-region: no-drag;
76
+ app-region: no-drag;
77
+ display: flex;
78
+ align-items: center;
79
+ height: 100%;
80
+ }
81
+
82
+ .hk-titlebar-btn {
83
+ width: 46px;
84
+ height: var(--hk-tb-height);
85
+ border: none;
86
+ background: transparent;
87
+ color: var(--hk-tb-fg);
88
+ cursor: pointer;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ transition: background-color 0.12s ease, color 0.12s ease;
93
+ outline: none;
94
+ padding: 0;
95
+ }
96
+
97
+ .hk-titlebar-btn:hover {
98
+ background: var(--hk-tb-hover);
99
+ color: var(--hk-tb-fg-strong);
100
+ }
101
+
102
+ .hk-titlebar-btn:active {
103
+ background: var(--hk-tb-active);
104
+ }
105
+
106
+ /* Only the rightmost (close) button carries the window's top-right
107
+ corner radius; the other captions highlight as plain rectangles. */
108
+ .hk-titlebar-btn-close {
109
+ border-radius: 0 8px 0 0;
110
+ }
111
+
112
+ .hk-titlebar-btn-close:hover {
113
+ background: var(--hk-tb-close-hover);
114
+ color: #fff;
115
+ }
116
+
117
+ .hk-titlebar-btn-close:active {
118
+ background: var(--hk-tb-close-active);
119
+ color: #fff;
120
+ }
121
+
122
+ .hk-titlebar-btn:focus-visible {
123
+ outline: 2px solid var(--hk-tb-focus);
124
+ outline-offset: -2px;
125
+ }
126
+
127
+ /* Host apps offset their own root container below the bar:
128
+ #app { position: absolute; top: var(--hk-tb-height); … } */
@@ -0,0 +1,132 @@
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
+
3
+ import "./HkTitleBar.scss";
4
+
5
+ /**
6
+ * HkTitleBar — hybrid-shell window caption bar.
7
+ *
8
+ * A pure visual + event component: it renders the bar (app icon, title,
9
+ * subtitle, draggable surface, right-side caption buttons) and EMITS
10
+ * `minimize` / `toggle-maximize` / `close` / custom-button clicks. It
11
+ * never touches a shell API itself — the host (Tauri, Electron, …) wires
12
+ * those events to its own window controls, keeping hikari dependency-free.
13
+ *
14
+ * Dragging is pure CSS (`app-region: drag` on the bar, `no-drag` on the
15
+ * interactive children) — supported by Tauri (WebView2/WKWebView/WebKitGTK)
16
+ * and Electron alike; no JS drag logic needed.
17
+ *
18
+ * Text selection is disabled across the bar (captions are chrome, not
19
+ * content).
20
+ *
21
+ * Buttons are selective: `showMinimize` / `showMaximize` / `showClose`
22
+ * (defaults true/true/true) and `customActions` render extra icon buttons
23
+ * to the LEFT of minimize, each emitting `action` with its id.
24
+ *
25
+ * The maximized state is data-driven: the host passes `maximized` and the
26
+ * component swaps the maximize/restore glyph — no shell probing inside.
27
+ */
28
+ export default defineComponent({
29
+ name: "HkTitleBar",
30
+ props: {
31
+ title: { type: String, default: "" },
32
+ subtitle: { type: String, default: "" },
33
+ /** App icon rendered left of the title (img src or inline node). */
34
+ icon: { type: String, default: "" },
35
+ maximized: { type: Boolean, default: false },
36
+ showMinimize: { type: Boolean, default: true },
37
+ showMaximize: { type: Boolean, default: true },
38
+ showClose: { type: Boolean, default: true },
39
+ /** Extra icon buttons rendered left of minimize. */
40
+ customActions: {
41
+ type: Array as () => { id: string; label: string; icon?: unknown }[],
42
+ default: () => [],
43
+ },
44
+ },
45
+ emits: {
46
+ minimize: () => true,
47
+ "toggle-maximize": () => true,
48
+ close: () => true,
49
+ /** A custom action button was clicked (id from `customActions`). */
50
+ action: (_id: string) => true,
51
+ },
52
+ setup(props, { emit, slots }) {
53
+ return () => (
54
+ <div class="hk-titlebar" data-drag-region>
55
+ {slots.left?.() ?? (
56
+ <span class="hk-titlebar-title">
57
+ {props.icon && <img class="hk-titlebar-icon" src={props.icon} alt="" />}
58
+ <span class="hk-titlebar-title-text">{props.title}</span>
59
+ {props.subtitle && (
60
+ <span class="hk-titlebar-subtitle">{props.subtitle}</span>
61
+ )}
62
+ </span>
63
+ )}
64
+ <span class="hk-titlebar-spacer" />
65
+ <div class="hk-titlebar-actions">
66
+ {slots.actions?.()}
67
+ {props.customActions.map((a) => (
68
+ <button
69
+ key={a.id}
70
+ type="button"
71
+ class="hk-titlebar-btn"
72
+ title={a.label}
73
+ aria-label={a.label}
74
+ onClick={(e: MouseEvent) => {
75
+ e.stopPropagation();
76
+ emit("action", a.id);
77
+ }}
78
+ >
79
+ {a.icon}
80
+ </button>
81
+ ))}
82
+ {props.showMinimize && (
83
+ <button
84
+ type="button"
85
+ class="hk-titlebar-btn"
86
+ title="Minimize"
87
+ aria-label="Minimize"
88
+ onClick={(e: MouseEvent) => {
89
+ e.stopPropagation();
90
+ emit("minimize");
91
+ }}
92
+ >
93
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 12h14" /></svg>
94
+ </button>
95
+ )}
96
+ {props.showMaximize && (
97
+ <button
98
+ type="button"
99
+ class="hk-titlebar-btn"
100
+ title={props.maximized ? "Restore" : "Maximize"}
101
+ aria-label={props.maximized ? "Restore" : "Maximize"}
102
+ onClick={(e: MouseEvent) => {
103
+ e.stopPropagation();
104
+ emit("toggle-maximize");
105
+ }}
106
+ >
107
+ {props.maximized ? (
108
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="8" width="13" height="13" rx="2" /><path d="M8 8V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-3" /></svg>
109
+ ) : (
110
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="4" width="16" height="16" rx="2" /></svg>
111
+ )}
112
+ </button>
113
+ )}
114
+ {props.showClose && (
115
+ <button
116
+ type="button"
117
+ class="hk-titlebar-btn hk-titlebar-btn-close"
118
+ title="Close"
119
+ aria-label="Close"
120
+ onClick={(e: MouseEvent) => {
121
+ e.stopPropagation();
122
+ emit("close");
123
+ }}
124
+ >
125
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
126
+ </button>
127
+ )}
128
+ </div>
129
+ </div>
130
+ );
131
+ },
132
+ });
package/src/index.ts CHANGED
@@ -79,6 +79,7 @@ export { default as HWindowedItem } from "./components/HkWindowedItem";
79
79
  export { default as HDateTimePicker } from "./components/HkDateTimePicker";
80
80
  export { default as HDatePicker } from "./components/HkDatePicker";
81
81
  export { default as HTimeline } from "./components/HkTimeline";
82
+ export { default as HTitleBar } from "./components/HkTitleBar";
82
83
  export { default as HStepFlow } from "./components/HkStepFlow";
83
84
  export type { StepFlowSlotProps } from "./components/HkStepFlow";
84
85
 
@@ -114,6 +115,11 @@ export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
114
115
  export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
115
116
  export { type BoardCamera, type BoardRect, type BoardViewport } from "./utils/boardCamera";
116
117
  export { type ModalAction } from "./components/HkModal";
118
+ export {
119
+ type ModalWidth,
120
+ type ModalWidthPreset,
121
+ resolveModalWidth,
122
+ } from "./components/HkModal";
117
123
  export { type TreeNode, type TreeSize, type TreeRowScope } from "./components/HkTree";
118
124
  export { type DragListItem } from "./components/HkDraggableList";
119
125
  export { type GridItem } from "./components/HkDraggableGrid";
@@ -0,0 +1,162 @@
1
+ // Vendored from packages/vue/src/tokens.scss so npm registry installs can
2
+ // resolve it — the exports map only exposes ./styles/*, and consumers that
3
+ // build their own shell (non-chest webuis) need the --color-* channel
4
+ // defaults + resolved --hi-* aliases at CSS level, before the runtime
5
+ // (initTheme) injects inline overrides. Edit the upstream file, then
6
+ // re-copy; do not hand-edit here.
7
+
8
+ /* ── Syntax highlighting palette family: --code-* ───────────────────
9
+ * Token names consumed by hikari's highlighted-code surfaces (the
10
+ * HkToolBlock syntax layer and its JSON viewer, see HkToolBlock.scss).
11
+ * The VALUES are intentionally NOT defined here: the family is owned by
12
+ * the host theme, which maps each token to mode-aware colors. The
13
+ * canonical mapping (dark = One Dark, light = GitHub Light) lives in the
14
+ * celestia webui theme.scss; components reference the tokens with their
15
+ * original One Dark values as fallbacks so standalone consumers render
16
+ * unchanged. Extend this family only by adding a --code-* token with a
17
+ * fallback equal to the current visual.
18
+ * --code-keyword hljs-keyword / selector-tag (purple)
19
+ * --code-builtin hljs-built_in (purple → blue in light)
20
+ * --code-string hljs-string (green)
21
+ * --code-number hljs-number / literal / attr (orange → blue in light)
22
+ * --code-comment hljs-comment / doctag (grey, italic)
23
+ * --code-function hljs-title.function_ (blue)
24
+ * --code-type hljs-type / class title (yellow → green in light)
25
+ * --code-variable hljs-variable / template-var (red → orange in light)
26
+ */
27
+ /* hikari color aliases — fallback values for theme tokens.
28
+ These CSS variables are used by hikari components (HkButton, HkCard,
29
+ HkInput, HkSelect, etc.) and are expected to be overridden by
30
+ consuming apps via initTheme() or custom theme CSS. */
31
+ :root {
32
+ --c-primary-faint: rgb(var(--color-primary, 122 162 247) / 5%);
33
+ --c-primary-subtle: rgb(var(--color-primary, 122 162 247) / 8%);
34
+ --c-primary-dim: rgb(var(--color-primary, 122 162 247) / 10%);
35
+ --c-primary-light: rgb(var(--color-primary, 122 162 247) / 15%);
36
+ --c-primary-overlay: rgb(var(--color-primary, 122 162 247) / 15%);
37
+ --c-primary-medium: rgb(var(--color-primary, 122 162 247) / 25%);
38
+ --c-primary-strong: rgb(var(--color-primary, 122 162 247) / 40%);
39
+ --c-primary-intense: rgb(var(--color-primary, 122 162 247) / 40%);
40
+ --c-error-dim: rgb(var(--color-error, 247 118 142) / 10%);
41
+ --c-error-overlay: rgb(var(--color-error, 247 118 142) / 20%);
42
+ --c-error-strong: rgb(var(--color-error, 247 118 142) / 50%);
43
+ --shadow-elevated: 0 8px 32px rgb(0 0 0 / 15%);
44
+ --shadow-dropdown: 0 4px 24px rgb(0 0 0 / 12%), 0 0 0 1px rgb(255 255 255 / 4%);
45
+ --shadow-button: 0 4px 14px rgb(var(--color-primary, 122 162 247) / 35%);
46
+ --shadow-button-danger: 0 4px 14px rgb(var(--color-error, 247 118 142) / 35%);
47
+ --shadow-focus: 0 0 0 3px rgb(var(--color-primary, 122 162 247) / 12%);
48
+ --shadow-focus-error: 0 0 0 3px rgb(var(--color-error, 247 118 142) / 12%);
49
+ --border-input: rgb(var(--color-border, 100 100 100) / 15%);
50
+ --border-faint: rgb(var(--color-border, 100 100 100) / 10%);
51
+ --border-subtle: rgb(var(--color-border, 100 100 100) / 12%);
52
+
53
+ /* Icon-button state bridge — HkIconButtonVars references this vocabulary
54
+ but nothing ever defined it, so every var() without an in-file fallback
55
+ was invalid at computed-value time: non-ghost variants had no active
56
+ background, focus rings and glow vanished, and one missing item even
57
+ invalidated the whole transition shorthand (all icon buttons lost their
58
+ hover/press animation). Defined once here as live expressions over the
59
+ theme triplets so they track whichever palette is active; the -dark
60
+ variants are the pressed-step of their base slot. */
61
+ --hi-icon-color: rgb(var(--color-text, 30 30 30));
62
+ --hi-icon-size-xs: 0.75rem;
63
+ --hi-icon-size-sm: 1rem;
64
+ --hi-icon-size-md: 1.25rem;
65
+ --hi-icon-size-lg: 1.5rem;
66
+ --hi-duration-instant: var(--duration-instant, 0.1s);
67
+ --hi-border-color-focus: rgb(var(--color-primary, 122 162 247));
68
+ --hi-glow-color: transparent;
69
+ --hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
70
+ --hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
71
+ --hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
72
+ --hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 200 50 50)) 82%, #000);
73
+ --hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 0 150 0)) 82%, #000);
74
+ --hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
75
+ --hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
76
+ --hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));
77
+ }
78
+
79
+ /* Static default light theme — renders correctly even without initTheme().
80
+ initTheme() overrides these at runtime via inline styles on
81
+ documentElement, which beat stylesheet :root declarations. */
82
+ :root {
83
+ --color-primary: 122 162 247;
84
+ --color-on-primary: 255 255 255;
85
+ --color-on-solid: 255 255 255;
86
+ --color-on-solid-text: 255 255 255;
87
+ --color-on-solid-icon: 255 255 255;
88
+ --color-secondary: 81 154 115;
89
+ --color-accent: 247 181 0;
90
+ --color-text: 30 30 30;
91
+ --color-text-secondary: 102 102 102;
92
+ --color-text-tertiary: 140 140 140;
93
+ --color-muted: 108 108 108;
94
+ --color-border: 100 100 100;
95
+ --color-focused-border: 122 162 247;
96
+ --color-background: 214 236 240;
97
+ --color-surface: 240 244 248;
98
+ --color-success: 14 184 64;
99
+ --color-error: 247 118 142;
100
+ --color-warning: 238 214 119;
101
+ --color-info: 106 190 214;
102
+
103
+ --hi-color-primary: rgb(var(--color-primary));
104
+ --hi-color-secondary: rgb(var(--color-secondary));
105
+ --hi-color-surface: rgb(var(--color-surface));
106
+ --hi-color-background: rgb(var(--color-background));
107
+ --hi-color-border: rgb(var(--color-border));
108
+ --hi-color-text-primary: rgb(var(--color-text));
109
+ --hi-color-text-secondary: rgb(var(--color-text-secondary));
110
+ --hi-color-muted: rgb(var(--color-muted));
111
+ --hi-color-on-primary: rgb(var(--color-on-primary));
112
+ /* Deprecated in-tree: luminance-computed contrast, kept only for external
113
+ consumers. In-tree components use the on-solid slots instead. */
114
+ --hi-color-text-on-primary: rgb(var(--color-on-primary));
115
+ --hi-color-text-on-solid: rgb(var(--color-on-solid-text, 255 255 255));
116
+ --hi-color-icon-on-solid: rgb(var(--color-on-solid-icon, 255 255 255));
117
+ --hi-color-success: rgb(var(--color-success));
118
+ --hi-color-error: rgb(var(--color-error));
119
+ --hi-color-warning: rgb(var(--color-warning));
120
+ --hi-color-info: rgb(var(--color-info));
121
+
122
+ /* Design tokens consumed by hikari components. These used to live only in
123
+ plana-ui's token set; without them the background/backdrop declarations
124
+ of fields and surfaces resolve to nothing. Values align with plana-ui. */
125
+ --space-2: 0.125rem;
126
+ --space-4: 0.25rem;
127
+ --space-6: 0.375rem;
128
+ --space-8: 0.5rem;
129
+ --space-10: 0.625rem;
130
+ --space-12: 0.75rem;
131
+ --space-14: 0.875rem;
132
+ --space-16: 1rem;
133
+ --space-20: 1.25rem;
134
+ --space-24: 1.5rem;
135
+ --space-28: 1.75rem;
136
+ --space-32: 2rem;
137
+ --space-40: 2.5rem;
138
+ --text-2xs: 0.625rem;
139
+ --text-xs: 0.75rem;
140
+ --text-sm: 0.8125rem;
141
+ --text-base: 0.875rem;
142
+ --text-md: 1rem;
143
+ --text-lg: 1.125rem;
144
+ --radius-sm: 4px;
145
+ --radius-md: 8px;
146
+ --opacity-half: 0.92;
147
+ --blur-sm: 8px;
148
+ --blur-md: 16px;
149
+ --blur-lg: 24px;
150
+ --duration-fast: 0.15s;
151
+ --duration-short: 0.15s;
152
+ --duration-normal: 0.3s;
153
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
154
+ --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
155
+ --ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
156
+ --s-footer-height: 3rem;
157
+ --z-header: 30;
158
+ --z-sidebar: 40;
159
+ /* Apple-style stack + CJK UI tail — keep in sync with packages/vue/src/theme/fontContext.ts */
160
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
161
+ --font-reading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
162
+ }