@kiva/kv-components 8.11.4 → 8.12.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.
@@ -0,0 +1,529 @@
1
+ import { defineComponent as Ue, useSlots as Ye, computed as l, ref as m, watch as V, onMounted as We, onBeforeUnmount as Xe, openBlock as v, createElementBlock as d, normalizeStyle as $, createElementVNode as g, Fragment as Ge, renderList as qe, unref as C, createVNode as L, createCommentVNode as T, Transition as Ke, withCtx as je, normalizeClass as Je, renderSlot as Qe, toDisplayString as le } from "vue";
2
+ import { mdiPlus as et, mdiMinus as tt, mdiPlay as nt } from "@mdi/js";
3
+ import N from "@kiva/kv-tokens";
4
+ import ot from "../data/simpleMapPaths.js";
5
+ import lt from "../data/simpleMapCountryPaths.js";
6
+ import U from "../data/simpleMapCentroids.js";
7
+ import { ALL_COUNTRIES_ISO_MAP as ae } from "../data/allCountriesISOMap.js";
8
+ import { useMapTourCycle as at } from "../utils/useMapTourCycle.js";
9
+ import Y from "./KvMaterialIcon.js";
10
+ const ut = ["viewBox"], rt = ["d", "fill", "onPointerenter", "onPointerleave"], it = {
11
+ key: 0,
12
+ class: "kv-simple-map__zoom-controls tw-absolute tw-top-2 tw-right-2 tw-flex tw-flex-col tw-gap-1"
13
+ }, st = ["disabled"], ct = ["disabled"], pt = { class: "kv-simple-map__popup-layer tw-absolute tw-inset-0 tw-pointer-events-none" }, ft = { class: "kv-simple-map__popup-content" }, mt = { class: "kv-simple-map__default-popup" }, vt = {
14
+ key: 0,
15
+ class: "tw-text-label"
16
+ }, dt = {
17
+ key: 1,
18
+ class: "tw-text-caption"
19
+ }, R = 1300.02, D = 571.784, yt = "cubic-bezier(0.76, 0, 0.24, 1)", xt = 60, St = /* @__PURE__ */ Ue({
20
+ __name: "KvSimpleMap",
21
+ props: {
22
+ /**
23
+ * Countries to make interactive: each entry is hover-able and (when autoplay
24
+ * is true) becomes a stop on the scripted tour. Countries not in this list
25
+ * still render as background but do not show popups.
26
+ */
27
+ countries: {
28
+ type: Array,
29
+ default: () => []
30
+ },
31
+ /**
32
+ * Width / height ratio that drives the default container height when no
33
+ * explicit height is set. Defaults to the source SVG's natural ratio.
34
+ */
35
+ aspectRatio: {
36
+ type: Number,
37
+ default: R / D
38
+ },
39
+ /**
40
+ * Explicit height override in pixels. Takes precedence over aspectRatio.
41
+ */
42
+ height: {
43
+ type: Number,
44
+ default: null
45
+ },
46
+ /**
47
+ * Explicit width override in pixels. Defaults to 100% of parent.
48
+ */
49
+ width: {
50
+ type: Number,
51
+ default: null
52
+ },
53
+ /**
54
+ * When true, the map runs an automatic tour through `countries`, panning
55
+ * and zooming to each in turn. Drag and zoom controls are suspended.
56
+ */
57
+ autoplay: {
58
+ type: Boolean,
59
+ default: !1
60
+ },
61
+ /**
62
+ * When true (and autoplay is true), the tour repeats indefinitely.
63
+ */
64
+ loop: {
65
+ type: Boolean,
66
+ default: !0
67
+ },
68
+ /**
69
+ * Allow click-and-drag to pan the map. Ignored during autoplay.
70
+ */
71
+ allowDragging: {
72
+ type: Boolean,
73
+ default: !0
74
+ },
75
+ /**
76
+ * Show + / − zoom buttons. Hidden during autoplay.
77
+ */
78
+ showZoomControls: {
79
+ type: Boolean,
80
+ default: !0
81
+ },
82
+ /**
83
+ * Multiplier applied to the base "fit-to-width" scale when focusing on a
84
+ * country during the tour. 2.0 ≈ desktop default, 2.4 ≈ more impact on small viewports.
85
+ */
86
+ zoomFactor: {
87
+ type: Number,
88
+ default: 2
89
+ },
90
+ /**
91
+ * Minimum zoom (1 = full overview).
92
+ */
93
+ minZoom: {
94
+ type: Number,
95
+ default: 1
96
+ },
97
+ /**
98
+ * Maximum zoom multiplier on top of base scale.
99
+ */
100
+ maxZoom: {
101
+ type: Number,
102
+ default: 4
103
+ },
104
+ /**
105
+ * Step applied per zoom-button click.
106
+ */
107
+ zoomStep: {
108
+ type: Number,
109
+ default: 0.5
110
+ },
111
+ /** Highlight color for active / hovered countries. Defaults to eco-green DEFAULT. */
112
+ highlightColor: {
113
+ type: String,
114
+ default: N.colors["eco-green"].DEFAULT
115
+ },
116
+ /** Base color for countries that aren't in the `countries` list. */
117
+ baseColor: {
118
+ type: String,
119
+ default: N.colors.gray[200]
120
+ },
121
+ /**
122
+ * Loan-count thresholds that bucket each country into one of the four
123
+ * eco-green tiers. The default mirrors the design spec:
124
+ * 1–3 → tier 1, 4–8 → tier 2, 9–14 → tier 3, 15+ → tier 4.
125
+ * Pass an array of four ascending lower-bounds to customize.
126
+ */
127
+ loanCountTiers: {
128
+ type: Array,
129
+ default: () => [1, 4, 9, 15]
130
+ },
131
+ /** Background color of the ocean / unfilled area. */
132
+ oceanColor: {
133
+ type: String,
134
+ default: "#e8f4fa"
135
+ },
136
+ /**
137
+ * Where to place the hover/tour popup relative to the country:
138
+ * 'top' — centered above the country (auto-flips below if it
139
+ * would clip the top edge of the container).
140
+ * 'bottom-right' — top-left of the popup hugs the country's
141
+ * bottom-right bbox corner.
142
+ */
143
+ popupPlacement: {
144
+ type: String,
145
+ default: "top",
146
+ validator: (k) => ["top", "bottom-right"].includes(k)
147
+ },
148
+ /**
149
+ * Pixel gap between the popup and the country edge, applied to whichever
150
+ * placement is active. Accepts negative values to make the popup overlap
151
+ * the country (useful for tightly hugging the bbox).
152
+ */
153
+ popupOffset: {
154
+ type: Number,
155
+ default: 0
156
+ },
157
+ /**
158
+ * On mount (and on resize), fit the camera to the bounding box of the
159
+ * `countries` list with a small padding buffer. When false, the map opens
160
+ * on the full world overview. The user's drag/zoom always overrides this
161
+ * — the fit is only applied until the first interaction.
162
+ */
163
+ fitToCountries: {
164
+ type: Boolean,
165
+ default: !0
166
+ },
167
+ /**
168
+ * Padding around the fit bbox, expressed as a fraction of the bbox extent
169
+ * on each side (0.15 = 15% breathing room around all four edges). Only
170
+ * applies when `fitToCountries` is true.
171
+ */
172
+ fitPadding: {
173
+ type: Number,
174
+ default: 0.15
175
+ },
176
+ /** Tour: ms before the first pan begins. */
177
+ initialDelay: { type: Number, default: 1e3 },
178
+ /** Tour: ms per pan animation. */
179
+ panDuration: { type: Number, default: 1200 },
180
+ /** Tour: ms to dwell on each country after the pan settles. */
181
+ holdPerStep: { type: Number, default: 2e3 },
182
+ /** Tour: ms before the next pan to hide the active popup. */
183
+ popupHideBefore: { type: Number, default: 350 },
184
+ /** Tour: ms held on the full overview at the end of a cycle. */
185
+ holdAll: { type: Number, default: 2e3 },
186
+ /** Highlight fade duration, in ms. */
187
+ fadeDuration: { type: Number, default: 500 }
188
+ },
189
+ setup(k) {
190
+ const n = k, ue = Ye(), W = l(() => !!ue.popup), E = m(null), i = m(0), x = m(0);
191
+ function re() {
192
+ return n.height != null ? `${n.height}px` : i.value ? `${i.value / n.aspectRatio}px` : `${100 / n.aspectRatio}%`;
193
+ }
194
+ const ie = l(() => ({
195
+ width: n.width != null ? `${n.width}px` : "100%",
196
+ height: re(),
197
+ paddingBottom: n.height != null ? void 0 : `${100 / n.aspectRatio}%`
198
+ }));
199
+ let P = null;
200
+ function X() {
201
+ const e = E.value;
202
+ if (!e) return;
203
+ const t = e.getBoundingClientRect();
204
+ i.value = t.width, x.value = t.height;
205
+ }
206
+ const se = l(() => n.countries), ce = l(() => n.loop), A = m(!1), pe = l(() => n.autoplay && !A.value), fe = l(() => ({
207
+ initialDelay: n.initialDelay,
208
+ panDuration: n.panDuration,
209
+ holdPerStep: n.holdPerStep,
210
+ popupHideBefore: n.popupHideBefore,
211
+ holdAll: n.holdAll,
212
+ fadeDuration: n.fadeDuration
213
+ })), {
214
+ panIdx: G,
215
+ highlighted: me,
216
+ showPopupIdx: q,
217
+ isRunning: s,
218
+ start: ve
219
+ } = at(se, pe, ce, fe.value), c = l(() => i.value ? i.value / R : 1), K = l(() => {
220
+ const e = c.value, t = D * e;
221
+ return {
222
+ x: 0,
223
+ y: Math.max(0, (x.value - t) / 2),
224
+ scale: e
225
+ };
226
+ });
227
+ function de(e) {
228
+ return e.cx != null && e.cy != null ? { cx: e.cx, cy: e.cy } : U[e.id] ?? null;
229
+ }
230
+ const ye = l(() => {
231
+ if (G.value < 0) return null;
232
+ const e = n.countries[G.value];
233
+ if (!e) return null;
234
+ const t = de(e);
235
+ if (!t) return null;
236
+ const o = c.value * n.zoomFactor;
237
+ return {
238
+ x: i.value / 2 - t.cx * o,
239
+ y: x.value / 2 - t.cy * o,
240
+ scale: o
241
+ };
242
+ }), u = m({ x: 0, y: 0, scale: 1 }), B = m(!1), j = m(!1), I = m(!1), xe = l(() => {
243
+ if (!n.fitToCountries || !n.countries.length || !i.value || !x.value) return null;
244
+ let e = 1 / 0, t = 1 / 0, o = -1 / 0, a = -1 / 0;
245
+ if (n.countries.forEach((r) => {
246
+ if (r.cx != null && r.cy != null) {
247
+ r.cx < e && (e = r.cx), r.cx > o && (o = r.cx), r.cy < t && (t = r.cy), r.cy > a && (a = r.cy);
248
+ return;
249
+ }
250
+ const f = U[r.id];
251
+ if (!f) return;
252
+ const ne = 2 * f.cx - f.xMax, oe = 2 * f.cy - f.yMax;
253
+ ne < e && (e = ne), oe < t && (t = oe), f.xMax > o && (o = f.xMax), f.yMax > a && (a = f.yMax);
254
+ }), !Number.isFinite(e)) return null;
255
+ const y = Math.max(0, n.fitPadding), w = Math.max(1, o - e) * (1 + y * 2), M = Math.max(1, a - t) * (1 + y * 2), b = Math.min(i.value / w, x.value / M), He = c.value * n.minZoom, ze = c.value * n.maxZoom, F = Math.max(He, Math.min(ze, b)), Fe = (e + o) / 2, Ve = (t + a) / 2;
256
+ return {
257
+ x: i.value / 2 - Fe * F,
258
+ y: x.value / 2 - Ve * F,
259
+ scale: F
260
+ };
261
+ }), he = l(() => xe.value ?? K.value), O = l(() => s.value ? ye.value ?? K.value : u.value);
262
+ V(he, (e) => {
263
+ s.value || I.value || (u.value = e);
264
+ }, { immediate: !0 }), V(() => n.countries, () => {
265
+ I.value = !1;
266
+ }), V(() => n.autoplay, () => {
267
+ A.value = !1;
268
+ });
269
+ const we = l(() => {
270
+ const { x: e, y: t, scale: o } = O.value, a = !B.value && j.value;
271
+ return {
272
+ width: `${R}px`,
273
+ height: `${D}px`,
274
+ transformOrigin: "0 0",
275
+ transform: `translate(${e}px, ${t}px) scale(${o})`,
276
+ transition: a ? `transform ${n.panDuration}ms ${yt}` : "none",
277
+ willChange: "transform"
278
+ };
279
+ }), _ = m(null), Z = l(() => {
280
+ const e = /* @__PURE__ */ new Map();
281
+ return n.countries.forEach((t) => e.set(t.id, t)), e;
282
+ });
283
+ function be(e) {
284
+ return Z.value.has(e) || !!ae[e];
285
+ }
286
+ function H(e) {
287
+ return be(e);
288
+ }
289
+ const S = N.colors["eco-green"], ge = [S[1], S[2], S[3], S[4]], J = l(() => n.highlightColor ?? S.DEFAULT), _e = l(() => n.baseColor ?? N.colors.gray[200]), Me = N.colors.gray[300];
290
+ function Ce(e) {
291
+ if (e == null || e < n.loanCountTiers[0]) return null;
292
+ for (let t = n.loanCountTiers.length - 1; t >= 0; t -= 1)
293
+ if (e >= n.loanCountTiers[t]) return ge[t];
294
+ return null;
295
+ }
296
+ function ke(e) {
297
+ return {
298
+ transition: `fill ${n.fadeDuration}ms ease-in-out`,
299
+ cursor: H(e) && !s.value ? "pointer" : "inherit"
300
+ };
301
+ }
302
+ function Pe(e) {
303
+ const t = Z.value.get(e), o = t ? Ce(t.loanCount) : null, a = _.value === e && H(e);
304
+ return me.value.has(e) ? J.value : a ? o ? J.value : Me : o ?? _e.value;
305
+ }
306
+ function Se(e) {
307
+ s.value || H(e) && (_.value = e);
308
+ }
309
+ function $e(e) {
310
+ _.value === e && (_.value = null);
311
+ }
312
+ function Te(e) {
313
+ if (e.cx != null && e.cy != null)
314
+ return {
315
+ cx: e.cx,
316
+ cy: e.cy,
317
+ xMin: e.cx,
318
+ xMax: e.cx,
319
+ yMin: e.cy,
320
+ yMax: e.cy
321
+ };
322
+ const t = U[e.id];
323
+ return t ? {
324
+ cx: t.cx,
325
+ cy: t.cy,
326
+ xMax: t.xMax,
327
+ yMax: t.yMax,
328
+ xMin: 2 * t.cx - t.xMax,
329
+ yMin: 2 * t.cy - t.yMax
330
+ } : null;
331
+ }
332
+ function Q(e) {
333
+ const t = Te(e);
334
+ if (!t) return null;
335
+ const o = O.value, a = { "--kv-simple-map-popup-offset": `${n.popupOffset}px` };
336
+ if (n.popupPlacement === "bottom-right")
337
+ return {
338
+ placement: "bottom-right",
339
+ style: {
340
+ left: `${t.xMax * o.scale + o.x}px`,
341
+ top: `${t.yMax * o.scale + o.y}px`,
342
+ ...a
343
+ }
344
+ };
345
+ const y = t.cx * o.scale + o.x, w = t.yMin * o.scale + o.y, M = t.yMax * o.scale + o.y, b = w < xt ? "bottom" : "top";
346
+ return {
347
+ placement: b,
348
+ style: {
349
+ left: `${y}px`,
350
+ top: `${b === "top" ? w : M}px`,
351
+ ...a
352
+ }
353
+ };
354
+ }
355
+ function ee(e) {
356
+ return !!e.name || e.loanCount != null;
357
+ }
358
+ const p = l(() => {
359
+ if (s.value && q.value >= 0) {
360
+ const e = n.countries[q.value];
361
+ if (!e || !W.value && !ee(e)) return null;
362
+ const t = Q(e);
363
+ return t ? { country: e, ...t } : null;
364
+ }
365
+ if (!s.value && _.value) {
366
+ const e = _.value, t = Z.value.get(e) ?? { id: e, name: ae[e] };
367
+ if (!W.value && !ee(t)) return null;
368
+ const o = Q(t);
369
+ return o ? { country: t, ...o } : null;
370
+ }
371
+ return null;
372
+ });
373
+ function Ne(e) {
374
+ return e === 1 ? "1 loan" : `${e} loans`;
375
+ }
376
+ let h = null;
377
+ function z(e) {
378
+ h && (u.value = {
379
+ ...u.value,
380
+ x: h.tx + (e.clientX - h.x),
381
+ y: h.ty + (e.clientY - h.y)
382
+ });
383
+ }
384
+ function Re() {
385
+ B.value = !1, h = null, window.removeEventListener("pointermove", z);
386
+ }
387
+ function De(e) {
388
+ e.button !== 0 && e.pointerType === "mouse" || (s.value && (u.value = { ...O.value }, A.value = !0), n.allowDragging && (I.value = !0, B.value = !0, h = {
389
+ x: e.clientX,
390
+ y: e.clientY,
391
+ tx: u.value.x,
392
+ ty: u.value.y
393
+ }, window.addEventListener("pointermove", z), window.addEventListener("pointerup", Re, { once: !0 })));
394
+ }
395
+ const Ee = l(() => n.autoplay && !s.value);
396
+ function Ae() {
397
+ A.value = !1, ve();
398
+ }
399
+ const Be = l(() => !n.allowDragging || s.value ? "default" : B.value ? "grabbing" : "grab"), Ie = l(() => u.value.scale < c.value * n.maxZoom - 1e-3), Le = l(() => u.value.scale > c.value * n.minZoom + 1e-3);
400
+ function te(e) {
401
+ const t = u.value, o = c.value * n.minZoom, a = c.value * n.maxZoom, y = Math.min(a, Math.max(o, t.scale + e * c.value));
402
+ if (Math.abs(y - t.scale) < 1e-6) return;
403
+ I.value = !0;
404
+ const w = i.value, M = x.value, b = y / t.scale;
405
+ u.value = {
406
+ x: w / 2 - (w / 2 - t.x) * b,
407
+ y: M / 2 - (M / 2 - t.y) * b,
408
+ scale: y
409
+ };
410
+ }
411
+ function Oe() {
412
+ te(n.zoomStep);
413
+ }
414
+ function Ze() {
415
+ te(-n.zoomStep);
416
+ }
417
+ return We(() => {
418
+ X(), typeof ResizeObserver < "u" && E.value && (P = new ResizeObserver(X), P.observe(E.value)), requestAnimationFrame(() => {
419
+ requestAnimationFrame(() => {
420
+ j.value = !0;
421
+ });
422
+ });
423
+ }), Xe(() => {
424
+ P == null || P.disconnect(), window.removeEventListener("pointermove", z);
425
+ }), (e, t) => (v(), d("div", {
426
+ ref_key: "rootRef",
427
+ ref: E,
428
+ class: "kv-simple-map tw-relative tw-block tw-overflow-hidden",
429
+ style: $(ie.value)
430
+ }, [
431
+ g("div", {
432
+ class: "kv-simple-map__clip tw-absolute tw-inset-0 tw-overflow-hidden",
433
+ style: $({ backgroundColor: k.oceanColor, cursor: Be.value }),
434
+ onPointerdown: De
435
+ }, [
436
+ g("div", {
437
+ class: "kv-simple-map__pan-layer tw-absolute tw-top-0 tw-left-0",
438
+ style: $(we.value)
439
+ }, [
440
+ (v(), d("svg", {
441
+ width: R,
442
+ height: D,
443
+ viewBox: `0 0 ${R} ${D}`,
444
+ fill: "none",
445
+ "aria-hidden": "true",
446
+ class: "tw-block"
447
+ }, [
448
+ (v(!0), d(Ge, null, qe(C(lt), (o) => (v(), d("path", {
449
+ key: o.id,
450
+ d: C(ot)[o.key],
451
+ fill: Pe(o.id),
452
+ "fill-rule": "evenodd",
453
+ "clip-rule": "evenodd",
454
+ stroke: "white",
455
+ "stroke-linejoin": "round",
456
+ style: $(ke(o.id)),
457
+ onPointerenter: (a) => Se(o.id),
458
+ onPointerleave: (a) => $e(o.id)
459
+ }, null, 44, rt))), 128))
460
+ ], 8, ut))
461
+ ], 4)
462
+ ], 36),
463
+ k.showZoomControls && !C(s) ? (v(), d("div", it, [
464
+ g("button", {
465
+ type: "button",
466
+ class: "kv-simple-map__control-btn",
467
+ disabled: !Ie.value,
468
+ "aria-label": "Zoom in",
469
+ onClick: Oe
470
+ }, [
471
+ L(Y, {
472
+ class: "tw-w-2 tw-h-2",
473
+ icon: C(et)
474
+ }, null, 8, ["icon"])
475
+ ], 8, st),
476
+ g("button", {
477
+ type: "button",
478
+ class: "kv-simple-map__control-btn",
479
+ disabled: !Le.value,
480
+ "aria-label": "Zoom out",
481
+ onClick: Ze
482
+ }, [
483
+ L(Y, {
484
+ class: "tw-w-2 tw-h-2",
485
+ icon: C(tt)
486
+ }, null, 8, ["icon"])
487
+ ], 8, ct)
488
+ ])) : T("", !0),
489
+ Ee.value ? (v(), d("button", {
490
+ key: 1,
491
+ type: "button",
492
+ class: "kv-simple-map__control-btn kv-simple-map__play-btn tw-absolute tw-bottom-2 tw-right-2",
493
+ "aria-label": "Resume tour",
494
+ onClick: Ae
495
+ }, [
496
+ L(Y, {
497
+ class: "tw-w-2 tw-h-2",
498
+ icon: C(nt)
499
+ }, null, 8, ["icon"])
500
+ ])) : T("", !0),
501
+ g("div", pt, [
502
+ L(Ke, { name: "kv-simple-map-popup" }, {
503
+ default: je(() => [
504
+ p.value ? (v(), d("div", {
505
+ key: p.value.country.id,
506
+ class: Je(["kv-simple-map__popup tw-absolute", `kv-simple-map__popup--${p.value.placement}`]),
507
+ style: $(p.value.style)
508
+ }, [
509
+ g("div", ft, [
510
+ Qe(e.$slots, "popup", {
511
+ country: p.value.country
512
+ }, () => [
513
+ g("div", mt, [
514
+ p.value.country.name ? (v(), d("div", vt, le(p.value.country.name), 1)) : T("", !0),
515
+ p.value.country.loanCount != null ? (v(), d("div", dt, le(Ne(p.value.country.loanCount)), 1)) : T("", !0)
516
+ ])
517
+ ], !0)
518
+ ])
519
+ ], 6)) : T("", !0)
520
+ ]),
521
+ _: 3
522
+ })
523
+ ])
524
+ ], 4));
525
+ }
526
+ });
527
+ export {
528
+ St as default
529
+ };
@@ -55,6 +55,8 @@ export { default as KvLoadingPlaceholder } from './KvLoadingPlaceholder.vue';
55
55
  export { default as KvLoadingSpinner } from './KvLoadingSpinner.vue';
