@leavepulse/ui 0.28.5 → 0.29.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.
@@ -0,0 +1,124 @@
1
+ import { ref, watch, toValue, onScopeDispose } from "vue";
2
+ const ENVELOPE_VERSION = 1;
3
+ function storage() {
4
+ try {
5
+ if (typeof window === "undefined") return null;
6
+ return window.localStorage;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
12
+ function useFormDraft(options) {
13
+ const hasDraft = ref(false);
14
+ const savedAt = ref(null);
15
+ const maxAge = options.maxAgeMs ?? WEEK_MS;
16
+ function currentKey() {
17
+ return String(toValue(options.key) || "").trim();
18
+ }
19
+ function read() {
20
+ const store = storage();
21
+ const key = currentKey();
22
+ if (!store || !key) return null;
23
+ let raw = null;
24
+ try {
25
+ raw = store.getItem(key);
26
+ } catch {
27
+ return null;
28
+ }
29
+ if (!raw) return null;
30
+ try {
31
+ const parsed = JSON.parse(raw);
32
+ if (!parsed || parsed.version !== ENVELOPE_VERSION) {
33
+ discard();
34
+ return null;
35
+ }
36
+ if (maxAge > 0) {
37
+ const age = Date.now() - Date.parse(parsed.savedAt);
38
+ if (age > maxAge) {
39
+ discard();
40
+ return null;
41
+ }
42
+ }
43
+ return parsed.value;
44
+ } catch {
45
+ discard();
46
+ return null;
47
+ }
48
+ }
49
+ function write(value) {
50
+ const store = storage();
51
+ const key = currentKey();
52
+ if (!store || !key) return;
53
+ const stamp = (/* @__PURE__ */ new Date()).toISOString();
54
+ try {
55
+ store.setItem(
56
+ key,
57
+ JSON.stringify({
58
+ version: ENVELOPE_VERSION,
59
+ savedAt: stamp,
60
+ value
61
+ })
62
+ );
63
+ } catch {
64
+ return;
65
+ }
66
+ hasDraft.value = true;
67
+ savedAt.value = stamp;
68
+ }
69
+ function discard() {
70
+ const store = storage();
71
+ const key = currentKey();
72
+ hasDraft.value = false;
73
+ savedAt.value = null;
74
+ if (!store || !key) return;
75
+ try {
76
+ store.removeItem(key);
77
+ } catch {
78
+ }
79
+ }
80
+ function save() {
81
+ write(toValue(options.value));
82
+ }
83
+ watch(
84
+ () => currentKey(),
85
+ () => {
86
+ hasDraft.value = false;
87
+ savedAt.value = null;
88
+ const store = storage();
89
+ const key = currentKey();
90
+ if (!store || !key) return;
91
+ let raw = null;
92
+ try {
93
+ raw = store.getItem(key);
94
+ } catch {
95
+ return;
96
+ }
97
+ if (!raw) return;
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ if (parsed?.version !== ENVELOPE_VERSION) return;
101
+ hasDraft.value = true;
102
+ savedAt.value = parsed.savedAt;
103
+ } catch {
104
+ }
105
+ },
106
+ { immediate: true }
107
+ );
108
+ watch(
109
+ () => [toValue(options.value), toValue(options.dirty), toValue(options.disabled)],
110
+ ([value, dirty, disabled]) => {
111
+ if (disabled) return;
112
+ if (options.dirty !== void 0 && !dirty) return;
113
+ write(value);
114
+ },
115
+ { deep: true }
116
+ );
117
+ onScopeDispose(() => {
118
+ hasDraft.value = false;
119
+ });
120
+ return { hasDraft, savedAt, read, discard, save };
121
+ }
122
+ export {
123
+ useFormDraft
124
+ };
package/dist/index.d.ts CHANGED
@@ -133,6 +133,8 @@ export { useReveal } from './composables/useReveal';
133
133
  export type { UseReveal, UseRevealOptions } from './composables/useReveal';
134
134
  export { useRovingFocus } from './composables/useRovingFocus';
135
135
  export type { RovingItem, UseRovingFocusOptions } from './composables/useRovingFocus';
136
+ export { useFormDraft } from './composables/useFormDraft';
137
+ export type { UseFormDraft, UseFormDraftOptions } from './composables/useFormDraft';
136
138
  export { useSettled } from './composables/useSettled';
137
139
  export type { UseSettledOptions } from './composables/useSettled';
138
140
  export { useShift } from './composables/useShift';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, format
30
30
  import { _ as _25 } from "./chunks/LpFormField-fV_2ZdCk.js";
31
31
  import { _ as _26 } from "./chunks/LpIcon-CCnX5_2j.js";
32
32
  import { default as default4 } from "./components/LpImageEditor.vue.js";
33
- import { _ as _27 } from "./chunks/LpInfraNode-qIL3l7IO.js";
33
+ import { _ as _27 } from "./chunks/LpInfraNode-Ds_8SZgN.js";
34
34
  import { _ as _28 } from "./chunks/LpInput-CYcTL9gj.js";
35
35
  import { _ as _29 } from "./chunks/LpLightbox-Ci35Y_xP.js";
36
36
  import { fileNameOf } from "./components/lightbox.js";
@@ -65,17 +65,17 @@ import { _ as _55 } from "./chunks/LpSparkline-C6xQ2dwv.js";
65
65
  import { _ as _56 } from "./chunks/LpStat-CDanpzU0.js";
66
66
  import { _ as _57 } from "./chunks/LpStepper-Wtl-6nUh.js";
67
67
  import { _ as _58 } from "./chunks/LpSwitch-v6fnccSM.js";
68
- import { _ as _59 } from "./chunks/LpTable-DTj52qlg.js";
69
- import { _ as _60 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
70
- import { _ as _61 } from "./chunks/LpTabs-Cefe_lys.js";
71
- import { _ as _62 } from "./chunks/LpTextarea-BMIOfw5l.js";
72
- import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-7mAK6bI7.js";
73
- import { D, _ as _63, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-7mAK6bI7.js";
74
- import { _ as _64 } from "./chunks/LpTilt-DuyKbpkA.js";
75
- import { _ as _65 } from "./chunks/LpToaster-gDB1EaU3.js";
76
- import { _ as _66 } from "./chunks/LpTooltip-hCLPAqMG.js";
77
- import { _ as _67 } from "./chunks/LpTopologyCanvas-ebP67CLx.js";
78
- import { _ as _68 } from "./chunks/LpUptimeBar-DcQ8HikG.js";
68
+ import { default as default6 } from "./components/LpTable.vue.js";
69
+ import { _ as _59 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
70
+ import { _ as _60 } from "./chunks/LpTabs-Cefe_lys.js";
71
+ import { _ as _61 } from "./chunks/LpTextarea-BMIOfw5l.js";
72
+ import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-Dj4VcTu7.js";
73
+ import { D, _ as _62, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-Dj4VcTu7.js";
74
+ import { _ as _63 } from "./chunks/LpTilt-DuyKbpkA.js";
75
+ import { _ as _64 } from "./chunks/LpToaster-gDB1EaU3.js";
76
+ import { _ as _65 } from "./chunks/LpTooltip-hCLPAqMG.js";
77
+ import { _ as _66 } from "./chunks/LpTopologyCanvas-Cm1L39uL.js";
78
+ import { _ as _67 } from "./chunks/LpUptimeBar-DcQ8HikG.js";
79
79
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
80
80
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
81
81
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -85,6 +85,7 @@ import { useInputFilter } from "./composables/useInputFilter.js";
85
85
  import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
86
86
  import { useReveal } from "./composables/useReveal.js";
87
87
  import { useRovingFocus } from "./composables/useRovingFocus.js";
88
+ import { useFormDraft } from "./composables/useFormDraft.js";
88
89
  import { useSettled } from "./composables/useSettled.js";
89
90
  import { useShift } from "./composables/useShift.js";
90
91
  import { useTilt } from "./composables/useTilt.js";
@@ -185,16 +186,16 @@ export {
185
186
  _56 as LpStat,
186
187
  _57 as LpStepper,
187
188
  _58 as LpSwitch,
188
- _59 as LpTable,
189
- _60 as LpTableOfContents,
190
- _61 as LpTabs,
191
- _62 as LpTextarea,
192
- _63 as LpThemeSwitcher,
193
- _64 as LpTilt,
194
- _65 as LpToaster,
195
- _66 as LpTooltip,
196
- _67 as LpTopologyCanvas,
197
- _68 as LpUptimeBar,
189
+ default6 as LpTable,
190
+ _59 as LpTableOfContents,
191
+ _60 as LpTabs,
192
+ _61 as LpTextarea,
193
+ _62 as LpThemeSwitcher,
194
+ _63 as LpTilt,
195
+ _64 as LpToaster,
196
+ _65 as LpTooltip,
197
+ _66 as LpTopologyCanvas,
198
+ _67 as LpUptimeBar,
198
199
  N as NO_BACKDROP_ATTR,
199
200
  OSM_TILES,
200
201
  T as THEME_COOKIE,
@@ -265,6 +266,7 @@ export {
265
266
  m as themeToCssVars,
266
267
  tileUrl,
267
268
  unproject,
269
+ useFormDraft,
268
270
  useHotkeys,
269
271
  useInputFilter,
270
272
  useLayout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.28.5",
3
+ "version": "0.29.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -13,7 +13,13 @@ import { computed } from "vue"
13
13
 
14
14
  export interface InfraNodeData {
15
15
  name: string
16
+ /** Role key: selects the colour/glyph, and shown as-is unless `roleLabel`. */
16
17
  role: string
18
+ /**
19
+ * What to print for the role. The key itself ("host", "proxy") is an API
20
+ * value, not a word for an operator to read in their own language.
21
+ */
22
+ roleLabel?: string
17
23
  overlay?: string
18
24
  online?: boolean
19
25
  kind?: string
@@ -31,6 +37,31 @@ export interface InfraNodeData {
31
37
  staleFor?: string
32
38
  /** Fade the node back (search miss / not part of the focused node's graph). */
33
39
  dimmed?: boolean
40
+ /**
41
+ * Wording for this node's badges. Carried in `data` because that is what
42
+ * LpTopologyCanvas forwards to the node renderer; a component prop would
43
+ * never reach it.
44
+ */
45
+ labels?: InfraNodeLabels
46
+ }
47
+
48
+ /**
49
+ * Wording for the badges, so the node speaks the app's language.
50
+ *
51
+ * The kit ships English defaults; a localised app passes its own. `{n}` is the
52
+ * count, `{age}` the value of `staleFor`.
53
+ */
54
+ export interface InfraNodeLabels {
55
+ /** Tooltip on the firewall badge. Default: "{n} firewall rule(s)". */
56
+ firewallRules?: string
57
+ /** Tooltip on the service badge. Default: "{n} service(s)". */
58
+ services?: string
59
+ /** Tooltip on the service badge when readings are stale. */
60
+ servicesStale?: string
61
+ /** Tooltip on the staleness badge. */
62
+ noContact?: string
63
+ /** Suffix after the age, e.g. "old" in "4m old". */
64
+ old?: string
34
65
  }
35
66
 
36
67
  const props = defineProps<{
@@ -40,6 +71,21 @@ const props = defineProps<{
40
71
  roles?: Record<string, InfraRoleStyle>
41
72
  }>()
42
73
 
74
+ /** Fill `{n}` / `{age}` in a label template. */
75
+ function fill(tpl: string, vars: Record<string, string | number>): string {
76
+ return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""))
77
+ }
78
+
79
+ const text = computed(() => ({
80
+ firewallRules: props.data.labels?.firewallRules ?? "{n} firewall rule(s)",
81
+ services: props.data.labels?.services ?? "{n} service(s)",
82
+ servicesStale:
83
+ props.data.labels?.servicesStale ?? "{n} service(s) as of {age} ago - not current",
84
+ noContact:
85
+ props.data.labels?.noContact ?? "No contact for {age} - everything shown here is that old",
86
+ old: props.data.labels?.old ?? "old",
87
+ }))
88
+
43
89
  /*
44
90
  * Role → look. Colours are token references, never literals, so a theme (or a
45
91
  * consumer) restyles the graph without forking this component; the glyph keeps
@@ -80,13 +126,28 @@ const accentLine = computed(
80
126
  </script>
81
127
 
82
128
  <template>
129
+ <!--
130
+ An offline host is drawn offline, not merely faded.
131
+
132
+ Opacity alone was the only signal the frame carried, and at 0.5 on a dark
133
+ canvas it is nearly invisible: a machine that had not reported for minutes
134
+ sat in a role-coloured frame that read as healthy, contradicted only by a
135
+ 7px dot. Offline now takes the frame and the role bar as well, so the three
136
+ signals on a node agree instead of arguing.
137
+ -->
83
138
  <div
84
139
  class="w-[168px] overflow-hidden rounded-card border bg-surface-raised shadow-panel transition-[border-color,box-shadow]"
85
- :class="selected ? 'border-[var(--accent)]' : 'border-[var(--accent-line)]'"
140
+ :class="[
141
+ selected
142
+ ? 'border-[var(--accent)]'
143
+ : data.online === false
144
+ ? 'border-dashed border-danger/50'
145
+ : 'border-[var(--accent-line)]',
146
+ ]"
86
147
  :style="{
87
148
  '--accent': accent,
88
149
  '--accent-line': accentLine,
89
- opacity: data.dimmed ? 0.3 : data.online === false ? 0.5 : 1,
150
+ opacity: data.dimmed ? 0.3 : data.online === false ? 0.75 : 1,
90
151
  ...(selected
91
152
  ? { boxShadow: '0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent), var(--shadow-panel)' }
92
153
  : {}),
@@ -103,7 +164,12 @@ const accentLine = computed(
103
164
  class="!size-2 !border-2 !border-[var(--accent)] !bg-surface"
104
165
  />
105
166
 
106
- <div class="h-[3px] bg-[var(--accent)]" />
167
+ <!-- The role bar reads as "this node is alive and is a proxy"; on an
168
+ offline host it must not keep asserting the first half. -->
169
+ <div
170
+ class="h-[3px]"
171
+ :class="data.online === false ? 'bg-danger/50' : 'bg-[var(--accent)]'"
172
+ />
107
173
  <div class="px-[11px] pb-2.5 pt-[9px]">
108
174
  <div class="flex items-center gap-[7px]">
109
175
  <span class="text-[13px] text-[var(--accent)]">{{ icon }}</span>
@@ -116,7 +182,7 @@ const accentLine = computed(
116
182
  </div>
117
183
  <div class="mt-1.5 flex items-center justify-between">
118
184
  <span class="text-[10px] font-semibold uppercase tracking-wide text-[var(--accent)]">
119
- {{ data.role }}
185
+ {{ data.roleLabel ?? data.role }}
120
186
  </span>
121
187
  <span v-if="data.overlay" class="font-mono text-[10px] text-muted">{{ data.overlay }}</span>
122
188
  </div>
@@ -127,7 +193,7 @@ const accentLine = computed(
127
193
  <span
128
194
  v-if="data.firewallCount"
129
195
  class="inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger"
130
- :title="`${data.firewallCount} firewall rule(s)`"
196
+ :title="fill(text.firewallRules, { n: data.firewallCount! })"
131
197
  >
132
198
  <span>⛨</span>{{ data.firewallCount }}
133
199
  </span>
@@ -137,8 +203,8 @@ const accentLine = computed(
137
203
  :class="data.staleFor ? 'line-through decoration-danger/70' : ''"
138
204
  :title="
139
205
  data.staleFor
140
- ? `${data.serviceCount} service(s) as of ${data.staleFor} ago — not current`
141
- : `${data.serviceCount} service(s)`
206
+ ? fill(text.servicesStale, { n: data.serviceCount!, age: data.staleFor })
207
+ : fill(text.services, { n: data.serviceCount! })
142
208
  "
143
209
  >
144
210
  <span>◇</span>{{ data.serviceCount }}
@@ -146,9 +212,9 @@ const accentLine = computed(
146
212
  <span
147
213
  v-if="data.staleFor"
148
214
  class="inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger"
149
- :title="`No contact for ${data.staleFor} — everything shown here is that old`"
215
+ :title="fill(text.noContact, { age: data.staleFor })"
150
216
  >
151
- <span>⚠</span>{{ data.staleFor }} old
217
+ <span>⚠</span>{{ data.staleFor }} {{ text.old }}
152
218
  </span>
153
219
  </div>
154
220
  </div>
@@ -77,10 +77,18 @@ function menuFor(row: T): ContextMenuItemDef[] {
77
77
  }
78
78
 
79
79
  // ── sorting ──────────────────────────────────────────────────
80
- // Client-side sort is applied only when the parent doesn't drive `sort` via
81
- // v-model (server-side). We compare by raw cell value with a stable-ish coerce.
80
+ //
81
+ // The parent may drive `sort` through v-model, which is how a server-side sort
82
+ // is expressed. When it does not, the table keeps the state itself and sorts
83
+ // its own rows — otherwise `sortable` rendered a header that could be clicked,
84
+ // emitted an event nobody was listening for, and left the rows exactly as they
85
+ // were: a control that answers a click by doing nothing.
86
+ const ownSort = ref<SortState | null>(null)
87
+ const activeSort = computed(() => props.sort ?? ownSort.value)
88
+
89
+ // We compare by raw cell value with a stable-ish coerce.
82
90
  const displayRows = computed<T[]>(() => {
83
- const s = props.sort
91
+ const s = activeSort.value
84
92
  if (!s) return props.rows
85
93
  const col = props.columns.find((c) => c.key === s.key)
86
94
  if (!col?.sortable) return props.rows
@@ -98,18 +106,22 @@ function compare(a: unknown, b: unknown): number {
98
106
 
99
107
  function toggleSort(col: TableColumn<T>) {
100
108
  if (!col.sortable) return
101
- const s = props.sort
109
+ const s = activeSort.value
102
110
  // Cycle: none → asc → desc → none, scoped to the clicked column.
103
111
  let next: SortState | null
104
112
  if (!s || s.key !== col.key) next = { key: col.key, dir: "asc" }
105
113
  else if (s.dir === "asc") next = { key: col.key, dir: "desc" }
106
114
  else next = null
115
+ // Kept locally as well as emitted: a parent that binds v-model overwrites
116
+ // this on the way back, and one that does not still gets a table that sorts.
117
+ ownSort.value = next
107
118
  emit("update:sort", next)
108
119
  }
109
120
 
110
121
  function sortIcon(col: TableColumn<T>): string {
111
- if (props.sort?.key !== col.key) return "lucide:chevrons-up-down"
112
- return props.sort.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
122
+ const s = activeSort.value
123
+ if (s?.key !== col.key) return "lucide:chevrons-up-down"
124
+ return s.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
113
125
  }
114
126
 
115
127
  // ── selection ────────────────────────────────────────────────
@@ -237,7 +249,11 @@ const barInsetTop = computed(() =>
237
249
  class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
238
250
  :class="alignClass(col.align)"
239
251
  :aria-sort="
240
- sort?.key === col.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined
252
+ activeSort?.key === col.key
253
+ ? activeSort.dir === 'asc'
254
+ ? 'ascending'
255
+ : 'descending'
256
+ : undefined
241
257
  "
242
258
  >
243
259
  <button
@@ -246,7 +262,7 @@ const barInsetTop = computed(() =>
246
262
  class="group/sort -mx-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
247
263
  :class="[
248
264
  col.align === 'right' ? 'flex-row-reverse' : '',
249
- sort?.key === col.key ? 'text-ink' : '',
265
+ activeSort?.key === col.key ? 'text-ink' : '',
250
266
  ]"
251
267
  @click="toggleSort(col)"
252
268
  >
@@ -255,7 +271,11 @@ const barInsetTop = computed(() =>
255
271
  :name="sortIcon(col)"
256
272
  :size="13"
257
273
  class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
258
- :class="sort?.key === col.key ? 'opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
274
+ :class="
275
+ activeSort?.key === col.key
276
+ ? 'opacity-100'
277
+ : 'opacity-40 group-hover/sort:opacity-70'
278
+ "
259
279
  />
260
280
  </button>
261
281
  <template v-else>{{ col.label }}</template>
@@ -266,8 +286,17 @@ const barInsetTop = computed(() =>
266
286
  steer, and a stray key from a control inside a cell must not move the
267
287
  cursor. The container is bound only so the composable can find rows
268
288
  to focus. -->
269
- <tbody :ref="(el) => roving.setContainer(el as HTMLElement | null)">
270
- <tr v-if="displayRows.length === 0">
289
+ <!-- Rows fade and settle rather than snapping in and out. Filtering a
290
+ list is a change the eye has to follow: rows vanishing between two
291
+ frames read as the table being redrawn, and the one that stayed is
292
+ impossible to keep track of. `move` is what carries the survivors to
293
+ their new position instead of teleporting them. -->
294
+ <TransitionGroup
295
+ tag="tbody"
296
+ name="lp-row"
297
+ :ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
298
+ >
299
+ <tr v-if="displayRows.length === 0" key="lp-table-empty">
271
300
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
272
301
  <LpIcon :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
273
302
  <div>{{ emptyLabel }}</div>
@@ -282,8 +311,12 @@ const barInsetTop = computed(() =>
282
311
  :key="keyFor(row, index)"
283
312
  :items="menuFor(row)"
284
313
  >
314
+ <!-- `group` so a cell's own content can answer the row's hover: a
315
+ link inside a row wants its underline to sweep in when the row
316
+ lights up, not when the pointer crosses the text exactly. The
317
+ class paints nothing by itself. -->
285
318
  <tr
286
- class="border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
319
+ class="group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
287
320
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
288
321
  v-bind="roving.itemProps(String(keyFor(row, index)))"
289
322
  @click="emit('rowClick', row)"
@@ -307,7 +340,57 @@ const barInsetTop = computed(() =>
307
340
  </td>
308
341
  </tr>
309
342
  </LpContextMenu>
310
- </tbody>
343
+ </TransitionGroup>
311
344
  </table>
312
345
  </LpScrollArea>
313
346
  </template>
347
+
348
+ <style scoped>
349
+ /*
350
+ * Rows arriving, leaving and moving as the filter changes.
351
+ *
352
+ * A row cannot be translated on its Y axis without the table reflowing around
353
+ * it, so the entrance is opacity plus a small horizontal offset — enough to
354
+ * read as movement, not enough to fight the column grid. `--ease-emphasized`
355
+ * because this is interface responding to a click, not data being drawn.
356
+ *
357
+ * The leaving row is taken out of the flow, or the rows below it wait for the
358
+ * fade before closing the gap and the whole list lurches at the end instead of
359
+ * gliding.
360
+ */
361
+ .lp-row-enter-active,
362
+ .lp-row-leave-active {
363
+ transition:
364
+ opacity var(--duration-fast) var(--ease-emphasized),
365
+ transform var(--duration-fast) var(--ease-emphasized);
366
+ }
367
+
368
+ .lp-row-move {
369
+ transition: transform var(--duration-medium) var(--ease-emphasized);
370
+ }
371
+
372
+ .lp-row-enter-from,
373
+ .lp-row-leave-to {
374
+ opacity: 0;
375
+ transform: translateX(-0.5rem);
376
+ }
377
+
378
+ .lp-row-leave-active {
379
+ position: absolute;
380
+ width: 100%;
381
+ }
382
+
383
+ /* A filter that redraws the list is information, not decoration: with motion
384
+ turned down the rows change without animating rather than not changing. */
385
+ @media (prefers-reduced-motion: reduce) {
386
+ .lp-row-enter-active,
387
+ .lp-row-leave-active,
388
+ .lp-row-move {
389
+ transition: none;
390
+ }
391
+
392
+ .lp-row-leave-active {
393
+ position: static;
394
+ }
395
+ }
396
+ </style>
@@ -11,7 +11,7 @@
11
11
  * and the choice survives a reload (anti-flash bootstrap). The component never
12
12
  * fetches; it just drives the theme engine.
13
13
  */
14
- import { computed, onMounted } from "vue"
14
+ import { computed, onMounted, ref } from "vue"
15
15
  import { presets } from "../theme/presets"
16
16
  import type { TokenSet } from "../theme/tokens"
17
17
  import { useTheme } from "../theme/useTheme"
@@ -153,8 +153,30 @@ const menu = computed<ContextMenuItemDef[]>(() =>
153
153
  })),
154
154
  )
155
155
 
156
+ /*
157
+ * The theme the SERVER rendered is not necessarily the one the client restores:
158
+ * SSR paints from the `lp-theme` cookie (or the default), while on the client
159
+ * `bootstrapTheme()` deliberately wins from localStorage, which holds the full
160
+ * token set including custom themes the server cannot name. The colours cope —
161
+ * they are CSS variables, reapplied on boot — but a label that spells the theme
162
+ * OUT is text, and Vue compares text: every SSR consumer logged a hydration
163
+ * mismatch on this attribute alone.
164
+ *
165
+ * So the label says what the server said until the component is mounted, then
166
+ * names the theme actually in effect. Nobody reads a tooltip in the frame
167
+ * before hydration, and the a11y name settles on the truth.
168
+ */
169
+ const hydrated = ref(false)
170
+ onMounted(() => {
171
+ hydrated.value = true
172
+ })
173
+
174
+ const labelName = computed(() =>
175
+ hydrated.value ? activeName.value : (list.value[0]?.name ?? ""),
176
+ )
177
+
156
178
  const triggerLabel = computed(
157
- () => props["aria-label"] ?? `Theme: ${activeName.value}. Click to switch, right-click to choose.`,
179
+ () => props["aria-label"] ?? `Theme: ${labelName.value}. Click to switch, right-click to choose.`,
158
180
  )
159
181
  </script>
160
182
 
@@ -195,7 +217,7 @@ const triggerLabel = computed(
195
217
  />
196
218
  </span>
197
219
 
198
- <span v-if="wantsLabel" class="text-sm font-medium">{{ activeName }}</span>
220
+ <span v-if="wantsLabel" class="text-sm font-medium">{{ labelName }}</span>
199
221
  </button>
200
222
  </LpContextMenu>
201
223
  </template>
@@ -439,6 +439,10 @@ defineExpose({ fitView, setViewport: applyViewport })
439
439
  </svg>
440
440
  {{ l.label }}
441
441
  </div>
442
+ <!-- Edges are only half of what a graph encodes; a node also carries
443
+ state (offline, stale readings) that the legend cannot know the
444
+ meaning of. The host explains its own node encoding here. -->
445
+ <slot name="legend-extra" />
442
446
  </div>
443
447
  </Panel>
444
448
  </VueFlow>