@celestia-island/hikari 0.51.2 → 0.52.0

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.51.2",
3
+ "version": "0.52.0",
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",
@@ -295,6 +295,28 @@
295
295
  vertical-align: baseline;
296
296
  }
297
297
 
298
+ // Accent face: bare text like `plain`, but tinted in the primary colour so
299
+ // the link visibly stands out of the run it sits in — the "this is
300
+ // clickable" cue (user direction 2026-09-15; the pure `plain` face melted
301
+ // the credits names into the sentence too well). No frame, no pill padding,
302
+ // and no underline at rest; the hover deepens the tint and adds one.
303
+ //
304
+ // Same specificity story as the `plain` block above: this rule (0,2,0) beats
305
+ // the chip base (0,1,0), and the `[data-face="accent"]:hover` / `:active`
306
+ // rules below (0,3,0) keep the chip's tinted hover box off the bare text.
307
+ .s-about-modal-link[data-face="accent"] {
308
+ display: inline;
309
+ margin: 0;
310
+ padding: 0;
311
+ border: 0;
312
+ border-radius: 0;
313
+ background: none;
314
+ color: rgb(var(--color-primary));
315
+ font-size: inherit;
316
+ line-height: inherit;
317
+ vertical-align: baseline;
318
+ }
319
+
298
320
  // A link carrying an icon is one aligned unit (the mark, then the optional
299
321
  // label) — icon-only links are how the GitHub mark rides beside the domains.
300
322
  // Same specificity as the `plain` block above, so source order decides: this
@@ -339,6 +361,15 @@
339
361
  color: rgb(var(--color-primary));
340
362
  }
341
363
 
364
+ // The accent face already rests in the primary colour, so the hover cue is
365
+ // the deepened tint plus an underline — the glyph colour alone cannot move.
366
+ .s-about-modal-link[data-face="accent"]:hover {
367
+ background: none;
368
+ color: var(--c-primary-strong, rgb(var(--color-primary) / 80%));
369
+ text-decoration: underline;
370
+ text-underline-offset: 0.15em;
371
+ }
372
+
342
373
  .s-about-modal-link:active {
343
374
  background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
344
375
  }
@@ -348,6 +379,11 @@
348
379
  color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
349
380
  }
350
381
 
