@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,85 @@
1
+ <script setup lang="ts">
2
+ import SearchInput from "./SearchInput.vue";
3
+
4
+ /**
5
+ * Text filter for a long list inside an editor card.
6
+ *
7
+ * Extracted from the fixed-values editor, which grew this pattern first
8
+ * (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
9
+ * richer variant with a tag menu attached; this is the plain-text case, which
10
+ * is what the constraint exceptions and derivation rules need.
11
+ *
12
+ * Since the component merge it is SearchInput with a list's rules on top:
13
+ * the "3 of 12" count inside the box, and the hide-until-long gate below.
14
+ *
15
+ * Deliberately dumb: it owns the query string and the count readout, and
16
+ * nothing else. Each caller keeps its own filtering, because what counts as a
17
+ * match differs per surface — an exception matches on resolved source/target
18
+ * labels, a rule matches on every variable name and value across its branches.
19
+ */
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ /** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
23
+ total: number;
24
+ /** Row count after filtering. */
25
+ visible: number;
26
+ /** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
27
+ noun: string;
28
+ /** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear` (the ✕). */
29
+ testPrefix: string;
30
+ /**
31
+ * Hide the control until the list is longer than this.
32
+ *
33
+ * Callers set it from how tall their rows are, not from a house style: a
34
+ * derivation rule is a whole card and three of them already scroll, while
35
+ * a fixed-value is one table row and eight fit comfortably.
36
+ *
37
+ * The default of 8 came from the fixed-values filter and was applied
38
+ * unchanged to the constraint and derivation editors, where it hid the
39
+ * feature outright — a real library measured 2026-08-06 had at most 3
40
+ * rules and 2 exceptions per module, so nobody ever saw it.
41
+ *
42
+ * Gated on `total`, never on `visible`, so the box cannot disappear
43
+ * underneath a query that filtered the list down past the threshold.
44
+ */
45
+ minRows?: number | undefined;
46
+ }>(),
47
+ { minRows: 8 },
48
+ );
49
+
50
+ const query = defineModel<string>({ required: true });
51
+ </script>
52
+
53
+ <template>
54
+ <SearchInput
55
+ v-if="total > props.minRows"
56
+ v-model="query"
57
+ class="wp-lfilter"
58
+ size="sm"
59
+ :placeholder="`Filter ${total} ${noun}…`"
60
+ :aria-label="`Filter ${noun}`"
61
+ :total="total"
62
+ :visible="visible"
63
+ :noun="noun"
64
+ clear-label="Clear filter"
65
+ :test-prefix="testPrefix"
66
+ :data-test="`${testPrefix}-search`"
67
+ />
68
+ </template>
69
+
70
+ <style scoped>
71
+ .wp-lfilter {
72
+ /* Card's header puts a `.wp-spacer` (flex: 1) between the title and this
73
+ slot. With a grow factor of 1 the two split the free space evenly and the
74
+ search box ends up half the width it should be. A far larger factor takes
75
+ effectively all of the slack while leaving the spacer in place, which is
76
+ what still separates the title from the controls. */
77
+ flex: 1000 1 auto;
78
+ /* A floor, not a width. Below this the input is too small to read what you
79
+ typed, so the card header (which wraps) drops the filter onto its own row
80
+ instead of shaving it to nothing. The derivation card, with four buttons
81
+ beside it, is the case that forces this. */
82
+ min-width: 220px;
83
+ margin-right: var(--wp-space-4);
84
+ }
85
+ </style>
@@ -0,0 +1,204 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One row of a list you click through: a facet ("portrait 12"), a child in a
4
+ * bundle's list, a category in a tree, a dependency that opens its page. The
5
+ * whole row is the target, full width, with an optional icon (or `#start`
6
+ * for anything else, e.g. a kind Chip), a label, a second `description` line,
7
+ * and a `count` or `#end` at the right; `arrow` adds a "goes somewhere"
8
+ * arrow that moves on hover.
9
+ *
10
+ * <ListRow :selected="tag === t" toggle icon="tag" :count="12" @click="pick(t)">portrait</ListRow>
11
+ * <ListRow :selected="current === c.key" :icon="kindIcon(c.type)" :description="summary(c)" :muted="!c.enabled">{{ c.name }}</ListRow>
12
+ * <ListRow :as="RouterLink" :to="`/p/${dep.slug}`" arrow><template #start><Chip :color="kindColour(t)">{{ t }}</Chip></template>{{ dep.slug }}</ListRow>
13
+ *
14
+ * `selected` marks the row in the accent with a bar on its left. A row that
15
+ * turns a filter on and off says so with `aria-pressed` (pass `toggle`);
16
+ * otherwise a selected row is `aria-current`, the one being shown. `muted`
17
+ * dims a row that is still clickable (a child that is switched off);
18
+ * `disabled` stops it. `depth` indents a row for a simple tree.
19
+ *
20
+ * ArrowUp / ArrowDown / Home / End move between the rows of the same list:
21
+ * the nearest ancestor with `data-wp-rows` (put it on the outer element of a
22
+ * nested tree), else the row's parent. It reads only the shared tokens, so it
23
+ * works on a page and on the canvas.
24
+ */
25
+ import { computed, useSlots, type Component } from "vue";
26
+ import Icon from "./Icon.vue";
27
+
28
+ const props = withDefaults(defineProps<{
29
+ /** What to render: a button (default), `"a"`, or a link component such as RouterLink. */
30
+ as?: string | Component | undefined;
31
+ selected?: boolean | undefined;
32
+ /** The row turns something on and off: `aria-pressed` instead of `aria-current`. */
33
+ toggle?: boolean | undefined;
34
+ /** PrimeIcons name (`tag` or `pi-tag`) before the label. */
35
+ icon?: string | undefined;
36
+ /** Colour of the icon: any CSS colour. */
37
+ iconColor?: string | undefined;
38
+ /** A second, smaller line under the label. */
39
+ description?: string | undefined;
40
+ count?: number | string | undefined;
41
+ /** An arrow at the right end, for a row that opens another page. */
42
+ arrow?: boolean | undefined;
43
+ /** Dim the row; it stays clickable. */
44
+ muted?: boolean | undefined;
45
+ disabled?: boolean | undefined;
46
+ /** Indent level for a simple tree (0 = top). */
47
+ depth?: number | undefined;
48
+ size?: "sm" | "md" | undefined;
49
+ }>(), { as: "button", size: "md", depth: 0 });
50
+
51
+ const slots = useSlots();
52
+ const isButton = computed(() => props.as === "button");
53
+
54
+ const aria = computed(() => {
55
+ const a: Record<string, string | boolean | undefined> = {};
56
+ if (props.toggle) a["aria-pressed"] = !!props.selected;
57
+ else if (props.selected) a["aria-current"] = "true";
58
+ if (props.disabled && !isButton.value) a["aria-disabled"] = "true";
59
+ return a;
60
+ });
61
+
62
+ const style = computed(() => {
63
+ const s: Record<string, string> = {};
64
+ if (props.depth) s["--wp-list-row-depth"] = String(props.depth);
65
+ if (props.iconColor) s["--wp-list-row-icon"] = props.iconColor;
66
+ return Object.keys(s).length ? s : undefined;
67
+ });
68
+
69
+ function onKey(e: KeyboardEvent): void {
70
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
71
+ const el = e.currentTarget as HTMLElement;
72
+ const list = el.closest("[data-wp-rows]") ?? el.parentElement;
73
+ if (!list) return;
74
+ const rows = Array.from(list.querySelectorAll<HTMLElement>(".wp-list-row"))
75
+ .filter((r) => !r.matches(":disabled, [aria-disabled='true']"));
76
+ const at = rows.indexOf(el);
77
+ if (at < 0) return;
78
+ const next = e.key === "Home" ? 0
79
+ : e.key === "End" ? rows.length - 1
80
+ : e.key === "ArrowDown" ? Math.min(rows.length - 1, at + 1)
81
+ : Math.max(0, at - 1);
82
+ e.preventDefault();
83
+ rows[next]?.focus();
84
+ }
85
+
86
+ function onClick(e: MouseEvent): void {
87
+ // A link can't be disabled natively; stop it here.
88
+ if (props.disabled && !isButton.value) {
89
+ e.preventDefault();
90
+ e.stopImmediatePropagation();
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <template>
96
+ <component
97
+ :is="as"
98
+ class="wp-list-row"
99
+ :class="[`wp-list-row--${size}`, { 'wp-list-row--muted': muted, 'wp-list-row--two-line': !!description || !!slots.description }]"
100
+ :type="isButton ? 'button' : undefined"
101
+ :disabled="isButton ? disabled : undefined"
102
+ :data-selected="selected || undefined"
103
+ :style="style"
104
+ v-bind="aria"
105
+ @keydown="onKey"
106
+ @click.capture="onClick"
107
+ >
108
+ <span v-if="slots.start" class="wp-list-row__start"><slot name="start" /></span>
109
+ <Icon v-else-if="icon" class="wp-list-row__icon" :name="icon" />
110
+ <span class="wp-list-row__main">
111
+ <span class="wp-list-row__label"><slot /></span>
112
+ <span v-if="description || slots.description" class="wp-list-row__desc">
113
+ <slot name="description">{{ description }}</slot>
114
+ </span>
115
+ </span>
116
+ <span v-if="count !== undefined" class="wp-list-row__count">{{ count }}</span>
117
+ <span v-if="slots.end" class="wp-list-row__end"><slot name="end" /></span>
118
+ <Icon v-if="arrow" class="wp-list-row__arrow" name="arrow-right" />
119
+ </component>
120
+ </template>
121
+
122
+ <style scoped>
123
+ .wp-list-row {
124
+ --wp-list-row-icon: var(--wp-text-muted);
125
+ display: flex;
126
+ align-items: center;
127
+ gap: var(--wp-space-3);
128
+ box-sizing: border-box;
129
+ width: 100%;
130
+ min-height: var(--wp-control-h-md);
131
+ margin: 0;
132
+ padding: var(--wp-space-2) var(--wp-space-4);
133
+ padding-left: calc(var(--wp-space-4) + var(--wp-list-row-depth, 0) * var(--wp-space-6));
134
+ border: 0;
135
+ border-radius: min(6px, var(--wp-radius-cap, 999px));
136
+ background: transparent;
137
+ color: var(--wp-text);
138
+ font: inherit;
139
+ font-family: var(--wp-font-sans);
140
+ font-size: var(--wp-text-sm);
141
+ line-height: 1.35;
142
+ text-align: left;
143
+ text-decoration: none;
144
+ cursor: pointer;
145
+ transition: background var(--wp-motion-quick) ease, color var(--wp-motion-quick) ease;
146
+ }
147
+ .wp-list-row--sm {
148
+ min-height: var(--wp-control-h-sm);
149
+ padding-top: var(--wp-space-1);
150
+ padding-bottom: var(--wp-space-1);
151
+ gap: var(--wp-space-2);
152
+ color: var(--wp-text-muted);
153
+ font-size: var(--wp-text-xs);
154
+ }
155
+ .wp-list-row--two-line { align-items: flex-start; }
156
+ .wp-list-row:hover:not(:disabled) { background: var(--wp-row-hover); color: var(--wp-text); }
157
+ .wp-list-row:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
158
+ .wp-list-row:disabled, .wp-list-row[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
159
+ .wp-list-row--muted { opacity: 0.55; }
160
+ .wp-list-row[data-selected] {
161
+ background: color-mix(in oklab, var(--wp-accent-500) 12%, transparent);
162
+ box-shadow: inset 2px 0 var(--wp-accent-500);
163
+ color: var(--wp-accent-text-strong);
164
+ }
165
+ .wp-list-row[data-selected]:focus-visible { box-shadow: inset 2px 0 var(--wp-accent-500), var(--wp-focus-ring); }
166
+ .wp-list-row__icon {
167
+ flex: none;
168
+ width: 1.1em;
169
+ font-size: 0.9em;
170
+ text-align: center;
171
+ color: var(--wp-list-row-icon);
172
+ }
173
+ .wp-list-row--two-line .wp-list-row__icon { padding-top: 0.25em; }
174
+ .wp-list-row[data-selected] .wp-list-row__icon { color: var(--wp-accent-text); }
175
+ .wp-list-row__start { display: inline-flex; align-items: center; flex: none; }
176
+ .wp-list-row__main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
177
+ .wp-list-row__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
178
+ .wp-list-row__desc {
179
+ color: var(--wp-text-dim);
180
+ font-size: var(--wp-text-xs);
181
+ overflow-wrap: anywhere;
182
+ }
183
+ .wp-list-row__count {
184
+ flex: none;
185
+ color: var(--wp-text-dim);
186
+ font-family: var(--wp-font-mono);
187
+ font-size: var(--wp-text-xs);
188
+ font-variant-numeric: tabular-nums;
189
+ }
190
+ .wp-list-row[data-selected] .wp-list-row__count { color: inherit; }
191
+ .wp-list-row__end { display: inline-flex; align-items: center; gap: var(--wp-space-2); flex: none; }
192
+ .wp-list-row__arrow {
193
+ flex: none;
194
+ align-self: center;
195
+ font-size: 0.85em;
196
+ color: var(--wp-text-dim);
197
+ transition: transform var(--wp-motion-quick) ease, color var(--wp-motion-quick) ease;
198
+ }
199
+ .wp-list-row:hover .wp-list-row__arrow { color: var(--wp-accent-text); transform: translateX(2px); }
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .wp-list-row, .wp-list-row__arrow { transition: none; }
202
+ .wp-list-row:hover .wp-list-row__arrow { transform: none; }
203
+ }
204
+ </style>
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ /** Gradient ids must be unique on the page. `useId` restarts per Vue app and
3
+ * the extension mounts many small apps, so count across all of them. */
4
+ let nextId = 0;
5
+ </script>
6
+
7
+ <script setup lang="ts">
8
+ /**
9
+ * The loading mark. Two drawings, one per kind of place:
10
+ *
11
+ * - `logo` (pages and panels): the full logo. Its two rings slide apart and
12
+ * back while the big star dips and turns and the small stars twinkle. A
13
+ * `block` mark, or one 24 px or bigger, gets it by default.
14
+ * - `sparkle` (tight inline spots: a text row, a button, a list item): the
15
+ * logo's three stars without the rings, the big one dipping while the small
16
+ * ones swap places, like the AI working line. Small marks get it by
17
+ * default, so an inline "Loading bundle…" is not a big colourful logo.
18
+ *
19
+ * Alone it is an icon (`size`, 14 inline and 40 as a block). With slot text it
20
+ * becomes a row ("[mark] Loading bundle…"); with `block` it is centred in its
21
+ * box, for a panel or page that is loading. `mono` draws it in the text
22
+ * colour, for a host with its own colours (a busy button).
23
+ */
24
+ import { computed, useSlots } from "vue";
25
+ import { BRAND_COLOURS, RING_A, RING_B, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
26
+
27
+ const props = withDefaults(defineProps<{
28
+ /** Width and height in px. Default 14, or 40 with `block`. */
29
+ size?: number | undefined;
30
+ /** Accessible name when there is no slot text. */
31
+ label?: string | undefined;
32
+ /** Centre it (and its text, below) in the space it is given. */
33
+ block?: boolean | undefined;
34
+ /** Which drawing; default `logo` for a block or a 24 px+ mark, else `sparkle`. */
35
+ variant?: "logo" | "sparkle" | undefined;
36
+ /** Draw in the current text colour instead of the logo colours. */
37
+ mono?: boolean | undefined;
38
+ /** Hidden from screen readers, for a host that says it is busy itself
39
+ * (a button with `aria-busy`). */
40
+ decorative?: boolean | undefined;
41
+ /** Whose colours: the extension's (default) or the community site's. */
42
+ brand?: LogoBrand | undefined;
43
+ }>(), { size: undefined, label: "Loading", block: false, variant: undefined, mono: false, decorative: false, brand: "extension" });
44
+
45
+ const slots = useSlots();
46
+ const id = `wp-lm-${nextId++}`;
47
+ const px = computed(() => props.size ?? (props.block ? 40 : 14));
48
+ const drawing = computed(() => props.variant ?? (props.block || px.value >= 24 ? "logo" : "sparkle"));
49
+ /** The logo's own colours (brand, not theme tokens: the mark looks the same
50
+ * in light and dark). */
51
+ const VIOLET = computed(() => BRAND_COLOURS[props.brand][0]);
52
+ const INDIGO = computed(() => BRAND_COLOURS[props.brand][1]);
53
+ const TEAL = computed(() => BRAND_COLOURS[props.brand][2]);
54
+ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
55
+ </script>
56
+
57
+ <template>
58
+ <span
59
+ class="wp-lm"
60
+ :class="{ 'wp-lm--block': block }"
61
+ :role="decorative ? undefined : 'status'"
62
+ :aria-label="decorative || slots.default ? undefined : label"
63
+ :aria-hidden="decorative || undefined"
64
+ data-test="loading-mark"
65
+ >
66
+ <svg
67
+ class="wp-lm__svg"
68
+ :data-variant="drawing"
69
+ viewBox="0 0 1024 1024"
70
+ :width="px"
71
+ :height="px"
72
+ aria-hidden="true"
73
+ >
74
+ <template v-if="drawing === 'logo'">
75
+ <defs v-if="!mono">
76
+ <linearGradient :id="`${id}a`" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
77
+ <stop offset="0" :stop-color="VIOLET" />
78
+ <stop offset="1" :stop-color="INDIGO" />
79
+ </linearGradient>
80
+ <linearGradient :id="`${id}b`" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
81
+ <stop offset="0" :stop-color="INDIGO" />
82
+ <stop offset="1" :stop-color="TEAL" />
83
+ </linearGradient>
84
+ </defs>
85
+ <path class="wp-lm__ring wp-lm__ring--a" :fill="fill(`url(#${id}a)`)" :d="RING_A" />
86
+ <path class="wp-lm__ring wp-lm__ring--b" :fill="fill(`url(#${id}b)`)" :d="RING_B" />
87
+ <path class="wp-lm__star wp-lm__star--big" :fill="fill(VIOLET)" :d="STAR_BIG" />
88
+ <path class="wp-lm__star wp-lm__star--tw" :fill="fill(INDIGO)" :d="STAR_A" />
89
+ <path class="wp-lm__star wp-lm__star--tw" :fill="fill(TEAL)" :d="STAR_B" />
90
+ </template>
91
+ <g v-else transform="translate(512 512) scale(1.45) translate(-512 -512)">
92
+ <path class="wp-lm__star wp-lm__star--big" :fill="fill(VIOLET)" :d="STAR_BIG" />
93
+ <g class="wp-lm__swap">
94
+ <path class="wp-lm__star" :fill="fill(INDIGO)" :d="STAR_A" />
95
+ <path class="wp-lm__star" :fill="fill(TEAL)" :d="STAR_B" />
96
+ </g>
97
+ </g>
98
+ </svg>
99
+ <span v-if="slots.default" class="wp-lm__text"><slot /></span>
100
+ </span>
101
+ </template>
102
+
103
+ <style scoped>
104
+ .wp-lm {
105
+ --wp-lm-t: 2.4s;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ gap: 8px;
109
+ vertical-align: middle;
110
+ }
111
+ .wp-lm--block {
112
+ display: flex;
113
+ flex-direction: column;
114
+ justify-content: center;
115
+ gap: 12px;
116
+ width: 100%;
117
+ padding: 48px 16px;
118
+ color: var(--wp-text-muted);
119
+ text-align: center;
120
+ }
121
+ .wp-lm__svg { flex-shrink: 0; overflow: visible; }
122
+ /* Every move animates a registered custom property, never `transform` or
123
+ * `opacity` itself. A browser hands a `transform` / `opacity` animation to
124
+ * the compositor, which lifts the path onto its own layer the moment the
125
+ * animation starts: the layer is rasterised once, at a fixed scale, and
126
+ * snapped to whole pixels, so the crisp mark turned soft and jumped by up to
127
+ * half a pixel as it began to move. A custom property animates on the main
128
+ * thread, so each frame repaints the vector paths in place, as sharp as the
129
+ * mark at rest. The paths are tiny; the repaint costs nothing. */
130
+ .wp-lm__ring { transform-box: view-box; transform-origin: 512px 512px; }
131
+ .wp-lm__star { transform-box: fill-box; transform-origin: center; }
132
+ .wp-lm__ring--a { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-a var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
133
+ .wp-lm__ring--b { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-b var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
134
+ .wp-lm__star--big { transform: rotate(var(--wp-lm-r)) scale(var(--wp-lm-s)); animation: wp-lm-dip var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
135
+ .wp-lm__star--tw { opacity: var(--wp-lm-o); transform: scale(var(--wp-lm-s)); animation: wp-lm-twinkle var(--wp-lm-t) ease-in-out infinite; }
136
+ .wp-lm__swap {
137
+ transform-box: view-box; transform-origin: 512px 512px;
138
+ transform: rotate(var(--wp-lm-r));
139
+ animation: wp-lm-swap calc(var(--wp-lm-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
140
+ }
141
+
142
+ @property --wp-lm-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
143
+ @property --wp-lm-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
144
+ @property --wp-lm-s { syntax: "<number>"; inherits: false; initial-value: 1; }
145
+ @property --wp-lm-o { syntax: "<number>"; inherits: false; initial-value: 1; }
146
+
147
+ @keyframes wp-lm-split-a {
148
+ 0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
149
+ 40%, 60% { --wp-lm-x: -70px; }
150
+ }
151
+ @keyframes wp-lm-split-b {
152
+ 0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
153
+ 40%, 60% { --wp-lm-x: 70px; }
154
+ }
155
+ @keyframes wp-lm-dip {
156
+ 0%, 15% { --wp-lm-s: 1; --wp-lm-r: 0deg; }
157
+ 32% { --wp-lm-s: 0.7; --wp-lm-r: 45deg; }
158
+ 50%, 100% { --wp-lm-s: 1; --wp-lm-r: 90deg; }
159
+ }
160
+ @keyframes wp-lm-swap {
161
+ 0%, 15% { --wp-lm-r: 0deg; }
162
+ 50%, 65% { --wp-lm-r: 180deg; }
163
+ 100% { --wp-lm-r: 360deg; }
164
+ }
165
+ @keyframes wp-lm-twinkle {
166
+ 0%, 100% { --wp-lm-o: 1; --wp-lm-s: 1; }
167
+ 50% { --wp-lm-o: 0.35; --wp-lm-s: 0.6; }
168
+ }
169
+
170
+ @media (prefers-reduced-motion: reduce) {
171
+ .wp-lm__svg * { animation: none !important; }
172
+ .wp-lm__svg { opacity: var(--wp-lm-o); animation: wp-lm-fade 2.4s ease-in-out infinite; }
173
+ @keyframes wp-lm-fade { 0%, 100% { --wp-lm-o: 1; } 50% { --wp-lm-o: 0.55; } }
174
+ }
175
+ </style>
package/src/Logo.vue ADDED
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ /** Gradient ids must be unique on the page, and two logos may wear different
3
+ * brands, so count across every app on it. */
4
+ let nextId = 0;
5
+ </script>
6
+
7
+ <script setup lang="ts">
8
+ /**
9
+ * The Wildcard Pipeline logo, drawn inline so it needs no file. It names
10
+ * itself to screen readers; `decorative` hides it when the name is written
11
+ * beside it (the top bar's brand). `brand="community"` draws it in the
12
+ * community site's colours.
13
+ *
14
+ * <Logo :size="22" decorative />
15
+ */
16
+ import { computed } from "vue";
17
+ import { BRAND_COLOURS, type LogoBrand } from "./logo-paths";
18
+
19
+ const props = withDefaults(defineProps<{
20
+ size?: number | undefined;
21
+ decorative?: boolean | undefined;
22
+ brand?: LogoBrand | undefined;
23
+ }>(), { size: 28, brand: "extension" });
24
+
25
+ const id = `wp-logo-${nextId++}`;
26
+ const c = computed(() => BRAND_COLOURS[props.brand]);
27
+ </script>
28
+
29
+ <template>
30
+ <svg
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ viewBox="0 0 1024 1024"
33
+ :width="size"
34
+ :height="size"
35
+ :role="decorative ? undefined : 'img'"
36
+ :aria-label="decorative ? undefined : 'Wildcard Pipeline'"
37
+ :aria-hidden="decorative || undefined"
38
+ class="wp-logo"
39
+ >
40
+ <defs>
41
+ <linearGradient :id="`${id}a`" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
42
+ <stop offset="0" :stop-color="c[0]" />
43
+ <stop offset="1" :stop-color="c[1]" />
44
+ </linearGradient>
45
+ <linearGradient :id="`${id}b`" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
46
+ <stop offset="0" :stop-color="c[1]" />
47
+ <stop offset="1" :stop-color="c[2]" />
48
+ </linearGradient>
49
+ </defs>
50
+ <path :fill="`url(#${id}a)`" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z" />
51
+ <path :fill="`url(#${id}b)`" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z" />
52
+ <path :fill="c[0]" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z" />
53
+ <path :fill="c[1]" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
54
+ <path :fill="c[2]" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
55
+ </svg>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .wp-logo { display: block; flex-shrink: 0; }
60
+ </style>
package/src/Modal.vue ADDED
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import CloseButton from "./CloseButton.vue";
3
+ import { computed, ref, useId, useSlots } from "vue";
4
+ import { useDialogFocus } from "./useDialogFocus";
5
+
6
+ interface Props {
7
+ open: boolean;
8
+ title?: string | undefined;
9
+ size?: "sm" | "md" | "lg" | undefined;
10
+ closeOnBackdrop?: boolean | undefined;
11
+ }
12
+ const props = withDefaults(defineProps<Props>(), { size: "md", closeOnBackdrop: true });
13
+
14
+ const emit = defineEmits<{
15
+ (e: "update:open", v: boolean): void;
16
+ }>();
17
+
18
+ const slots = useSlots();
19
+
20
+ const modalClasses = computed(() => ["wp-modal", `wp-modal--${props.size}`]);
21
+ const dialog = ref<HTMLElement | null>(null);
22
+ const titleId = `wp-modal-title-${useId()}`;
23
+
24
+ function close() {
25
+ emit("update:open", false);
26
+ }
27
+
28
+ function onBackdrop() {
29
+ if (props.closeOnBackdrop) close();
30
+ }
31
+
32
+ useDialogFocus(dialog, () => props.open, {
33
+ onEscape: close,
34
+ // The first control in the body, else the dialog itself (the close button
35
+ // would make Enter close it).
36
+ initial: (root, els) => {
37
+ const body = root.querySelector(".wp-modal__body");
38
+ return els.find((el) => body?.contains(el)) ?? root;
39
+ },
40
+ });
41
+ </script>
42
+
43
+ <template>
44
+ <Teleport to="body">
45
+ <!-- The backdrop is a click target only; keyboard users close with Escape. -->
46
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
47
+ <div
48
+ v-if="open"
49
+ class="wp-modal__backdrop"
50
+ data-test="modal-backdrop"
51
+ @mousedown.self="onBackdrop"
52
+ >
53
+ <div
54
+ ref="dialog"
55
+ :class="modalClasses"
56
+ role="dialog"
57
+ aria-modal="true"
58
+ :aria-labelledby="title || slots.header ? titleId : undefined"
59
+ tabindex="-1"
60
+ @mousedown.stop
61
+ >
62
+ <header v-if="title || slots.header" class="wp-modal__head">
63
+ <h3 v-if="title" :id="titleId" class="wp-modal__title">{{ title }}</h3>
64
+ <!-- A custom header puts :id="titleId" on its own heading. -->
65
+ <slot name="header" :title-id="titleId" />
66
+ <span class="wp-spacer" />
67
+ <CloseButton kbd="Esc" @click="close" />
68
+ </header>
69
+ <div class="wp-modal__body">
70
+ <slot />
71
+ </div>
72
+ <footer v-if="slots.footer" class="wp-modal__foot">
73
+ <slot name="footer" />
74
+ </footer>
75
+ </div>
76
+ </div>
77
+ </Teleport>
78
+ </template>