56
56
  export { default as KvMap } from './KvMap.vue';
57
57
  export { default as KvMaterialIcon } from './KvMaterialIcon.vue';
58
+ export { default as KvSimpleMap } from './KvSimpleMap.vue';
59
+ export type { SimpleMapCountry } from './KvSimpleMap.vue';
58
60
  export { default as KvPageContainer } from './KvPageContainer.vue';
59
61
  export { default as KvPagination } from './KvPagination.vue';
60
62
  export { default as KvPieChart } from './KvPieChart.vue';
@@ -90,6 +90,31 @@ Figma Make output is presentational/demo code. Determine what needs to change:
90
90
  - **Presentational strings** -> Configurable via props/slots
91
91
  - **React animation libraries** -> CSS transitions or Vue composables (prefer no extra deps)
92
92
 
93
+ ### 1.4 New Sibling Component vs. Extending an Existing One
94
+
95
+ When the source overlaps an existing kv-component, decide which path to take:
96
+
97
+ | Situation | Decision |
98
+ |-----------|----------|
99
+ | Same renderer, additional variant or mode | Extend the existing component (new prop, new slot) |
100
+ | Same renderer, breaking change | Create a `V2` (e.g. `KvPieChartV2`) |
101
+ | **Different renderer or fundamentally different runtime cost** | **New sibling component** |
102
+
103
+ Example: `KvMap` uses MapLibre/Leaflet (WebGL/canvas, tile fetching, ~200KB+ over the wire). A storytelling map driven by inline SVG paths and CSS transforms has none of those costs. Adding a `mode` prop would force every `KvMap` consumer to think about both paths and would bundle code most callers don't use. The right call is a sibling (`KvSimpleMap`) that shares the design vocabulary but not the implementation.
104
+
105
+ **Stop-and-ask** if the choice isn't obvious — the wrong call is expensive to undo.
106
+
107
+ ### 1.5 External Data Modules
108
+
109
+ If the source imports a generated or large data module (SVG path dictionaries, GeoJSON, country lists, icon registries, etc.), **copy it into `src/data/` as-is**. Do not translate, reformat, or hand-edit. These files are usually exported from a tool (Figma, Mapshaper, etc.) and should be treated as opaque assets.
110
+
111
+ - Place the file alongside existing data assets in `src/data/`
112
+ - Rename to a descriptive, kv-style name (e.g. `simpleMapPaths.ts`, not `svg-iw48hjyvei.ts`)
113
+ - Re-export the default export with a typed name where useful
114
+ - Do **not** inline the data into the component file — keeps the component readable and lets the data module be tree-shaken or lazy-imported if needed
115
+
116
+ Reference: `KvMap.vue` lazy-imports `data/ne_110m_admin_0_countries.json` via dynamic `import()` to keep it out of the main chunk. Do the same when the data is large and only needed when the component actually mounts.
117
+
93
118
  ## Phase 2: Translation Patterns
