@celestia-island/hikari 0.55.27 → 0.55.28

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.27",
3
+ "version": "0.55.28",
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",
@@ -14,8 +14,10 @@
14
14
  align-items: center;
15
15
  gap: var(--space-4, 4px);
16
16
 
17
- /* ── page anchor: absolute bottom-center of the nearest positioned
18
- * ancestor, resting --hk-dock-inset above its content floor. */
17
+ /* ── page anchor (SOUTH edge): absolute bottom-center of the nearest
18
+ * positioned ancestor, resting --hk-dock-inset above its content
19
+ * floor. `bottom` below is the compass spelling of the same
20
+ * geometry. */
19
21
  &[data-anchor="page"] {
20
22
  position: absolute;
21
23
  left: 50%;
@@ -31,6 +33,42 @@
31
33
  margin: 0 auto var(--hk-dock-inset, 12px);
32
34
  }
33
35
 
36
+ /* ── horizontal edge anchors: north and the compass spelling of the
37
+ * south edge — absolute, centered along the edge. */
38
+ &[data-anchor="top"],
39
+ &[data-anchor="bottom"] {
40
+ position: absolute;
41
+ left: 50%;
42
+ transform: translateX(-50%);
43
+ z-index: 50;
44
+ }
45
+
46
+ &[data-anchor="top"] {
47
+ top: var(--hk-dock-inset, 12px);
48
+ }
49
+
50
+ &[data-anchor="bottom"] {
51
+ bottom: var(--hk-dock-inset, 12px);
52
+ }
53
+
54
+ /* ── vertical edge anchors (west / east): the dock runs vertically,
55
+ * centered on the edge midpoint. */
56
+ &[data-anchor="left"],
57
+ &[data-anchor="right"] {
58
+ position: absolute;
59
+ top: 50%;
60
+ transform: translateY(-50%);
61
+ z-index: 50;
62
+ }
63
+
64
+ &[data-anchor="left"] {
65
+ left: var(--hk-dock-inset, 12px);
66
+ }
67
+
68
+ &[data-anchor="right"] {
69
+ right: var(--hk-dock-inset, 12px);
70
+ }
71
+
34
72
  /* ── corner anchors: absolute attachment with the same inset, for
35
73
  * pagers, toolbars and HUD panels that live off-center. */
36
74
  &[data-anchor="top-left"],
@@ -73,6 +111,7 @@
73
111
  align-items: stretch;
74
112
  width: var(--dock-width, auto);
75
113
  max-width: var(--dock-max-width, calc(100vw - var(--space-16, 16px)));
114
+ max-height: var(--dock-max-height, none);
76
115
  padding: var(--dock-padding, var(--space-8, 8px) var(--space-12, 12px));
77
116
  background: rgb(var(--color-surface));
78
117
  border: 1px solid var(--border-subtle);
@@ -82,6 +121,14 @@
82
121
  box-shadow: var(--glow-dock);
83
122
  }
84
123
 
124
+ /* Vertical side docks (west / east edges) stack along the viewport's
125
+ * tall axis — cap them viewport-aware by default so a tall dock never
126
+ * runs off-screen; --dock-max-height still overrides. */
127
+ .hk-dock-bar[data-anchor="left"] .hk-dock-bar-surface,
128
+ .hk-dock-bar[data-anchor="right"] .hk-dock-bar-surface {
129
+ max-height: var(--dock-max-height, calc(100dvh - var(--space-16, 16px)));
130
+ }
131
+
85
132
  /* Glass surface (default) — blur the page canvas behind the dock. */
