@zakkster/lite-gradient-studio 1.0.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Multi-format exporters for 1D and mesh gradients.
3
+ *
4
+ * Pattern follows `@zakkster/lite-hueforge` — each format is a small
5
+ * pure function taking the gradient data + opts and returning a
6
+ * string; a `toTokens1d` / `toTokensMesh` dispatcher picks one by id.
7
+ *
8
+ * The 1D state shape (matches g1d serialize/load in the app):
9
+ * {
10
+ * mode: 'linear' | 'radial' | 'conic',
11
+ * stops: [{ l, c, h, stop }, ...],
12
+ * angle: number, // linear
13
+ * radShape:'circle'|'ellipse', // radial
14
+ * radCx: number, radCy: number, // 0–100 percent
15
+ * conFrom: number, // conic
16
+ * conCx: number, conCy: number, // 0–100 percent
17
+ * }
18
+ *
19
+ * Backward compat: pre-v0.0.17 snapshots used keyword strings
20
+ * (radPos / conPos: 'center' | 'top right' | ...). `posFor` accepts
21
+ * either schema, preferring the numeric form when both are present.
22
+ *
23
+ * The mesh state shape:
24
+ * {
25
+ * cols: number, rows: number,
26
+ * stops: [{ x, y, l, c, h }, ...],
27
+ * }
28
+ *
29
+ * All exporters honor `name` (default 'gradient') for token naming.
30
+ */
31
+
32
+ import { toCssOklch } from '@zakkster/lite-color';
33
+ import { toHex } from './color-convert.js';
34
+ import {
35
+ formatCssLinear, formatCssRadial, formatCssConic,
36
+ } from './css-emitters.js';
37
+ import { formatCssMesh } from './mesh-css.js';
38
+
39
+ /* Format identifiers — frozen so consumers can iterate without
40
+ accidentally mutating. */
41
+ export const EXPORT_FORMATS_1D = Object.freeze([
42
+ 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg',
43
+ ]);
44
+ export const EXPORT_FORMATS_MESH = Object.freeze([
45
+ 'css', 'css-var', 'json',
46
+ ]);
47
+
48
+ /* Format → human label + a one-line hint for the export modal foot. */
49
+ export const FORMAT_META = Object.freeze({
50
+ 'css': { label: 'CSS', hint: 'Drop into any `background:` property. Uses `in oklch` interpolation.' },
51
+ 'css-var': { label: 'CSS variable', hint: 'Named `:root` custom property. Reference via `var(--name)`.' },
52
+ 'scss': { label: 'SCSS', hint: 'SCSS variable. Reference via `$name`.' },
53
+ 'tailwind': { label: 'Tailwind', hint: 'Add to `theme.extend.backgroundImage` in tailwind.config.js (v3 syntax).' },
54
+ 'json': { label: 'JSON', hint: 'Portable definition — re-importable by Gradient Studio or your build tool.' },
55
+ 'svg': { label: 'SVG', hint: 'Standalone SVG. Hex stops for renderer compatibility.' },
56
+ });
57
+
58
+ /* ── dispatchers ─────────────────────────────────────────────────── */
59
+
60
+ export function toTokens1d(g1d, format, opts = {}) {
61
+ switch (format) {
62
+ case 'css': return toCss1d(g1d, opts);
63
+ case 'css-var': return toCssVar1d(g1d, opts);
64
+ case 'scss': return toScss1d(g1d, opts);
65
+ case 'tailwind': return toTailwind1d(g1d, opts);
66
+ case 'json': return toJson1d(g1d, opts);
67
+ case 'svg': return toSvg1d(g1d, opts);
68
+ default: throw new Error(
69
+ `Unknown 1D export format: "${format}". Try one of: ${EXPORT_FORMATS_1D.join(', ')}`);
70
+ }
71
+ }
72
+
73
+ export function toTokensMesh(mesh, format, opts = {}) {
74
+ switch (format) {
75
+ case 'css': return toCssMesh(mesh, opts);
76
+ case 'css-var': return toCssVarMesh(mesh, opts);
77
+ case 'json': return toJsonMesh(mesh, opts);
78
+ default: throw new Error(
79
+ `Unknown mesh export format: "${format}". Try one of: ${EXPORT_FORMATS_MESH.join(', ')}`);
80
+ }
81
+ }
82
+
83
+ /* ── 1D exporters ────────────────────────────────────────────────── */
84
+
85
+ /** Return the raw CSS gradient string for this 1D state. */
86
+ function cssBodyFor1d(g) {
87
+ // Build a Gradient-shaped stops array (renames `stop` → `pos`).
88
+ // Alpha (s.a) passes through so the CSS emitters can emit
89
+ // `oklch(L C H / A)` syntax.
90
+ const stopsForCss = g.stops.map((s) => ({
91
+ l: s.l, c: s.c, h: s.h,
92
+ a: s.a === undefined ? 1 : s.a,
93
+ pos: s.stop,
94
+ }));
95
+ const fake = { stops: stopsForCss }; // formatCss* only reads .stops
96
+ if (g.mode === 'linear') {
97
+ return formatCssLinear(fake, { angle: g.angle });
98
+ }
99
+ if (g.mode === 'radial') {
100
+ return formatCssRadial(fake, {
101
+ shape: g.radShape, position: posFor(g, 'rad'),
102
+ });
103
+ }
104
+ return formatCssConic(fake, { from: g.conFrom, position: posFor(g, 'con') });
105
+ }
106
+
107
+ /**
108
+ * Read a position from either schema. v0.0.17+ uses numeric
109
+ * cx/cy fields ('rad' + 'Cx' = radCx); pre-v0.0.17 used keyword
110
+ * strings (radPos: 'center'). Both still load; new emits prefer
111
+ * the numeric form because it survives round-trips with no loss.
112
+ */
113
+ function posFor(g, prefix) {
114
+ const cx = g[prefix + 'Cx'];
115
+ const cy = g[prefix + 'Cy'];
116
+ if (Number.isFinite(cx) && Number.isFinite(cy)) {
117
+ return `${cx}% ${cy}%`;
118
+ }
119
+ return g[prefix + 'Pos'] || 'center';
120
+ }
121
+
122
+ export function toCss1d(g, opts = {}) {
123
+ return `background: ${cssBodyFor1d(g)};`;
124
+ }
125
+
126
+ export function toCssVar1d(g, opts = {}) {
127
+ const name = opts.name || 'gradient';
128
+ return `:root {\n --${slugify(name)}: ${cssBodyFor1d(g)};\n}`;
129
+ }
130
+
131
+ export function toScss1d(g, opts = {}) {
132
+ const name = opts.name || 'gradient';
133
+ return `$${slugify(name)}: ${cssBodyFor1d(g)};`;
134
+ }
135
+
136
+ export function toTailwind1d(g, opts = {}) {
137
+ const name = opts.name || 'gradient';
138
+ const body = cssBodyFor1d(g).replace(/'/g, "\\'");
139
+ return [
140
+ '// tailwind.config.js (v3)',
141
+ 'module.exports = {',
142
+ ' theme: {',
143
+ ' extend: {',
144
+ ' backgroundImage: {',
145
+ ` '${slugify(name)}': '${body}',`,
146
+ ' },',
147
+ ' },',
148
+ ' },',
149
+ '};',
150
+ '',
151
+ `// Usage: <div class="bg-${slugify(name)}">…</div>`,
152
+ ].join('\n');
153
+ }
154
+
155
+ export function toJson1d(g, opts = {}) {
156
+ const name = opts.name || 'gradient';
157
+ const out = {
158
+ $studio: 'gradient-studio',
159
+ $version: 1,
160
+ name,
161
+ type: g.mode,
162
+ interpolation: 'oklch',
163
+ stops: g.stops.map((s) => {
164
+ const stop = {
165
+ position: round3(s.stop),
166
+ color: { l: round3(s.l), c: round3(s.c), h: round1(s.h) },
167
+ hex: toHex(s),
168
+ };
169
+ // Only emit alpha when not fully opaque — keeps the common
170
+ // case clean and matches the toHex 6-vs-8 char convention.
171
+ if (s.a !== undefined && s.a < 1) {
172
+ stop.color.a = round3(s.a);
173
+ stop.alpha = round3(s.a);
174
+ }
175
+ return stop;
176
+ }),
177
+ };
178
+ if (g.mode === 'linear') {
179
+ out.angle = g.angle;
180
+ } else if (g.mode === 'radial') {
181
+ out.shape = g.radShape;
182
+ out.position = posFor(g, 'rad');
183
+ } else if (g.mode === 'conic') {
184
+ out.from = g.conFrom;
185
+ out.position = posFor(g, 'con');
186
+ }
187
+ return JSON.stringify(out, null, 2);
188
+ }
189
+
190
+ export function toSvg1d(g, opts = {}) {
191
+ const W = opts.width || 800;
192
+ const H = opts.height || 320;
193
+ const id = slugify(opts.name || 'gradient');
194
+
195
+ let defs;
196
+ if (g.mode === 'linear') {
197
+ // SVG linearGradient endpoint coords for a CSS-style angle.
198
+ // CSS angle: clockwise from north. SVG default is 0%,0% → 100%,0%.
199
+ const rad = g.angle * Math.PI / 180;
200
+ const dx = Math.sin(rad);
201
+ const dy = -Math.cos(rad);
202
+ // Normalize to [0%, 100%] coords. Endpoints are projected onto
203
+ // the angle line passing through the box center.
204
+ const halfX = Math.abs(dx) / 2;
205
+ const halfY = Math.abs(dy) / 2;
206
+ const cx = 50, cy = 50;
207
+ defs = ` <linearGradient id="${id}" x1="${(cx - dx * 50).toFixed(2)}%" y1="${(cy - dy * 50).toFixed(2)}%" x2="${(cx + dx * 50).toFixed(2)}%" y2="${(cy + dy * 50).toFixed(2)}%">\n`
208
+ + g.stops.map((s) => stopLine(s)).join('\n') + '\n'
209
+ + ` </linearGradient>`;
210
+ } else if (g.mode === 'radial') {
211
+ const { cx, cy } = posToPct(posFor(g, 'rad'));
212
+ defs = ` <radialGradient id="${id}" cx="${cx}%" cy="${cy}%" r="100%">\n`
213
+ + g.stops.map((s) => stopLine(s)).join('\n') + '\n'
214
+ + ` </radialGradient>`;
215
+ } else {
216
+ // SVG has no native conic gradient. Hint the user.
217
+ defs = ` <!-- SVG has no native conic-gradient. -->\n`
218
+ + ` <!-- Rasterize via the PNG export instead, or use a CSS-in-foreignObject fallback. -->\n`
219
+ + ` <linearGradient id="${id}" x1="0%" y1="0%" x2="100%" y2="0%">\n`
220
+ + g.stops.map((s) => stopLine(s)).join('\n') + '\n'
221
+ + ` </linearGradient>`;
222
+ }
223
+
224
+ return [
225
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`,
226
+ ` <defs>`,
227
+ defs,
228
+ ` </defs>`,
229
+ ` <rect width="100%" height="100%" fill="url(#${id})" />`,
230
+ `</svg>`,
231
+ ].join('\n');
232
+ }
233
+
234
+ function stopLine(s) {
235
+ // SVG's <stop> supports stop-opacity as a separate attribute. We
236
+ // emit `stop-color` (6-char hex, no alpha) plus `stop-opacity` when
237
+ // alpha < 1 — many SVG processors choke on 8-char hex strings.
238
+ const hex = toHex({ l: s.l, c: s.c, h: s.h });
239
+ const op = s.a !== undefined && s.a < 1
240
+ ? ` stop-opacity="${(s.a).toFixed(3)}"`
241
+ : '';
242
+ return ` <stop offset="${(s.stop * 100).toFixed(2)}%" stop-color="${hex}"${op} />`;
243
+ }
244
+
245
+ /* ── mesh exporters ──────────────────────────────────────────────── */
246
+
247
+ export function toCssMesh(mesh, opts = {}) {
248
+ return `background: ${formatCssMesh(mesh)};`;
249
+ }
250
+
251
+ export function toCssVarMesh(mesh, opts = {}) {
252
+ const name = opts.name || 'mesh-gradient';
253
+ return `:root {\n --${slugify(name)}: ${formatCssMesh(mesh)};\n}`;
254
+ }
255
+
256
+ export function toJsonMesh(mesh, opts = {}) {
257
+ const name = opts.name || 'mesh-gradient';
258
+ return JSON.stringify({
259
+ $studio: 'gradient-studio',
260
+ $version: 1,
261
+ name,
262
+ type: 'mesh',
263
+ interpolation: 'bilinear-oklch',
264
+ cols: mesh.cols,
265
+ rows: mesh.rows,
266
+ stops: mesh.stops.map((s) => {
267
+ const stop = {
268
+ x: round3(s.x),
269
+ y: round3(s.y),
270
+ color: { l: round3(s.l), c: round3(s.c), h: round1(s.h) },
271
+ hex: toHex(s),
272
+ };
273
+ if (s.a !== undefined && s.a < 1) {
274
+ stop.color.a = round3(s.a);
275
+ stop.alpha = round3(s.a);
276
+ }
277
+ return stop;
278
+ }),
279
+ }, null, 2);
280
+ }
281
+
282
+ /* ── helpers ─────────────────────────────────────────────────────── */
283
+
284
+ function posToPct(pos) {
285
+ // Numeric format first: "30% 70%" or "30 70" — used by Gradient
286
+ // Studio v0.0.17+. Position strings reach the SVG emitter via the
287
+ // app's `${cx}% ${cy}%` formatter, not as keywords.
288
+ const m = /^\s*(-?[\d.]+)\s*%?\s+(-?[\d.]+)\s*%?\s*$/.exec(pos);
289
+ if (m) {
290
+ return { cx: parseFloat(m[1]), cy: parseFloat(m[2]) };
291
+ }
292
+ // Keyword fallback for older callers / hand-written input.
293
+ let cx = 50, cy = 50;
294
+ if (pos.includes('top')) cy = 0;
295
+ if (pos.includes('bottom')) cy = 100;
296
+ if (pos.includes('left')) cx = 0;
297
+ if (pos.includes('right')) cx = 100;
298
+ return { cx, cy };
299
+ }
300
+
301
+ function slugify(s) {
302
+ return String(s)
303
+ .toLowerCase()
304
+ .replace(/[^a-z0-9-]+/g, '-')
305
+ .replace(/^-+|-+$/g, '')
306
+ || 'gradient';
307
+ }
308
+
309
+ function round1(v) { return Math.round(v * 10) / 10; }
310
+ function round3(v) { return Math.round(v * 1000) / 1000; }