@celestia-island/hikari 0.55.332 → 0.55.335

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.332",
3
+ "version": "0.55.335",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -42,11 +42,32 @@ export type HkDockBarSurface = "glass" | "solid";
42
42
  *
43
43
  * Relationship to HkScrollContainer: that component's dockTop /
44
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.
45
+ * scrolling region. Those slots place a dock two ways (2026-11-10
46
+ * addition — `dockMode`):
47
+ * - `ride` (the pinned-to-the-visible-bottom-edge form): the dock
48
+ * mounts inside the scroll viewport after the content, on a spacer
49
+ * exactly one measured dock height tall, `position: sticky; bottom:
50
+ * 0`. The air between the content end and the dock is therefore
51
+ * exactly one dock height; short content lets the dock follow the
52
+ * content end (the leftover window height falls below it), long
53
+ * content pins it to the visible bottom edge. Use it for a dock that
54
+ * belongs to the CONTENT (a cruise canvas's bottom bar under its
55
+ * last row). The container's ride wrapper is `pointer-events: none`
56
+ * (it covers content while pinned), so this carrier's own box
57
+ * contract is what keeps the dock interactive: the anchor box catches
58
+ * nothing, the surface catches everything.
59
+ * - `pin` (the default, older form): the dock is a non-scrolling flex
60
+ * SIBLING of the viewport, so it sits on the container's edge and the
61
+ * viewport shrinks by its height — correct for chrome that owns the
62
+ * region's frame rather than trailing its content.
63
+ * HkDockBar is the carrier for docks that FLOAT over a canvas or page
64
+ * instead. Pick by host: scrolling region → HkScrollContainer dock slots
65
+ * (`ride` to follow the content, `pin` to own the edge); canvas/page
66
+ * overlay → HkDockBar. Do not nest an HkDockBar inside a scroll viewport
67
+ * expecting the container to place it — the container's slots do the
68
+ * placing, and a dock it mounted inside the viewport (ride) makes
69
+ * `closest(".hk-scroll-dock")` true, which is how a carrier knows to stop
70
+ * publishing shell-level clearance for it.
50
71
  *
51
72
  * Surfaces:
52
73
  * - `glass` (default): translucent surface + backdrop blur.
@@ -17,9 +17,32 @@
17
17
  display: flex;
18
18
  align-items: stretch;
19
19
  gap: var(--space-12);
20
+ /* A short board fills the strip: the lanes box is at least the viewport
21
+ height, so lane chrome reaches the strip's floor. */
20
22
  min-height: 100%;
21
23
  }
22
24
 
25
+ /* ── Ride boards: the lanes box must follow its CONTENT ────────────────
26
+ 2026-11-10 review S0, measured on a real board (chest cruise canvas):
27
+ in a column-flex strip viewport `min-height: 100%` makes the lanes BOX
28
+ exactly as tall as the viewport, so the ride spacer + sticky dock —
29
+ which are IN FLOW after that box — always sit below the fold. The dock
30
+ can then only hug the visible bottom edge, the spacer clears nothing,
31
+ and the air goes back to being the window's leftover (measured 275.8px
32
+ static / 563.8px at the scroll end against a 139px dock, i.e. no
33
+ improvement over the pin board and worse when scrolled). Releasing the
34
+ min-height lets the box end at the real content end, which is what the
35
+ ride placement needs: the spacer becomes the visible air and the dock
36
+ trails the content.
37
+
38
+ Ride only, by the root hook HkKanban emits for a ride host: a pin board
39
+ must keep the full-height lanes box it always had, so this rule never
40
+ reaches an existing board. Specificity (0,3,0) beats the base rule
41
+ above; a host that wants its own rule can override it the same way. */
42
+ .hk-kanban[data-dock-mode="ride"] .hk-kanban-lanes {
43
+ min-height: 0;
44
+ }
45
+
23
46
  .hk-kanban[data-axis="vertical"] .hk-kanban-lanes {
24
47
  flex-direction: column;
25
48
  }
