@celestia-island/hikari 0.35.1 → 0.36.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.35.1",
3
+ "version": "0.36.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",
@@ -0,0 +1,259 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { HkAuthCard } from "./HkAuthCard";
5
+
6
+ /** Injectable ResizeObserver: captures the callback so tests can fire
7
+ * content changes deterministically (happy-dom's own RO never fires —
8
+ * there is no layout engine). Same harness as useSizeMorph.test.ts. */
9
+ class FakeResizeObserver {
10
+ static instances: FakeResizeObserver[] = [];
11
+ callback: () => void;
12
+ observed: Element[] = [];
13
+ disconnected = false;
14
+
15
+ constructor(callback: () => void) {
16
+ this.callback = callback;
17
+ FakeResizeObserver.instances.push(this);
18
+ }
19
+ observe(el: Element): void {
20
+ this.observed.push(el);
21
+ }
22
+ disconnect(): void {
23
+ this.disconnected = true;
24
+ }
25
+ }
26
+
27
+ const originalRO = globalThis.ResizeObserver;
28
+
29
+ /** The prototype that actually defines `offsetHeight` in this DOM
30
+ * implementation, so the stub below is portable across environments. */
31
+ function offsetHeightOwner(): object {
32
+ let proto = Object.getPrototypeOf(document.createElement("div")) as object | null;
33
+ while (proto) {
34
+ if (Object.getOwnPropertyDescriptor(proto, "offsetHeight")) return proto;
35
+ proto = Object.getPrototypeOf(proto);
36
+ }
37
+ return HTMLElement.prototype;
38
+ }
39
+
40
+ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
41
+ offsetHeightOwner(),
42
+ "offsetHeight",
43
+ );
44
+
45
+ /** Natural height every element reports for `offsetHeight` in tests. */
46
+ let naturalHeight = 0;
47
+
48
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
49
+
50
+ function mount(node: ReturnType<typeof h>) {
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const app = createApp({ render: () => node });
54
+ app.mount(container);
55
+ mounts.push({ app, container });
56
+ return container;
57
+ }
58
+
59
+ function cardOf(c: HTMLElement) {
60
+ return c.querySelector<HTMLElement>(".s-auth-card")!;
61
+ }
62
+
63
+ function wrapperOf(c: HTMLElement) {
64
+ return c.querySelector<HTMLElement>(".s-auth-card-height")!;
65
+ }
66
+
67
+ function fireTransitionEnd(el: HTMLElement, propertyName: string) {
68
+ const event = new Event("transitionend", { bubbles: true });
69
+ Object.defineProperty(event, "propertyName", { value: propertyName });
70
+ el.dispatchEvent(event);
71
+ }
72
+
73
+ beforeEach(() => {
74
+ FakeResizeObserver.instances = [];
75
+ naturalHeight = 140;
76
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
77
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", {
78
+ configurable: true,
79
+ get: () => naturalHeight,
80
+ });
81
+ });
82
+
83
+ afterEach(() => {
84
+ globalThis.ResizeObserver = originalRO;
85
+ if (originalOffsetHeight) {
86
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", originalOffsetHeight);
87
+ } else {
88
+ delete (offsetHeightOwner() as { offsetHeight?: number }).offsetHeight;
89
+ }
90
+ vi.restoreAllMocks();
91
+ vi.useRealTimers();
92
+ for (const { app, container } of mounts.splice(0)) {
93
+ app.unmount();
94
+ container.remove();
95
+ }
96
+ });
97
+
98
+ describe("HkAuthCard", () => {
99
+ it("renders title, subtitle and every slot inside the unchanged card shell", () => {
100
+ const c = mount(
101
+ h(HkAuthCard, { title: "Sign in", subtitle: "Welcome back" }, {
102
+ default: () => h("input", { name: "probe-field" }),
103
+ methods: () => h("div", { class: "probe-methods" }, "methods"),
104
+ footer: () => h("div", { class: "probe-footer" }, "footer"),
105
+ }),
106
+ );
107
+ // The measuring wrapper is present and the card is still `.s-auth-card`.
108
+ const wrapper = wrapperOf(c);
109
+ const card = cardOf(c);
110
+ expect(wrapper).toBeTruthy();
111
+ expect(card).toBeTruthy();
112
+ expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
113
+ expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
114
+ expect(card.querySelector(".s-auth-form input[name='probe-field']")).toBeTruthy();
115
+ expect(card.querySelector(".s-auth-methods .probe-methods")).toBeTruthy();
116
+ expect(card.querySelector(".s-auth-footer .probe-footer")).toBeTruthy();
117
+ // The wrapper is transparent to descendant styling: the header, form,
118
+ // methods and footer blocks all remain children of `.s-auth-card`.
119
+ expect(wrapper.querySelector(".s-auth-card")).toBe(card);
120
+ });
121
+
122
+ it("seeds the wrapper height from the card on mount without animating", () => {
123
+ naturalHeight = 140;
124
+ const c = mount(h(HkAuthCard, { title: "T" }));
125
+ const wrapper = wrapperOf(c);
126
+ expect(wrapper.style.height).toBe("140px");
127
+ // Seeding must not animate: the measuring class is absent on first paint.
128
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
129
+ // The observer watches the card element itself, not the wrapper.
130
+ expect(FakeResizeObserver.instances).toHaveLength(1);
131
+ expect(FakeResizeObserver.instances[0]!.observed).toEqual([cardOf(c)]);
132
+ });
133
+
134
+ it("animates growth through the measuring class and releases it on the backstop timer", () => {
135
+ vi.useFakeTimers();
136
+ naturalHeight = 140;
137
+ const c = mount(h(HkAuthCard, { title: "T" }));
138
+ const wrapper = wrapperOf(c);
139
+ expect(wrapper.style.height).toBe("140px");
140
+
141
+ naturalHeight = 240;
142
+ FakeResizeObserver.instances[0]!.callback();
143
+ expect(wrapper.style.height).toBe("240px");
144
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
145
+
146
+ vi.advanceTimersByTime(449);
147
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
148
+ vi.advanceTimersByTime(1);
149
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
150
+ });
151
+
152
+ it("releases the measuring class when the height transition finishes", () => {
153
+ naturalHeight = 100;
154
+ const c = mount(h(HkAuthCard, { title: "T" }));
155
+ const wrapper = wrapperOf(c);
156
+
157
+ naturalHeight = 180;
158
+ FakeResizeObserver.instances[0]!.callback();
159
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
160
+
161
+ // An unrelated property finishing its transition must not release.
162
+ fireTransitionEnd(wrapper, "color");
163
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
164
+
165
+ fireTransitionEnd(wrapper, "height");
166
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
167
+ });
168
+
169
+ it("ignores height transitionsend events bubbling up from descendant content", () => {
170
+ naturalHeight = 100;
171
+ const c = mount(
172
+ h(HkAuthCard, { title: "T" }, { default: () => h("div", { class: "probe-anim" }) }),
173
+ );
174
+ const wrapper = wrapperOf(c);
175
+ const child = c.querySelector<HTMLElement>(".probe-anim")!;
176
+
177
+ naturalHeight = 180;
178
+ FakeResizeObserver.instances[0]!.callback();
179
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
180
+
181
+ // A descendant slot element's own height transition bubbling up must
182
+ // NOT release the clip — it did not open this measuring window.
183
+ fireTransitionEnd(child, "height");
184
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
185
+
186
+ // Only the wrapper's own height transition may end it.
187
+ fireTransitionEnd(wrapper, "height");
188
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
189
+ });
190
+
191
+ it("snaps unclipped under prefers-reduced-motion without arming the backstop", () => {
192
+ vi.useFakeTimers();
193
+ vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as unknown as MediaQueryList);
194
+ naturalHeight = 140;
195
+ const c = mount(h(HkAuthCard, { title: "T" }));
196
+ const wrapper = wrapperOf(c);
197
+
198
+ naturalHeight = 240;
199
+ FakeResizeObserver.instances[0]!.callback();
200
+ // Height still tracks the content…
201
+ expect(wrapper.style.height).toBe("240px");
202
+ // …but with the transition disabled there is nothing to animate, so
203
+ // no measuring window opens: no clip, no pending backstop timer.
204
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
205
+ expect(vi.getTimerCount()).toBe(0);
206
+ });
207
+
208
+ it("ignores observer firings that move the height by less than a pixel", () => {
209
+ naturalHeight = 140;
210
+ const c = mount(h(HkAuthCard, { title: "T" }));
211
+ const wrapper = wrapperOf(c);
212
+
213
+ naturalHeight = 140.6;
214
+ FakeResizeObserver.instances[0]!.callback();
215
+ expect(wrapper.style.height).toBe("140px");
216
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
217
+ });
218
+
219
+ it("with smoothHeight=false renders the wrapper but installs no measuring", () => {
220
+ naturalHeight = 140;
221
+ const c = mount(h(HkAuthCard, { title: "T", smoothHeight: false }));
222
+ const wrapper = wrapperOf(c);
223
+ // The wrapper still exists (stable DOM shape for consumers) but is
224
+ // completely unmanaged: no explicit height and no observer.
225
+ expect(wrapper).toBeTruthy();
226
+ expect(wrapper.style.height).toBe("");
227
+ expect(FakeResizeObserver.instances).toHaveLength(0);
228
+ });
229
+
230
+ it("degrades to an unmanaged wrapper when ResizeObserver is unavailable", () => {
231
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = undefined;
232
+ naturalHeight = 140;
233
+ const c = mount(h(HkAuthCard, { title: "T" }));
234
+ const wrapper = wrapperOf(c);
235
+ expect(wrapper).toBeTruthy();
236
+ expect(wrapper.style.height).toBe("");
237
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
238
+ });
239
+
240
+ it("disconnects the observer and clears the backstop on unmount", () => {
241
+ vi.useFakeTimers();
242
+ naturalHeight = 140;
243
+ const c = mount(h(HkAuthCard, { title: "T" }));
244
+ const ro = FakeResizeObserver.instances[0]!;
245
+
246
+ naturalHeight = 200;
247
+ ro.callback();
248
+ expect(wrapperOf(c).classList.contains("s-auth-card--measuring")).toBe(true);
249
+
250
+ // Pop the entry so the shared afterEach does not unmount it twice.
251
+ const entry = mounts.pop()!;
252
+ entry.app.unmount();
253
+ expect(ro.disconnected).toBe(true);
254
+ // The pending backstop timer was cleared on unmount: nothing is left
255
+ // scheduled (a leaked timer here would also fire releaseClip on a
256
+ // dead element).
257
+ expect(vi.getTimerCount()).toBe(0);
258
+ });
259
+ });
@@ -1,4 +1,10 @@
1
- import { defineComponent, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
+
3
+ /** Grace window that force-releases the `--measuring` clip if the height
4
+ * `transitionend` never arrives (interrupted transition, backgrounded
5
+ * tab, reduced-motion). Slightly longer than the 0.3s CSS height
6
+ * transition so a normal completion is always released by the event. */
7
+ const MEASURE_BACKSTOP_MS = 450;
2
8
 
3
9
  /**
4
10
  * HkAuthCard — the shared shell of every Celestia auth screen (login,
@@ -19,42 +25,162 @@ import { defineComponent, type PropType } from "vue";
19
25
  * per-row centering; content that must sit on one row belongs inside
20
26
  * one child element.
21
27
  * - `logo` swaps the header's logo slot; `default` is the form body.
28
+ *
29
+ * Smooth height contract:
30
+ * The card's content is consumer-driven and changes at runtime — channel
31
+ * tabs swap the OAuth `methods` list (3 buttons ↔ 1), validation banners
32
+ * appear, locale switches reflow labels — and the card's height would
33
+ * otherwise snap between the two layouts. The root card is therefore
34
+ * wrapped in a `.s-auth-card-height` measuring wrapper that owns an
35
+ * explicit `height` and animates toward whatever natural height the card
36
+ * grows or shrinks to:
37
+ *
38
+ * - On mount the wrapper seeds its `height` from the card's current
39
+ * `offsetHeight` while the animating state is impossible, so the first
40
+ * paint never animates.
41
+ * - A `ResizeObserver` on the card element (not the wrapper) fires when
42
+ * the content-driven natural height changes. Changes smaller than 1px
43
+ * are ignored to keep rounding noise from starting a cycle. A real
44
+ * change adds the `s-auth-card--measuring` class — the ONLY place
45
+ * `overflow: hidden` is applied — then rewrites the height, and the CSS
46
+ * `height` transition animates the wrapper between the two sizes.
47
+ * - The clipping class is released by the wrapper's `transitionend`
48
+ * (propertyName === "height") and, if that event never fires, by a
49
+ * 450ms backstop timer. Between animations the card carries no clip,
50
+ * so its shadow, focus rings and popovers are never cut off at rest.
51
+ * - `smoothHeight: false` opts out entirely: the wrapper still renders
52
+ * (the DOM shape stays stable for consumers) but no height style, no
53
+ * observer and no listeners are installed, and height changes snap as
54
+ * before. The prop is read ONCE at mount time — runtime toggles are
55
+ * ignored, making it a static opt-out rather than a reactive switch
56
+ * (remount with a changed `:key` to apply a new value).
57
+ * - Environments without `ResizeObserver` degrade the same way: instant
58
+ * snap, no measurement, no clipping.
59
+ * - `prefers-reduced-motion: reduce` disables the transition in CSS, so
60
+ * the height still tracks the content, just without animation. While
61
+ * it matches, the observer updates the height directly WITHOUT opening
62
+ * a measuring window — no clip and no backstop timer — because a clip
63
+ * held for the backstop would cut off the card's shadow with nothing
64
+ * animating to mask it.
65
+ *
66
+ * The wrapper is transparent to descendant styling: nothing inside
67
+ * `.s-auth-card` changes, and no hikari stylesheet targets the card as a
68
+ * direct child. Only consumer styles that select `.s-auth-card` as a
69
+ * DIRECT child of a specific parent must account for the extra wrapper
70
+ * (e.g. `.us-main > .s-auth-card` becomes
71
+ * `.us-main > .s-auth-card-height > .s-auth-card`).
22
72
  */
23
73
  export const HkAuthCard = defineComponent({
24
74
  name: "HkAuthCard",
25
75
  props: {
26
76
  title: { type: String, required: true },
27
77
  subtitle: { type: String, default: "" },
78
+ smoothHeight: { type: Boolean, default: true },
28
79
  },
29
80
  setup(props, { slots }) {
81
+ const heightEl = ref<HTMLElement | null>(null);
82
+ const cardEl = ref<HTMLElement | null>(null);
83
+
84
+ let observer: ResizeObserver | null = null;
85
+ let backstopTimer: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ /** End the current measuring window: drop the clip and the backstop. */
88
+ const releaseClip = () => {
89
+ if (backstopTimer !== null) {
90
+ clearTimeout(backstopTimer);
91
+ backstopTimer = null;
92
+ }
93
+ heightEl.value?.classList.remove("s-auth-card--measuring");
94
+ };
95
+
96
+ const onTransitionEnd = (event: TransitionEvent) => {
97
+ // Only the wrapper's OWN height transition may release the clip —
98
+ // a descendant slot element's height transition bubbling up must
99
+ // not end a measuring window it did not open.
100
+ if (event.target !== heightEl.value) return;
101
+ if (event.propertyName !== "height") return;
102
+ releaseClip();
103
+ };
104
+
105
+ onMounted(() => {
106
+ if (!props.smoothHeight) return;
107
+ const wrapper = heightEl.value;
108
+ const card = cardEl.value;
109
+ if (!wrapper || !card || typeof ResizeObserver === "undefined") return;
110
+
111
+ // Seed the explicit height BEFORE any animating class exists, so
112
+ // the first paint (wrapper already at the natural size) never
113
+ // animates.
114
+ wrapper.style.height = `${card.offsetHeight}px`;
115
+ wrapper.addEventListener("transitionend", onTransitionEnd);
116
+
117
+ observer = new ResizeObserver(() => {
118
+ const next = card.offsetHeight;
119
+ const current = parseFloat(wrapper.style.height) || 0;
120
+ // Ignore sub-pixel churn — rounding noise must not start a
121
+ // clip-and-animate cycle.
122
+ if (Math.abs(next - current) < 1) return;
123
+
124
+ // Reduced motion: the CSS transition is `none`, so transitionend
125
+ // will never fire and the clip would just sit there for the
126
+ // backstop window, cutting off the shadow with nothing to mask.
127
+ // Snap unclipped instead — no class, no timer.
128
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
129
+ wrapper.style.height = `${next}px`;
130
+ return;
131
+ }
132
+
133
+ // Clip ONLY while a height transition is actually running; the
134
+ // class is released by transitionend or the backstop timer.
135
+ wrapper.classList.add("s-auth-card--measuring");
136
+ wrapper.style.height = `${next}px`;
137
+
138
+ if (backstopTimer !== null) clearTimeout(backstopTimer);
139
+ backstopTimer = setTimeout(releaseClip, MEASURE_BACKSTOP_MS);
140
+ });
141
+ observer.observe(card);
142
+ });
143
+
144
+ onBeforeUnmount(() => {
145
+ observer?.disconnect();
146
+ observer = null;
147
+ heightEl.value?.removeEventListener("transitionend", onTransitionEnd);
148
+ if (backstopTimer !== null) {
149
+ clearTimeout(backstopTimer);
150
+ backstopTimer = null;
151
+ }
152
+ });
153
+
30
154
  return () => (
31
- <div class="s-auth-card">
32
- {slots.logo && (
33
- <div class="s-auth-header">
34
- {slots.logo()}
35
- <h1 class="s-auth-title">{props.title}</h1>
36
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
37
- </div>
38
- )}
39
- {!slots.logo && (
40
- <div class="s-auth-header">
41
- <h1 class="s-auth-title">{props.title}</h1>
42
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
155
+ <div class="s-auth-card-height" ref={heightEl}>
156
+ <div class="s-auth-card" ref={cardEl}>
157
+ {slots.logo && (
158
+ <div class="s-auth-header">
159
+ {slots.logo()}
160
+ <h1 class="s-auth-title">{props.title}</h1>
161
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
162
+ </div>
163
+ )}
164
+ {!slots.logo && (
165
+ <div class="s-auth-header">
166
+ <h1 class="s-auth-title">{props.title}</h1>
167
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
168
+ </div>
169
+ )}
170
+ <div class="s-auth-form">
171
+ {slots.default?.()}
43
172
  </div>
44
- )}
45
- <div class="s-auth-form">
46
- {slots.default?.()}
173
+ {slots.methods && (
174
+ <div class="s-auth-methods">
175
+ {slots.methods()}
176
+ </div>
177
+ )}
178
+ {slots.footer && (
179
+ <div class="s-auth-footer">
180
+ {slots.footer()}
181
+ </div>
182
+ )}
47
183
  </div>
48
- {slots.methods && (
49
- <div class="s-auth-methods">
50
- {slots.methods()}
51
- </div>
52
- )}
53
- {slots.footer && (
54
- <div class="s-auth-footer">
55
- {slots.footer()}
56
- </div>
57
- )}
58
184
  </div>
59
185
  );
60
186
  },
@@ -195,42 +195,53 @@
195
195
  word-break: break-word;
196
196
  }
197
197
 
198
+ /* Syntax highlighting palette. Values are app-overridable through the
199
+ * --code-* custom properties (dark = One Dark, light = GitHub Light in
200
+ * themes that define the family — e.g. celestia webui theme.scss);
201
+ * the fallbacks below are the One Dark originals so standalone
202
+ * consumers render exactly as before. */
198
203
  .s-tool-code-block .hljs-keyword,
199
- .s-tool-code-block .hljs-selector-tag,
204
+ .s-tool-code-block .hljs-selector-tag {
205
+ color: var(--code-keyword, #c678dd);
206
+ }
207
+
200
208
  .s-tool-code-block .hljs-built_in {
201
- color: #c678dd;
209
+ color: var(--code-builtin, #c678dd);
210
+ }
211
+
212
+ .s-tool-code-block .hljs-string {
213
+ color: var(--code-string, #98c379);
202
214
  }
203
215
 
204
- .s-tool-code-block .hljs-string,
205
216
  .s-tool-code-block .hljs-attr {
206
- color: #98c379;
217
+ color: var(--code-number, #d19a66);
207
218
  }
208
219
 
209
220
  .s-tool-code-block .hljs-number,
210
221
  .s-tool-code-block .hljs-literal {
211
- color: #d19a66;
222
+ color: var(--code-number, #d19a66);
212
223
  }
213
224
 
214
225
  .s-tool-code-block .hljs-comment,
215
226
  .s-tool-code-block .hljs-doctag {
216
- color: #5c6370;
227
+ color: var(--code-comment, #5c6370);
217
228
  font-style: italic;
218
229
  }
219
230
 
220
231
  .s-tool-code-block .hljs-function .hljs-title,
221
232
  .s-tool-code-block .hljs-title.function_ {
222
- color: #61afef;
233
+ color: var(--code-function, #61afef);
223
234
  }
224
235
 
225
236
  .s-tool-code-block .hljs-type,
226
237
  .s-tool-code-block .hljs-class .hljs-title {
227
- color: #e5c07b;
238
+ color: var(--code-type, #e5c07b);
228
239
  }
229
240
 
230
241
  .s-tool-code-block .hljs-variable,
231
242
  .s-tool-code-block .hljs-template-variable,
232
243
  .s-tool-code-block .hljs-property {
233
- color: #e06c75;
244
+ color: var(--code-variable, #e06c75);
234
245
  }
235
246
 
236
247
  .s-tool-code-block .hljs-params {
@@ -352,20 +363,20 @@
352
363
  }
353
364
 
354
365
  .s-jv-str {
355
- color: #98c379;
366
+ color: var(--code-string, #98c379);
356
367
  word-break: break-all;
357
368
  }
358
369
 
359
370
  .s-jv-num {
360
- color: #d19a66;
371
+ color: var(--code-number, #d19a66);
361
372
  }
362
373
 
363
374
  .s-jv-bool {
364
- color: #c678dd;
375
+ color: var(--code-keyword, #c678dd);
365
376
  }
366
377
 
367
378
  .s-jv-str-raw {
368
- color: #98c379;
379
+ color: var(--code-string, #98c379);
369
380
  white-space: pre-wrap;
370
381
  word-break: break-all;
371
382
  }
@@ -527,6 +527,30 @@
527
527
  to { opacity: 1; transform: translateY(0) scale(1); }
528
528
  }
529
529
 
530
+ /* Measuring wrapper (HkAuthCard): owns an explicit height so content-
531
+ driven height changes (channel tabs swapping the methods list, error
532
+ banners, …) animate instead of snapping. The transition lives here,
533
+ always on; the `--measuring` clip below is applied by HkAuthCard only
534
+ for the duration of a height transition, so an idle card never clips
535
+ its shadow, focus rings or popovers. */
536
+ .s-auth-card-height {
537
+ /* The wrapper is sized from the card's offsetHeight (border-box); an
538
+ explicit box-sizing keeps that mapping honest on hosts without a
539
+ global border-box reset. */
540
+ box-sizing: border-box;
541
+ transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
542
+
543
+ &.s-auth-card--measuring {
544
+ overflow: hidden;
545
+ }
546
+ }
547
+
548
+ @media (prefers-reduced-motion: reduce) {
549
+ .s-auth-card-height {
550
+ transition: none;
551
+ }
552
+ }
553
+
530
554
  .s-auth-card input {
531
555
  transition: color calc(infinity * 1s) step-end, background-color calc(infinity * 1s) step-end;
532
556
  appearance: none;