94
119
 
95
120
  ### 2.1 React Hooks to Vue Composition API
@@ -119,36 +144,60 @@ Figma Make output is presentational/demo code. Determine what needs to change:
119
144
 
120
145
  Figma Make outputs raw Tailwind classes. Convert to kv-components patterns:
121
146
 
122
- - **Add `tw-` prefix** to all Tailwind utility classes
123
- - **Replace hardcoded font families** with design system fonts (`tw-font-sans`, `tw-font-serif`)
124
- - **Replace hardcoded colors** with token references where possible
125
- - **Replace pixel values** in Tailwind classes with spacing scale values (8px increments)
126
- - **Use design tokens** from `@kiva/kv-tokens` for colors, spacing, typography
147
+ - **Always use the project's Tailwind config** (`@kiva/kv-tokens/configs/tailwind.config.js`) as the source of truth for available utilities, the `tw-` prefix, the spacing scale (8px-based, not Tailwind's default 4px), and the colour palette. Don't introduce raw hex values, hardcoded `px`/`rem` literals, or arbitrary class names where a token-backed utility exists.
148
+ - **Apply the `tw-` prefix everywhere.** That includes class attributes in markup *and* `@apply` directives inside `<style lang="postcss" scoped>` blocks. `@apply tw-w-4 tw-h-4 tw-bg-white tw-border tw-border-tertiary tw-rounded-xs;` is the preferred way to assemble shared styles and pseudo-state rules; raw CSS belongs only where no utility exists (e.g. one-off `box-shadow` values, animation keyframes).
149
+ - **Replace hardcoded font families** with design system fonts (`tw-font-sans`, `tw-font-serif`).
150
+ - **Replace hardcoded colors** with token references — semantic Tailwind utilities (`tw-bg-primary`, `tw-text-secondary`, `tw-border-tertiary`) first, then named-palette utilities (`tw-bg-eco-green-2`, `tw-text-gray-300`); reach into `kvTokensPrimitives.colors[...]` from JS only when the colour must reach a non-class consumer (e.g. an SVG `:fill` binding).
151
+ - **Replace pixel values** in Tailwind classes with spacing scale values (8px increments). `tw-w-4` = 32px, not 16px — verify against the token scale, not Tailwind defaults.
152
+ - **Reference the kv-tokens skills** as the canonical guide for any text-styling decision before writing custom CSS. Start with [@kiva/kv-tokens/docs/skills/typography.md](../../kv-tokens/docs/skills/typography.md) for the semantic type scale, heading hierarchy, font pairing, and HTML / Tailwind mappings — always consult it before picking a heading element or text utility.
127
153
 