@@ -105,6 +105,26 @@ export default defineComponent({
105
105
  * contract generates no spacer and the shrink pin is inert in the
106
106
  * block viewport — a horizontal board gets a no-op by design. */
107
107
  dockTailClearance: { type: Boolean, default: false },
108
+ /** Where a `dockBottom` dock lives, forwarded to the strip's
109
+ * HkScrollContainer (see its `dockMode`): "pin" (default) keeps the
110
+ * dock on the strip's bottom edge, "ride" moves it into the strip's
111
+ * content flow so the air above it is the measured dock occupancy and
112
+ * the dock follows the content. Only effective on axis="vertical"/
113
+ * "both" boards — a horizontal strip carries its tail on the cross
114
+ * axis, where the container's ride placement is gated off by design.
115
+ *
116
+ * A ride board ALSO carries `data-dock-mode="ride"` on its root — only
117
+ * while a `dockBottom` slot is actually mounted, since that hook
118
+ * releases this component's full-height lanes box
119
+ * (see HkKanban.scss): `.hk-kanban-lanes { min-height: 100% }` keeps
120
+ * the lanes box as tall as the viewport, which would push the in-flow
121
+ * spacer + dock below the fold and turn the air back into the
122
+ * window's leftover (measured on a real board: 275.8px static vs the
123
+ * 139px dock, i.e. zero improvement over the pin board). The
124
+ * attribute is emitted only while a ride dock is genuinely mounted (not
125
+ * for pin, not for a horizontal board, not for an empty slot), so every
126
+ * existing board is untouched. */
127
+ dockMode: { type: String as PropType<"pin" | "ride">, default: "pin" },
108
128
  },
109
129
  emits: ["move"],
110
130
  setup(props, { slots, emit, expose }) {
@@ -185,90 +205,117 @@ export default defineComponent({
185
205
 
186
206
  expose({ scrollHost, dragging });
187
207
 
188
- return () => (
189
- <div
190
- class="hk-kanban"
191
- data-axis={props.axis}
192
- data-dragging={dragging.value ? "" : undefined}
193
- aria-label={props.ariaLabel}
194
- >
195
- {props.lanes.length === 0
196
- ? (slots.empty?.() ?? null)
197
- : (
198
- <HScrollContainer
199
- ref={scrollHost}
200
- class="hk-kanban-strip"
201
- axis={stripAxis.value}
202
- overscanScreens={1}
203
- dockTailClearance={props.dockTailClearance}
204
- >
205
- <div
206
- class="hk-kanban-lanes"
207
- // One variable; the stylesheet decides whether it is the
208
- // lane's width (horizontal boards) or its height (vertical).
209
- style={
210
- props.laneSize ? { "--hk-kanban-lane-size": props.laneSize } : undefined
211
- }
208
+ return () => {
209
+ // Docks attach to the strip's VERTICAL tail: `axis="horizontal"`
210
+ // boards keep the pre-2026-11-10 shape (no forwarded dock slot), the
211
+ // same gate the container itself applies to the ride placement —
212
+ // there the tail is the cross axis, where an in-flow block child
213
+ // leaks onto the hidden vertical axis.
214
+ const docksOnTail = stripAxis.value !== "horizontal";
215
+ // Styling hook AND behaviour switch for the CONTENT HEIGHT of the
216
+ // board (see the dockMode prop doc): a ride dock only follows the
217
+ // content when the lanes box is allowed to end above the viewport
218
+ // bottom. Ride only — a pin board keeps the full-height lanes box it
219
+ // always had, so no existing board changes. Gated on the dock actually
220
+ // being mounted too (S2-2): a requested-but-empty slot is legal, and
221
+ // releasing the lanes box for it would only shrink lane chrome on a
222
+ // short board while no dock is there to justify it.
223
+ const rideBoard = docksOnTail && props.dockMode === "ride" && !!slots.dockBottom;
224
+ return (
225
+ <div
226
+ class="hk-kanban"
227
+ data-axis={props.axis}
228
+ data-dock-mode={rideBoard ? "ride" : undefined}
229
+ data-dragging={dragging.value ? "" : undefined}
230
+ aria-label={props.ariaLabel}
231
+ >
232
+ {props.lanes.length === 0
233
+ ? (slots.empty?.() ?? null)
234
+ : (
235
+ <HScrollContainer
236
+ ref={scrollHost}
237
+ class="hk-kanban-strip"
238
+ axis={stripAxis.value}
239
+ overscanScreens={1}
240
+ dockTailClearance={props.dockTailClearance}
241
+ dockMode={props.dockMode}
212
242
  >
213
- {props.lanes.map((lane, laneIndex) => {
214
- const laneKey = props.laneKeyOf(lane, laneIndex);
215
- const cards = cardsFor(lane, laneIndex);
216
- return (
217
- <section
218
- key={laneKey}
219
- class="hk-kanban-lane"
220
- data-lane={laneKey}
221
- data-drop-target={dropTarget.value === laneKey ? "" : undefined}
222
- onDragover={(event: DragEvent) => onDragOver(laneKey, event)}
223
- onDragleave={(event: DragEvent) => onDragLeave(laneKey, event)}
224
- onDrop={(event: DragEvent) => onDrop(laneKey, cards.length, event)}
243
+ {{
244
+ default: () => (
245
+ <div
246
+ class="hk-kanban-lanes"
247
+ // One variable; the stylesheet decides whether it is the
248
+ // lane's width (horizontal boards) or its height (vertical).
249
+ style={
250
+ props.laneSize ? { "--hk-kanban-lane-size": props.laneSize } : undefined
251
+ }
225
252
  >
226
- <header class="hk-kanban-lane-header">
227
- {slots.laneHeader?.({
228
- lane,
229
- laneIndex,
230
- laneKey,
231
- cards,
232
- })}
233
- </header>
234
- <div class="hk-kanban-cards">
235
- {cards.map((card, cardIndex) => {
236
- const cardKey =
237
- props.cardKeyOf?.(card, cardIndex) ?? `${laneKey}#${cardIndex}`;
238
- return (
239
- <div
240
- key={cardKey}
241
- class="hk-kanban-card"
242
- data-card={cardKey}
243
- data-card-index={cardIndex}
244
- draggable={props.draggable || undefined}
245
- onDragstart={(event: DragEvent) =>
246
- onDragStart(card, laneKey, cardIndex, event)
247
- }
248
- onDragend={onDragEnd}
249
- >
250
- {slots.card?.({
251
- card,
252
- cardIndex,
253
+ {props.lanes.map((lane, laneIndex) => {
254
+ const laneKey = props.laneKeyOf(lane, laneIndex);
255
+ const cards = cardsFor(lane, laneIndex);
256
+ return (
257
+ <section
258
+ key={laneKey}
259
+ class="hk-kanban-lane"
260
+ data-lane={laneKey}
261
+ data-drop-target={dropTarget.value === laneKey ? "" : undefined}
262
+ onDragover={(event: DragEvent) => onDragOver(laneKey, event)}
263
+ onDragleave={(event: DragEvent) => onDragLeave(laneKey, event)}
264
+ onDrop={(event: DragEvent) => onDrop(laneKey, cards.length, event)}
265
+ >
266
+ <header class="hk-kanban-lane-header">
267
+ {slots.laneHeader?.({
253
268
  lane,
254
269
  laneIndex,
255
270
  laneKey,
271
+ cards,
256
272
  })}
273
+ </header>
274
+ <div class="hk-kanban-cards">
275
+ {cards.map((card, cardIndex) => {
276
+ const cardKey =
277
+ props.cardKeyOf?.(card, cardIndex) ?? `${laneKey}#${cardIndex}`;
278
+ return (
279
+ <div
280
+ key={cardKey}
281
+ class="hk-kanban-card"
282
+ data-card={cardKey}
283
+ data-card-index={cardIndex}
284
+ draggable={props.draggable || undefined}
285
+ onDragstart={(event: DragEvent) =>
286
+ onDragStart(card, laneKey, cardIndex, event)
287
+ }
288
+ onDragend={onDragEnd}
289
+ >
290
+ {slots.card?.({
291
+ card,
292
+ cardIndex,
293
+ lane,
294
+ laneIndex,
295
+ laneKey,
296
+ })}
297
+ </div>
298
+ );
299
+ })}
300
+ {/* The tail keeps the lane's empty space hittable
301
+ (the lane itself owns the drop). */}
302
+ <div class="hk-kanban-lane-tail" />
257
303
  </div>
258
- );
259
- })}
260
- {/* The tail keeps the lane's empty space hittable
261
- (the lane itself owns the drop). */}
262
- <div class="hk-kanban-lane-tail" />
263
- </div>
264
- {slots.laneFooter?.({ lane, laneIndex, laneKey, cards })}
265
- </section>
266
- );
267
- })}
268
- </div>
269
- </HScrollContainer>
270
- )}
271
- </div>
272
- );
304
+ {slots.laneFooter?.({ lane, laneIndex, laneKey, cards })}
305
+ </section>
306
+ );
307
+ })}
308
+ </div>
309
+ ),
310
+ // Forwarded so a board's own chrome (a cruise canvas's dock
311
+ // under the lanes) keeps the container's dock contract —
312
+ // including the --hk-scroll-dock-* heights it publishes.
313
+ dockBottom: docksOnTail ? slots.dockBottom : undefined,
314
+ }}
315
+ </HScrollContainer>
316
+ )}
317
+ </div>
318
+ );
319
+ };
273
320
  },
274
321
  });
@@ -111,6 +111,57 @@
111
111
  background: rgb(var(--color-primary));
112
112
  }
113
113
 
114
+ // ── overspend (max + overValue) ─────────────────────────────────────
115
+ // The track grows to `max + overValue`: the main fill keeps the quota's
116
+ // share, the overspend stretch takes the rest to the inline end, and the
117
+ // 1px divider is CARVED out of the stretch's inline-start edge — the track
118
+ // background shows through the seam. A painted line would need a colour of
119
+ // its own (and could not sit correctly over the translucent track); the
120
+ // carved gap is one geometry that also mirrors correctly in RTL.
121
+ .hk-progress-bar-overspend {
122
+ position: absolute;
123
+ top: 0;
124
+ height: 100%;
125
+ // Square on the seam side, rounded at the track's end: the 1px gap then
126
+ // reads as a divider over the whole track height instead of a lens
127
+ // between two round caps.
128
+ border-start-start-radius: 0;
129
+ border-end-start-radius: 0;
130
+ border-start-end-radius: var(--radius-full);
131
+ border-end-end-radius: var(--radius-full);
132
+ transition:
133
+ width var(--duration-normal) var(--ease-standard),
134
+ inset-inline-start var(--duration-normal) var(--ease-standard);
135
+
136
+ &-muted {
137
+ background: rgb(var(--color-muted));
138
+ }
139
+
140
+ &-danger {
141
+ background: rgb(var(--color-error));
142
+ }
143
+ }
144
+
145
+ // While an overspend stretch follows it, the main fill squares its
146
+ // inline-end corners so the seam divider stays exactly 1px wide.
147
+ .hk-progress-bar-track--overspend .hk-progress-bar-fill {
148
+ border-start-end-radius: 0;
149
+ border-end-end-radius: 0;
150
+ }
151
+
152
+ // Overspend caption ("超支 0.8 度"), riding the label row after the value.
153
+ .hk-progress-bar-overspend-label {
154
+ margin-inline-start: var(--space-6);
155
+
156
+ &-muted {
157
+ color: rgb(var(--color-muted));
158
+ }
159
+
160
+ &-danger {
161
+ color: rgb(var(--color-error));
162
+ }
163
+ }
164
+
114
165
  // Keyframes animate the logical inset so the sweep mirrors in RTL together
115
166
  // with the fill/secondary anchoring (LTR-identical).
116
167
  @keyframes hk-progress-indeterminate {
@@ -138,6 +189,7 @@
138
189
  // (house guard: the family bar-fill pattern).
139
190
  .hk-progress-bar-fill,
140
191
  .hk-progress-bar-secondary,
192
+ .hk-progress-bar-overspend,
141
193
  .hk-progress-bar-seg {
142
194
  transition: none;
143
195
  }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Source contract for the overspend stretch's look. vitest cannot see
9
+ * stylesheets (happy-dom loads no SCSS), so a deleted rule would leave the
10
+ * DOM geometry guards green while the stretch painted as another primary
11
+ * fill — the whole point of the second segment is that it reads as PAST
12
+ * quota.
13
+ */
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const scss = readFileSync(join(here, "HkProgressBar.scss"), "utf8");
16
+
17
+ /** The DECLARATIONS of a rule, comments stripped (a comment that names a
18
+ * property must not satisfy a guard about it). */
19
+ function ruleBlock(selector: string): string {
20
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ const head = new RegExp(`${escaped}\\s*\\{`).exec(scss);
22
+ if (!head) throw new Error(`no rule for ${selector}`);
23
+ // Nested SCSS blocks: walk braces to the matching close.
24
+ const open = head.index + head[0].length - 1;
25
+ let depth = 0;
26
+ let end = open;
27
+ for (; end < scss.length; end++) {
28
+ if (scss[end] === "{") depth += 1;
29
+ else if (scss[end] === "}") {
30
+ depth -= 1;
31
+ if (depth === 0) break;
32
+ }
33
+ }
34
+ return scss.slice(open + 1, end)
35
+ .split("\n")
36
+ .filter((line) => {
37
+ const t = line.trim();
38
+ return !t.startsWith("*") && !t.startsWith("/*") && !t.startsWith("//");
39
+ })
40
+ .join("\n");
41
+ }
42
+
43
+ describe("HkProgressBar overspend sheet contract", () => {
44
+ it("positions the stretch as an inline-end block on the stretched track", () => {
45
+ const rule = ruleBlock(".hk-progress-bar-overspend");
46
+ expect(rule).toMatch(/position:\s*absolute/);
47
+ // Logical anchoring — the stretch sits at the inline end in RTL too.
48
+ expect(scss).not.toMatch(/\.hk-progress-bar-overspend[^{]*\{[^}]*\b(?:left|right):/);
49
+ });
50
+
51
+ it("tones the stretch muted by default and error-toned for danger", () => {
52
+ const block = ruleBlock(".hk-progress-bar-overspend");
53
+ expect(block).toMatch(/&-muted\s*\{[^}]*background:\s*rgb\(var\(--color-muted\)\)/);
54
+ expect(block).toMatch(/&-danger\s*\{[^}]*background:\s*rgb\(var\(--color-error\)\)/);
55
+ });
56
+
57
+ it("squares both sides of the 1px seam so it reads as a divider", () => {
58
+ const block = ruleBlock(".hk-progress-bar-overspend");
59
+ expect(block).toMatch(/border-start-start-radius:\s*0/);
60
+ expect(block).toMatch(/border-end-start-radius:\s*0/);
61
+ const fill = ruleBlock(".hk-progress-bar-track--overspend .hk-progress-bar-fill");
62
+ expect(fill).toMatch(/border-start-end-radius:\s*0/);
63
+ expect(fill).toMatch(/border-end-end-radius:\s*0/);
64
+ });
65
+
66
+ it("moves with the family cadence and stops under reduced motion", () => {
67
+ const block = ruleBlock(".hk-progress-bar-overspend");
68
+ expect(block).toMatch(/transition:[\s\S]*width var\(--duration-normal\)/);
69
+ const media = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/.exec(scss);
70
+ expect(media, "reduced-motion guard").not.toBeNull();
71
+ expect(media![1]!).toMatch(/\.hk-progress-bar-overspend[\s\S]*transition:\s*none/);
72
+ });
73
+
74
+ it("co-locates the caption with the value label row", () => {
75
+ const rule = ruleBlock(".hk-progress-bar-overspend-label");
76
+ expect(rule).toMatch(/margin-inline-start:\s*var\(--space-6\)/);
77
+ expect(rule).toMatch(/&-danger\s*\{[^}]*color:\s*rgb\(var\(--color-error\)\)/);
78
+ });
79
+ });