382
+ .s-about-modal-link[data-face="accent"]:active {
383
+ background: none;
384
+ color: rgb(var(--color-muted));
385
+ }
386
+
351
387
  .s-about-modal-link:focus-visible {
352
388
  outline: none;
353
389
  box-shadow:
@@ -136,6 +136,44 @@ describe("HkAboutModal stylesheet contract", () => {
136
136
  expect(declaration(ruleBody(css, `${PLAIN}:active`), "background")).toBe("none");
137
137
  });
138
138
 
139
+ const ACCENT = ".s-about-modal-link[data-face=accent]";
140
+
141
+ it("keeps the accent face a bare text link tinted in the primary colour", () => {
142
+ const accent = ruleBody(css, ACCENT);
143
+ expect(accent, "the accent-face rule must survive compilation").not.toBe("");
144
+ expect({
145
+ padding: declaration(accent, "padding"),
146
+ border: declaration(accent, "border"),
147
+ radius: declaration(accent, "border-radius"),
148
+ background: declaration(accent, "background"),
149
+ // The tint IS the face: bare geometry like `plain`, but the colour
150
+ // comes from the theme's primary token rather than `inherit`.
151
+ color: declaration(accent, "color"),
152
+ fontSize: declaration(accent, "font-size"),
153
+ lineHeight: declaration(accent, "line-height"),
154
+ }).toEqual({
155
+ padding: "0",
156
+ border: "0",
157
+ radius: "0",
158
+ background: "none",
159
+ color: "rgb(var(--color-primary))",
160
+ fontSize: "inherit",
161
+ lineHeight: "inherit",
162
+ });
163
+ // No underline at rest — the tint is the cue; the hover adds one.
164
+ expect(declaration(accent, "text-decoration")).not.toContain("underline");
165
+ });
166
+
167
+ it("gives the accent hover an added cue beyond the resting tint", () => {
168
+ // The accent face already rests in the primary colour, so a hover that
169
+ // only re-set the same colour would be invisible — the deepened tint
170
+ // plus the underline is the contract.
171
+ const hover = ruleBody(css, `${ACCENT}:hover`);
172
+ expect(declaration(hover, "background")).toBe("none");
173
+ expect(declaration(hover, "text-decoration")).toContain("underline");
174
+ expect(declaration(ruleBody(css, `${ACCENT}:active`), "background")).toBe("none");
175
+ });
176
+
139
177
  it("keeps an icon link one aligned unit with a visible mark", () => {
140
178
  const iconLink = ruleBody(css, ".s-about-modal-link.s-about-modal-link-has-icon");
141
179
  expect(declaration(iconLink, "display")).toBe("inline-flex");
@@ -16,7 +16,8 @@ import { setLocale } from "../i18n/context";
16
16
  * - the backdrop factory renders inside the clipped backdrop layer
17
17
  * - licenses and links render as chips under one centered block
18
18
  * - footer (filing) links render as external links
19
- * - `face: "plain"` turns any of them into a bare text link (still a link,
19
+ * - `face: "plain"` / `face: "accent"` turn any of them into a bare text
20
+ * link (still a link,
20
21
  * still a new tab), and an entry carrying an `icon` renders the mark, with
21
22
  * an accessible name when it has no label to be named by
22
23
  *
@@ -156,6 +157,31 @@ describe("HkAboutModal branding", () => {
156
157
  }
157
158
  });
158
159
 
160
+ it("renders a credit name as a tinted accent link when the entry asks for it", async () => {
161
+ mountAbout({
162
+ credits: [
163
+ { text: "来自 " },
164
+ { name: "Celestia Island", href: "https://github.com/celestia-island", face: "accent" },
165
+ { text: ",由 " },
166
+ { name: "伊欧", href: "https://github.com/langyo", face: "accent" },
167
+ { text: " 主创" },
168
+ ],
169
+ });
170
+ await flushModal();
171
+ const links = [
172
+ ...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
173
+ ];
174
+ // Same bare-text shape as `plain` (no chip geometry), but the entry's
175
+ // face reaches the stylesheet as `accent`.
176
+ expect(links.map((link) => link.textContent)).toEqual(["Celestia Island", "伊欧"]);
177
+ for (const link of links) {
178
+ expect(link.getAttribute("data-face")).toBe("accent");
179
+ expect(link.classList.contains("s-about-modal-link")).toBe(true);
180
+ expect(link.getAttribute("target")).toBe("_blank");
181
+ expect(link.getAttribute("aria-label")).toBeNull();
182
+ }
183
+ });
184
+
159
185
  it("renders plain faces across the link rows and the legal filings", async () => {
160
186
  mountAbout({
161
187
  links: [
@@ -11,11 +11,13 @@ import "./HkAboutModal.scss";
11
11
  *
12
12
  * `chip` is the dialog's original face — a ghost tag. `plain` is bare text:
13
13
  * no frame, no underline, so the hover colour shift is the whole
14
- * affordance. Chosen per link, because one dialog can want both (the
15
- * credits names / URLs / filings read as sentence text, the licenses stay
16
- * tags).
14
+ * affordance. `accent` is also bare text, but tinted in the primary colour
15
+ * so the link visibly stands out of the sentence it sits in — the "this is
16
+ * clickable" cue (user direction 2026-09-15). Chosen per link, because one
17
+ * dialog can want both (the credits names / URLs / filings read as sentence
18
+ * text, the licenses stay tags).
17
19
  */
18
- export type HAboutLinkFace = "chip" | "plain";
20
+ export type HAboutLinkFace = "chip" | "plain" | "accent";
19
21
 
20
22
  /** Leading icon a link can carry; an icon-only link shows it alone. */
21
23
  export type HAboutLinkIcon = "github";
@@ -90,8 +92,8 @@ export interface HAboutComponentVersion {
90
92
  * bordered spec card holding the software-component versions, and the
91
93
  * link rows — licenses, external links and legal filings. Every link opens
92
94
  * in a new tab and renders in the face its entry asks for: the ghost chip,
93
- * or bare text (`plain`) for names / URLs / filings that should read as
94
- * ordinary sentence text. Every branding prop is optional — the modal
95
+ * or bare text (`plain` / tinted `accent`) for names / URLs / filings that
96
+ * should read as ordinary sentence text. Every branding prop is optional — the modal
95
97
  * degrades to the plain identity card when none are given.
96
98
  */
97
99
  export const HkAboutModal = defineComponent({
@@ -111,7 +113,8 @@ export const HkAboutModal = defineComponent({
111
113
  /**
112
114
  * Credits sentence, assembled from text runs and linked names
113
115
  * (e.g. 来自 <Celestia Island>,由 <伊欧> 主创). Each name renders in the
114
- * face its entry asks for — `chip`, or bare text with `plain`.
116
+ * face its entry asks for — `chip`, bare text with `plain`, or bare
117
+ * text tinted in the primary colour with `accent`.
115
118
  */
116
119
  credits: { type: Array as PropType<HAboutCredit[]>, default: () => [] },
117
120
  /** License links (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
@@ -0,0 +1,92 @@
1
+ // Dock-slot contract for HkScrollContainer.
2
+ //
3
+ // A dock (dockTop / dockBottom named slot) renders as a NON-scrolling
4
+ // flex sibling of the viewport: pinned to its edge, occupying layout
5
+ // space (the flex: 1 viewport shrinks by the dock's height — content
6
+ // can never slide under it). The container measures each mounted dock
7
+ // and publishes --hk-scroll-dock-top / --hk-scroll-dock-bottom on the
8
+ // host so consumers size their content end-padding against the dock
9
+ // without hand-rolled px. The dock-edge fade rides on data-dock-fade.
10
+ //
11
+ // House style: raw createApp mounts on shared containers (no
12
+ // @vue/test-utils).
13
+ import { afterEach, describe, expect, it } from "vitest";
14
+ import { createApp, h, defineComponent } from "vue";
15
+
16
+ import HkScrollContainer from "./HkScrollContainer";
17
+
18
+ const mounts: ReturnType<typeof createApp>[] = [];
19
+ const containers: HTMLElement[] = [];
20
+
21
+ function mountDock(slots: Record<string, () => any>, props: Record<string, unknown> = {}) {
22
+ const container = document.createElement("div");
23
+ document.body.appendChild(container);
24
+ containers.push(container);
25
+ const app = createApp(defineComponent({
26
+ setup() {
27
+ return () => h(HkScrollContainer, props, slots);
28
+ },
29
+ }));
30
+ mounts.push(app);
31
+ return app.mount(container);
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const app of mounts) app.unmount();
36
+ mounts.length = 0;
37
+ for (const el of containers) el.remove();
38
+ containers.length = 0;
39
+ });
40
+
41
+ describe("HkScrollContainer dock slots", () => {
42
+ it("renders a bottom dock as a non-scrolling sibling with the fade marker", () => {
43
+ const vm = mountDock({
44
+ default: () => h("div", { class: "content" }, "rows"),
45
+ dockBottom: () => h("div", { class: "the-dock" }, "dock"),
46
+ });
47
+ const host = vm.$el as HTMLElement;
48
+ const dock = host.querySelector(".hk-scroll-dock[data-side='bottom']");
49
+ expect(dock).toBeTruthy();
50
+ // Sibling of the viewport, not inside it — the dock never scrolls.
51
+ const viewport = host.querySelector(".hk-scroll-container-viewport")!;
52
+ expect(viewport.contains(dock as Node)).toBe(false);
53
+ // The dock-edge fade engages by default for a bottom dock.
54
+ expect(host.getAttribute("data-dock-fade")).toBe("bottom");
55
+ // The measured-height vars are published on the host (0px in
56
+ // happy-dom — no layout; the contract is the vars exist + RO wired).
57
+ expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("0px");
58
+ expect(host.style.getPropertyValue("--hk-scroll-dock-top")).toBe("0px");
59
+ });
60
+
61
+ it("renders a top dock and fades the top edge instead", () => {
62
+ const vm = mountDock({
63
+ default: () => h("div", { class: "content" }, "rows"),
64
+ dockTop: () => h("div", { class: "the-dock" }, "progress"),
65
+ });
66
+ const host = vm.$el as HTMLElement;
67
+ expect(host.querySelector(".hk-scroll-dock[data-side='top']")).toBeTruthy();
68
+ expect(host.getAttribute("data-dock-fade")).toBe("top");
69
+ expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeNull();
70
+ });
71
+
72
+ it("omits the dock fade marker when dockFade is off", () => {
73
+ const vm = mountDock(
74
+ {
75
+ default: () => h("div", { class: "content" }, "rows"),
76
+ dockBottom: () => h("div", { class: "the-dock" }, "dock"),
77
+ },
78
+ { dockFade: false },
79
+ );
80
+ const host = vm.$el as HTMLElement;
81
+ expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeTruthy();
82
+ expect(host.getAttribute("data-dock-fade")).toBeNull();
83
+ });
84
+
85
+ it("publishes no dock marker without dock slots", () => {
86
+ const vm = mountDock({ default: () => h("div", { class: "content" }, "rows") });
87
+ const host = vm.$el as HTMLElement;
88
+ expect(host.querySelector(".hk-scroll-dock")).toBeNull();
89
+ expect(host.getAttribute("data-dock-fade")).toBeNull();
90
+ expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("");
91
+ });
92
+ });
@@ -53,6 +53,38 @@
53
53
  mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
54
54
  }
55
55
 
56
+ /* ── Anchored docks (dockTop / dockBottom slots) ─────────────────
57
+ A dock renders as a NON-scrolling flex sibling of the viewport: it
58
+ stays pinned to its edge, never scrolls, and occupies layout space —
59
+ the flex: 1 viewport shrinks by the dock's height, so the content's
60
+ scroll end lands exactly at the dock's edge and can never slide
61
+ under it. The container measures each mounted dock (ResizeObserver)
62
+ and publishes --hk-scroll-dock-top / --hk-scroll-dock-bottom on the
63
+ host, so a consumer's content end-padding clears the dock with
64
+ breathing room (padding-bottom: calc(var(--hk-scroll-dock-bottom)
65
+ + 2rem)) instead of hand-rolled px guesses that go stale when the
66
+ dock reflows. */
67
+ .hk-scroll-dock {
68
+ flex: none;
69
+ min-height: 0;
70
+ }
71
+
72
+ /* Dock-edge fade: mid-scroll content dissolving at the dock's edge
73
+ (opacity 100 -> 0 across --hk-scroll-dock-fade) reads as intentional
74
+ instead of a hard clip against the dock's top edge. Content never
75
+ sits under the dock, so this fades the viewport's last few pixels,
76
+ not the dock itself. */
77
+ .hk-scroll-container[data-dock-fade="bottom"] > .hk-scroll-container-viewport {
78
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
79
+ mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
80
+ }
81
+
82
+ .hk-scroll-container[data-dock-fade="top"] > .hk-scroll-container-viewport {
83
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
84
+ mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
85
+ }
86
+
87
+
56
88
  .hk-scroll-container[data-fade="true"][data-h-overflow="start"] > .hk-scroll-container-viewport {
57
89
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
58
90
  mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
@@ -80,12 +80,21 @@ export default defineComponent({
80
80
  * approaching (`approachEnd`). Read live, so runtime changes need
81
81
  * no remount. */
82
82
  approachDistance: { type: Number, default: 160 },
83
+ /** Fade the viewport's bottom edge (CSS mask, --hk-scroll-dock-fade
84
+ * tall, opacity 100 -> 0) while a dock slot is mounted, so mid-
85
+ * scroll content dissolving at the dock's edge reads as
86
+ * intentional instead of a hard clip. */
87
+ dockFade: { type: Boolean, default: true },
83
88
  },
84
89
  emits: { approachEnd: () => true },
85
90
  setup(props, { slots, expose, emit }) {
86
91
  const { t } = useI18n();
87
92
  const viewportRef = ref<HTMLElement>();
93
+ const hostRef = ref<HTMLElement>();
94
+ const dockTopRef = ref<HTMLElement>();
95
+ const dockBottomRef = ref<HTMLElement>();
88
96
  let ro: ResizeObserver | null = null;
97
+ let dockRO: ResizeObserver | null = null;
89
98
  let scheduled: AnimationHandle | null = null;
90
99
  // Overlay track/thumb machinery lives in the shared composable;
91
100
  // this component keeps overflow sensing, autoFollow, the aligner,
@@ -292,10 +301,40 @@ export default defineComponent({
292
301
  }
293
302
  }
294
303
 
304
+ /** Publish each mounted dock's measured height as a host-level custom
305
+ * property (`--hk-scroll-dock-top` / `--hk-scroll-dock-bottom`). The
306
+ * dock is a non-scrolling flex sibling, so the viewport already
307
+ * shrinks by its height; the published var exists so a consumer's
308
+ * content end-padding can clear the dock with breathing room
309
+ * (padding-bottom: calc(var(--hk-scroll-dock-bottom, 0px) + 2rem))
310
+ * instead of hand-rolled px guesses that go stale when the dock
311
+ * reflows. ResizeObserver keeps it live across dock reflows. */
312
+ function publishDockSizes() {
313
+ const host = hostRef.value;
314
+ if (!host) return;
315
+ const top = dockTopRef.value;
316
+ const bottom = dockBottomRef.value;
317
+ host.style.setProperty(
318
+ "--hk-scroll-dock-top",
319
+ top ? `${Math.round(top.getBoundingClientRect().height)}px` : "0px",
320
+ );
321
+ host.style.setProperty(
322
+ "--hk-scroll-dock-bottom",
323
+ bottom ? `${Math.round(bottom.getBoundingClientRect().height)}px` : "0px",
324
+ );
325
+ }
326
+
295
327
  onMounted(() => {
296
328
  const vp = viewportRef.value;
297
329
  if (!vp) return;
298
330
 
331
+ if (dockTopRef.value || dockBottomRef.value) {
332
+ dockRO = new ResizeObserver(publishDockSizes);
333
+ if (dockTopRef.value) dockRO.observe(dockTopRef.value);
334
+ if (dockBottomRef.value) dockRO.observe(dockBottomRef.value);
335
+ publishDockSizes();
336
+ }
337
+
299
338
  if (props.scrollbar) {
300
339
  mountScrollbars();
301
340
  }
@@ -340,6 +379,8 @@ export default defineComponent({
340
379
  settleHandle = null;
341
380
  ro?.disconnect();
342
381
  ro = null;
382
+ dockRO?.disconnect();
383
+ dockRO = null;
343
384
  followRO?.disconnect();
344
385
  followRO = null;
345
386
  alignRO?.disconnect();
@@ -431,13 +472,34 @@ export default defineComponent({
431
472
  if (alignCenter()) {
432
473
  content = <div ref={setAligner} class="hk-scroll-container-aligner">{content}</div>;
433
474
  }
475
+ // Anchored dock slots: a dock renders as a NON-scrolling flex
476
+ // sibling of the viewport — it stays pinned to its edge, never
477
+ // scrolls, and occupies layout space (the flex: 1 viewport shrinks
478
+ // by the dock's height). The container measures each mounted dock
479
+ // and publishes --hk-scroll-dock-top/bottom so consumers can size
480
+ // their content end-padding against the dock without guessing px.
481
+ const dockTopSlot = slots.dockTop;
482
+ const dockBottomSlot = slots.dockBottom;
434
483
  return (
435
484
  <Tag
485
+ ref={hostRef}
436
486
  class="hk-scroll-container"
437
487
  data-axis={props.axis}
438
488
  data-align={alignCenter() ? "center" : undefined}
439
489
  data-fade={props.fade ? "true" : undefined}
490
+ data-dock-fade={
491
+ props.dockFade && dockBottomSlot
492
+ ? "bottom"
493
+ : props.dockFade && dockTopSlot
494
+ ? "top"
495
+ : undefined
496
+ }
440
497
  >
498
+ {dockTopSlot && (
499
+ <div ref={dockTopRef} class="hk-scroll-dock" data-side="top">
500
+ {dockTopSlot()}
501
+ </div>
502
+ )}
441
503
  {/* Scroll-pin host marker: the viewport participates in the pin
442
504
  contract with its live axis; generic containers declare no
443
505
  standard padding, so pins inside resolve their strategy from
@@ -449,6 +511,11 @@ export default defineComponent({
449
511
  >
450
512
  {content}
451
513
  </div>
514
+ {dockBottomSlot && (
515
+ <div ref={dockBottomRef} class="hk-scroll-dock" data-side="bottom">
516
+ {dockBottomSlot()}
517
+ </div>
518
+ )}
452
519
  {showAutoTag.value && (
453
520
  <span class="hk-scroll-container-autotag" aria-hidden="true">{t("hikari::scrollContainer.auto", "Auto")}</span>
454
521
  )}