@celestia-island/hikari 0.55.99 → 0.55.101

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.99",
3
+ "version": "0.55.101",
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",
@@ -218,8 +218,9 @@
218
218
  // stretching the card. The 9rem term keeps short viewports (20vh < 9rem)
219
219
  // at nine rem of scroll area. The pane div is the track's positioning
220
220
  // context and wraps EXACTLY the scrolling body (useOverlayScrollbar
221
- // host contract); the family overlay scrollbar's observers keep the
222
- // thumb geometry fresh on fold/expand.
221
+ // host contract); the family overlay scrollbar — BOTH axes, the body
222
+ // below hides the native bar horizontally too — keeps the thumb
223
+ // geometry fresh on fold/expand.
223
224
  .hk-error-landing__details-pane {
224
225
  position: relative;
225
226
  margin-top: var(--space-6, 0.375rem);
@@ -119,10 +119,20 @@ describe("HkErrorLanding", () => {
119
119
  expect(el.querySelector(".hk-error-landing__desc")!.textContent).toBe("line1\nline2");
120
120
  });
121
121
 
122
- it("attaches the overlay scrollbar chrome inside the details pane", () => {
122
+ it("attaches the overlay scrollbar chrome inside the details pane on BOTH axes", () => {
123
123
  const el = mountLanding({ details: () => h("pre", { class: "s-tool-json-tree" }, "raw") });
124
- expect(el.querySelector(".hk-error-landing__details-pane .hk-scrollbar-track")).not.toBeNull();
125
- expect(el.querySelector(".hk-error-landing__details-pane .hk-scrollbar-thumb")).not.toBeNull();
124
+ const pane = el.querySelector(".hk-error-landing__details-pane")!;
125
+ expect(pane.querySelector(".hk-scrollbar-track")).not.toBeNull();
126
+ expect(pane.querySelector(".hk-scrollbar-thumb")).not.toBeNull();
127
+ // The pane's CSS hides the NATIVE bar on both axes, so the overlay is
128
+ // the only scrollbar the raw payload can ever show. A vertical-only
129
+ // attach (the pre-fix default) leaves a horizontal overflow — deep
130
+ // JSON nesting, long unwrapped keys (preview rows ellipsize by
131
+ // design) — with no scrollbar at all: pin one track per axis.
132
+ expect(pane.querySelector(".hk-scrollbar-track[data-axis='horizontal']")).not.toBeNull();
133
+ expect(pane.querySelector(".hk-scrollbar-track:not([data-axis='horizontal'])")).not.toBeNull();
134
+ // Exactly the two tracks — no duplicate chrome from a double attach.
135
+ expect(pane.querySelectorAll(".hk-scrollbar-track")).toHaveLength(2);
126
136
  });
127
137
 
128
138
  it("renders no scrollbar chrome without a details pane", () => {
@@ -45,7 +45,8 @@ function normalizeSlotNodes(nodes: VNode | VNode[] | undefined): VNode[] {
45
45
  * error code / HTTP status as HkBadge chips above the headline, an
46
46
  * always-open raw-details pane (the default slot — hosts render HkJsonTree
47
47
  * there) with a STABLE standing frame — the larger of 9rem and ~20vh,
48
- * carried by the family overlay scrollbar — and an actions slot.
48
+ * carried by the family overlay scrollbar on BOTH axes (vertical for long
49
+ * stacks, horizontal for deep/wide JSON) — and an actions slot.
49
50
  *
50
51
  * The pane never breathes with its content: folding every JSON node only
51
52
  * changes what renders INSIDE the standing frame, and a long stack trace
@@ -129,7 +130,12 @@ export const HkErrorLanding = defineComponent({
129
130
 
130
131
  function ensureDetailsScrollbars() {
131
132
  if (detailsScrollbars || !detailsBodyRef.value) return;
132
- detailsScrollbars = attachOverlayScrollbars(detailsBodyRef.value);
133
+ // BOTH axes: the raw payload overflows down (long stacks) and across
134
+ // (deep JSON nesting, long unwrapped keys — the tree's preview rows
135
+ // ellipsize by design and never push the row wide) alike, and the
136
+ // pane's CSS hides the native bar on both axes — a vertical-only
137
+ // attach leaves a horizontal overflow with no scrollbar at all.
138
+ detailsScrollbars = attachOverlayScrollbars(detailsBodyRef.value, { axis: "both" });
133
139
  contentResizeObserver = new ResizeObserver(() => detailsScrollbars?.update());
134
140
  observeDetailsContent();
135
141
  }
@@ -25,6 +25,73 @@ import { fileURLToPath } from "node:url";
25
25
  const here = dirname(fileURLToPath(import.meta.url));
26
26
  const read = (f: string): string => readFileSync(join(here, f), "utf-8");
27
27
 
28
+ describe("HkThemeToggle theme-row pill contract", () => {
29
+ // 2026-10-01 user report: with the item-trailing slot reserving a real
30
+ // column, the row button's hover wash stopped short of the row edge —
31
+ // theme rows and the standalone 自定义 row hovered at different widths.
32
+ // The pill therefore lives on `.s-theme-item-row` (full row width no
33
+ // matter how wide the trailing column is) and the row's button is
34
+ // chromeless. happy-dom has no layout engine, so the geometry is pinned
35
+ // as scss-text assertions.
36
+ it("puts the unified menu-item pill on the row, not the row button", () => {
37
+ const css = read("HkThemeToggle.scss");
38
+ const rowBlock = css.match(/\.s-theme-item-row\s*\{[^}]*\}/);
39
+ expect(rowBlock).not.toBeNull();
40
+ expect(rowBlock![0]).toContain("@include mi.item;");
41
+ // The overlaid delete affordance pins to the row (positioning context).
42
+ expect(rowBlock![0]).toContain("position: relative");
43
+ });
44
+
45
+ it("strips the row button's own chrome so the wash is not doubled", () => {
46
+ const css = read("HkThemeToggle.scss");
47
+ const block = css.match(/\.s-theme-item-row \.s-theme-item-btn\s*\{[^}]*\}/);
48
+ expect(block).not.toBeNull();
49
+ // The ROW owns the frame: its border 1 + padding 12 put the content at
50
+ // the same 13px the standalone pills measure (border 1 + padding 12), so
51
+ // the button drops its own border entirely.
52
+ expect(block![0]).toContain("border: none;");
53
+ expect(block![0]).toContain("background: transparent");
54
+ expect(block![0]).toContain("min-height: 0");
55
+ // The button stretches over the row's frame (negative block margins
56
+ // mirrored by re-applied padding) so the WHOLE pill is the click/focus
57
+ // target — master's pill WAS the button — while the name's content box
58
+ // keeps the standalone offsets. Losing the stretch drops the button
59
+ // back to a centered auto-height box inside the row (misaligned and
60
+ // undersized); losing the bleed pair desyncs the content offset by the
61
+ // frame width.
62
+ expect(block![0]).toContain("align-self: stretch");
63
+ expect(block![0]).toContain("margin-block: calc(");
64
+ expect(block![0]).toContain("padding-block: calc(");
65
+ });
66
+
67
+ it("suppresses the row button's own hover and active state chrome", () => {
68
+ const css = read("HkThemeToggle.scss");
69
+ // The hover override must repeat mi.item's guard chain to out-specify
70
+ // the mixin's hover rule; the active/checked suppression targets the
71
+ // state attributes directly.
72
+ expect(css).toContain(
73
+ ".s-theme-item-row .s-theme-item-btn:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked])",
74
+ );
75
+ expect(css).toMatch(
76
+ /\.s-theme-item-row \.s-theme-item-btn\[data-active\][^{]*\{[^}]*background: transparent/,
77
+ );
78
+ });
79
+
80
+ it("registers the theme row family for the mobile sheet token swap", () => {
81
+ const mi = read("_menu-item.scss");
82
+ // The row carries the pill, so the sheet-context token binding must
83
+ // target it too — otherwise sheets keep the desktop 34px metrics.
84
+ const sheetList = mi.match(
85
+ /\.hk-popover-panel\.hk-is-sheet[^{]*\{[^{]*\{[^}]*\}/,
86
+ );
87
+ expect(sheetList).not.toBeNull();
88
+ expect(sheetList![0]).toContain(".s-theme-item-row");
89
+ const modalList = mi.match(/\.hk-modal-content\s*\{[^{]*\{[^}]*\}/);
90
+ expect(modalList).not.toBeNull();
91
+ expect(modalList![0]).toContain(".s-theme-item-row");
92
+ });
93
+ });
94
+
28
95
  describe("HkThemeToggle row lead-cell contract", () => {
29
96
  it("sizes the theme-row lead cell to fit a 28px swatch mark", () => {
30
97
  const css = read("HkThemeToggle.scss");
@@ -136,32 +136,84 @@
136
136
  }
137
137
 
138
138
  /* ── Theme list rows ────────────────────────────────────────────────────
139
- * Every row is ONE full-width pill: the delete affordance for custom
140
- * themes overlays the pill's trailing edge (the row is the positioning
141
- * context), so built-in rows reserve no trailing column — the old
142
- * invisible slot left a permanent dead strip right of every row in the
143
- * mobile bottom sheet — and every pill and highlight still shares the
144
- * exact same width.
139
+ * Every row is ONE full-width pill, and the pill is THE ROW (2026-10-01
140
+ * user report: with the trailing slot reserving a real column, the row
141
+ * button's wash stopped short of the row edge — theme rows and the
142
+ * standalone 自定义 row hovered at different widths). The unified
143
+ * menu-item chrome therefore lives on `.s-theme-item-row` (which also
144
+ * carries `data-active` from the TSX), and the row's button surrenders
145
+ * its own chrome below, so the hover/active wash, border and radius
146
+ * span the full row no matter how wide the trailing column is. The
147
+ * standalone pills (自定义 row, host mode-extra rows) keep the chrome on
148
+ * the button itself — same spec, same width as a row.
145
149
  *
146
150
  * Metrics/states come from the unified menu-item spec (./_menu-item.scss)
147
151
  * — the same grammar as menu rows and select options; inside the mobile
148
152
  * sheet the shared `.hk-popover-panel.hk-is-sheet` token context applies
149
- * the 44px touch geometry, so the old component-local media bump is gone
150
- * (only the overlaid delete button keeps its own touch sizing below).
153
+ * the 44px touch geometry (the row family is registered there), so the
154
+ * old component-local media bump is gone (only the overlaid delete
155
+ * button keeps its own touch sizing below).
151
156
  */
152
157
 
153
158
  .s-theme-item-row {
159
+ @include mi.item;
160
+
154
161
  position: relative;
155
- display: flex;
156
- align-items: center;
157
162
  }
158
163
 
159
- /* Theme rows speak the SAME grammar as every dropdown option
160
- * (.hk-select-option): base-size text, roomy pill padding, hairline
161
- * border that tints on hover and strengthens when active — so a theme
162
- * list inside the popover reads like any select/menu panel. The old
163
- * bespoke 6×8 tight rows (small text, borderless 12% wash) are what
164
- * made the theme sheet read as "not from the standard dropdown". */
164
+ /* The row's button keeps the CLICK and FOCUS semantics (keyboard parity,
165
+ * screen-reader role) but no visual chrome of its own: background goes
166
+ * transparent and the border is dropped entirely — the ROW now owns the
167
+ * frame, and the content x lands on the same offset as the standalone
168
+ * pills anyway (row border 1 + row padding 12 = the standalone border 1 +
169
+ * padding 12 = 13px from the pill edge). Vertically the button bleeds
170
+ * over the row's frame (negative margins) and re-applies the padding
171
+ * inside, so the WHOLE pill is the click/focus target — master's pill
172
+ * WAS the button — while the name's content box keeps the standalone
173
+ * offsets (border 1 + pad-y). Row heights are unchanged: the button's
174
+ * net layout contribution is its content, so the row's own min-height
175
+ * rules exactly as the button's did before. */
176
+ .s-theme-item-row .s-theme-item-btn {
177
+ min-width: 0;
178
+ min-height: 0;
179
+ flex: 1;
180
+ width: auto;
181
+ /* Fill the row's full cross size (overriding the row's align-items:
182
+ * center FOR THIS ITEM only) so the click/focus target is the whole
183
+ * pill, not just the name's line box. */
184
+ align-self: stretch;
185
+ /* Bleed over the row's frame — its vertical padding plus the 1px
186
+ * mi.item border — then re-apply that padding inside: the name's
187
+ * content box keeps the standalone pills' offsets (border 1 + pad)
188
+ * while the hit area covers the pill edge to edge. The 1px constant
189
+ * mirrors mi.item's border width; if the mixin's border ever changes,
190
+ * this pair moves with it. */
191
+ margin-block: calc(-1 * var(--hk-menu-item-pad-y, 6px) - 1px);
192
+ padding-block: calc(var(--hk-menu-item-pad-y, 6px) + 1px);
193
+ padding-inline: 0;
194
+ border: none;
195
+ /* Follow the row's pill radius: the button keeps mi.item's :focus-visible
196
+ * inset outline (keyboard parity), and outlines follow border-radius —
197
+ * a zero radius here would render the ring as a sharp rectangle poking
198
+ * past the row's rounded corners. */
199
+ border-radius: inherit;
200
+ background: transparent;
201
+ font: inherit;
202
+ color: inherit;
203
+ }
204
+
205
+ /* Suppress the button's own state chrome — the row above owns the wash.
206
+ * The hover guard repeats mi.item's guard chain so this override
207
+ * out-specifies the mixin's (0,6,0) hover rule. */
208
+ .s-theme-item-row .s-theme-item-btn:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]),
209
+ .s-theme-item-row .s-theme-item-btn[data-active],
210
+ .s-theme-item-row .s-theme-item-btn[data-checked] {
211
+ background: transparent;
212
+ border-color: transparent;
213
+ color: inherit;
214
+ font-weight: inherit;
215
+ }
216
+
165
217
  .s-theme-item-btn {
166
218
  @include mi.item;
167
219
 
@@ -283,6 +283,13 @@ describe("HkThemeToggle color-mode group", () => {
283
283
  // Active check glyph sits inside the pill, leading the name.
284
284
  const activeBtn = document.body.querySelector<HTMLButtonElement>(".s-theme-item-btn[data-active]");
285
285
  expect(activeBtn?.querySelector(".s-theme-item-check")).toBeTruthy();
286
+
287
+ // The ROW is the pill (2026-10-01): the active state surfaces on the
288
+ // row element too, so the highlight spans the full row width instead
289
+ // of stopping short at a trailing column.
290
+ const activeRows = rows.filter((r) => r.hasAttribute("data-active"));
291
+ expect(activeRows).toHaveLength(1);
292
+ expect(activeRows[0].querySelector(".s-theme-item-btn[data-active]")).toBeTruthy();
286
293
  });
287
294
  it("renders the mode-extra strip under the mode group when provided", async () => {
288
295
  const container = document.createElement("div");
@@ -181,16 +181,18 @@ export const HkThemeToggle = defineComponent({
181
181
  * cache icon vnodes across renders, or closing/reopening the popover
182
182
  * would re-mount the same vnode instances. In AUTO mode the Light/
183
183
  * Dark options collapse into the group's merged cell (the altitude
184
- * strip) via `mergeKeys`; they stay flagged disabled for the
185
- * degradation path (merge requested without the #merged slot) and
186
- * for semantic clarity.
184
+ * strip) via `mergeKeys`. They stay ENABLED (2026-10-01 user report:
185
+ * disabled Light/Dark read as the mode being locked to the sun): in
186
+ * the merged rendering they are not rendered at all, and on the
187
+ * degradation path (merge requested without the #merged slot) a
188
+ * click must drop OUT of auto onto the pressed side instead of
189
+ * doing nothing.
187
190
  */
188
191
  function modeOptions(): TabItem[] {
189
- const auto = isAutoMode.value;
190
192
  return [
191
193
  { key: "system", label: t("hikari::theme.modeAuto"), icon: <Monitor size={16} /> },
192
- { key: "light", label: t("hikari::theme.modeLight"), icon: <Sun size={16} />, disabled: auto },
193
- { key: "dark", label: t("hikari::theme.modeDark"), icon: <MoonStar size={16} />, disabled: auto },
194
+ { key: "light", label: t("hikari::theme.modeLight"), icon: <Sun size={16} /> },
195
+ { key: "dark", label: t("hikari::theme.modeDark"), icon: <MoonStar size={16} /> },
194
196
  ];
195
197
  }
196
198
 
@@ -336,6 +338,13 @@ export const HkThemeToggle = defineComponent({
336
338
  data-custom={th.isCustom || undefined}
337
339
  data-trailing={trailingSlot ? "slot" : undefined}
338
340
  data-name-suffix={nameSuffixSlot ? "slot" : undefined}
341
+ // The ROW is the pill: the highlight state lives here so
342
+ // the hover/active wash spans the full row width — a
343
+ // trailing slot column must not shrink it (2026-10-01
344
+ // user report: theme rows and the 自定义 row hovered at
345
+ // different widths). The button keeps the click/focus
346
+ // semantics; the stylesheet strips its own chrome.
347
+ data-active={currentTheme.value === th.id || undefined}
339
348
  >
340
349
  <button
341
350
  type="button"
@@ -79,6 +79,7 @@
79
79
  .hk-menu-action-item,
80
80
  .hk-select-option,
81
81
  .hk-popup-select-item,
82
+ .s-theme-item-row,
82
83
  .s-theme-item-btn,
83
84
  .s-popup-menu-item,
84
85
  .hk-popup-menu-item,
@@ -98,6 +99,7 @@
98
99
  .hk-menu-row,
99
100
  .hk-menu-action-item,
100
101
  .hk-select-option,
102
+ .s-theme-item-row,
101
103
  .s-theme-item-btn,
102
104
  .s-popup-menu-item,
103
105
  .hk-popup-menu-item,
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Source contract for the shared overlay-scrollbar chrome.
9
+ *
10
+ * The `.hk-scrollbar-track` / `.hk-scrollbar-thumb` DOM is created
11
+ * imperatively by useOverlayScrollbar; its styling lives in the theme
12
+ * partial `packages/theme/styles/_scrollbar.scss`, vendored byte-exact
13
+ * into `src/styles/theme/scrollbar.scss` (see vendoredSync.test.ts for
14
+ * the drift guard). This contract reads the SHIPPED vendored copy — the
15
+ * one npm consumers resolve through the `./styles/*` export — and pins
16
+ * the two properties the error landing's themeless contract depends on:
17
+ *
18
+ * 1. Thumb color must survive a missing `--hi-color-muted`: HkErrorLanding
19
+ * is designed to render standalone (server error page without hikari
20
+ * theme init; its SCSS carries `--hel-*` fallbacks for exactly that
21
+ * case). An unfallback'd `var(--hi-color-muted)` inside `color-mix()`
22
+ * computes the whole color to invalid-at-computed-value-time — the
23
+ * thumb renders invisible on BOTH axes while the pane's CSS hides the
24
+ * native bar, i.e. no scrollbar at all. (2026-10-01: found while
25
+ * giving the error details pane its horizontal overlay track.)
26
+ * 2. Both axis rails exist with their axis-keyed selectors — the engine
27
+ * mounts a `data-axis="horizontal"` track and an attribute-less
28
+ * vertical track; the chrome must style both.
29
+ */
30
+
31
+ const stylesDir = resolve(dirname(fileURLToPath(import.meta.url)), "../styles");
32
+ const chrome = readFileSync(resolve(stylesDir, "theme", "scrollbar.scss"), "utf8");
33
+
34
+ describe("overlay scrollbar chrome contract", () => {
35
+ it("ships real chrome (positive control for the reads below)", () => {
36
+ // Zero-yield guard: these extractions must prove they saw the file.
37
+ expect(chrome).toContain(".hk-scrollbar-track");
38
+ expect(chrome).toContain(".hk-scrollbar-thumb");
39
+ expect(chrome.length).toBeGreaterThan(200);
40
+ });
41
+
42
+ it("styles both axis rails with their axis-keyed selectors", () => {
43
+ expect(chrome).toMatch(/\.hk-scrollbar-track\[data-axis="horizontal"\]/);
44
+ expect(chrome).toMatch(/\.hk-scrollbar-track:not\(\[data-axis="horizontal"\]\)/);
45
+ });
46
+
47
+ it("keeps the thumb visible without --hi-color-muted (themeless render)", () => {
48
+ // Every color-mix() over the muted channel must carry a fallback.
49
+ // (Match to the declaration's semicolon: the fallback's own `)` would
50
+ // truncate a paren-counting match.)
51
+ const mixes = chrome.match(/color-mix\([^;]+/g) ?? [];
52
+ // Positive control: the file is known to mix the muted channel at the
53
+ // resting and hovered/dragged opacities — a rename would 0-match and
54
+ // make this check vacuously green, so pin the count too.
55
+ expect(mixes.length).toBeGreaterThanOrEqual(2);
56
+ const muted = mixes.filter((m) => m.includes("--hi-color-muted"));
57
+ expect(muted.length).toBeGreaterThanOrEqual(2);
58
+ for (const mix of muted) {
59
+ expect(
60
+ mix,
61
+ "color-mix over --hi-color-muted must declare a fallback color",
62
+ ).toMatch(/var\(--hi-color-muted,\s*rgb\(118 122 128\)\)/);
63
+ }
64
+ });
65
+
66
+ it("keeps the rails pointer-transparent until they flash in", () => {
67
+ // An invisible rail must never steal clicks from the content hugging
68
+ // the region's edge (end-of-line links) — pointer-events: none at
69
+ // rest, auto only while visible.
70
+ expect(chrome).toMatch(/\.hk-scrollbar-track\s*\{[^}]*pointer-events:\s*none/);
71
+ expect(chrome).toMatch(/&?\.is-scrolling,\s*&?\.is-hovering\s*\{[^}]*pointer-events:\s*auto/);
72
+ });
73
+ });
@@ -56,12 +56,17 @@
56
56
  .hk-scrollbar-thumb {
57
57
  position: absolute;
58
58
  border-radius: 3px;
59
- background: color-mix(in srgb, var(--hi-color-muted) 35%, transparent);
59
+ /* Fallback = the landing's neutral muted seed (HkErrorLanding's
60
+ --hel-muted 118 122 128): themeless standalone renders (the error
61
+ landing's no-theme-init contract) get a visible neutral thumb; an
62
+ undefined --hi-color-muted would make color-mix invalid at
63
+ computed-value time and the thumb invisible on BOTH axes. */
64
+ background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 35%, transparent);
60
65
  transition: background var(--hi-duration-fast, 0.15s) ease;
61
66
 
62
67
  .is-hovering &,
63
68
  &.is-dragging {
64
- background: color-mix(in srgb, var(--hi-color-muted) 55%, transparent);
69
+ background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 55%, transparent);
65
70
  }
66
71
  }
67
72