@celestia-island/hikari 0.55.313 → 0.55.315

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.313",
3
+ "version": "0.55.315",
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",
@@ -15,7 +15,10 @@ const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
15
 
16
16
  /** Recursively collect SCSS/TS/TSX sources under src/, skipping the
17
17
  * animation registrar's own directory (it is the thing under test, not
18
- * an animation-owning component). */
18
+ * an animation-owning component). Test files are skipped too: a keyframe
19
+ * NAME quoted inside a test (a negative assertion, a fixture) is not a
20
+ * declaration — counting it made the parity below agree with itself
21
+ * instead of with the sheets (2026-11-07 review, M7). */
19
22
  function collectSourceFiles(dir: string): string[] {
20
23
  const out: string[] = [];
21
24
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -23,7 +26,7 @@ function collectSourceFiles(dir: string): string[] {
23
26
  if (entry.isDirectory()) {
24
27
  if (p === path.resolve(srcDir, "animation")) continue;
25
28
  out.push(...collectSourceFiles(p));
26
- } else if (/\.(scss|ts|tsx)$/.test(entry.name)) {
29
+ } else if (/\.(scss|ts|tsx)$/.test(entry.name) && !/\btest\.[tj]sx?$/.test(entry.name)) {
27
30
  out.push(p);
28
31
  }
29
32
  }
@@ -65,7 +68,6 @@ describe("registerAnimations", () => {
65
68
  expect(byName.get("hk-range-ribbon-pulse")?.infinite).toBe(true);
66
69
  expect(byName.get("s-auth-card-in")?.infinite).toBe(false);
67
70
  expect(byName.get("hk-rolling-number-up")?.infinite).toBe(false);
68
- expect(byName.get("hk-modal-breadcrumb-in")?.infinite).toBe(false);
69
71
  expect(byName.get("hk-pwd-flash")?.infinite).toBe(false);
70
72
  });
71
73
 
@@ -84,8 +84,6 @@ registerCssAnimation("hk-status-pill-pulse", { infinite: true });
84
84
  registerCssAnimation("hk-rolling-number-up");
85
85
  // components/HkSpinner.scss — spinner rotation (loops forever).
86
86
  registerCssAnimation("hk-spinner-rotate", { infinite: true });
87
- // components/HkModalBreadcrumb.scss — breadcrumb entrance (plays once).
88
- registerCssAnimation("hk-modal-breadcrumb-in");
89
87
  // components/HkToast.scss — toast spinner rotation (loops forever).
90
88
  registerCssAnimation("hk-toast-spin", { infinite: true });
91
89
  // components/HkSkeleton.scss — skeleton shimmer (loops forever).
@@ -143,3 +141,7 @@ registerCssAnimation("hk-screen-identify-in");
143
141
  // brushed window still reads as live. Reduced motion keeps the dashed
144
142
  // pending look and drops the loop (house pattern, see HkSkeleton).
145
143
  registerCssAnimation("hk-range-ribbon-pulse", { infinite: true });
144
+ // HkBadge's presence pulse (opacity-only, loops forever): the `pulse`
145
+ // prop's dot breathes while its state is in-flight — a settled dot sits
146
+ // static. Reduced motion drops the loop (house pattern, see HkSkeleton).
147
+ registerCssAnimation("hk-badge-dot-pulse", { infinite: true });
@@ -64,6 +64,29 @@
64
64
  flex-shrink: 0;
65
65
  }
66
66
 
