@dumiflex/wp-kit 0.0.0-stage → 0.4.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +674 -0
  3. package/README.md +72 -2
  4. package/package.json +76 -4
  5. package/src/Avatar.vue +83 -0
  6. package/src/Badge.vue +97 -0
  7. package/src/Banner.vue +118 -0
  8. package/src/BookmarkButton.vue +27 -0
  9. package/src/BundleMeta.vue +51 -0
  10. package/src/Button.vue +103 -0
  11. package/src/Card.vue +68 -0
  12. package/src/Checkbox.vue +109 -0
  13. package/src/Chip.vue +163 -0
  14. package/src/ChoiceCards.vue +133 -0
  15. package/src/CloseButton.vue +69 -0
  16. package/src/CodeBlock.vue +119 -0
  17. package/src/ColorField.vue +188 -0
  18. package/src/ColorPicker.vue +201 -0
  19. package/src/Combobox.vue +331 -0
  20. package/src/ConfirmDialog.vue +165 -0
  21. package/src/ConfirmModal.vue +147 -0
  22. package/src/ConstraintMatrixPreview.vue +708 -0
  23. package/src/ContextMenu.vue +386 -0
  24. package/src/CopyButton.vue +156 -0
  25. package/src/DataTable.vue +292 -0
  26. package/src/DatePicker.vue +234 -0
  27. package/src/DescriptionList.vue +73 -0
  28. package/src/DiffView.vue +68 -0
  29. package/src/Disclosure.vue +166 -0
  30. package/src/DragHandle.vue +49 -0
  31. package/src/Drawer.vue +131 -0
  32. package/src/DropZone.vue +196 -0
  33. package/src/DropdownMenu.vue +72 -0
  34. package/src/EmptyState.vue +111 -0
  35. package/src/Field.vue +28 -0
  36. package/src/HsvPicker.vue +208 -0
  37. package/src/Icon.vue +39 -0
  38. package/src/IconPicker.vue +209 -0
  39. package/src/ImageGrid.vue +125 -0
  40. package/src/InlineEdit.vue +183 -0
  41. package/src/Input.vue +99 -0
  42. package/src/Kbd.vue +56 -0
  43. package/src/Lightbox.vue +173 -0
  44. package/src/ListFilter.vue +85 -0
  45. package/src/ListRow.vue +204 -0
  46. package/src/LoadingMark.vue +175 -0
  47. package/src/Logo.vue +60 -0
  48. package/src/Modal.vue +78 -0
  49. package/src/ModalShell.vue +109 -0
  50. package/src/Pagination.vue +106 -0
  51. package/src/Popover.vue +180 -0
  52. package/src/PostCard.vue +210 -0
  53. package/src/ProgressBar.vue +90 -0
  54. package/src/RadioGroup.vue +177 -0
  55. package/src/RangeSlider.vue +183 -0
  56. package/src/RelativeDate.vue +35 -0
  57. package/src/ResizeHandle.vue +143 -0
  58. package/src/SearchInput.vue +192 -0
  59. package/src/Select.vue +644 -0
  60. package/src/Separator.vue +54 -0
  61. package/src/Skeleton.vue +82 -0
  62. package/src/Slider.vue +198 -0
  63. package/src/SortableList.vue +260 -0
  64. package/src/SplitPane.vue +68 -0
  65. package/src/StarButton.vue +40 -0
  66. package/src/Stepper.vue +141 -0
  67. package/src/SupportLinks.vue +93 -0
  68. package/src/TagInput.vue +297 -0
  69. package/src/Textarea.vue +57 -0
  70. package/src/Timeline.vue +92 -0
  71. package/src/ToastCard.vue +119 -0
  72. package/src/ToastHost.vue +40 -0
  73. package/src/Toggle.vue +36 -0
  74. package/src/TokenizedValue.vue +310 -0
  75. package/src/Toolbar.vue +110 -0
  76. package/src/VirtualList.vue +87 -0
  77. package/src/VisuallyHidden.vue +29 -0
  78. package/src/WpButton.vue +75 -0
  79. package/src/WpCheck.vue +104 -0
  80. package/src/WpIconButton.vue +107 -0
  81. package/src/WpImageZoom.vue +544 -0
  82. package/src/WpNumber.vue +178 -0
  83. package/src/WpSegmented.vue +175 -0
  84. package/src/WpSelect.vue +93 -0
  85. package/src/WpSwitch.vue +163 -0
  86. package/src/WpTabs.vue +188 -0
  87. package/src/_modal-motion.css +33 -0
  88. package/src/_skeleton.css +50 -0
  89. package/src/axis-color.ts +42 -0
  90. package/src/button.css +169 -0
  91. package/src/calendar.ts +59 -0
  92. package/src/clipboard.ts +50 -0
  93. package/src/colour-presets.ts +12 -0
  94. package/src/diff.ts +51 -0
  95. package/src/drop-zone.ts +41 -0
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -0
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -0
  100. package/src/hsv.ts +100 -0
  101. package/src/icon-catalog.ts +52 -0
  102. package/src/image-zoom.ts +93 -0
  103. package/src/kind-colour.ts +28 -0
  104. package/src/logo-paths.ts +21 -0
  105. package/src/matrix-axis.ts +91 -0
  106. package/src/pagination.ts +27 -0
  107. package/src/platform-keys.ts +29 -0
  108. package/src/rich-tokenize.ts +357 -0
  109. package/src/select-types.ts +32 -0
  110. package/src/slider-math.ts +45 -0
  111. package/src/sortable.ts +22 -0
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -0
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -0
  115. package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  116. package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
  117. package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
  118. package/src/theme/looks.css +428 -0
  119. package/src/theme/palettes.css +254 -0
  120. package/src/theme/tokens.css +2896 -0
  121. package/src/tokens.ts +56 -0
  122. package/src/tooltip.css +43 -0
  123. package/src/tooltip.ts +146 -0
  124. package/src/useDialogFocus.ts +73 -0
  125. package/src/useGrowableField.ts +357 -0
  126. package/src/useModalKeyShield.ts +116 -0
  127. package/src/useToast.ts +48 -0
  128. package/src/wp-logo.svg +14 -0
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Timeline: events in order, each a dot on one line with its time, title
4
+ * and an optional body (History, an AI trace, a pack build log). Works on
5
+ * the canvas and in the manager.
6
+ *
7
+ * <Timeline :items="[{ id: 1, title: 'Saved Hair colour', time: '5 min ago', tone: 'success', icon: 'pi-check' }]" />
8
+ * <Timeline :items="steps"><template #body="{ item }"><CodeBlock :code="item.args" bare /></template></Timeline>
9
+ *
10
+ * It is an ordered list. `#body` and `#time` slots let a screen draw its own
11
+ * body (a diff, a code block) or a RelativeDate. The last item can be
12
+ * `pending` (a pulsing dot) while a job is still running.
13
+ */
14
+ export interface TimelineItem {
15
+ id: string | number;
16
+ title: string;
17
+ /** Shown after the title, e.g. "5 min ago". */
18
+ time?: string | undefined;
19
+ /** A line or two of text under the title. */
20
+ body?: string | undefined;
21
+ tone?: "neutral" | "info" | "success" | "warn" | "danger" | "accent" | undefined;
22
+ /** A PrimeIcons class inside the dot. */
23
+ icon?: string | undefined;
24
+ /** Still running: the dot pulses. */
25
+ pending?: boolean | undefined;
26
+ }
27
+
28
+ withDefaults(defineProps<{
29
+ items: TimelineItem[];
30
+ size?: "sm" | "md" | undefined;
31
+ ariaLabel?: string | undefined;
32
+ }>(), { size: "md", ariaLabel: undefined });
33
+ </script>
34
+
35
+ <template>
36
+ <ol class="wp-timeline" :data-size="size" :aria-label="ariaLabel">
37
+ <li
38
+ v-for="it in items"
39
+ :key="it.id"
40
+ class="wp-timeline__item"
41
+ :data-tone="it.tone ?? 'neutral'"
42
+ :data-pending="it.pending || undefined"
43
+ :data-icon="it.icon ? 'true' : undefined"
44
+ >
45
+ <span class="wp-timeline__dot" aria-hidden="true"><i v-if="it.icon" class="pi" :class="it.icon" /></span>
46
+ <div class="wp-timeline__main">
47
+ <div class="wp-timeline__head">
48
+ <span class="wp-timeline__title">{{ it.title }}</span>
49
+ <span v-if="it.time || $slots.time" class="wp-timeline__time"><slot name="time" :item="it">{{ it.time }}</slot></span>
50
+ </div>
51
+ <div v-if="it.body || $slots.body" class="wp-timeline__body"><slot name="body" :item="it">{{ it.body }}</slot></div>
52
+ </div>
53
+ </li>
54
+ </ol>
55
+ </template>
56
+
57
+ <style scoped>
58
+ .wp-timeline { --_dot: 20px; margin: 0; padding: 0; list-style: none; font: 400 var(--wp-text-sm)/1.45 var(--wp-font-sans); min-width: 0; }
59
+ .wp-timeline[data-size="sm"] { --_dot: 16px; font-size: var(--wp-text-xs); }
60
+ .wp-timeline__item { --_tone: var(--wp-text-dim); position: relative; display: flex; gap: var(--wp-space-4); padding-bottom: var(--wp-space-5); }
61
+ .wp-timeline[data-size="sm"] .wp-timeline__item { gap: var(--wp-space-3); padding-bottom: var(--wp-space-4); }
62
+ .wp-timeline__item:last-child { padding-bottom: 0; }
63
+ .wp-timeline__item[data-tone="info"] { --_tone: var(--wp-info); }
64
+ .wp-timeline__item[data-tone="success"] { --_tone: var(--wp-success); }
65
+ .wp-timeline__item[data-tone="warn"] { --_tone: var(--wp-warn); }
66
+ .wp-timeline__item[data-tone="danger"] { --_tone: var(--wp-danger); }
67
+ .wp-timeline__item[data-tone="accent"] { --_tone: var(--wp-accent); }
68
+ .wp-timeline__item:not(:last-child)::before {
69
+ content: ""; position: absolute; top: calc(var(--_dot) + 2px); bottom: 2px; left: calc(var(--_dot) / 2 - 1px);
70
+ width: 2px; border-radius: min(1px, var(--wp-radius-cap, 999px)); background: var(--wp-border);
71
+ }
72
+ .wp-timeline__dot {
73
+ position: relative; display: grid; place-items: center; flex: none; box-sizing: border-box;
74
+ width: var(--_dot); height: var(--_dot); margin-top: 1px; border-radius: 50%; /* round: timeline dot */
75
+ border: 2px solid var(--_tone); background: var(--wp-bg-1); color: var(--_tone);
76
+ }
77
+ .wp-timeline__item:not([data-icon]) .wp-timeline__dot { transform: scale(0.6); background: var(--_tone); }
78
+ .wp-timeline__dot .pi { font-size: calc(var(--_dot) * 0.45); }
79
+ .wp-timeline__item[data-pending] .wp-timeline__dot::after {
80
+ content: ""; position: absolute; inset: -4px; border-radius: 50%; /* round: timeline dot ring */ border: 2px solid var(--_tone);
81
+ animation: wp-timeline-pulse 1.6s ease-out infinite;
82
+ }
83
+ @keyframes wp-timeline-pulse { from { opacity: 0.7; transform: scale(0.8); } to { opacity: 0; transform: scale(1.4); } }
84
+ .wp-timeline__main { flex: 1; min-width: 0; }
85
+ .wp-timeline__head { display: flex; align-items: baseline; gap: var(--wp-space-3); flex-wrap: wrap; }
86
+ .wp-timeline__title { color: var(--wp-text); font-weight: 500; }
87
+ .wp-timeline__time { color: var(--wp-text-dim); font-size: var(--wp-text-xs); font-variant-numeric: tabular-nums; }
88
+ .wp-timeline__body { margin-top: 2px; color: var(--wp-text-muted); overflow-wrap: anywhere; }
89
+ @media (prefers-reduced-motion: reduce) {
90
+ .wp-timeline__item[data-pending] .wp-timeline__dot::after { animation: none; display: none; }
91
+ }
92
+ </style>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One toast: the look both toast stacks share.
4
+ *
5
+ * The manager (`ui/ToastHost.vue`, fed by `useToast()`) and the canvas
6
+ * (`shared/Toast.vue`, fed by `toast-store`) used to draw toasts two ways:
7
+ * different surfaces, borders, icons and action buttons. Now each host keeps
8
+ * only its own store and its own place on screen, and renders this card.
9
+ * It reads only SHARED_TOKENS, so it wears the colours of the side it is on.
10
+ *
11
+ * `tone` follows the kit's names; the canvas store's `warning` maps to
12
+ * `warn` in its host.
13
+ */
14
+ const props = defineProps<{
15
+ tone: "info" | "success" | "warn" | "error";
16
+ summary: string;
17
+ detail?: string | undefined;
18
+ /** Label of the inline action (Undo, Open…); emits `action`. */
19
+ actionLabel?: string | undefined;
20
+ }>();
21
+
22
+ const emit = defineEmits<{ action: []; dismiss: [] }>();
23
+
24
+ const ICONS = {
25
+ info: "pi-info-circle",
26
+ success: "pi-check-circle",
27
+ warn: "pi-exclamation-triangle",
28
+ error: "pi-times-circle",
29
+ } as const;
30
+ </script>
31
+
32
+ <template>
33
+ <div :class="['wp-toast', `wp-toast--${props.tone}`]" role="status" data-test="toast">
34
+ <i :class="['pi', ICONS[props.tone], 'wp-toast__icon']" aria-hidden="true" />
35
+ <div class="wp-toast__text">
36
+ <div class="wp-toast__summary">{{ summary }}</div>
37
+ <div v-if="detail" class="wp-toast__detail">{{ detail }}</div>
38
+ </div>
39
+ <button
40
+ v-if="actionLabel"
41
+ type="button"
42
+ class="wp-toast__action"
43
+ data-test="toast-action"
44
+ @click="emit('action')"
45
+ >{{ actionLabel }}</button>
46
+ <button
47
+ type="button"
48
+ class="wp-toast__close"
49
+ aria-label="Dismiss"
50
+ @click="emit('dismiss')"
51
+ ><i class="pi pi-times" aria-hidden="true" /></button>
52
+ </div>
53
+ </template>
54
+
55
+ <style scoped>
56
+ .wp-toast {
57
+ --toast-tone: var(--wp-info);
58
+ pointer-events: auto;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: var(--wp-space-4);
62
+ min-width: 220px;
63
+ max-width: 420px;
64
+ padding: var(--wp-space-4) var(--wp-space-4) var(--wp-space-4) var(--wp-space-5);
65
+ /* Slightly see-through with a blur, so it reads as floating over
66
+ whatever is underneath without a heavy shadow. */
67
+ background: color-mix(in oklab, var(--wp-bg-3) 94%, transparent);
68
+ backdrop-filter: blur(10px);
69
+ -webkit-backdrop-filter: blur(10px);
70
+ border: 1px solid var(--wp-border-strong);
71
+ border-left: 3px solid var(--toast-tone);
72
+ border-radius: var(--wp-radius-md);
73
+ box-shadow: var(--wp-shadow-md);
74
+ color: var(--wp-text);
75
+ font-family: var(--wp-font-sans);
76
+ font-size: var(--wp-text-sm);
77
+ line-height: 1.4;
78
+ }
79
+ .wp-toast--success { --toast-tone: var(--wp-success); }
80
+ .wp-toast--warn { --toast-tone: var(--wp-warn); }
81
+ .wp-toast--error { --toast-tone: var(--wp-danger); }
82
+
83
+ .wp-toast__icon { flex-shrink: 0; font-size: 14px; color: var(--toast-tone); }
84
+ .wp-toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
85
+ .wp-toast__summary { font-weight: 600; }
86
+ .wp-toast__detail { margin-top: 1px; color: var(--wp-text-muted); font-size: var(--wp-text-xs); }
87
+
88
+ .wp-toast__action {
89
+ flex-shrink: 0;
90
+ height: var(--wp-control-h-sm);
91
+ padding: 0 var(--wp-space-4);
92
+ background: transparent;
93
+ border: 1px solid var(--wp-border-strong);
94
+ border-radius: var(--wp-radius-sm);
95
+ color: var(--wp-text);
96
+ font: 600 var(--wp-text-xs) var(--wp-font-sans);
97
+ cursor: pointer;
98
+ transition: background var(--wp-motion-quick), border-color var(--wp-motion-quick);
99
+ }
100
+ .wp-toast__action:hover { background: var(--wp-bg-4); border-color: var(--wp-accent-500); }
101
+
102
+ .wp-toast__close {
103
+ display: grid;
104
+ place-items: center;
105
+ flex-shrink: 0;
106
+ width: 24px;
107
+ height: 24px;
108
+ padding: 0;
109
+ background: none;
110
+ border: 0;
111
+ border-radius: var(--wp-radius-sm);
112
+ color: var(--wp-text-dim);
113
+ cursor: pointer;
114
+ }
115
+ .wp-toast__close .pi { font-size: 10px; }
116
+ .wp-toast__close:hover { color: var(--wp-text); background: var(--wp-bg-4); }
117
+ .wp-toast__action:focus-visible,
118
+ .wp-toast__close:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
119
+ </style>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The manager's toast stack (bottom right). The toast itself is the shared
4
+ * ToastCard, the same one the canvas stack draws.
5
+ */
6
+ import { useToast } from "./useToast";
7
+ import ToastCard from "./ToastCard.vue";
8
+
9
+ const { toasts, push, dismiss } = useToast();
10
+
11
+ async function runAction(id: string, handler: () => void | Promise<void>) {
12
+ try {
13
+ await handler();
14
+ } catch (e) {
15
+ push({
16
+ severity: "error",
17
+ summary: "Undo failed",
18
+ detail: e instanceof Error ? e.message : String(e),
19
+ life: 4000,
20
+ });
21
+ } finally {
22
+ dismiss(id);
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div class="wp-toast-stack" aria-live="polite" aria-atomic="true">
29
+ <ToastCard
30
+ v-for="t in toasts"
31
+ :key="t.id"
32
+ :tone="t.severity"
33
+ :summary="t.summary"
34
+ :detail="t.detail"
35
+ :action-label="t.action?.label"
36
+ @action="t.action && runAction(t.id, t.action.run)"
37
+ @dismiss="dismiss(t.id)"
38
+ />
39
+ </div>
40
+ </template>
package/src/Toggle.vue ADDED
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The manager's on/off switch: the shared WpSwitch at the
4
+ * manager size, so the canvas and the manager draw one switch. Use it for a
5
+ * setting that takes effect at once.
6
+ */
7
+ import WpSwitch from "./WpSwitch.vue";
8
+
9
+ interface Props {
10
+ modelValue: boolean;
11
+ label?: string | undefined;
12
+ size?: "sm" | "md" | "lg" | undefined;
13
+ disabled?: boolean | undefined;
14
+ invalid?: boolean | undefined;
15
+ /** @deprecated use `invalid`. */
16
+ error?: boolean | undefined;
17
+ ariaLabel?: string | undefined;
18
+ }
19
+ const props = withDefaults(defineProps<Props>(), { size: "md", label: undefined, ariaLabel: undefined });
20
+
21
+ const emit = defineEmits<{
22
+ (e: "update:modelValue", v: boolean): void;
23
+ }>();
24
+ </script>
25
+
26
+ <template>
27
+ <WpSwitch
28
+ :model-value="props.modelValue"
29
+ :size="props.size"
30
+ :label="props.label"
31
+ :aria-label="props.ariaLabel ?? props.label"
32
+ :disabled="props.disabled"
33
+ :invalid="props.invalid || props.error"
34
+ @update:model-value="emit('update:modelValue', $event)"
35
+ />
36
+ </template>
@@ -0,0 +1,310 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Read-only renderer for a wildcard-grammar value string.
4
+ *
5
+ * Consumes the token stream from `tokenizeRich` (chunk A1) and paints each
6
+ * token as a coloured inline span or chip — vars (green), refs (magenta),
7
+ * brace alternations (amber scaffolding) and multi-picks (green scaffolding).
8
+ * The component is RECURSIVE: brace arms re-enter `TokenizedValue` so a
9
+ * `@{uuid}` or `$var` nested inside `{a|@{…}|$rank}` still renders as a chip.
10
+ *
11
+ * No `v-html` — every token becomes a `v-for`-rendered element (CLAUDE.md
12
+ * confines raw-HTML rendering to `utils/sanitize.ts`). This mirrors the
13
+ * sister project's RefChip + rich-text.css chrome (read, not imported — the
14
+ * two repos diverge) but emits a flat, framework-local span tree.
15
+ *
16
+ * The token stream is pre-flattened in `<script>` into a single list of
17
+ * `RenderItem`s (text / escape / var / ref / scaffolding-glyph / nested
18
+ * child). That keeps the template a flat `v-for` — no nested branch loops —
19
+ * which both reads cleanly and lets adjacent items abut with no stray
20
+ * whitespace (tokens are contiguous; an inter-element space would corrupt
21
+ * the rendered value).
22
+ */
23
+ import { computed } from "vue";
24
+ import {
25
+ splitRefFilter,
26
+ tokenizeRich,
27
+ type RichToken,
28
+ } from "./rich-tokenize";
29
+
30
+ defineOptions({ name: "TokenizedValue" });
31
+
32
+ const props = withDefaults(
33
+ defineProps<{
34
+ /** The raw wildcard-grammar string to render. */
35
+ value: string;
36
+ /** uuid → display-name map for resolving `@{uuid}` refs. Empty by default
37
+ * (callers without a catalog still render; refs fall back to #name/uuid). */
38
+ uuidToName?: ReadonlyMap<string, string>;
39
+ }>(),
40
+ { uuidToName: () => new Map<string, string>() },
41
+ );
42
+
43
+ /** Concrete (never-undefined) map for binding into nested instances —
44
+ * `withDefaults` guarantees a value, but the recursive self-reference's
45
+ * optional prop type needs an explicitly non-optional source under
46
+ * `exactOptionalPropertyTypes`. */
47
+ const refMap = computed<ReadonlyMap<string, string>>(() => props.uuidToName);
48
+
49
+ /** One drawable unit. A brace/multi token expands into several of these
50
+ * (scaffolding glyphs interleaved with `child` arms); every other token
51
+ * maps to exactly one. */
52
+ type RenderItem =
53
+ | { key: string; kind: "text" | "escape"; text: string }
54
+ | { key: string; kind: "var"; label: string; neg: boolean }
55
+ | { key: string; kind: "scaf"; text: string; flavor: "brace" | "multi" }
56
+ | { key: string; kind: "child"; value: string }
57
+ | {
58
+ key: string;
59
+ kind: "ref";
60
+ uuid: string;
61
+ label: string;
62
+ resolved: boolean;
63
+ expr: string;
64
+ excludeNull: boolean;
65
+ };
66
+
67
+ /** Var chip label: `$name` plus an optional `.K` list accessor (SP2a) and
68
+ * `.neg` negatives read (schema v8). */
69
+ function varLabel(meta: RichToken["meta"]): string {
70
+ const idx = meta?.index;
71
+ return (
72
+ "$" +
73
+ (meta?.name ?? "") +
74
+ (idx !== undefined ? "." + idx : "") +
75
+ (meta?.neg ? ".neg" : "")
76
+ );
77
+ }
78
+
79
+ /** Resolve a ref token into its chip view. Resolution precedence: live map
80
+ * name → cached `#name` → bare uuid. The raw `:`-filter is peeled into an
81
+ * expression + exclude-null flag (the expression rides in the hover title,
82
+ * never inline, so long filters can't blow out the chip). */
83
+ function refItem(token: RichToken, key: string): Extract<RenderItem, { kind: "ref" }> {
84
+ const meta = token.meta ?? {};
85
+ const uuid = meta.uuid ?? "";
86
+ const mapped = props.uuidToName.get(uuid);
87
+ const fromMap = typeof mapped === "string" && mapped.length > 0 ? mapped : "";
88
+ const cached = typeof meta.name === "string" && meta.name.length > 0 ? meta.name : "";
89
+ const resolvedName = fromMap || cached;
90
+ const { expr, excludeNull } = splitRefFilter((meta.sub_categories ?? []).join(","));
91
+ return {
92
+ key,
93
+ kind: "ref",
94
+ uuid,
95
+ label: resolvedName || uuid,
96
+ resolved: resolvedName.length > 0,
97
+ expr,
98
+ excludeNull,
99
+ };
100
+ }
101
+
102
+ /** Reconstruct a multi-pick's opening scaffolding from its meta: `{N`, `-M`
103
+ * (range only), `~` (independent), then the `$$sep$$` delimiter. Inverse of
104
+ * the tokenizer so a decomposed block reads identically to its source. */
105
+ function multiPrefix(meta: RichToken["meta"]): string {
106
+ const min = meta?.min ?? 0;
107
+ const max = meta?.max ?? min;
108
+ const range = max !== min ? "-" + max : "";
109
+ const indep = meta?.independent ? "~" : "";
110
+ return "{" + min + range + indep + "$$" + (meta?.sep ?? "") + "$$";
111
+ }
112
+
113
+ /** Flatten a brace/multi token into scaffolding glyphs + child arms. */
114
+ function expandBlock(
115
+ token: RichToken,
116
+ flavor: "brace" | "multi",
117
+ open: string,
118
+ base: string,
119
+ ): RenderItem[] {
120
+ const out: RenderItem[] = [];
121
+ const branches = token.meta?.branches ?? [];
122
+ out.push({ key: base + "-o", kind: "scaf", text: open, flavor });
123
+ branches.forEach((branch, bi) => {
124
+ if (bi > 0) out.push({ key: `${base}-p${bi}`, kind: "scaf", text: "|", flavor });
125
+ out.push({ key: `${base}-c${bi}`, kind: "child", value: branch });
126
+ });
127
+ out.push({ key: base + "-x", kind: "scaf", text: "}", flavor });
128
+ return out;
129
+ }
130
+
131
+ const items = computed<RenderItem[]>(() => {
132
+ const out: RenderItem[] = [];
133
+ for (const token of tokenizeRich(props.value)) {
134
+ const key = String(token.start);
135
+ switch (token.kind) {
136
+ case "text":
137
+ out.push({ key, kind: "text", text: token.raw });
138
+ break;
139
+ case "escape":
140
+ out.push({ key, kind: "escape", text: token.raw });
141
+ break;
142
+ case "var":
143
+ out.push({ key, kind: "var", label: varLabel(token.meta), neg: token.meta?.neg === true });
144
+ break;
145
+ case "ref":
146
+ out.push(refItem(token, key));
147
+ break;
148
+ case "dp-brace":
149
+ out.push(...expandBlock(token, "brace", "{", key));
150
+ break;
151
+ case "dp-multi":
152
+ out.push(...expandBlock(token, "multi", multiPrefix(token.meta), key));
153
+ break;
154
+ default:
155
+ // Defensive: any future/unhandled kind still shows its raw text so
156
+ // the lossless invariant holds visually.
157
+ out.push({ key, kind: "text", text: token.raw });
158
+ }
159
+ }
160
+ return out;
161
+ });
162
+ </script>
163
+
164
+ <template>
165
+ <span class="wp-tv">
166
+ <template
167
+ v-for="item in items"
168
+ :key="item.key"
169
+ >
170
+ <span
171
+ v-if="item.kind === 'text'"
172
+ class="wp-tv-text"
173
+ >{{ item.text }}</span>
174
+ <span
175
+ v-else-if="item.kind === 'escape'"
176
+ class="wp-tv-escape"
177
+ >{{ item.text }}</span>
178
+ <span
179
+ v-else-if="item.kind === 'var'"
180
+ class="wp-tv-var"
181
+ :data-neg="item.neg || null"
182
+ >{{ item.label }}</span>
183
+ <span
184
+ v-else-if="item.kind === 'scaf'"
185
+ class="wp-tv-scaf"
186
+ :class="item.flavor === 'multi' ? 'wp-tv-scaf--multi' : 'wp-tv-scaf--brace'"
187
+ >{{ item.text }}</span>
188
+ <TokenizedValue
189
+ v-else-if="item.kind === 'child'"
190
+ :value="item.value"
191
+ :uuid-to-name="refMap"
192
+ />
193
+ <span
194
+ v-else-if="item.kind === 'ref'"
195
+ class="wp-tv-ref"
196
+ :class="{ 'wp-tv-ref--unresolved': !item.resolved }"
197
+ :title="item.expr || undefined"
198
+ >
199
+ <i
200
+ class="pi wp-tv-ref__icon"
201
+ :class="item.resolved ? 'pi-sparkles' : 'pi-question-circle'"
202
+ aria-hidden="true"
203
+ />
204
+ <span class="wp-tv-ref__label">{{ item.label }}</span>
205
+ <i
206
+ v-if="item.expr"
207
+ class="pi pi-filter wp-tv-ref__funnel"
208
+ aria-hidden="true"
209
+ />
210
+ <i
211
+ v-if="item.excludeNull"
212
+ class="pi pi-ban wp-tv-ref__nonull"
213
+ aria-hidden="true"
214
+ />
215
+ </span>
216
+ </template>
217
+ </span>
218
+ </template>
219
+
220
+ <style scoped>
221
+ .wp-tv {
222
+ /* Host text size — chips deliberately read SMALLER than this line. */
223
+ font-size: 13px;
224
+ line-height: 1.6;
225
+ word-break: break-word;
226
+ }
227
+
228
+ .wp-tv-text {
229
+ color: var(--wp-text);
230
+ }
231
+
232
+ .wp-tv-escape {
233
+ color: var(--wp-text-dim);
234
+ }
235
+
236
+ /* ── Chips (var + ref) ───────────────────────────────────────────────
237
+ * Inline (not inline-flex) so they wrap with the surrounding prose and
238
+ * sit on the text baseline. 11px < the 13px host line keeps them from
239
+ * towering over the text. */
240
+ .wp-tv-var,
241
+ .wp-tv-ref {
242
+ display: inline;
243
+ font-size: 11px;
244
+ padding: 1px 4px;
245
+ border-radius: min(4px, var(--wp-radius-cap, 999px));
246
+ white-space: nowrap;
247
+ vertical-align: baseline;
248
+ }
249
+
250
+ /* $var — green. */
251
+ .wp-tv-var {
252
+ background: color-mix(in oklab, var(--wp-success) 16%, transparent);
253
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-success) 50%, transparent);
254
+ color: var(--wp-success);
255
+ font-weight: 500;
256
+ }
257
+ /* $var.neg — reads the variable's negatives, so it wears the negative red. */
258
+ .wp-tv-var[data-neg] {
259
+ background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
260
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-danger) 45%, transparent);
261
+ color: var(--wp-danger-text, var(--wp-danger));
262
+ }
263
+
264
+ /* @{uuid} ref — magenta. Carries a hover title (the filter), so `help`. */
265
+ .wp-tv-ref {
266
+ background: color-mix(in oklab, var(--wp-kind-ref) 15%, transparent);
267
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-ref) 50%, transparent);
268
+ color: var(--wp-kind-ref);
269
+ font-weight: 500;
270
+ cursor: help;
271
+ }
272
+
273
+ /* Unresolved ref — red. Overrides the magenta tone above. */
274
+ .wp-tv-ref--unresolved {
275
+ background: color-mix(in oklab, var(--wp-danger) 15%, transparent);
276
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-danger) 50%, transparent);
277
+ color: var(--wp-danger);
278
+ }
279
+
280
+ /* In-chip PrimeIcons — small + baseline-nudged so they don't lift the line. */
281
+ .wp-tv-ref__icon,
282
+ .wp-tv-ref__funnel,
283
+ .wp-tv-ref__nonull {
284
+ font-size: 8px;
285
+ vertical-align: 0;
286
+ }
287
+ .wp-tv-ref__icon {
288
+ margin-right: 3px;
289
+ }
290
+ .wp-tv-ref__funnel {
291
+ margin-left: 3px;
292
+ color: var(--wp-warn);
293
+ }
294
+ .wp-tv-ref__nonull {
295
+ margin-left: 2px;
296
+ color: var(--wp-text-dim);
297
+ }
298
+
299
+ /* ── Brace / multi scaffolding ───────────────────────────────────────
300
+ * Colour-only (no background, no box) — the bordered chips are the nested
301
+ * arms. Brace = amber, multi = green. */
302
+ .wp-tv-scaf--brace {
303
+ color: var(--wp-warn);
304
+ font-weight: 600;
305
+ }
306
+ .wp-tv-scaf--multi {
307
+ color: var(--wp-kind-derivation);
308
+ font-weight: 600;
309
+ }
310
+ </style>