@dumiflex/wp-kit 0.0.0-stage → 0.3.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 +51 -0
  2. package/LICENSE +674 -0
  3. package/README.md +76 -3
  4. package/package.json +77 -6
  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 +107 -0
  13. package/src/Chip.vue +97 -0
  14. package/src/ChipToggle.vue +92 -0
  15. package/src/ChoiceCards.vue +133 -0
  16. package/src/CloseButton.vue +69 -0
  17. package/src/CodeBlock.vue +119 -0
  18. package/src/ColorField.vue +206 -0
  19. package/src/ColorPicker.vue +201 -0
  20. package/src/Combobox.vue +334 -0
  21. package/src/ConfirmDialog.vue +163 -0
  22. package/src/ConfirmModal.vue +147 -0
  23. package/src/ConstraintMatrixPreview.vue +708 -0
  24. package/src/ContextMenu.vue +379 -0
  25. package/src/CopyButton.vue +156 -0
  26. package/src/DataTable.vue +292 -0
  27. package/src/DatePicker.vue +234 -0
  28. package/src/DescriptionList.vue +73 -0
  29. package/src/DiffView.vue +68 -0
  30. package/src/Disclosure.vue +166 -0
  31. package/src/DragHandle.vue +49 -0
  32. package/src/Drawer.vue +131 -0
  33. package/src/DropZone.vue +196 -0
  34. package/src/DropdownMenu.vue +72 -0
  35. package/src/EmptyState.vue +111 -0
  36. package/src/Field.vue +28 -0
  37. package/src/HsvPicker.vue +208 -0
  38. package/src/Icon.vue +39 -0
  39. package/src/IconPicker.vue +209 -0
  40. package/src/ImageGrid.vue +125 -0
  41. package/src/InlineEdit.vue +183 -0
  42. package/src/Input.vue +99 -0
  43. package/src/Kbd.vue +56 -0
  44. package/src/KindChip.vue +53 -0
  45. package/src/Lightbox.vue +173 -0
  46. package/src/ListFilter.vue +85 -0
  47. package/src/ListRow.vue +204 -0
  48. package/src/LoadingMark.vue +161 -0
  49. package/src/Logo.vue +60 -0
  50. package/src/Modal.vue +76 -0
  51. package/src/ModalShell.vue +107 -0
  52. package/src/Pagination.vue +106 -0
  53. package/src/Popover.vue +171 -0
  54. package/src/PostCard.vue +211 -0
  55. package/src/ProgressBar.vue +90 -0
  56. package/src/RadioGroup.vue +177 -0
  57. package/src/RangeSlider.vue +183 -0
  58. package/src/RatingBadge.vue +42 -0
  59. package/src/RelativeDate.vue +35 -0
  60. package/src/ResizeHandle.vue +143 -0
  61. package/src/SearchInput.vue +199 -0
  62. package/src/Select.vue +641 -0
  63. package/src/Separator.vue +54 -0
  64. package/src/Skeleton.vue +82 -0
  65. package/src/Slider.vue +198 -0
  66. package/src/SortableList.vue +260 -0
  67. package/src/SplitPane.vue +68 -0
  68. package/src/StarButton.vue +40 -0
  69. package/src/Stepper.vue +141 -0
  70. package/src/SupportLinks.vue +93 -0
  71. package/src/TagInput.vue +300 -0
  72. package/src/Textarea.vue +57 -0
  73. package/src/Timeline.vue +92 -0
  74. package/src/ToastCard.vue +119 -0
  75. package/src/ToastHost.vue +40 -0
  76. package/src/Toggle.vue +36 -0
  77. package/src/TokenizedValue.vue +310 -0
  78. package/src/Toolbar.vue +110 -0
  79. package/src/VirtualList.vue +87 -0
  80. package/src/VisuallyHidden.vue +29 -0
  81. package/src/WpButton.vue +75 -0
  82. package/src/WpCheck.vue +102 -0
  83. package/src/WpIconButton.vue +107 -0
  84. package/src/WpImageZoom.vue +544 -0
  85. package/src/WpNumber.vue +183 -0
  86. package/src/WpSegmented.vue +175 -0
  87. package/src/WpSelect.vue +89 -0
  88. package/src/WpSwitch.vue +163 -0
  89. package/src/WpTabs.vue +188 -0
  90. package/src/_modal-motion.css +33 -0
  91. package/src/_skeleton.css +50 -0
  92. package/src/axis-color.ts +42 -0
  93. package/src/button.css +169 -0
  94. package/src/calendar.ts +59 -0
  95. package/src/clipboard.ts +50 -0
  96. package/src/colour-presets.ts +12 -0
  97. package/src/diff.ts +51 -0
  98. package/src/drop-zone.ts +41 -0
  99. package/src/flip.ts +237 -0
  100. package/src/floating.ts +99 -0
  101. package/src/hsv.ts +100 -0
  102. package/src/icon-catalog.ts +52 -0
  103. package/src/image-zoom.ts +93 -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 +2894 -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