86
133
  .hk-dock-bar[data-surface="glass"] .hk-dock-bar-surface {
87
134
  backdrop-filter: blur(var(--blur-lg));
@@ -1,4 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { createApp, defineComponent, h, nextTick } from "vue";
3
6
 
4
7
  import HkDockBar from "./HkDockBar";
@@ -49,23 +52,34 @@ describe("HkDockBar", () => {
49
52
  expect(root.dataset.surface).toBe("glass");
50
53
  });
51
54
 
52
- it.each(["plane", "top-left", "top-right", "bottom-left", "bottom-right"] as const)(
53
- "accepts the %s anchor",
54
- async (anchor) => {
55
- const root = await mountDock({ anchor });
56
- expect(root.dataset.anchor).toBe(anchor);
57
- },
58
- );
55
+ it.each(
56
+ [
57
+ "plane",
58
+ "top",
59
+ "bottom",
60
+ "left",
61
+ "right",
62
+ "top-left",
63
+ "top-right",
64
+ "bottom-left",
65
+ "bottom-right",
66
+ ] as const,
67
+ )("accepts the %s anchor", async (anchor) => {
68
+ const root = await mountDock({ anchor });
69
+ expect(root.dataset.anchor).toBe(anchor);
70
+ });
59
71
 
60
- it("exposes style hooks for width, max-width and padding", async () => {
72
+ it("exposes style hooks for width, max-width, max-height and padding", async () => {
61
73
  const root = await mountDock({
62
74
  width: "22rem",
63
75
  maxWidth: "30rem",
76
+ maxHeight: "60vh",
64
77
  padding: "4px 6px",
65
78
  });
66
79
  const style = root.style;
67
80
  expect(style.getPropertyValue("--dock-width")).toBe("22rem");
68
81
  expect(style.getPropertyValue("--dock-max-width")).toBe("30rem");
82
+ expect(style.getPropertyValue("--dock-max-height")).toBe("60vh");
69
83
  expect(style.getPropertyValue("--dock-padding")).toBe("4px 6px");
70
84
  });
71
85
 
@@ -74,3 +88,116 @@ describe("HkDockBar", () => {
74
88
  expect(root.dataset.surface).toBe("solid");
75
89
  });
76
90
  });
91
+
92
+ // ── Anchor geometry contract ──────────────────────────────────────────────
93
+ // SCSS source contract (house pattern, cf. HkButton icon-only contract):
94
+ // happy-dom does not lay out, so each compass direction's geometry is
95
+ // pinned textually against HkDockBar.scss. Dropping any one direction's
96
+ // rule — or regressing its mapping (inset side / centering transform) —
97
+ // turns the matching assertion red.
98
+
99
+ const here = dirname(fileURLToPath(import.meta.url));
100
+ const scss = readFileSync(join(here, "HkDockBar.scss"), "utf8");
101
+
102
+ /** Every SCSS rule body whose selector list mentions the anchor. */
103
+ function rulesFor(anchor: string): string {
104
+ const re = new RegExp(`&\\[data-anchor="${anchor}"\\][^{]*\\{[^}]*\\}`, "g");
105
+ return scss.match(re)?.join("\n") ?? "";
106
+ }
107
+
108
+ describe("HkDockBar anchor geometry contract", () => {
109
+ const INSET = "var(--hk-dock-inset, 12px)";
110
+
111
+ it.each(["page", "bottom"] as const)(
112
+ "%s docks to the south edge, centered (the two spellings share geometry)",
113
+ (anchor) => {
114
+ const rules = rulesFor(anchor);
115
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
116
+ expect(rules).toContain("position: absolute");
117
+ expect(rules).toContain("left: 50%");
118
+ expect(rules).toContain(`bottom: ${INSET}`);
119
+ expect(rules).toContain("transform: translateX(-50%)");
120
+ },
121
+ );
122
+
123
+ it("top docks to the north edge, centered", () => {
124
+ const rules = rulesFor("top");
125
+ expect(rules, "top rules must exist").toBeTruthy();
126
+ expect(rules).toContain("position: absolute");
127
+ expect(rules).toContain("top: " + INSET);
128
+ expect(rules).toContain("left: 50%");
129
+ expect(rules).toContain("transform: translateX(-50%)");
130
+ expect(rules).not.toContain(`bottom: ${INSET}`);
131
+ });
132
+
133
+ it.each(["left", "right"] as const)(
134
+ "%s docks to the vertical edge midpoint, centered by translateY",
135
+ (anchor) => {
136
+ const rules = rulesFor(anchor);
137
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
138
+ expect(rules).toContain("position: absolute");
139
+ expect(rules).toContain("top: 50%");
140
+ expect(rules).toContain(`${anchor}: ${INSET}`);
141
+ expect(rules).toContain("transform: translateY(-50%)");
142
+ // A side dock must not pick up the horizontal-edge centering.
143
+ expect(rules).not.toContain("transform: translateX(-50%)");
144
+ },
145
+ );
146
+
147
+ it.each(
148
+ [
149
+ ["top-left", "top", "left", "flex-start"],
150
+ ["top-right", "top", "right", "flex-end"],
151
+ ["bottom-left", "bottom", "left", "flex-start"],
152
+ ["bottom-right", "bottom", "right", "flex-end"],
153
+ ] as const,
154
+ )("%s docks into its corner with both insets", (anchor, side1, side2, align) => {
155
+ const rules = rulesFor(anchor);
156
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
157
+ expect(rules).toContain("position: absolute");
158
+ expect(rules).toContain(`${side1}: ${INSET}`);
159
+ expect(rules).toContain(`${side2}: ${INSET}`);
160
+ expect(rules).toContain(`align-items: ${align}`);
161
+ });
162
+
163
+ it("page and plane stay semantically distinct: page floats, plane flows", () => {
164
+ const page = rulesFor("page");
165
+ const plane = rulesFor("plane");
166
+ expect(page).toContain("position: absolute");
167
+ expect(plane, "plane rule must exist").toBeTruthy();
168
+ expect(plane).toContain("margin: 0 auto");
169
+ expect(plane).not.toContain("position: absolute");
170
+ });
171
+
172
+ it("consumes the size custom properties on every anchor via the base surface rule", () => {
173
+ // The anchor-agnostic base surface rule is the ONLY SCSS consumer of
174
+ // the size props for page/top/bottom/plane/corner docks — only the
175
+ // vertical side anchors get the viewport-aware cap override below.
176
+ // The style-hook tests above pin that the custom properties are set;
177
+ // this pins that the base rule still consumes them, so maxWidth /
178
+ // maxHeight cannot silently stop working off-side.
179
+ const base = scss.match(/\.hk-dock-bar-surface\s*\{[^}]*\}/)?.[0];
180
+ expect(base, "base surface rule must exist").toBeTruthy();
181
+ expect(base).toContain("width: var(--dock-width, auto)");
182
+ expect(base).toContain("max-width: var(--dock-max-width");
183
+ expect(base).toContain("max-height: var(--dock-max-height, none)");
184
+ expect(base).toContain("padding: var(--dock-padding");
185
+ });
186
+
187
+ it("caps vertical side docks viewport-aware by default", () => {
188
+ const sideCap = scss.match(
189
+ /\.hk-dock-bar\[data-anchor="left"\] \.hk-dock-bar-surface[^{]*\{[^}]*\}/,
190
+ )?.[0];
191
+ expect(sideCap, "side-anchor surface cap rule must exist").toBeTruthy();
192
+ expect(sideCap).toContain("max-height: var(--dock-max-height");
193
+ expect(sideCap).toContain("100dvh");
194
+ });
195
+
196
+ it("solid surface drops the backdrop blur in CSS, not just in data", () => {
197
+ const solid = scss.match(
198
+ /\.hk-dock-bar\[data-surface="solid"\] \.hk-dock-bar-surface\s*\{[^}]*\}/,
199
+ )?.[0];
200
+ expect(solid, "solid surface rule must exist").toBeTruthy();
201
+ expect(solid).toContain("backdrop-filter: none");
202
+ });
203
+ });
@@ -5,6 +5,10 @@ import "./HkDockBar.scss";
5
5
  export type HkDockBarAnchor =