128
154
  ### 2.4 Animation Translation
129
155
 
130
- Prefer CSS transitions over JavaScript animation libraries:
156
+ Prefer CSS-native animations: CSS transitions, Tailwind utility classes, and Vue's built-in `<Transition>` / `<TransitionGroup>` for enter/leave. Reach for a JS-driven composable only when the animation is genuinely numeric (count-up, interpolated values, multi-step choreography that can't be expressed declaratively).
131
157
 
132
158
  | Figma Make / React | Vue Equivalent |
133
159
  |--------------------|----------------|
134
- | `motion/react` animate prop | CSS `transition` property |
135
- | `transition={{ duration: 0.5 }}` | `transition: property 500ms ease-in-out` |
160
+ | `motion.div` with `animate={...}` | Bind reactive style/class; let CSS transition handle the tween |
161
+ | `transition={{ duration: 0.5 }}` | `transition: property 500ms ease-in-out` (or `tw-transition tw-duration-500`) |
162
+ | `transition={{ ease: [...] }}` | `transition-timing-function: cubic-bezier(...)` |
136
163
  | Staggered animations | Computed `transition-delay` per item index |
137
- | Spring animations | CSS `cubic-bezier()` or `ease-in-out` |
164
+ | Spring animations | CSS `cubic-bezier()` or `ease-in-out` (springs rarely visibly differ at small distances) |
165
+ | `<AnimatePresence>` + `initial`/`animate`/`exit` | Vue `<Transition>` with `enter-from`/`enter-to`/`leave-from`/`leave-to` classes |
138
166
  | `requestAnimationFrame` hooks | Vue composable with `onMounted`/`onUnmounted` lifecycle |