package/src/WpTabs.vue ADDED
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts" generic="T extends string">
2
+ /**
3
+ * Tabs: a row of tabs that switch the panel under them. Works on the canvas
4
+ * (`size="sm"`) and in the manager (`md`, the default).
5
+ *
6
+ * Keyboard follows the APG tabs pattern with automatic activation: only the
7
+ * current tab is in the Tab order, the arrow keys move to the next enabled
8
+ * tab and select it, Home/End jump to the ends. A row too wide for its box
9
+ * scrolls sideways and keeps the current tab in view.
10
+ *
11
+ * <WpTabs v-model="tab" :tabs="[{ id: 'options', label: 'Options', count: 12 }]" aria-label="Editor sections">
12
+ * <template #panel="{ id }">…</template>
13
+ * </WpTabs>
14
+ *
15
+ * Leave out `#panel` when the panels live elsewhere; give them
16
+ * `:id="panelId(tab)"`-style wiring yourself or let the tabs stand alone.
17
+ */
18
+ import { nextTick, ref, useId, useSlots } from "vue";
19
+
20
+ export interface TabItem<I extends string = string> {
21
+ id: I;
22
+ label: string;
23
+ /** A PrimeIcons class, e.g. `pi-list`. */
24
+ icon?: string | undefined;
25
+ disabled?: boolean | undefined;
26
+ /** A small count after the label (rows, errors…). */
27
+ count?: number | string | undefined;
28
+ /** Colour of the count: `success` for all good, `warn` for problems. */
29
+ tone?: "neutral" | "success" | "warn" | "danger" | undefined;
30
+ }
31
+
32
+ const props = withDefaults(defineProps<{
33
+ modelValue: T;
34
+ tabs: TabItem<T>[];
35
+ size?: "sm" | "md" | undefined;
36
+ ariaLabel?: string | undefined;
37
+ /** `data-test` of each tab is `<testPrefix>-<id>`. */
38
+ testPrefix?: string | undefined;
39
+ }>(), { size: "md" });
40
+
41
+ const emit = defineEmits<{ (e: "update:modelValue", id: T): void }>();
42
+ const slots = useSlots();
43
+
44
+ const uid = `wp-tabs-${useId()}`;
45
+ const tabId = (id: string): string => `${uid}-tab-${id}`;
46
+ const panelId = (id: string): string => `${uid}-panel-${id}`;
47
+ const listEl = ref<HTMLElement | null>(null);
48
+
49
+ function select(id: T): void {
50
+ if (id !== props.modelValue) emit("update:modelValue", id);
51
+ }
52
+
53
+ function focusTab(id: string): void {
54
+ void nextTick(() => {
55
+ const i = props.tabs.findIndex((t) => t.id === id);
56
+ const el = listEl.value?.querySelectorAll<HTMLElement>("[role=tab]")[i];
57
+ el?.focus();
58
+ el?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
59
+ });
60
+ }
61
+
62
+ /** The tab that takes Tab focus: the current one, else the first enabled. */
63
+ function tabStop(): T | undefined {
64
+ const enabled = props.tabs.filter((t) => !t.disabled);
65
+ return (enabled.find((t) => t.id === props.modelValue) ?? enabled[0])?.id;
66
+ }
67
+
68
+ function onKeydown(ev: KeyboardEvent): void {
69
+ const enabled = props.tabs.filter((t) => !t.disabled);
70
+ if (!enabled.length) return;
71
+ const at = enabled.findIndex((t) => t.id === props.modelValue);
72
+ let next: number;
73
+ switch (ev.key) {
74
+ case "ArrowRight": next = (at + 1) % enabled.length; break;
75
+ case "ArrowLeft": next = at < 0 ? enabled.length - 1 : (at - 1 + enabled.length) % enabled.length; break;
76
+ case "Home": next = 0; break;
77
+ case "End": next = enabled.length - 1; break;
78
+ default: return;
79
+ }
80
+ ev.preventDefault();
81
+ const id = enabled[next].id;
82
+ select(id);
83
+ focusTab(id);
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <div class="wp-tabset" :data-size="size">
89
+ <div
90
+ ref="listEl"
91
+ class="wp-tabset__list"
92
+ role="tablist"
93
+ :aria-label="ariaLabel"
94
+ >
95
+ <button
96
+ v-for="t in tabs"
97
+ :id="tabId(t.id)"
98
+ :key="t.id"
99
+ type="button"
100
+ role="tab"
101
+ class="wp-tabset__tab"
102
+ :aria-selected="t.id === modelValue"
103
+ :aria-controls="slots.panel ? panelId(t.id) : undefined"
104
+ :tabindex="t.id === tabStop() ? 0 : -1"
105
+ :disabled="t.disabled"
106
+ :data-active="t.id === modelValue"
107
+ :data-test="testPrefix ? `${testPrefix}-${t.id}` : undefined"
108
+ @click="select(t.id)"
109
+ @keydown="onKeydown"
110
+ >
111
+ <slot name="tab" :tab="t" :active="t.id === modelValue">
112
+ <i v-if="t.icon" class="pi" :class="t.icon" aria-hidden="true" />
113
+ <span>{{ t.label }}</span>
114
+ <span v-if="t.count !== undefined" class="wp-tabset__count" :data-tone="t.tone">{{ t.count }}</span>
115
+ </slot>
116
+ </button>
117
+ </div>
118
+ <div
119
+ v-if="slots.panel"
120
+ :id="panelId(modelValue)"
121
+ class="wp-tabset__panel"
122
+ role="tabpanel"
123
+ :aria-labelledby="tabId(modelValue)"
124
+ tabindex="0"
125
+ >
126
+ <slot :id="modelValue" name="panel" />
127
+ </div>
128
+ </div>
129
+ </template>
130
+
131
+ <style scoped>
132
+ .wp-tabset { display: flex; flex-direction: column; min-width: 0; }
133
+ .wp-tabset__list {
134
+ display: flex;
135
+ gap: 2px;
136
+ border-bottom: 1px solid var(--wp-border);
137
+ overflow-x: auto;
138
+ scrollbar-width: thin;
139
+ scrollbar-color: var(--wp-scrollbar-thumb) transparent;
140
+ }
141
+ .wp-tabset__tab {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 6px;
145
+ flex: none;
146
+ height: var(--wp-control-h-md);
147
+ margin: 0 0 -1px;
148
+ padding: 0 var(--wp-space-5);
149
+ border: 0;
150
+ border-bottom: 2px solid transparent;
151
+ background: none;
152
+ color: var(--wp-text-muted);
153
+ font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
154
+ white-space: nowrap;
155
+ cursor: pointer;
156
+ transition: color var(--wp-motion-hover), border-color var(--wp-motion-hover);
157
+ }
158
+ .wp-tabset[data-size="sm"] .wp-tabset__tab {
159
+ height: var(--wp-control-h-sm);
160
+ padding: 0 var(--wp-space-4);
161
+ font-size: var(--wp-text-xs);
162
+ gap: 4px;
163
+ }
164
+ .wp-tabset__tab:hover:not(:disabled) { color: var(--wp-text); }
165
+ .wp-tabset__tab[data-active="true"] { color: var(--wp-text); border-bottom-color: var(--wp-accent-400); }
166
+ .wp-tabset__tab:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--wp-border-focus); border-radius: var(--wp-radius-sm); }
167
+ .wp-tabset__tab:disabled { opacity: 0.45; cursor: not-allowed; }
168
+ .wp-tabset__tab .pi { font-size: 0.95em; }
169
+ .wp-tabset__count {
170
+ min-width: 16px;
171
+ padding: 1px 5px;
172
+ border-radius: var(--wp-radius-cap, 999px);
173
+ background: var(--wp-bg-3);
174
+ color: var(--wp-text-muted);
175
+ font-size: var(--wp-text-xs);
176
+ font-variant-numeric: tabular-nums;
177
+ text-align: center;
178
+ }
179
+ .wp-tabset__tab[data-active="true"] .wp-tabset__count { background: var(--wp-accent-glow); color: var(--wp-accent-text); }
180
+ .wp-tabset__count[data-tone="success"] { background: color-mix(in srgb, var(--wp-success) 16%, transparent); color: var(--wp-success); }
181
+ .wp-tabset__count[data-tone="warn"] { background: color-mix(in srgb, var(--wp-warn) 16%, transparent); color: var(--wp-warn); }
182
+ .wp-tabset__count[data-tone="danger"] { background: color-mix(in srgb, var(--wp-danger) 16%, transparent); color: var(--wp-danger); }
183
+ .wp-tabset__panel { min-width: 0; }
184
+ .wp-tabset__panel:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); border-radius: var(--wp-radius-sm); }
185
+ @media (prefers-reduced-motion: reduce) {
186
+ .wp-tabset__tab { transition: none; }
187
+ }
188
+ </style>
@@ -0,0 +1,33 @@
1
+ /* Shared modal open/close transition — drives every consumer that
2
+ * wraps its modal in `<Transition name="wp-modal">`. Two simultaneous
3
+ * legs:
4
+ * 1. Overlay: opacity fade across `--wp-motion-swap` (200ms).
5
+ * 2. Panel: transform scale 0.96 → 1.0 with the decel curve so
6
+ * the dialog grows into place.
7
+ *
8
+ * Selector for the panel is `> *` (immediate child of the overlay
9
+ * element). Works for ModalShell's `.wp-modal-wrapper`, ConfirmDialog's
10
+ * `.wp-confirm`, InjectorBindingModal's `.ibm`, and any future modal
11
+ * that follows the overlay-with-one-child layout.
12
+ *
13
+ * Imported globally via ContextWidget's unscoped style block — class
14
+ * names are namespaced (`wp-modal-*` for the transition, scoped per
15
+ * Vue's named-transition convention) so no leakage.
16
+ */
17
+ .wp-modal-enter-active,
18
+ .wp-modal-leave-active {
19
+ transition: opacity var(--wp-motion-swap) ease;
20
+ }
21
+ .wp-modal-enter-from,
22
+ .wp-modal-leave-to {
23
+ opacity: 0;
24
+ }
25
+ .wp-modal-enter-active > *,
26
+ .wp-modal-leave-active > * {
27
+ transition: transform var(--wp-motion-swap) var(--wp-motion-curve-flip);
28
+ transform-origin: center;
29
+ }
30
+ .wp-modal-enter-from > *,
31
+ .wp-modal-leave-to > * {
32
+ transform: scale(0.96);
33
+ }
@@ -0,0 +1,50 @@
1
+ /* Skeleton loading frames.
2
+ *
3
+ * Used by modals + widgets that surface async API loads (bundle picker,
4
+ * module picker). The flat base background is token-driven so the
5
+ * frames track theme changes; a moving highlight gradient overlays
6
+ * via the `::after` pseudo so consumers don't need to mark up two
7
+ * elements. Animation loops on `--wp-motion-shimmer` (1400ms).
8
+ *
9
+ * Imported by each consumer's scoped style block — the `wp-skel`
10
+ * class names are intentionally global so a parent's scoped wrapper
11
+ * (`wp-bp__skel-row` etc.) can lay out the shapes while the base
12
+ * `wp-skel` rules drive the look. `prefers-reduced-motion` kills the
13
+ * sweep so reduced-motion users still get a flat placeholder.
14
+ */
15
+ .wp-skel {
16
+ background: var(--wp-bg2);
17
+ border-radius: min(4px, var(--wp-radius-cap, 999px));
18
+ position: relative;
19
+ overflow: hidden;
20
+ }
21
+ .wp-skel::after {
22
+ content: "";
23
+ position: absolute;
24
+ inset: 0;
25
+ background: linear-gradient(
26
+ 90deg,
27
+ transparent 0%,
28
+ color-mix(in srgb, var(--wp-text) 8%, transparent) 50%,
29
+ transparent 100%
30
+ );
31
+ transform: translateX(-100%);
32
+ animation: wp-skel-shimmer var(--wp-motion-shimmer) ease-in-out infinite;
33
+ }
34
+ .wp-skel--text {
35
+ height: 11px;
36
+ }
37
+ .wp-skel--circle {
38
+ border-radius: 50%; /* round: stands in for an avatar */
39
+ }
40
+ .wp-skel--pill {
41
+ border-radius: var(--wp-radius-cap, 999px);
42
+ }
43
+
44
+ @keyframes wp-skel-shimmer {
45
+ 100% { transform: translateX(100%); }
46
+ }
47
+
48
+ @media (prefers-reduced-motion: reduce) {
49
+ .wp-skel::after { animation: none; }
50
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-axis sub-category colours — one source of truth for the wildcard
3
+ * editor pills, the option-row tag chips, and the pool palette, so a tag
4
+ * reads with the same colour on every surface.
5
+ *
6
+ * Each sub-category GROUP (axis) gets a distinct hue by its position among
7
+ * its siblings. The old per-component copies shared TWO failings that both
8
+ * produced "two groups, same colour": the palette held two near-identical
9
+ * purples (`#a78bfa` and `#a970ff`), and assignment wrapped with
10
+ * `% length`, repeating once there were more groups than hues. This palette
11
+ * is de-duplicated and ordered for maximum separation between consecutive
12
+ * axes; past it, a golden-angle hue keeps every additional group distinct.
13
+ */
14
+
15
+ /** Neutral hue for ungrouped / "other" tags. */
16
+ export const UNGROUPED_HUE = "var(--wp-text-dim, var(--wp-text3))";
17
+
18
+ /** Curated distinct hues (no two similar), ordered so consecutive axes land
19
+ * far apart on the wheel. Theme tokens where they exist, hex otherwise. */
20
+ const AXIS_HUES = [
21
+ "var(--wp-kind-wildcard, #a78bfa)", // purple
22
+ "var(--wp-teal, #33d6c6)", // teal
23
+ "var(--wp-status-modified, #fb923c)", // orange
24
+ "#60a5fa", // blue
25
+ "var(--wp-success, #22c55e)", // green
26
+ "#fb7185", // rose
27
+ "#fbbf24", // amber
28
+ "#e879f9", // magenta
29
+ ];
30
+
31
+ /**
32
+ * Distinct, non-repeating hue for the sub-category group (axis) at `index`
33
+ * among its siblings. `index < 0` (ungrouped) → the neutral token. Past the
34
+ * curated palette, a golden-angle hue keeps additional groups distinct
35
+ * rather than wrapping back onto an earlier colour.
36
+ */
37
+ export function axisHueAt(index: number): string {
38
+ if (index < 0) return UNGROUPED_HUE;
39
+ if (index < AXIS_HUES.length) return AXIS_HUES[index];
40
+ const hue = ((index * 137.508) % 360).toFixed(1);
41
+ return `hsl(${hue} 68% 66%)`;
42
+ }
package/src/button.css ADDED
@@ -0,0 +1,169 @@
1
+ /* Button — the one .wp-btn stylesheet, shared by the manager Button
2
+ * (manager/components/ui/Button.vue, loaded globally by manager/main.ts) and
3
+ * the canvas twin WpButton (components/shared/WpButton.vue, component
4
+ * standard rule 8), which pulls it in scoped. It reads only the
5
+ * SHARED_TOKENS names, so it wears the manager colours on a page and the
6
+ * canvas colours in a node.
7
+ *
8
+ * Unlayered on purpose: the manager loads it beside tokens.css, where an
9
+ * unlayered reset would otherwise beat it. The canvas never loads it
10
+ * globally: WpButton pulls in a scoped copy, so its classes never meet
11
+ * another extension's `.wp-btn`.
12
+ *
13
+ * Heights follow the control scale: sm 24 / md 32 / lg 40.
14
+ *
15
+ * States:
16
+ * disabled — 45% opacity, not-allowed cursor, no hover change; the filled
17
+ * secondary drops its fill so it no longer reads as a live
18
+ * secondary button.
19
+ * loading — keeps its own colours (aria-busy), progress cursor, so the
20
+ * label stays readable while it works.
21
+ */
22
+
23
+ .wp-btn {
24
+ height: var(--wp-control-h-md);
25
+ padding: 0 12px;
26
+ border-radius: min(7px, var(--wp-radius-cap, 999px));
27
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
28
+ font-family: var(--wp-font-sans);
29
+ font-size: 12.5px;
30
+ font-weight: 500;
31
+ /* Normal, not 1: a line box shorter than the font's ascent + descent
32
+ gets its negative leading rounded unevenly and the label rides high. */
33
+ line-height: normal;
34
+ cursor: pointer;
35
+ background: var(--wp-bg-3);
36
+ color: var(--wp-text);
37
+ border: 1px solid var(--wp-border);
38
+ transition: background .12s, border-color .12s, color .12s, opacity .12s, transform .04s;
39
+ white-space: nowrap;
40
+ user-select: none;
41
+ box-sizing: border-box;
42
+ }
43
+ .wp-btn:hover { background: var(--wp-bg-4); border-color: var(--wp-border-strong); }
44
+ .wp-btn:active { transform: translateY(0.5px); }
45
+ .wp-btn:focus-visible {
46
+ outline: none;
47
+ box-shadow: var(--wp-focus-ring);
48
+ }
49
+ .wp-btn .pi, .wp-btn svg { font-size: 13px; }
50
+ /* The label box (not on a link, which has no fixed height) is trimmed to
51
+ * the cap height where the browser can, so the
52
+ * flex centring puts the capitals, not the ascender-to-descender box, on the
53
+ * button's middle line, level with the icon. */
54
+ @supports (text-box: trim-both cap alphabetic) {
55
+ .wp-btn:not(.wp-btn--link) .wp-btn__label { text-box: trim-both cap alphabetic; }
56
+ }
57
+
58
+ /* Sizes */
59
+ .wp-btn--sm { height: var(--wp-control-h-sm); padding: 0 8px; gap: 4px; font-size: 12px; border-radius: min(6px, var(--wp-radius-cap, 999px)); }
60
+ .wp-btn--sm .pi, .wp-btn--sm svg { font-size: 12px; }
61
+ .wp-btn--md { /* the default size */ }
62
+ .wp-btn--lg { height: var(--wp-control-h-lg); padding: 0 var(--wp-space-6); font-size: 13.5px; border-radius: var(--wp-radius); }
63
+ .wp-btn--icon { width: var(--wp-control-h-md); padding: 0; }
64
+ .wp-btn--icon.wp-btn--sm { width: var(--wp-control-h-sm); }
65
+ .wp-btn--icon.wp-btn--lg { width: var(--wp-control-h-lg); }
66
+
67
+ /* Variants */
68
+ .wp-btn--secondary { /* the default look */ }
69
+
70
+ .wp-btn--primary {
71
+ background: var(--wp-accent-600);
72
+ border-color: var(--wp-accent-600);
73
+ color: #fff; /* audit-exempt: white on accent-600 is 6.3:1 */
74
+ }
75
+ .wp-btn--primary:hover { background: var(--wp-accent-700); border-color: var(--wp-accent-700); }
76
+ .wp-btn--primary:active { background: var(--wp-accent-800); border-color: var(--wp-accent-800); }
77
+ .wp-btn--primary:focus-visible { box-shadow: var(--wp-focus-ring); }
78
+
79
+ .wp-btn--ghost { background: transparent; border-color: transparent; color: var(--wp-text-muted); }
80
+ .wp-btn--ghost:hover { background: var(--wp-bg-3); border-color: transparent; color: var(--wp-text); }
81
+ .wp-btn--ghost:active { background: var(--wp-bg-4); }
82
+
83
+ /* Outline is neutral: a bordered ghost. An accent outline read as a second
84
+ * primary next to the real one. */
85
+ .wp-btn--outline { background: transparent; border-color: var(--wp-border-strong); color: var(--wp-text); }
86
+ .wp-btn--outline:hover { background: var(--wp-bg-3); border-color: var(--wp-border-strong); }
87
+ .wp-btn--outline:active { background: var(--wp-bg-4); }
88
+
89
+ .wp-btn--link {
90
+ background: transparent;
91
+ border-color: transparent;
92
+ color: var(--wp-accent-text);
93
+ height: auto;
94
+ padding: 0;
95
+ line-height: 1;
96
+ }
97
+ .wp-btn--link:hover {
98
+ background: transparent;
99
+ border-color: transparent;
100
+ color: var(--wp-accent-text-strong);
101
+ text-decoration: underline;
102
+ }
103
+ .wp-btn--link:focus-visible { box-shadow: none; text-decoration: underline; }
104
+
105
+ .wp-btn--danger { color: var(--wp-danger); }
106
+ .wp-btn--danger:hover {
107
+ background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
108
+ border-color: color-mix(in oklab, var(--wp-danger) 40%, transparent);
109
+ color: var(--wp-danger-text);
110
+ }
111
+ .wp-btn--danger:active { background: color-mix(in oklab, var(--wp-danger) 22%, transparent); }
112
+ .wp-btn--danger:focus-visible {
113
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--wp-danger) 35%, transparent); /* danger uses its own ring color */
114
+ }
115
+
116
+ /* Add: the quiet dashed "Add a row" button at the end of a list (Injector
117
+ * template rows, Model Info rules, fixed values, constraint exceptions). */
118
+ .wp-btn--add {
119
+ background: transparent;
120
+ border-style: dashed;
121
+ border-color: var(--wp-border-strong);
122
+ color: var(--wp-text-dim);
123
+ font-weight: 600;
124
+ }
125
+ .wp-btn--add:hover {
126
+ background: color-mix(in srgb, var(--wp-accent) 8%, transparent);
127
+ border-color: color-mix(in srgb, var(--wp-accent) 45%, var(--wp-border-strong));
128
+ color: var(--wp-text);
129
+ }
130
+ .wp-btn--add:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { background: transparent; border-color: var(--wp-border-strong); color: var(--wp-text-dim); }
131
+
132
+ .wp-btn--block { display: flex; width: 100%; }
133
+
134
+ /* Pressed: a toggle that is on (favourites only, filters open). */
135
+ .wp-btn[aria-pressed="true"] {
136
+ background: color-mix(in srgb, var(--wp-accent) 14%, transparent);
137
+ border-color: color-mix(in srgb, var(--wp-accent) 55%, var(--wp-border));
138
+ color: var(--wp-accent-text);
139
+ }
140
+ .wp-btn[aria-pressed="true"]:hover {
141
+ background: color-mix(in srgb, var(--wp-accent) 22%, transparent);
142
+ border-color: var(--wp-accent);
143
+ }
144
+
145
+ /* Disabled (not loading): faded, flat, and hover does nothing. */
146
+ .wp-btn:disabled:not([aria-busy="true"]),
147
+ .wp-btn[aria-disabled="true"] {
148
+ opacity: 0.45;
149
+ cursor: not-allowed;
150
+ transform: none;
151
+ }
152
+ .wp-btn--secondary:disabled:not([aria-busy="true"]),
153
+ .wp-btn--secondary[aria-disabled="true"] {
154
+ background: transparent;
155
+ border-color: var(--wp-border);
156
+ color: var(--wp-text-muted);
157
+ }
158
+ .wp-btn--secondary:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { background: transparent; border-color: var(--wp-border); }
159
+ .wp-btn--primary:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover {
160
+ background: var(--wp-accent-600);
161
+ border-color: var(--wp-accent-600);
162
+ }
163
+ .wp-btn--ghost:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { background: transparent; color: var(--wp-text-muted); }
164
+ .wp-btn--outline:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { background: transparent; border-color: var(--wp-border-strong); }
165
+ .wp-btn--danger:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { background: var(--wp-bg-3); border-color: var(--wp-border); color: var(--wp-danger); }
166
+ .wp-btn--link:is(:disabled:not([aria-busy="true"]), [aria-disabled="true"]):hover { color: var(--wp-accent-text); text-decoration: none; }
167
+
168
+ /* Loading keeps the button's own colours; only the cursor says "wait". */
169
+ .wp-btn[aria-busy="true"] { cursor: progress; transform: none; }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Date helpers for DatePicker. Dates travel as "YYYY-MM-DD" strings (local
3
+ * calendar days, no time zone), so a saved filter means the same day
4
+ * everywhere.
5
+ */
6
+ export interface Ymd { y: number; m: number; d: number } // m is 0-11
7
+
8
+ export function parseYmd(s: string | null | undefined): Ymd | null {
9
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s ?? "");
10
+ if (!m) return null;
11
+ const y = Number(m[1]); const mo = Number(m[2]) - 1; const d = Number(m[3]);
12
+ const dt = new Date(y, mo, d);
13
+ return dt.getFullYear() === y && dt.getMonth() === mo && dt.getDate() === d ? { y, m: mo, d } : null;
14
+ }
15
+
16
+ export function toYmd(v: Ymd): string {
17
+ return `${String(v.y).padStart(4, "0")}-${String(v.m + 1).padStart(2, "0")}-${String(v.d).padStart(2, "0")}`;
18
+ }
19
+
20
+ export function fromDate(dt: Date): Ymd {
21
+ return { y: dt.getFullYear(), m: dt.getMonth(), d: dt.getDate() };
22
+ }
23
+
24
+ export function addDays(v: Ymd, n: number): Ymd {
25
+ return fromDate(new Date(v.y, v.m, v.d + n));
26
+ }
27
+
28
+ /** The same day n months on, kept inside the shorter month (31 Jan + 1 → 28/29 Feb). */
29
+ export function addMonths(v: Ymd, n: number): Ymd {
30
+ const first = new Date(v.y, v.m + n, 1);
31
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
32
+ return { y: first.getFullYear(), m: first.getMonth(), d: Math.min(v.d, last) };
33
+ }
34
+
35
+ export function compareYmd(a: Ymd, b: Ymd): number {
36
+ return a.y - b.y || a.m - b.m || a.d - b.d;
37
+ }
38
+
39
+ /**
40
+ * The 6×7 grid of days shown for a month, starting on `weekStart`
41
+ * (0 = Sunday, 1 = Monday), with days of the months either side.
42
+ */
43
+ export function monthGrid(y: number, m: number, weekStart = 1): Ymd[] {
44
+ const first = new Date(y, m, 1);
45
+ const lead = (first.getDay() - weekStart + 7) % 7;
46
+ return Array.from({ length: 42 }, (_, i) => fromDate(new Date(y, m, 1 - lead + i)));
47
+ }
48
+
49
+ /** Short weekday names from `weekStart`, in the browser's language. */
50
+ export function weekdayNames(weekStart = 1, locale?: string): { short: string; long: string }[] {
51
+ // 2024-01-07 was a Sunday.
52
+ return Array.from({ length: 7 }, (_, i) => {
53
+ const dt = new Date(2024, 0, 7 + ((weekStart + i) % 7));
54
+ return {
55
+ short: dt.toLocaleDateString(locale, { weekday: "narrow" }),
56
+ long: dt.toLocaleDateString(locale, { weekday: "long" }),
57
+ };
58
+ });
59
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * clipboard — copy text, also where `navigator.clipboard` is missing.
3
+ *
4
+ * Browsers only give `navigator.clipboard` to secure pages (https or
5
+ * localhost), so ComfyUI opened over plain http on the LAN has none. There a
6
+ * hidden textarea is selected and copied with `execCommand("copy")`, which
7
+ * still works inside a click.
8
+ */
9
+
10
+ /** Copy `text`; true when it reached the clipboard. */
11
+ export async function copyText(text: string): Promise<boolean> {
12
+ const api = typeof navigator === "undefined" ? undefined : navigator.clipboard;
13
+ if (api?.writeText) {
14
+ try {
15
+ await api.writeText(text);
16
+ return true;
17
+ } catch {
18
+ // Refused (no permission, page not focused): try the old way.
19
+ }
20
+ }
21
+ return copyByTextarea(text);
22
+ }
23
+
24
+ function copyByTextarea(text: string): boolean {
25
+ if (typeof document === "undefined" || !document.body) return false;
26
+ const area = document.createElement("textarea");
27
+ area.value = text;
28
+ area.setAttribute("readonly", "");
29
+ area.setAttribute("aria-hidden", "true");
30
+ area.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;";
31
+ const focused = document.activeElement;
32
+ const selection = document.getSelection();
33
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
34
+ document.body.appendChild(area);
35
+ area.focus({ preventScroll: true });
36
+ area.select();
37
+ let ok = false;
38
+ try {
39
+ ok = typeof document.execCommand === "function" && document.execCommand("copy");
40
+ } catch {
41
+ ok = false;
42
+ }
43
+ area.remove();
44
+ if (range && selection) {
45
+ selection.removeAllRanges();
46
+ selection.addRange(range);
47
+ }
48
+ if (focused instanceof HTMLElement) focused.focus();
49
+ return ok;
50
+ }
@@ -0,0 +1,12 @@
1
+ /** The preset colours the colour pickers offer (bundle frames, categories),
2
+ * and the default a new one starts with. One list, so every picker and the
3
+ * canvas offer the same swatches. Brand colours, not theme tokens: a picked
4
+ * colour is stored as hex and looks the same in light and dark. */
5
+ export const COLOUR_PRESETS: readonly string[] = [
6
+ "#7c3aed", "#a78bfa", "#22d3ee", "#34d399",
7
+ "#fbbf24", "#f472b6", "#fb7185", "#ef4444",
8
+ "#6366f1", "#10b981", "#f59e0b", "#8b5cf6",
9
+ ];
10
+
11
+ /** The colour a new bundle or category starts with. */
12
+ export const DEFAULT_COLOUR = "#6366f1";
package/src/diff.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A small word or line diff for DiffView: the longest common subsequence of
3
+ * the two token lists, then the runs that are the same, added or removed.
4
+ * Inputs past `limit` tokens on either side fall back to "all removed, all
5
+ * added", which keeps the table (n × m) small.
6
+ */
7
+ export type DiffPart = { kind: "same" | "add" | "del"; text: string };
8
+
9
+ /** Words with the spaces after them, so joining the parts gives the text back. */
10
+ export function tokenize(text: string, by: "words" | "lines"): string[] {
11
+ if (!text) return [];
12
+ return by === "lines" ? text.split(/(?<=\n)/) : text.match(/\s+|[^\s]+/g) ?? [];
13
+ }
14
+
15
+ export function diffTokens(a: string[], b: string[], limit = 2000): DiffPart[] {
16
+ if (a.length > limit || b.length > limit) {
17
+ return merge([...a.map((t) => ({ kind: "del" as const, text: t })), ...b.map((t) => ({ kind: "add" as const, text: t }))]);
18
+ }
19
+ const n = a.length; const m = b.length;
20
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
21
+ for (let i = n - 1; i >= 0; i--) {
22
+ for (let j = m - 1; j >= 0; j--) {
23
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
24
+ }
25
+ }
26
+ const out: DiffPart[] = [];
27
+ let i = 0; let j = 0;
28
+ while (i < n && j < m) {
29
+ if (a[i] === b[j]) { out.push({ kind: "same", text: a[i] }); i++; j++; }
30
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ kind: "del", text: a[i] }); i++; }
31
+ else { out.push({ kind: "add", text: b[j] }); j++; }
32
+ }
33
+ while (i < n) out.push({ kind: "del", text: a[i++] });
34
+ while (j < m) out.push({ kind: "add", text: b[j++] });
35
+ return merge(out);
36
+ }
37
+
38
+ /** Join neighbours of one kind into one part. */
39
+ function merge(parts: DiffPart[]): DiffPart[] {
40
+ const out: DiffPart[] = [];
41
+ for (const p of parts) {
42
+ const last = out[out.length - 1];
43
+ if (last && last.kind === p.kind) last.text += p.text;
44
+ else out.push({ ...p });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export function diffText(before: string, after: string, by: "words" | "lines" = "words"): DiffPart[] {
50
+ return diffTokens(tokenize(before, by), tokenize(after, by));
51
+ }