6
6
  | "page"
7
7
  | "plane"
8
+ | "top"
9
+ | "bottom"
10
+ | "left"
11
+ | "right"
8
12
  | "top-left"
9
13
  | "top-right"
10
14
  | "bottom-left"
@@ -19,14 +23,30 @@ export type HkDockBarSurface = "glass" | "solid";
19
23
  * (surface, blur, border, radius, reverse glow) means no dock surface
20
24
  * ever re-declares its own background or shadow again.
21
25
  *
22
- * Anchors:
23
- * - `page` (default): absolute bottom-center of the nearest positioned
24
- * ancestor, `--hk-dock-inset` above its content floor.
25
- * - `plane`: in-flow — the host provides the plane (e.g. a fixed
26
- * footer band); this container centers the surface horizontally.
26
+ * Anchors — the full eight-way compass (4 edges + 4 corners):
27
+ * - `page` (default) and `bottom`: absolute bottom-center of the
28
+ * nearest positioned ancestor (the SOUTH edge), `--hk-dock-inset`
29
+ * above its content floor. `page` is the 2026-09-02 spelling kept
30
+ * for existing callers; `bottom` is its compass alias — identical
31
+ * geometry, pick either.
32
+ * - `top`: absolute top-center (north edge) with the same inset.
33
+ * - `left` / `right`: absolute middle of the west / east edge — the
34
+ * dock runs vertically and is centered with translateY; its surface
35
+ * gets a viewport-aware default max-height.
27
36
  * - `top-left` / `top-right` / `bottom-left` / `bottom-right`:
28
37
  * absolute corner attachment with the same inset, for pagers,
29
38
  * toolbars and HUD panels that live off-center.
39
+ * - `plane`: NOT a compass direction — in-flow; the host provides
40
+ * the plane (e.g. a fixed footer band) and this container centers
41
+ * the surface horizontally. Use it when the host owns positioning.
42
+ *
43
+ * Relationship to HkScrollContainer: that component's dockTop /
44
+ * dockBottom named slots are the contract for docks that live INSIDE a
45
+ * scrolling region — pinned to the scroll viewport, excluded from the
46
+ * scroll flow. HkDockBar is the carrier for docks that FLOAT over a
47
+ * canvas or page instead. Pick by host: scrolling region →
48
+ * HkScrollContainer dock slots; canvas/page overlay → HkDockBar. Do
49
+ * not nest an HkDockBar inside a scroll viewport expecting pinning.
30
50
  *
31
51
  * Surfaces:
32
52
  * - `glass` (default): translucent surface + backdrop blur.
@@ -39,8 +59,9 @@ export type HkDockBarSurface = "glass" | "solid";
39
59
  const HkDockBar = defineComponent({
40
60
  name: "HkDockBar",
41
61
  props: {
42
- /** Which plane carries the dock: page (bottom-center), a host plane
43
- * (in-flow), or one of the four corners. */
62
+ /** Which plane carries the dock: a compass anchor (4 edges + 4
63
+ * corners, `page` being the south edge), or a host plane
64
+ * (in-flow). */
44
65
  anchor: { type: String as PropType<HkDockBarAnchor>, default: "page" },
45
66
  /** Surface finish: glass (blur) or solid (moving canvas). */
46
67
  surface: { type: String as PropType<HkDockBarSurface>, default: "glass" },
@@ -48,6 +69,9 @@ const HkDockBar = defineComponent({
48
69
  width: { type: String, default: undefined },
49
70
  /** CSS max-width of the surface (content caps, e.g. minimap clearance). */
50
71
  maxWidth: { type: String, default: undefined },
72
+ /** CSS max-height of the surface; defaults to a viewport-aware cap
73
+ * on the vertical side anchors (left / right), none elsewhere. */
74
+ maxHeight: { type: String, default: undefined },
51
75
  /** CSS padding override; defaults to the shared chrome rhythm. */
52
76
  padding: { type: String, default: undefined },
53
77
  },
@@ -56,6 +80,7 @@ const HkDockBar = defineComponent({
56
80
  const s: Record<string, string> = {};
57
81
  if (props.width) s["--dock-width"] = props.width;
58
82
  if (props.maxWidth) s["--dock-max-width"] = props.maxWidth;
83
+ if (props.maxHeight) s["--dock-max-height"] = props.maxHeight;
59
84
  if (props.padding) s["--dock-padding"] = props.padding;
60
85
  return s;
61
86
  });
@@ -166,4 +166,57 @@ describe("HkSignInCard", () => {
166
166
  expect(c.querySelector(".s-auth-methods")).toBeTruthy();
167
167
  expect(c.querySelector(".methods-probe")).toBeTruthy();
168
168
  });
169
+
170
+ // ── Account-first mode (passwordField: false) ─────────────────────────
171
+
172
+ it("omits the password field entirely in account-first mode", () => {
173
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false }));
174
+ expect(usernameField(c)).toBeTruthy();
175
+ expect(passwordField(c)).toBeNull();
176
+ // No hidden input may survive either: a password-type field on a form
177
+ // that never carries a password would still trip credential-manager
178
+ // save prompts.
179
+ expect(c.querySelector('input[type="password"]')).toBeNull();
180
+ });
181
+
182
+ it("submits with an empty password once only the account name is typed", async () => {
183
+ const onSubmit = vi.fn();
184
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
185
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
186
+ // Empty username still gates the submit.
187
+ expect(btn.getAttribute("disabled")).not.toBeNull();
188
+ typeInto(usernameField(c), "demiurge");
189
+ await Promise.resolve();
190
+ expect(btn.getAttribute("disabled")).toBeNull(); // no password expected
191
+ btn.click();
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ expect(onSubmit).toHaveBeenCalledWith("demiurge", "");
195
+ });
196
+
197
+ it("does not submit on Enter with an empty username in account-first mode", async () => {
198
+ const onSubmit = vi.fn();
199
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
200
+ usernameField(c).dispatchEvent(
201
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
202
+ );
203
+ await Promise.resolve();
204
+ expect(onSubmit).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("seeds the username field from initialUsername", () => {
208
+ const c = mount(
209
+ h(HkSignInCard, { title: "T", passwordField: false, initialUsername: " demiurge " }),
210
+ );
211
+ expect((usernameField(c) as HTMLInputElement).value).toBe(" demiurge ");
212
+ // And the seeded name alone satisfies the submit guard.
213
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
214
+ expect(btn.getAttribute("disabled")).toBeNull();
215
+ });
216
+
217
+ it("keeps the password guard when passwordField is unset (default true)", () => {
218
+ const c = mount(h(HkSignInCard, { title: "T", initialUsername: "demiurge" }));
219
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
220
+ expect(btn.getAttribute("disabled")).not.toBeNull(); // password still empty
221
+ });
169
222
  });
@@ -28,6 +28,12 @@ import HkAuthSubmitButton from "./HkAuthSubmitButton";
28
28
  * - `usernamePlaceholder` / `usernameType` — override the username field
29
29
  * (e.g. email-identifier logins); the placeholder falls back to the
30
30
  * `hikari::signIn.usernamePlaceholder` locale when unset.
31
+ * - `passwordField: false` — account-first sign-in: the card collects ONLY
32
+ * the account name and `submit` fires with an empty password. The
33
+ * password itself is proven in the consumer's NEXT step (chest's
34
+ * verify-account panel), where it reappears as one factor among several.
35
+ * Pair with `initialUsername` so the typed name survives the step-flow
36
+ * round-trip (the card remounts when the user navigates back).
31
37
  * - `footer` slot — content below the submit button (remember-me,
32
38
  * protocol links, …).