139
167
  | Opacity/transform tweens | CSS transitions on those properties |
168
+ | `setTimeout`-driven cycles | Vue composable that owns the timer set + cleanup (see 2.5) |
140
169
 
141
- For complex numeric animations (count-up, interpolation), create a composable in `src/utils/`:
170
+ Guidelines:
142
171
 
143
- ```ts
144
- // src/utils/useCountUp.ts
145
- import { ref, watch, onUnmounted } from 'vue';
172
+ - **Default to declarative.** If the animation can be expressed as "this style depends on this state, transition between them," it belongs in CSS — not in a JS animation loop.
173
+ - **One key per remountable element.** When porting `AnimatePresence` keyed children, preserve the `:key` on the Vue side so `<Transition>` treats each instance as a fresh mount/unmount.
174
+ - **Match perceived timing, not the exact curve.** Spring → ease-in-out and motion's default cubic → a similar `cubic-bezier` are acceptable substitutions unless the user says otherwise. **Stop-and-ask** if the source uses a noticeably distinct easing (overshoot, bounce).
175
+ - **Tailwind first, scoped CSS second.** Prefer `tw-transition`, `tw-duration-*`, `tw-ease-*` utilities; fall back to `<style scoped>` only when a utility doesn't exist for the property or curve.
176
+ - **Don't add a JS animation library.** No `framer-motion` Vue equivalent, no `gsap`, no `motion-vue`. If a component genuinely needs orchestrated numeric animation, write a small composable.
146
177
 
147
- export function useCountUp(target: Ref<number>, active: Ref<boolean>, duration = 500) {
148
- // requestAnimationFrame-based tween with easing
149
- // Returns reactive ref with current display value
150
- }
151
- ```
178
+ ### 2.5 Timer-Driven Cycles Composables
179
+
180
+ `setTimeout`/`setInterval` choreography inside a React `useEffect` should become a composable that owns its timer set and cleans up on unmount. Component files stay declarative; the cycle becomes independently testable.
181
+
182
+ Guidelines:
183
+
184
+ - **One owner for timers.** The composable creates timers, holds the references, and clears them on unmount or `stop()`. The component doesn't manage timers directly.
185
+ - **Cancellation is non-negotiable.** Always provide a teardown path and call it from `onBeforeUnmount`. Stale timers firing after unmount are a common source of port bugs.
186
+ - **Expose state, not timers.** Return reactive refs (current step, active flag, etc.) and `start`/`stop` controls — never raw timer IDs.
187
+ - **Test with fake timers.** Use `jest.useFakeTimers()` to assert state transitions at each step boundary without rendering the component.
188
+ - **Make timings injectable.** Pass durations/delays as options with sensible defaults so the cycle can be tuned per consumer (and sped up in tests).
189
+
190
+ ### 2.6 Container-Responsive Sizing
191
+
192
+ Figma Make outputs **fixed pixel dimensions**. kv-components must be responsive by default, with optional explicit overrides.
193
+
194
+ Guidelines:
195
+
196
+ - **Default to 100% width.** Never hardcode a container width into the component.
197
+ - **Mirror existing sizing props.** When a kv-component already implements responsive sizing (e.g. `KvMap` with `aspectRatio` / `height` / `width` props and a `mapDimensions` computed), follow that prop shape so consumers have one mental model.
198
+ - **Decouple internal coordinates from rendered size.** SVG `viewBox`, math keyed off the source's `CONT_W`/`CONT_H` constants, etc. should remain at their natural values; the *container* is what scales. If the source's pan/zoom math is keyed off fixed pixel dimensions, derive those from `getBoundingClientRect()` at runtime.
199
+ - **Use `ResizeObserver` if the container can resize after mount.** Fall back gracefully where it isn't supported.
200
+ - **Aspect-ratio prop, not fixed height.** A numeric `aspectRatio` (width / height) lets the component scale fluidly; explicit `height`/`width` props are escape hatches, not the default path.
152
201
 