67
+ /* Presence pulse (data-pulse, dot required): an in-flight / live state
68
+ breathes instead of sitting static. Opacity-only so the pill's layout
69
+ box never moves; consumers wanting a glow layer their own shadow. */
70
+ @keyframes hk-badge-dot-pulse {
71
+ 0%,
72
+ 100% {
73
+ opacity: 1;
74
+ }
75
+ 50% {
76
+ opacity: 0.4;
77
+ }
78
+ }
79
+
80
+ .hk-badge[data-pulse] [data-el="dot"] {
81
+ animation: hk-badge-dot-pulse 1.6s ease-in-out infinite;
82
+ }
83
+
84
+ @media (prefers-reduced-motion: reduce) {
85
+ .hk-badge[data-pulse] [data-el="dot"] {
86
+ animation: none;
87
+ }
88
+ }
89
+
67
90
  /* dot-only: strip the pill padding/border/background so it's just the
68
91
  indicator dot, not a dot wrapped in a bordered pill. */
69
92
  .hk-badge[data-dot-only] {
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { compile } from "sass";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import HkBadge from "./HkBadge";
9
+
10
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
11
+
12
+ function mount(renderNode: () => ReturnType<typeof h>) {
13
+ const container = document.createElement("div");
14
+ document.body.appendChild(container);
15
+ const app = createApp({ render: renderNode });
16
+ app.mount(container);
17
+ mounts.push({ app, container });
18
+ return container;
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const { app, container } of mounts.splice(0)) {
23
+ app.unmount();
24
+ container.remove();
25
+ }
26
+ });
27
+
28
+ // Vitest stubs CSS, so the runtime suite can never catch a lost rule —
29
+ // the pulse animation contract is additionally pinned by the compiled
30
+ // stylesheet guard at the bottom of this file (HkCornerBadge pattern).
31
+ describe("HkBadge pulse", () => {
32
+ it("renders the dot and data-pulse only when both dot and pulse are set", () => {
33
+ const container = mount(() => h(HkBadge, { dot: true, pulse: true }, () => "Creating"));
34
+ expect(container.querySelector(".hk-badge [data-el='dot']")).toBeTruthy();
35
+ expect(container.querySelector(".hk-badge[data-pulse]")).toBeTruthy();
36
+ });
37
+
38
+ it("never sets data-pulse without a dot — a pulsing pill with no dot is meaningless", () => {
39
+ const container = mount(() => h(HkBadge, { pulse: true }, () => "Creating"));
40
+ expect(container.querySelector(".hk-badge")).toBeTruthy();
41
+ expect(container.querySelector("[data-pulse]")).toBeNull();
42
+ expect(container.querySelector("[data-el='dot']")).toBeNull();
43
+ });
44
+
45
+ it("keeps the static dot the default — pulse is opt-in per call site", () => {
46
+ const container = mount(() => h(HkBadge, { dot: true }, () => "Idle"));
47
+ expect(container.querySelector(".hk-badge [data-el='dot']")).toBeTruthy();
48
+ expect(container.querySelector("[data-pulse]")).toBeNull();
49
+ });
50
+ });
51
+
52
+ describe("HkBadge pulse stylesheet contract", () => {
53
+ const here = dirname(fileURLToPath(import.meta.url));
54
+ const css = compile(resolve(here, "HkBadge.scss"), { style: "expanded" }).css;
55
+
56
+ it("drives the pulse through the data-pulse selector on the dot only", () => {
57
+ // Sass compiles quoted attribute selectors unquoted ([data-el=dot]).
58
+ expect(css).toContain(".hk-badge[data-pulse] [data-el=dot]");
59
+ // The animation must never ride the pill itself — layout stays frozen.
60
+ expect(css.match(/\.hk-badge\s*\{[^}]*animation/)).toBeNull();
61
+ });
62
+
63
+ it("names the keyframes and runs them infinitely", () => {
64
+ expect(css).toContain("@keyframes hk-badge-dot-pulse");
65
+ expect(/\.hk-badge\[data-pulse\] \[data-el=dot\]\s*\{[^}]*animation:\s*hk-badge-dot-pulse[^;]*infinite/.test(css)).toBe(true);
66
+ });
67
+
68
+ it("switches the pulse off under prefers-reduced-motion", () => {
69
+ expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*\.hk-badge\[data-pulse\] \[data-el=dot\]\s*\{[^}]*animation:\s*none/);
70
+ });
71
+ });
@@ -15,6 +15,9 @@ export default defineComponent({
15
15
  props: {
16
16
  variant: { type: String as PropType<BadgeVariant>, default: "default" },
17
17
  dot: { type: Boolean, default: false },
18
+ /** Animate the dot with a presence pulse (in-flight / live state).
19
+ * Meaningful only with `dot`; honors prefers-reduced-motion. */
20
+ pulse: { type: Boolean, default: false },
18
21
  size: { type: String as PropType<"sm" | "md">, default: "md" },
19
22
  mono: { type: Boolean, default: false },
20
23
  uppercase: { type: Boolean, default: false },
@@ -38,6 +41,7 @@ export default defineComponent({
38
41
  data-size={props.size}
39
42
  data-pill={props.pill ? undefined : "false"}
40
43
  data-mono={props.mono ? "" : undefined}
44
+ data-pulse={props.pulse && props.dot ? "" : undefined}
41
45
  data-uppercase={props.uppercase ? "" : undefined}
42
46
  data-dot-only={(props.dot && !slots.default) || undefined}
43
47
  style={Object.keys(style.value).length ? style.value : undefined}
@@ -6,6 +6,7 @@
6
6
  // host themes (dark mode etc.) when the vars exist.
7
7
 
8
8
  @use "../styles/polyfill" as *;
9
+ @use "./hikari-vars" as vars;
9
10
 
10
11
  .hk-error-landing {
11
12
  --hel-background: var(--color-background, 246 247 249);
@@ -34,7 +35,7 @@
34
35
  padding: var(--space-20, 1.25rem);
35
36
  background: rgb(var(--hel-background));
36
37
  color: rgb(var(--hel-text));
37
- font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
38
+ font-family: var(--font-sans, vars.$hikari-font-family-sans);
38
39
  }
39
40
 
40
41
  .hk-error-landing__card {
@@ -151,6 +151,58 @@
151
151
  transition: transform var(--duration-fast, 0.15s) ease;
152
152
  }
153
153
 
154
+ /* surface — the window choreography (HkModal's whole-window entrance,
155
+ * user direction 2026-11-07): a row arrives rising 10px while settling
156
+ * from 97% scale and fading in, and leaves by sinking back to that same
157
+ * pose. A compositor-only signature end to end — opacity + transform
158
+ * only, so the row never reflows per frame (the lesson the modal's own
159
+ * height-ride era taught the hard way). Siblings glide into the freed
160
+ * space with the same ease. Shares the window family's curves: the
161
+ * modal's ease-out (0.33, 1, 0.68, 1) arriving, its ease-in
162
+ * (0.45, 0, 0.55, 1) departing. */
163
+ .hk-list-surface-enter-active {
164
+ transition:
165
+ opacity var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1),
166
+ transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
167
+ }
168
+
169
+ .hk-list-surface-leave-active {
170
+ transition:
171
+ opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
172
+ transform var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1);
173
+ position: absolute;
174
+ pointer-events: none;
175
+ }
176
+
177
+ /* A dying ghost's interactive descendants re-enable hit-testing on
178
+ themselves (the modal breadcrumb's trigger/reveal buttons carry auto —
179
+ the strip itself is pointer-transparent), and inheritance does not
180
+ beat an explicit auto. The ghost must not take clicks while it sinks:
181
+ a tap opened a popover anchored to the fading element (2026-11-07
182
+ review). Specificity (0,1,1) outranks the (0,1,0) button faces. */
183
+ .hk-list-surface-leave-active button {
184
+ pointer-events: none;
185
+ }
186
+
187
+ .hk-list-surface-enter-from,
188
+ .hk-list-surface-leave-to {
189
+ opacity: 0;
190
+ transform: translateY(10px) scale(0.97);
191
+ }
192
+
193
+ .hk-list-surface-move {
194
+ /* opacity rides along on purpose: if a multi-row batch ever classifies
195
+ a leaving ghost as moved (its FLIP delta measured before the
196
+ correction pass lands), the stamped move transition would REPLACE
197
+ the leave's transition shorthand — a transform-only move cancels the
198
+ fade mid-flight and snaps the ghost invisible. Carrying the leave's
199
+ own opacity curve keeps the fade alive under that stamp; for true
200
+ moved rows the property goes 1→1 and is inert. */
201
+ transition:
202
+ opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
203
+ transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
204
+ }
205
+
154
206
  .hk-list-none-enter-active,
155
207
  .hk-list-none-leave-active,
156
208
  .hk-list-none-move {
@@ -171,11 +223,14 @@
171
223
  .hk-list-grow-leave-active,
172
224
  .hk-list-reveal-enter-active,
173
225
  .hk-list-reveal-leave-active,
226
+ .hk-list-surface-enter-active,
227
+ .hk-list-surface-leave-active,
174
228
  .hk-list-pop-move,
175
229
  .hk-list-slide-move,
176
230
  .hk-list-fade-move,
177
231
  .hk-list-grow-move,
178
- .hk-list-reveal-move {
232
+ .hk-list-reveal-move,
233
+ .hk-list-surface-move {
179
234
  transition: none !important;
180
235
  }
181
236
  }
@@ -1,14 +1,15 @@
1
- import { defineComponent, onBeforeUpdate, ref, TransitionGroup, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUpdate, queuePostFlushCb, ref, TransitionGroup, type PropType } from "vue";
2
2
 
3
3
  import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
4
4
  import { useReportedTransition } from "../composables/useReportedTransition";
5
+ import { ancestorZoom } from "../runtime/cssZoom";
5
6
 
6
7
  import "./HkListTransition.scss";
7
8
 
8
- type ListAnimVariant = "pop" | "slide" | "fade" | "grow" | "reveal";
9
+ type ListAnimVariant = "pop" | "slide" | "fade" | "grow" | "reveal" | "surface";
9
10
 
10
- /** Enter/leave report window: the slowest variant (pop/slide/fade) runs
11
- * --hi-duration-normal, the fast ones (grow/reveal) run their own 150ms
11
+ /** Enter/leave report window: the slowest variant (pop/slide/fade/surface)
12
+ * runs --hi-duration-normal, the fast ones (grow/reveal) run their own 150ms
12
13
  * plus a 150ms stagger delay — both finish inside this budget. */
13
14
  const REPORT_MS = 320;
14
15
 
@@ -27,6 +28,13 @@ const REPORT_MS = 320;
27
28
  * their natural height (interpolate-size + calc-size), leaving rows
28
29
  * shrink back — the container height itself animates, so wrapping
29
30
  * groups and stacked cards both get a real space transition.
31
+ * - `surface` — the window choreography (HkModal's whole-window
32
+ * entrance, user direction 2026-11-07): a row rises 10px while
33
+ * settling from 97% scale and fading in, and leaves by sinking back
34
+ * to the same pose; siblings glide into the freed space (FLIP).
35
+ * The motion signature of the surface family, so a list whose rows
36
+ * ARE windows (HkModalBreadcrumb's stack) can animate them the way
37
+ * the windows themselves arrive.
30
38
  *
31
39
  * Animation-context participation: every enter/leave batch is REPORTED to
32
40
  * the runtime animation bus (`reportTransition`), so JS choreography that
@@ -69,7 +77,7 @@ export default defineComponent({
69
77
  // the geometry pin; `reveal` animates the row's own height in place
70
78
  // and `none` never leaves the flow, so a pin would freeze the very
71
79
  // property they animate.
72
- const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow"]);
80
+ const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow", "surface"]);
73
81
 
74
82
  // Pre-patch geometry of every row, refreshed on each update (the DOM
75
83
  // is still the pre-patch tree at onBeforeUpdate — same pattern as
@@ -82,6 +90,18 @@ export default defineComponent({
82
90
  // position:relative (the full-width leave flash).
83
91
  const hostRef = ref<{ $el?: Element } | null>(null);
84
92
  const prePatchBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
93
+ /** Pre-patch VIEWPORT position of every row (gBCR). The offset boxes
94
+ * above are relative to the offsetParent's padding box — for a host
95
+ * whose padding box MOVES when its content shrinks (a centered
96
+ * shrink-to-fit host) that origin shift is exactly the ghost-drift
97
+ * the compensation below cancels, so the viewport truth is what the
98
+ * compensation must restore. */
99
+ const prePatchRects = new WeakMap<Element, { x: number; y: number }>();
100
+ // Known limit: the rects include in-flight enter/move transforms — a
101
+ // row removed mid-entrance freezes its transient pose into the
102
+ // one-shot margin. Cosmetic (dying ghost, ≤300ms race window,
103
+ // ≤10px); excluded-transform capture would need offset math per
104
+ // host, not worth it for a generic primitive.
85
105
  onBeforeUpdate(() => {
86
106
  // Fragment hosts (tag="") have a comment anchor as $el — no
87
107
  // element children to measure there.
@@ -95,20 +115,118 @@ export default defineComponent({
95
115
  width: e.offsetWidth,
96
116
  height: e.offsetHeight,
97
117
  });
118
+ const r = e.getBoundingClientRect();
119
+ prePatchRects.set(e, { x: r.left, y: r.top });
98
120
  }
99
121
  });
100
122
 
101
123
  const usesAbsoluteLeave = () =>
102
124
  !props.disabled && ABSOLUTE_VARIANTS.has(props.variant);
103
125
 
126
+ /** Ghosts of the current batch (inline-absolute, awaiting removal).
127
+ * Iterated by the post-flush correction below; pruned when a leave
128
+ * aborts or an element leaves the DOM. */
129
+ const pinnedGhosts = new Set<HTMLElement>();
130
+ let ghostCorrectionQueued = false;
131
+
132
+ /** The centered-host compensation can only see the refits that have
133
+ * ALREADY happened when a ghost is pinned — in a MULTI-ROW batch
134
+ * (a fold evicting several crumbs in one patch) the later lifts
135
+ * re-shrink a centered host further, dragging every earlier ghost
136
+ * by Σ_w_later/2 before anything paints (2026-11-07 review, round
137
+ * 4). One post-flush pass fixes the whole batch exactly: it runs
138
+ * after the patch (all lifts done, final refit reached) but — being
139
+ * enqueued mid-patch, inside the leave hook, while TransitionGroup
140
+ * only enqueues its own updated hook once its update COMPLETES —
141
+ * BEFORE the FLIP pass measures positions. Exact ghosts mean
142
+ * dx=dy=0, so no ghost is ever mis-classified as moved (a move
143
+ * stamp would swap the leave's opacity transition for the move's
144
+ * transform-only one and snap the fade). Idempotent: the write
145
+ * derives the delta from the live rect including the current
146
+ * margin. */
147
+ function scheduleGhostCorrection(): void {
148
+ if (ghostCorrectionQueued) return;
149
+ ghostCorrectionQueued = true;
150
+ queuePostFlushCb(() => {
151
+ ghostCorrectionQueued = false;
152
+ for (const ghost of pinnedGhosts) {
153
+ if (!ghost.isConnected) {
154
+ pinnedGhosts.delete(ghost);
155
+ continue;
156
+ }
157
+ const pre = prePatchRects.get(ghost);
158
+ if (pre == null) continue;
159
+ // Same visual→local conversion as the first-phase compensation:
160
+ // gBCR deltas are zoom-scaled at paint, margins are local.
161
+ const z = ancestorZoom(ghost);
162
+ const now = ghost.getBoundingClientRect();
163
+ const dx = (pre.x - now.left) / z;
164
+ const dy = (pre.y - now.top) / z;
165
+ if (dx === 0 && dy === 0) continue;
166
+ const currentMx = Number.parseFloat(ghost.style.marginLeft || "0") || 0;
167
+ const currentMy = Number.parseFloat(ghost.style.marginTop || "0") || 0;
168
+ ghost.style.marginLeft = `${currentMx + dx}px`;
169
+ ghost.style.marginTop = `${currentMy + dy}px`;
170
+ }
171
+ });
172
+ }
173
+
104
174
  function pinLeaving(el: Element) {
105
175
  if (!usesAbsoluteLeave()) return;
176
+ const node = el as HTMLElement;
106
177
  pinLeaveGeometry(el, { anchorX: "left", box: prePatchBoxes.get(el) });
178
+ // ── Centered-host compensation (first phase) ───────────────────
179
+ // An absolute ghost is positioned against the host's PADDING box —
180
+ // and a shrink-to-fit host REFITS the instant the ghost leaves the
181
+ // flow. A centered host (the modal breadcrumb's nav: fixed +
182
+ // left:50% + translate(-50%,-50%)) then moves its padding origin
183
+ // right by half the freed width, and the pinned `left` lands the
184
+ // ghost ΔW/2 away from where the user last saw it (2026-11-07
185
+ // review, real-Chrome repro: 74px jump on a single removal). Cancel
186
+ // the ALREADY-HAPPENED refits while nobody has painted: lift the
187
+ // ghost out of flow inline — in the SAME patch pass as the pin
188
+ // (Vue adds the leave-active class synchronously here too; its
189
+ // nextFrame only gates the leave-from → leave-to swap) — force the
190
+ // refit with one layout read, and write the visual delta back as a
191
+ // ONE-SHOT margin. Refits that happen AFTER this hook (later ghosts
192
+ // of the same batch) are corrected by scheduleGhostCorrection's
193
+ // post-flush pass above — the pair lands every ghost of any batch
194
+ // on its pre-patch spot before the first leave frame paints, with
195
+ // the ghost's motion itself opacity + transform only (compositor
196
+ // contract intact; margins are static one-time writes).
197
+ const pre = prePatchRects.get(el);
198
+ if (pre == null || node.offsetParent == null) return;
199
+ pinnedGhosts.add(node);
200
+ // Dying ghosts take no clicks AND no Tab focus (pointer-events does
201
+ // not stop focus; a Tab landing mid-leave would focus a doomed
202
+ // button and drop to <body> when it unmounts).
203
+ node.inert = true;
204
+ node.style.position = "absolute";
205
+ // gBCR deltas are VISUAL px (scaled by any ancestor zoom, e.g.
206
+ // chest's manual DPI scale); margins are LOCAL px painted ×z —
207
+ // convert through the zoom read at the ghost, or the correction
208
+ // over-shoots by (z−1)·delta on zoomed hosts.
209
+ const z = ancestorZoom(node);
210
+ const now = node.getBoundingClientRect();
211
+ const dx = (pre.x - now.left) / z;
212
+ const dy = (pre.y - now.top) / z;
213
+ if (dx !== 0) node.style.marginLeft = `${dx}px`;
214
+ if (dy !== 0) node.style.marginTop = `${dy}px`;
215
+ scheduleGhostCorrection();
107
216
  }
108
217
 
109
218
  function unpinLeaving(el: Element) {
110
219
  if (!usesAbsoluteLeave()) return;
111
220
  clearLeaveGeometry(el);
221
+ // The compensation writes of pinLeaving — an aborted leave returns
222
+ // the row to the flow, where a stale position/margin would freeze
223
+ // it off its slot.
224
+ const node = el as HTMLElement;
225
+ pinnedGhosts.delete(node);
226
+ node.inert = false;
227
+ node.style.position = "";
228
+ node.style.marginLeft = "";
229
+ node.style.marginTop = "";
112
230
  }
113
231
 
114
232
  return () => {