33
39
  * - `methods` slot — forwarded to HkAuthCard's full-width methods block
@@ -67,21 +73,31 @@ export const HkSignInCard = defineComponent({
67
73
  usernameType: { type: String, default: "text" },
68
74
  /** Username placeholder; defaults to the hikari::signIn locale. */
69
75
  usernamePlaceholder: { type: String, default: undefined },
76
+ /** Account-first flows: hide the password field entirely — the card
77
+ * collects only the account name and `submit` fires with an empty
78
+ * password (the consumer's next step owns the actual proof). Default
79
+ * keeps the classic two-field card. */
80
+ passwordField: { type: Boolean, default: true },
81
+ /** Seed for the username field. Step flows remount this card when the
82
+ * user navigates back from a later step, so the typed account name
83
+ * must travel through a prop rather than module state. */
84
+ initialUsername: { type: String, default: "" },
70
85
  },
71
86
  emits: {
72
- /** Fired on explicit click or Enter; never with empty fields or while busy. */
87
+ /** Fired on explicit click or Enter; never with an empty username (or
88
+ * an empty password while `passwordField` is on) or while busy. */
73
89
  submit: (_username: string, _password: string) => true,
74
90
  },
75
91
  setup(props, { emit, slots }) {
76
92
  const { t } = useI18n();
77
- const username = ref("");
93
+ const username = ref(props.initialUsername);
78
94
  const password = ref("");
79
95
 
80
96
  function attemptSubmit() {
81
97
  if (props.loading || props.disabled) return;
82
98
  const u = username.value.trim();
83
- if (!u || !password.value) return;
84
- emit("submit", u, password.value);
99
+ if (!u || (props.passwordField && !password.value)) return;
100
+ emit("submit", u, props.passwordField ? password.value : "");
85
101
  }
86
102
 
87
103
  return () => (
@@ -132,21 +148,27 @@ export const HkSignInCard = defineComponent({
132
148
  ),
133
149
  }}
134
150
  </HkInput>
135
- <HkInput
136
- variant="password"
137
- modelValue={password.value}
138
- onUpdate:modelValue={(v: string) => (password.value = v)}
139
- name="signin-password"
140
- autocomplete={props.passwordAutocomplete ?? credentialAutocomplete("password", "current-password")}
141
- placeholder={t("hikari::signIn.passwordPlaceholder", "Password")}
142
- disabled={props.loading || props.disabled}
143
- submitOnEnter={attemptSubmit}
144
- />
151
+ {/* Account-first mode omits the field entirely — not hides
152
+ it with CSS: a hidden password input would still plant a
153
+ credential-manager save prompt and an autocomplete target
154
+ on a form that never carries a password. */}
155
+ {props.passwordField && (
156
+ <HkInput
157
+ variant="password"
158
+ modelValue={password.value}
159
+ onUpdate:modelValue={(v: string) => (password.value = v)}
160
+ name="signin-password"
161
+ autocomplete={props.passwordAutocomplete ?? credentialAutocomplete("password", "current-password")}
162
+ placeholder={t("hikari::signIn.passwordPlaceholder", "Password")}
163
+ disabled={props.loading || props.disabled}
164
+ submitOnEnter={attemptSubmit}
165
+ />
166
+ )}
145
167
  <HkAuthSubmitButton
146
168
  label={props.submitLabel ?? t("hikari::signIn.submit", "Sign in")}
147
169
  block
148
170
  loading={props.loading}
149
- disabled={props.disabled || !username.value.trim() || !password.value}
171
+ disabled={props.disabled || !username.value.trim() || (props.passwordField && !password.value)}
150
172
  doSubmit={() => Promise.resolve(attemptSubmit())}
151
173
  />
152
174
  </form>