153
202
  ## Phase 3: Component Structure
154
203
 
@@ -194,10 +243,13 @@ const { colors } = kvTokensPrimitives;
194
243
 
195
244
  ### 4.2 Typography
196
245
 
197
- Map Figma Make font references:
198
- - `Kiva Post Grot:Book` -> `font-weight: 300` (light/book)
199
- - `Kiva Post Grot:Medium` -> `font-weight: 500` (medium)
200
- - Font family -> `tw-font-sans` (PostGrotesk)
246
+ The canonical reference for type styling is the [kv-tokens typography skill](../../kv-tokens/docs/skills/typography.md). Consult it before picking a heading element or a text utility; it covers the semantic type scale (`tw-text-display`, `tw-text-headline`, `tw-text-title`, `tw-text-base`, `tw-text-caption`, etc.), heading hierarchy, font pairing, and the HTML / Tailwind mappings.
247
+
248
+ Quick guidance for a port:
249
+ - **Map Figma Make font references** by intent, not by raw weight: pick the semantic utility (e.g. `tw-text-headline`, `tw-text-title`, `tw-text-label`) that matches the role the text plays. Don't translate `font-weight: 500` into a hand-set numeric weight — pick the utility that the design system says owns "medium" emphasis.
250
+ - **Font family** → `tw-font-sans` (PostGrotesk) by default; only switch when the typography skill calls for `tw-font-serif`.
251
+ - **Heading elements** → follow the heading hierarchy described in the typography skill so any new `<h1>`–`<h6>` usage you introduce uses the correct semantic level for its role.
252
+ - If no existing element default or utility class fits the design, that's a signal to stop and confirm with the design system team — not to author bespoke CSS.
201
253
 
202
254
  ### 4.3 Spacing
203
255