windcraft-editor 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +53 -2
  3. package/core/board-layout.js +71 -0
  4. package/core/class-groups.js +210 -0
  5. package/core/design-spec.js +303 -0
  6. package/core/exporters.js +156 -0
  7. package/core/pages.js +39 -0
  8. package/core/prop-tailwind.js +158 -0
  9. package/core/split-sections.js +99 -0
  10. package/core/theme.js +87 -0
  11. package/editor/design/agent.js +2118 -0
  12. package/editor/design/classes-section.js +103 -0
  13. package/editor/design/code-editor.js +293 -0
  14. package/editor/design/code-view.js +158 -0
  15. package/editor/design/color-picker.js +107 -0
  16. package/editor/design/config.js +5 -0
  17. package/editor/design/controls.js +132 -0
  18. package/editor/design/css-props.js +111 -0
  19. package/editor/design/design-tab.js +120 -0
  20. package/editor/design/device-frame.js +40 -0
  21. package/editor/design/editor/add-menu.js +151 -0
  22. package/editor/design/editor/api.js +47 -0
  23. package/editor/design/editor/base.js +55 -0
  24. package/editor/design/editor/code-panel.js +206 -0
  25. package/editor/design/editor/color-ui.js +135 -0
  26. package/editor/design/editor/designs.js +606 -0
  27. package/editor/design/editor/docs.js +121 -0
  28. package/editor/design/editor/frame-handles.js +223 -0
  29. package/editor/design/editor/frame-messages.js +195 -0
  30. package/editor/design/editor/inspector.js +461 -0
  31. package/editor/design/editor/layers.js +61 -0
  32. package/editor/design/editor/pages-panel.js +161 -0
  33. package/editor/design/editor/panel-resize.js +47 -0
  34. package/editor/design/editor/plane.js +132 -0
  35. package/editor/design/editor/present.js +52 -0
  36. package/editor/design/editor/toolbar.js +267 -0
  37. package/editor/design/editor.js +47 -0
  38. package/editor/design/figma-sections.js +440 -0
  39. package/editor/design/float-toolbar.js +163 -0
  40. package/editor/design/group-controls.js +52 -0
  41. package/editor/design/host.js +22 -0
  42. package/editor/design/panel-model.js +130 -0
  43. package/editor/design/panel-state.js +40 -0
  44. package/editor/design/panel.js +212 -0
  45. package/editor/design/presenter.js +312 -0
  46. package/editor/design/prop-sections.js +18 -0
  47. package/editor/design/selection-overlay.js +93 -0
  48. package/editor/design/store.js +29 -0
  49. package/editor/design/style-sections.js +315 -0
  50. package/editor/design/tailwind-classes.js +65 -0
  51. package/editor/embed-frame.js +43 -0
  52. package/editor/embed.html +15 -0
  53. package/editor/embed.js +60 -0
  54. package/editor/index.js +62 -0
  55. package/editor/playground/component-source.js +124 -0
  56. package/editor/styles.css +402 -0
  57. package/editor/template.js +247 -0
  58. package/editor/vendor/codemirror.js +24 -0
  59. package/editor/vendor/preact-htm.js +1 -0
  60. package/editor/vendor/prettier.js +171 -0
  61. package/package.json +39 -5
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Swapnil Jadhav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Third-party code included in this repository is licensed under its own terms; see THIRD_PARTY_LICENSES.md.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # windcraft-editor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```bash
4
+ npm install windcraft-editor
5
+ ```
6
+
7
+ The Windcraft visual editor (HTML + Tailwind): canvas, layers, Properties panel, code panel and pages. It has no backend of its own: the page that mounts it gives it a **host** that loads and saves.
8
+
9
+ ```js
10
+ import { mountEditor } from "windcraft-editor";
11
+
12
+ await mountEditor(document.getElementById("root"), {
13
+ host, // saving, projects, AI and the address: see design/host.js
14
+ data, // the design to open: { id, title, htmlCode, ... }
15
+ });
16
+ ```
17
+
18
+ | Piece | Where |
19
+ |---|---|
20
+ | What the app must provide | `design/host.js` (the `Host` type) |
21
+ | A host with no backend (localStorage) | `demo/demo.js` |
22
+ | Run the demo | `npm run demo`, then open http://localhost:4000/editor/demo/ |
23
+ | Windcraft's own host | `src/public/js/app-host.js` |
24
+
25
+ The package carries the Tailwind logic inside it (`core/`) and reaches it by relative paths, so there is nothing else to map. On a plain page map `windcraft-editor` to `<where you serve the package>/editor/index.js` with an import map; a bundler does that itself (an import map, as in `demo/index.html`, or a bundler). `mountEditor` loads Tailwind's play CDN (v3) unless you pass `tailwind: false`. One editor per page; it takes over the page's size, keys and zoom.
26
+
27
+ The AI panel and "Generate" need `host.generate`; without a working one they show its error.
28
+
29
+ ## Embedding in a container
30
+
31
+ `embedEditor` puts the editor in an element of your page, isolated in an iframe (its own keys, zoom and styles), so several can share a page and it fills whatever size you give it. Leave out the parts you don't need with `ui`; keep the code box and the details form in your own page.
32
+
33
+ ```js
34
+ import { embedEditor } from "windcraft-editor";
35
+
36
+ const ed = embedEditor(el, {
37
+ host, // saving, as above
38
+ data: { htmlCode }, // what to open
39
+ ui: { panels: false, nav: false, tools: ["move", "hand", "interact", "frame", "shape", "text", "add"] },
40
+ onDetails: (fields) => showMyForm(fields), // the first save of a new design needs a name: you ask, then call saveDetails
41
+ });
42
+ ed.onChange(({ html, dirty, status, source }) => {}); // source: "host" (your setHtml), "editor" (a change there), "status" (save state only)
43
+ ed.setHtml(html); ed.getHtml(); ed.isDirty(); ed.save();
44
+ await ed.saveDetails({ title, category, description, tags, isPublished, useAsReference, project });
45
+ ed.destroy();
46
+ ```
47
+
48
+ | `ui` | Effect |
49
+ |---|---|
50
+ | `panels: false` | no Layers, Properties or Code panels |
51
+ | `nav: false` | no Back, page list or project |
52
+ | `tools: [...]` | only these tools (`move hand interact frame shape text add`); `null` keeps all |
53
+
54
+ The iframe loads `embed.html` (next to `embed.js`; pass `src` if you serve it elsewhere). It needs the same import map as any editor page. Run `npm run demo` and open `/editor/demo/embed.html` for a working example: a code box and the editor following each other.
@@ -0,0 +1,71 @@
1
+ // Where each design sits on a project's infinite plane. Shared by the project board and the editor.
2
+ // Saved positions win. Everything else is laid out automatically: pages in a row, each page's sections stacked beneath it.
3
+ // Pages that have a group ("Public site", "Customer area") get a row of their own per group, the rows one under the other in
4
+ // the order the groups first appear; without groups there is one row, as there always was.
5
+ export const GAP_X = 320;
6
+ export const SECTION_GAP = 200;
7
+ export const ROW_GAP = 360; // between the bottom of one group's row and the labels of the next
8
+ export const PAGE_H = 900;
9
+ export const SEC_H = 480;
10
+
11
+ const LABEL_H = 40; // the name above each frame belongs to it
12
+ const CLEAR = 60; // the least air between two frames
13
+
14
+ /**
15
+ * items: [{ id, kind, parent, group, board }]. Returns { [id]: { x, y } }. A frame may be wider than frameW (widthOf).
16
+ * Nothing placed automatically lands on another frame: a spot that is taken (by a frame with a saved position, or one placed before)
17
+ * is moved past it, a page to the right and a section below. Saved positions are never moved.
18
+ */
19
+ export function computeLayout(items, { frameW, heightOf, widthOf = () => frameW }) {
20
+ const pos = {};
21
+ const box = (it, p) => ({ x: p.x, y: p.y - LABEL_H, r: p.x + widthOf(it), b: p.y + heightOf(it) });
22
+ const taken = []; // the boxes of what has a place: the saved ones first, then each one placed
23
+ items.forEach((i) => { if (i.board) taken.push(box(i, i.board)); });
24
+ const hit = (a, b) => a.x < b.r + CLEAR && a.r + CLEAR > b.x && a.y < b.b + CLEAR && a.b + CLEAR > b.y;
25
+ /** The first spot from `p` that touches nothing, moving right (down: false) or down. */
26
+ const clear = (it, p, down) => {
27
+ let q = { ...p };
28
+ for (let n = 0; n < 400; n++) {
29
+ const me = box(it, q), other = taken.find((t) => hit(me, t));
30
+ if (!other) return q;
31
+ q = down ? { x: q.x, y: other.b + CLEAR + LABEL_H } : { x: other.r + CLEAR, y: q.y };
32
+ }
33
+ return q;
34
+ };
35
+ const place = (it, want, down) => {
36
+ const p = it.board ? { x: it.board.x, y: it.board.y } : clear(it, want, down);
37
+ if (!it.board) taken.push(box(it, p));
38
+ return p;
39
+ };
40
+ const pages = items.filter((i) => i.kind !== "section" || !items.some((p) => p.id === i.parent));
41
+ /** @type {{ group: string, pages: typeof pages }[]} */
42
+ const rows = [];
43
+ pages.forEach((p) => {
44
+ const group = p.group || "";
45
+ let row = rows.find((r) => r.group === group);
46
+ if (!row) rows.push((row = { group, pages: [] }));
47
+ row.pages.push(p);
48
+ });
49
+ let rowY = 0;
50
+ rows.forEach((row) => {
51
+ let nextX = 0;
52
+ // the lowest edge of this row, so the next row starts below it. A page moved by hand keeps its slot, its height included:
53
+ // dragging a page never moves another one (a row below doesn't move up when a tall page is picked up)
54
+ let bottom = rowY;
55
+ row.pages.forEach((p) => {
56
+ pos[p.id] = place(p, { x: nextX, y: rowY }, false);
57
+ nextX = (p.board ? nextX : pos[p.id].x) + widthOf(p) + GAP_X;
58
+ let y = pos[p.id].y + heightOf(p) + 260;
59
+ let slot = rowY + heightOf(p) + 260; // the same stack, as it stands in the slot
60
+ bottom = Math.max(bottom, rowY + heightOf(p));
61
+ items.filter((i) => i.kind === "section" && i.parent === p.id).forEach((s) => {
62
+ pos[s.id] = place(s, { x: pos[p.id].x, y }, true);
63
+ bottom = Math.max(bottom, slot + heightOf(s));
64
+ y = pos[s.id].y + heightOf(s) + SECTION_GAP;
65
+ slot += heightOf(s) + SECTION_GAP;
66
+ });
67
+ });
68
+ rowY = bottom + ROW_GAP;
69
+ });
70
+ return pos;
71
+ }
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+ // A registry of Tailwind utility groups. Each group is a set of mutually exclusive classes
3
+ // (for example every font size). Controls read and write a group for a chosen variant
4
+ // (breakpoint and state), e.g. "md:hover:".
5
+ export const HUES = ["slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "brand"]; // brand: every project has its own scale (brand-50 to brand-950, see theme.js)
6
+ export const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
7
+ export const SPECIAL_COLORS = ["black", "white", "transparent", "current"];
8
+
9
+ export const STEPS = ["0", "px", "0.5", "1", "1.5", "2", "2.5", "3", "3.5", "4", "5", "6", "7", "8", "9", "10", "11", "12", "14", "16", "20", "24", "28", "32", "36", "40", "44", "48", "52", "56", "60", "64", "72", "80", "96"];
10
+ export const SIZES = [...STEPS, "auto", "full", "screen", "min", "max", "fit", "1/2", "1/3", "2/3", "1/4", "3/4", "1/5", "2/5", "3/5", "4/5"];
11
+ export const MAX_W = ["none", "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "full", "prose", "screen-sm", "screen-md", "screen-lg", "screen-xl"];
12
+
13
+ const STEP = "\\d+(?:\\.5)?|px";
14
+ const SIZE = `${STEP}|auto|full|screen|min|max|fit|\\d+/\\d+`;
15
+ const ARB = "\\[[^\\]]+\\]";
16
+ const HUE_RE = HUES.join("|");
17
+ const COLOR = `(?:(?:${HUE_RE})-\\d{2,3}|black|white|transparent|current|inherit|\\[(?:#|rgb|hsl|var)[^\\]]*\\])(?:/\\d+|/\\[[^\\]]+\\])?`;
18
+
19
+ const normArb = (v) => {
20
+ v = String(v).trim();
21
+ if (/^\[.*\]$/.test(v)) return v;
22
+ if (/^-?\d*\.?\d+(px|rem|em|%|vh|vw|vmin|vmax|ch|deg|ms|s|fr)$/.test(v) || /^(calc|var|min|max|clamp)\(/.test(v)) return `[${v}]`;
23
+ return v;
24
+ };
25
+ const normColor = (v) => {
26
+ v = String(v).trim();
27
+ if (/^(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/.test(v)) return `[${v.replace(/\s+/g, "_")}]`;
28
+ return v;
29
+ };
30
+
31
+ // prefix-value utility (w-4, -mt-2, gap-x-[10px])
32
+ const P = (prefix, vals, { neg = false, arb = true } = {}) => ({
33
+ re: new RegExp(`^(${neg ? "-?" : ""})${prefix}-(${vals}${arb ? `|${ARB}` : ""})$`),
34
+ read: (m) => (m[1] || "") + m[2],
35
+ build: (v) => {
36
+ let sign = "";
37
+ v = String(v).trim();
38
+ if (neg && v.startsWith("-")) { sign = "-"; v = v.slice(1); }
39
+ return `${sign}${prefix}-${normArb(v)}`;
40
+ },
41
+ });
42
+ // exact words (display: flex)
43
+ const E = (alts) => ({ re: new RegExp(`^(${alts})$`), read: (m) => m[1], build: (v) => v });
44
+ // color utility with optional /opacity
45
+ const C = (prefix) => ({
46
+ re: new RegExp(`^${prefix}-(${COLOR})$`),
47
+ read: (m) => m[1],
48
+ build: (v) => `${prefix}-${normColor(v)}`,
49
+ });
50
+
51
+ export const GROUPS = {
52
+ // layout
53
+ display: E("block|inline-block|inline|flex|inline-flex|grid|inline-grid|hidden|contents|table|flow-root"),
54
+ position: E("static|fixed|absolute|relative|sticky"),
55
+ overflow: E("overflow-(?:auto|hidden|visible|scroll|clip)"),
56
+ overflowX: E("overflow-x-(?:auto|hidden|visible|scroll|clip)"),
57
+ overflowY: E("overflow-y-(?:auto|hidden|visible|scroll|clip)"),
58
+ z: P("z", "\\d+|auto", { neg: true }),
59
+ visibility: E("visible|invisible"),
60
+ // flex and grid
61
+ flexDir: E("flex-(?:row|col|row-reverse|col-reverse)"),
62
+ flexWrap: E("flex-(?:wrap|nowrap|wrap-reverse)"),
63
+ justify: E("justify-(?:start|center|end|between|around|evenly|stretch|normal)"),
64
+ items: E("items-(?:start|center|end|stretch|baseline)"),
65
+ content: E("content-(?:start|center|end|between|around|evenly|stretch)"),
66
+ self: E("self-(?:auto|start|center|end|stretch|baseline)"),
67
+ grow: E("grow|grow-0"),
68
+ shrink: E("shrink|shrink-0"),
69
+ flexNum: E("flex-(?:1|auto|initial|none)"),
70
+ gap: P("gap", STEP),
71
+ gapX: P("gap-x", STEP),
72
+ gapY: P("gap-y", STEP),
73
+ gridCols: P("grid-cols", "\\d+|none|subgrid"),
74
+ gridRows: P("grid-rows", "\\d+|none|subgrid"),
75
+ colSpan: P("col-span", "\\d+|full"),
76
+ rowSpan: P("row-span", "\\d+|full"),
77
+ order: P("order", "\\d+|first|last|none", { neg: true }),
78
+ // size
79
+ w: P("w", SIZE),
80
+ h: P("h", SIZE),
81
+ minW: P("min-w", `${STEP}|full|min|max|fit`),
82
+ maxW: P("max-w", `${STEP}|${MAX_W.join("|")}|fit|min|max`),
83
+ minH: P("min-h", `${STEP}|full|screen|min|max|fit`),
84
+ maxH: P("max-h", `${STEP}|full|screen|min|max|fit|none`),
85
+ aspect: P("aspect", "auto|square|video"),
86
+ objectFit: E("object-(?:contain|cover|fill|none|scale-down)"),
87
+ // spacing
88
+ p: P("p", STEP), px: P("px", STEP), py: P("py", STEP), pt: P("pt", STEP), pr: P("pr", STEP), pb: P("pb", STEP), pl: P("pl", STEP),
89
+ m: P("m", `${STEP}|auto`, { neg: true }), mx: P("mx", `${STEP}|auto`, { neg: true }), my: P("my", `${STEP}|auto`, { neg: true }),
90
+ mt: P("mt", `${STEP}|auto`, { neg: true }), mr: P("mr", `${STEP}|auto`, { neg: true }), mb: P("mb", `${STEP}|auto`, { neg: true }), ml: P("ml", `${STEP}|auto`, { neg: true }),
91
+ spaceX: P("space-x", STEP, { neg: true }),
92
+ spaceY: P("space-y", STEP, { neg: true }),
93
+ // position offsets
94
+ inset: P("inset", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
95
+ top: P("top", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
96
+ right: P("right", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
97
+ bottom: P("bottom", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
98
+ left: P("left", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
99
+ // typography
100
+ fontFamily: E("font-(?:sans|serif|mono)"),
101
+ fontSize: { re: /^text-(xs|sm|base|lg|xl|[2-9]xl|\[\d[^\]]*\])$/, read: (m) => m[1], build: (v) => `text-${normArb(v)}` },
102
+ fontWeight: E("font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)"),
103
+ fontStyle: E("italic|not-italic"),
104
+ leading: P("leading", "none|tight|snug|normal|relaxed|loose|\\d+"),
105
+ tracking: P("tracking", "tighter|tight|normal|wide|wider|widest"),
106
+ textAlign: E("text-(?:left|center|right|justify|start|end)"),
107
+ textColor: C("text"),
108
+ textDecoration: E("underline|overline|line-through|no-underline"),
109
+ textTransform: E("uppercase|lowercase|capitalize|normal-case"),
110
+ textOverflow: E("truncate|text-ellipsis|text-clip"),
111
+ whitespace: E("whitespace-(?:normal|nowrap|pre|pre-line|pre-wrap|break-spaces)"),
112
+ lineClamp: P("line-clamp", "\\d+|none"),
113
+ // background
114
+ bgColor: C("bg"),
115
+ bgImage: E("bg-gradient-to-(?:t|tr|r|br|b|bl|l|tl)|bg-none"),
116
+ gradFrom: C("from"), gradVia: C("via"), gradTo: C("to"),
117
+ bgSize: E("bg-(?:auto|cover|contain)"),
118
+ bgPosition: E("bg-(?:center|top|right|bottom|left|left-top|left-bottom|right-top|right-bottom)"),
119
+ bgRepeat: E("bg-(?:repeat|no-repeat|repeat-x|repeat-y)"),
120
+ fill: C("fill"), stroke: C("stroke"),
121
+ // border
122
+ borderWidth: { re: /^border(?:-([0248]|\[\d[^\]]*\]))?$/, read: (m) => m[1] || "1", build: (v) => (v === "1" || v === "" ? "border" : `border-${normArb(v)}`) },
123
+ borderT: P("border-t", "[0248]"), borderR: P("border-r", "[0248]"), borderB: P("border-b", "[0248]"), borderL: P("border-l", "[0248]"),
124
+ borderStyle: E("border-(?:solid|dashed|dotted|double|none)"),
125
+ borderColor: C("border"),
126
+ radius: { re: /^rounded(?:-(none|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]))?$/, read: (m) => m[1] || "", build: (v) => (v === "" || v === "default" ? "rounded" : `rounded-${normArb(v)}`) },
127
+ radiusTL: P("rounded-tl", "none|sm|md|lg|xl|2xl|3xl|full"), radiusTR: P("rounded-tr", "none|sm|md|lg|xl|2xl|3xl|full"),
128
+ radiusBR: P("rounded-br", "none|sm|md|lg|xl|2xl|3xl|full"), radiusBL: P("rounded-bl", "none|sm|md|lg|xl|2xl|3xl|full"),
129
+ ring: { re: /^ring(?:-([0124-8]))?$/, read: (m) => m[1] || "3", build: (v) => (v === "3" ? "ring" : `ring-${v}`) },
130
+ ringColor: C("ring"),
131
+ // effects
132
+ shadow: { re: /^shadow(?:-(sm|md|lg|xl|2xl|inner|none))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" || v === "" ? "shadow" : `shadow-${v}`) },
133
+ shadowColor: C("shadow"),
134
+ opacity: P("opacity", "\\d+"),
135
+ blur: { re: /^blur(?:-(none|sm|md|lg|xl|2xl|3xl))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "blur" : `blur-${v}`) },
136
+ backdropBlur: { re: /^backdrop-blur(?:-(none|sm|md|lg|xl|2xl|3xl))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "backdrop-blur" : `backdrop-blur-${v}`) },
137
+ brightness: P("brightness", "\\d+"),
138
+ grayscale: { re: /^grayscale(?:-(0))?$/, read: (m) => m[1] || "100", build: (v) => (v === "0" ? "grayscale-0" : "grayscale") },
139
+ mixBlend: E("mix-blend-(?:normal|multiply|screen|overlay|darken|lighten|difference)"),
140
+ rotate: P("rotate", "\\d+", { neg: true }),
141
+ scale: P("scale", "\\d+"),
142
+ // an outline: its style (outline = solid), width, colour and offset are four classes
143
+ outlineStyle: E("outline|outline-none|outline-dashed|outline-dotted|outline-double"),
144
+ outlineW: P("outline", "\\d+|\\[\\d+(?:\\.\\d+)?px\\]", { arb: false }),
145
+ outlineColor: C("outline"),
146
+ outlineOffset: P("outline-offset", "\\d+|\\[\\d+(?:\\.\\d+)?px\\]", { arb: false }),
147
+ flipX: E("-scale-x-100"), // a mirror image: the one scale class that works in Tailwind 3 and 4 alike
148
+ flipY: E("-scale-y-100"),
149
+ translateX: P("translate-x", `${STEP}|full|\\d+/\\d+`, { neg: true }),
150
+ translateY: P("translate-y", `${STEP}|full|\\d+/\\d+`, { neg: true }),
151
+ // motion and interaction
152
+ transition: { re: /^transition(?:-(none|all|colors|opacity|shadow|transform))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "transition" : `transition-${v}`) },
153
+ duration: P("duration", "\\d+"),
154
+ ease: E("ease-(?:linear|in|out|in-out)"),
155
+ delay: P("delay", "\\d+"),
156
+ cursor: E("cursor-(?:pointer|default|text|move|not-allowed|wait|grab|grabbing|crosshair|help|auto)"),
157
+ pointerEvents: E("pointer-events-(?:none|auto)"),
158
+ userSelect: E("select-(?:none|text|all|auto)"),
159
+ };
160
+
161
+ export const BREAKPOINTS = ["", "sm", "md", "lg", "xl", "2xl"];
162
+ export const STATES = ["", "hover", "focus", "active", "disabled", "dark", "group-hover", "focus-within", "first", "last", "odd", "even"];
163
+ export const BREAKPOINT_WIDTH = { "": 390, sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 };
164
+
165
+ const split = (str) => String(str || "").split(/\s+/).filter(Boolean);
166
+
167
+ /** "md:hover:bg-red-500" -> { variant: "md:hover:", base: "bg-red-500" } (ignores colons inside [...]) */
168
+ export function parseToken(tok) {
169
+ let depth = 0, cut = -1;
170
+ for (let i = 0; i < tok.length; i++) {
171
+ if (tok[i] === "[") depth++;
172
+ else if (tok[i] === "]") depth--;
173
+ else if (tok[i] === ":" && depth === 0) cut = i;
174
+ }
175
+ return cut === -1 ? { variant: "", base: tok } : { variant: tok.slice(0, cut + 1), base: tok.slice(cut + 1) };
176
+ }
177
+
178
+ /** Current value of a group for one variant, or null. */
179
+ export function getGroup(classes, key, variant = "") {
180
+ const g = GROUPS[key];
181
+ for (const tok of split(classes)) {
182
+ const { variant: v, base } = parseToken(tok);
183
+ if (v !== variant) continue;
184
+ const m = base.match(g.re);
185
+ if (m) return { token: tok, value: g.read(m) };
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /** Sets (or, with an empty value, clears) a group for one variant. Returns the new class string, or null if the value is invalid. */
191
+ export function setGroup(classes, key, value, variant = "") {
192
+ const g = GROUPS[key];
193
+ let built = "";
194
+ if (value !== "" && value !== null && value !== undefined) {
195
+ built = g.build(value);
196
+ if (!g.re.test(built)) return null;
197
+ }
198
+ // the new class takes the old one's place (or goes at the end), so the rest of the list keeps its order
199
+ const out = [];
200
+ let at = -1;
201
+ for (const tok of split(classes)) {
202
+ const { variant: v, base } = parseToken(tok);
203
+ if (v === variant && g.re.test(base)) { if (at === -1) at = out.length; continue; }
204
+ out.push(tok);
205
+ }
206
+ if (built) out.splice(at === -1 ? out.length : at, 0, variant + built);
207
+ return out.join(" ");
208
+ }
209
+
210
+ export const ALL_GROUP_KEYS = Object.keys(GROUPS);