gradient-lab 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,316 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { clamp, uid, round, rgbToHex, sortStops, freshStops } from "utils";
3
+
4
+ class ImageSamplingStage extends GradientElement {
5
+ constructor() {
6
+ super();
7
+ this.drag = null;
8
+ this.previewLine = null;
9
+ this.size = { w: 1, h: 1 };
10
+ }
11
+
12
+ mount() {
13
+ this.innerHTML = `
14
+ <div class="stage-frame">
15
+ <div class="drop-hint">
16
+ <div>
17
+ <div class="display-icon mb-3"><i class="bi bi-cloud-arrow-up"></i></div>
18
+ <h2 class="h5 mb-2">Drop an image here</h2>
19
+ <p class="text-lux-muted mb-3">Try a blade of grass, a sunset, brushed metal, a car finish, or any photograph with a natural color transition.</p>
20
+ <button class="btn btn-soft rounded-pill px-3 choose-image" type="button">Choose image</button>
21
+ </div>
22
+ </div>
23
+ <div class="canvas-wrap">
24
+ <canvas class="image-canvas"></canvas>
25
+ <svg class="overlay-svg" xmlns="http://www.w3.org/2000/svg" aria-label="Gradient sampling overlay"></svg>
26
+ </div>
27
+ </div>
28
+ `;
29
+
30
+ this.refs = {
31
+ frame: this.querySelector(".stage-frame"),
32
+ hint: this.querySelector(".drop-hint"),
33
+ choose: this.querySelector(".choose-image"),
34
+ wrap: this.querySelector(".canvas-wrap"),
35
+ canvas: this.querySelector(".image-canvas"),
36
+ overlay: this.querySelector(".overlay-svg"),
37
+ };
38
+
39
+ this.concern.on(this.refs.choose, "click", () => this.app.openFileDialog());
40
+ this.concern.on(this.refs.frame, "dragover", event => {
41
+ event.preventDefault();
42
+ this.refs.frame.classList.add("border-warning");
43
+ });
44
+ this.concern.on(this.refs.frame, "dragleave", () => this.refs.frame.classList.remove("border-warning"));
45
+ this.concern.on(this.refs.frame, "drop", event => {
46
+ event.preventDefault();
47
+ this.refs.frame.classList.remove("border-warning");
48
+ const [file] = event.dataTransfer?.files ?? [];
49
+ if (file && file.type.startsWith("image/")) this.app.loadFile(file);
50
+ });
51
+
52
+ this.concern.on(this.refs.overlay, "pointerdown", event => this.onPointerDown(event));
53
+ this.concern.on(this.refs.overlay, "pointermove", event => this.onPointerMove(event));
54
+ this.concern.on(this.refs.overlay, "pointerup", event => this.onPointerUp(event));
55
+ this.concern.on(this.refs.overlay, "pointercancel", event => this.onPointerUp(event));
56
+ this.concern.on(window, "resize", () => this.render(this.state));
57
+ this.watchState(state => this.render(state));
58
+ }
59
+
60
+ render(state) {
61
+ this.refs.hint.classList.toggle("hidden", Boolean(state.image));
62
+ this.drawImageToCanvas(state);
63
+ this.renderOverlay(state);
64
+ }
65
+
66
+ drawImageToCanvas(state) {
67
+ const { image } = state;
68
+ const { frame, canvas, wrap, overlay } = this.refs;
69
+ if (!image) {
70
+ canvas.width = 1;
71
+ canvas.height = 1;
72
+ canvas.style.width = "1px";
73
+ canvas.style.height = "1px";
74
+ overlay.style.width = "1px";
75
+ overlay.style.height = "1px";
76
+ wrap.style.width = "1px";
77
+ wrap.style.height = "1px";
78
+ overlay.setAttribute("viewBox", "0 0 1 1");
79
+ this.size = { w: 1, h: 1 };
80
+ return;
81
+ }
82
+
83
+ const frameWidth = Math.max(280, frame.clientWidth - 28);
84
+ const maxHeight = Math.min(620, Math.max(360, Math.round(window.innerHeight * .62)));
85
+ const aspect = image.naturalWidth / image.naturalHeight;
86
+ let width = Math.min(frameWidth, image.naturalWidth || frameWidth);
87
+ let height = width / aspect;
88
+
89
+ if (height > maxHeight) {
90
+ height = maxHeight;
91
+ width = height * aspect;
92
+ }
93
+
94
+ width = Math.max(240, Math.round(width));
95
+ height = Math.max(180, Math.round(height));
96
+
97
+ if (canvas.width !== width || canvas.height !== height) {
98
+ canvas.width = width;
99
+ canvas.height = height;
100
+ canvas.style.width = `${width}px`;
101
+ canvas.style.height = `${height}px`;
102
+ overlay.style.width = `${width}px`;
103
+ overlay.style.height = `${height}px`;
104
+ wrap.style.width = `${width}px`;
105
+ wrap.style.height = `${height}px`;
106
+ overlay.setAttribute("viewBox", `0 0 ${width} ${height}`);
107
+ }
108
+
109
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
110
+ ctx.clearRect(0, 0, width, height);
111
+ ctx.drawImage(image, 0, 0, width, height);
112
+ this.size = { w: width, h: height };
113
+ }
114
+
115
+ renderOverlay(state) {
116
+ const defs = [];
117
+ const layers = [];
118
+ for (const line of state.lines) {
119
+ const start = this.toView(line.start);
120
+ const end = this.toView(line.end);
121
+ const selected = line.id === state.selectedLineId;
122
+ const gradientId = `stroke-${line.id}`;
123
+ defs.push(`
124
+ <linearGradient id="${gradientId}" gradientUnits="userSpaceOnUse" x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}">
125
+ ${sortStops(line.stops.map(stop => ({ ...stop }))).map(stop => `<stop offset="${stop.pos}%" stop-color="${stop.color}"></stop>`).join("")}
126
+ </linearGradient>
127
+ `);
128
+ layers.push(`
129
+ <g>
130
+ <line data-role="line" data-line-id="${line.id}" x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="transparent" stroke-width="28" stroke-linecap="round"></line>
131
+ <line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="rgba(0,0,0,.58)" stroke-width="10" stroke-linecap="round" pointer-events="none"></line>
132
+ <line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="url(#${gradientId})" stroke-width="6" stroke-linecap="round" pointer-events="none"></line>
133
+ ${line.stops.map((stop, index) => {
134
+ const point = this.stopToView(line, stop.pos);
135
+ if (index === 0 || index === line.stops.length - 1) return "";
136
+ const stopSelected = selected && state.selectedStopId === stop.id;
137
+ return `<circle data-role="sample" data-line-id="${line.id}" data-stop-id="${stop.id}" cx="${point.x}" cy="${point.y}" r="${stopSelected ? 7 : 5}" fill="${stop.color}" stroke="${stopSelected ? "#ffc774" : "rgba(255,255,255,.88)"}" stroke-width="${stopSelected ? 3 : 1.5}"></circle>`;
138
+ }).join("")}
139
+ ${this.endpointCircle(line, "start", start, selected && state.selectedEndpoint === "start")}
140
+ ${this.endpointCircle(line, "end", end, selected && state.selectedEndpoint === "end")}
141
+ </g>
142
+ `);
143
+ }
144
+
145
+ if (this.previewLine) {
146
+ const start = this.toView(this.previewLine.start);
147
+ const end = this.toView(this.previewLine.end);
148
+ layers.push(`<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="#ffc774" stroke-width="4" stroke-dasharray="8 8" stroke-linecap="round"></line>`);
149
+ }
150
+
151
+ this.refs.overlay.innerHTML = `
152
+ <defs>${defs.join("")}</defs>
153
+ <rect width="${this.size.w}" height="${this.size.h}" fill="transparent"></rect>
154
+ ${layers.join("")}
155
+ `;
156
+ }
157
+
158
+ endpointCircle(line, endpoint, point, selected) {
159
+ const color = endpoint === "start" ? line.stops[0]?.color : line.stops.at(-1)?.color;
160
+ return `
161
+ <circle data-role="endpoint" data-line-id="${line.id}" data-endpoint="${endpoint}" cx="${point.x}" cy="${point.y}" r="${selected ? 13 : 10}" fill="${selected ? "#ffc774" : color}" stroke="${selected ? "#ffffff" : "rgba(255,255,255,.9)"}" stroke-width="3"></circle>
162
+ <circle cx="${point.x}" cy="${point.y}" r="3" fill="rgba(0,0,0,.72)" pointer-events="none"></circle>
163
+ `;
164
+ }
165
+
166
+ onPointerDown(event) {
167
+ if (!this.state.image) {
168
+ this.app.openFileDialog();
169
+ return;
170
+ }
171
+
172
+ const target = event.target;
173
+ const role = target.dataset?.role;
174
+ const point = this.eventToNorm(event);
175
+
176
+ if (role === "endpoint") {
177
+ this.drag = { type: "endpoint", lineId: target.dataset.lineId, endpoint: target.dataset.endpoint, pointerId: event.pointerId };
178
+ this.refs.overlay.setPointerCapture(event.pointerId);
179
+ this.app.selectLine(target.dataset.lineId, target.dataset.endpoint, null);
180
+ return;
181
+ }
182
+
183
+ if (role === "sample") {
184
+ this.drag = { type: "sample", lineId: target.dataset.lineId, stopId: target.dataset.stopId, pointerId: event.pointerId };
185
+ this.refs.overlay.setPointerCapture(event.pointerId);
186
+ this.app.selectLine(target.dataset.lineId, null, target.dataset.stopId);
187
+ return;
188
+ }
189
+
190
+ if (role === "line") {
191
+ this.app.selectLine(target.dataset.lineId, null, null);
192
+ return;
193
+ }
194
+
195
+ this.drag = { type: "draw", start: point, current: point, pointerId: event.pointerId };
196
+ this.previewLine = { start: point, end: point };
197
+ this.refs.overlay.setPointerCapture(event.pointerId);
198
+ this.renderOverlay(this.state);
199
+ }
200
+
201
+ onPointerMove(event) {
202
+ if (!this.drag) return;
203
+ const point = this.eventToNorm(event);
204
+
205
+ if (this.drag.type === "draw") {
206
+ this.drag.current = point;
207
+ this.previewLine = { start: this.drag.start, end: point };
208
+ this.renderOverlay(this.state);
209
+ return;
210
+ }
211
+
212
+ if (this.drag.type === "endpoint") {
213
+ this.commit(draft => {
214
+ const line = draft.lines.find(item => item.id === this.drag.lineId);
215
+ if (!line) return;
216
+ line[this.drag.endpoint] = point;
217
+ this.resampleLine(line);
218
+ });
219
+ return;
220
+ }
221
+
222
+ if (this.drag.type === "sample") {
223
+ this.commit(draft => {
224
+ const line = draft.lines.find(item => item.id === this.drag.lineId);
225
+ const stop = line?.stops.find(item => item.id === this.drag.stopId);
226
+ if (!line || !stop) return;
227
+ stop.pos = this.projectPointToLinePercent(point, line);
228
+ sortStops(line.stops);
229
+ draft.selectedStopId = stop.id;
230
+ this.resampleLine(line);
231
+ });
232
+ }
233
+ }
234
+
235
+ onPointerUp(event) {
236
+ if (!this.drag) return;
237
+ if (this.drag.type === "draw") {
238
+ const start = this.drag.start;
239
+ const end = this.drag.current ?? this.eventToNorm(event);
240
+ const dx = (end.x - start.x) * this.size.w;
241
+ const dy = (end.y - start.y) * this.size.h;
242
+ const distance = Math.hypot(dx, dy);
243
+ if (distance > 12) {
244
+ this.commit(draft => {
245
+ const line = {
246
+ id: uid("line"),
247
+ name: `Gradient ${draft.lines.length + 1}`,
248
+ direction: draft.direction,
249
+ start,
250
+ end,
251
+ stops: freshStops(draft.sampleCount),
252
+ };
253
+ this.resampleLine(line);
254
+ draft.lines.push(line);
255
+ draft.selectedLineId = line.id;
256
+ draft.selectedEndpoint = "end";
257
+ draft.selectedStopId = line.stops.at(-1)?.id ?? null;
258
+ });
259
+ }
260
+ }
261
+ this.previewLine = null;
262
+ this.drag = null;
263
+ try { this.refs.overlay.releasePointerCapture(event.pointerId); } catch {}
264
+ this.renderOverlay(this.state);
265
+ }
266
+
267
+ eventToNorm(event) {
268
+ const rect = this.refs.overlay.getBoundingClientRect();
269
+ return {
270
+ x: clamp((event.clientX - rect.left) / rect.width, 0, 1),
271
+ y: clamp((event.clientY - rect.top) / rect.height, 0, 1),
272
+ };
273
+ }
274
+
275
+ toView(point) {
276
+ return { x: point.x * this.size.w, y: point.y * this.size.h };
277
+ }
278
+
279
+ stopToView(line, percent) {
280
+ const t = percent / 100;
281
+ return {
282
+ x: (line.start.x + (line.end.x - line.start.x) * t) * this.size.w,
283
+ y: (line.start.y + (line.end.y - line.start.y) * t) * this.size.h,
284
+ };
285
+ }
286
+
287
+ projectPointToLinePercent(point, line) {
288
+ const p = this.toView(point);
289
+ const a = this.toView(line.start);
290
+ const b = this.toView(line.end);
291
+ const dx = b.x - a.x;
292
+ const dy = b.y - a.y;
293
+ const lengthSq = dx * dx + dy * dy || 1;
294
+ const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq;
295
+ return round(clamp(t, 0, 1) * 100, 1);
296
+ }
297
+
298
+ resampleLine(line) {
299
+ for (const stop of line.stops) {
300
+ const point = this.stopToView(line, stop.pos);
301
+ stop.color = this.sampleCanvas(point.x, point.y);
302
+ }
303
+ }
304
+
305
+ sampleCanvas(x, y) {
306
+ const { canvas } = this.refs;
307
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
308
+ const px = Math.round(clamp(x, 0, canvas.width - 1));
309
+ const py = Math.round(clamp(y, 0, canvas.height - 1));
310
+ const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
311
+ return rgbToHex({ r, g, b });
312
+ }
313
+ }
314
+
315
+ customElements.define("image-sampling-stage", ImageSamplingStage);
316
+ export { ImageSamplingStage };
@@ -0,0 +1,24 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { libraryCss } from "utils";
3
+
4
+ class LibraryCode extends GradientElement {
5
+ mount() {
6
+ this.innerHTML = `
7
+ <div class="collapse" id="libraryCodeCollapse">
8
+ <textarea class="form-control codebox library-css" readonly></textarea>
9
+ </div>
10
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-bs-toggle="collapse" data-bs-target="#libraryCodeCollapse" aria-expanded="false" aria-controls="libraryCodeCollapse">
11
+ <i class="bi bi-code-square me-1"></i> Show library code
12
+ </button>
13
+ `;
14
+ this.refs = { textarea: this.querySelector(".library-css") };
15
+ this.watchState(state => this.render(state));
16
+ }
17
+
18
+ render(state) {
19
+ this.refs.textarea.value = libraryCss(state.library, state.prefix) || "/* Add gradients to generate .gr-1, .gr-2, .gr-3... classes. */";
20
+ }
21
+ }
22
+
23
+ customElements.define("library-code", LibraryCode);
24
+ export { LibraryCode };
@@ -0,0 +1,45 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { libraryCss } from "utils";
3
+
4
+ class LibraryControls extends GradientElement {
5
+ mount() {
6
+ this.innerHTML = `
7
+ <div class="row g-3 align-items-end mb-3">
8
+ <div class="col-md-3">
9
+ <label class="form-label">Class prefix</label>
10
+ <input class="form-control prefix-input" value="gr-" spellcheck="false">
11
+ </div>
12
+ <div class="col-md-5">
13
+ <label class="form-label">Permalink domain</label>
14
+ <input class="form-control domain-input" spellcheck="false">
15
+ </div>
16
+ <div class="col-md-auto">
17
+ <button class="btn btn-lux rounded-pill copy-library" type="button">
18
+ <i class="bi bi-clipboard me-1"></i> Copy library CSS
19
+ </button>
20
+ </div>
21
+ </div>
22
+ `;
23
+ this.refs = {
24
+ prefix: this.querySelector(".prefix-input"),
25
+ domain: this.querySelector(".domain-input"),
26
+ copy: this.querySelector(".copy-library"),
27
+ };
28
+ this.concern.on(this.refs.prefix, "input", event => this.commit(draft => { draft.prefix = event.target.value || "gr-"; }));
29
+ this.concern.on(this.refs.domain, "input", event => this.commit(draft => { draft.domain = event.target.value || "gradientlab.local"; }));
30
+ this.concern.on(this.refs.copy, "click", () => {
31
+ const css = libraryCss(this.state.library, this.state.prefix);
32
+ if (!css) return this.app.toast("Library is empty.");
33
+ this.app.copyText(css, "Copied library CSS.");
34
+ });
35
+ this.watchState(state => this.render(state));
36
+ }
37
+
38
+ render(state) {
39
+ this.refs.prefix.value = state.prefix;
40
+ this.refs.domain.value = state.domain;
41
+ }
42
+ }
43
+
44
+ customElements.define("library-controls", LibraryControls);
45
+ export { LibraryControls };
@@ -0,0 +1,31 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { escapeHtml } from "utils";
3
+
4
+ class LuxCard extends GradientElement {
5
+ static observedAttributes = ["title", "icon", "subtitle"];
6
+
7
+ mount() {
8
+ const children = [...this.childNodes];
9
+ const title = this.getAttribute("title") ?? "Card";
10
+ const icon = this.getAttribute("icon") ?? "bi-stars";
11
+ const subtitle = this.getAttribute("subtitle") ?? "";
12
+ this.innerHTML = `
13
+ <div class="card lux-card h-100">
14
+ <div class="card-header d-flex align-items-start justify-content-between gap-3 px-4 py-3">
15
+ <div>
16
+ <div class="d-flex align-items-center gap-2 fw-semibold">
17
+ <i class="bi ${icon} text-lux-gold"></i>
18
+ <span>${escapeHtml(title)}</span>
19
+ </div>
20
+ ${subtitle ? `<div class="small text-lux-muted mt-1">${escapeHtml(subtitle)}</div>` : ""}
21
+ </div>
22
+ </div>
23
+ <div class="card-body p-4"></div>
24
+ </div>
25
+ `;
26
+ this.querySelector(".card-body").append(...children);
27
+ }
28
+ }
29
+
30
+ customElements.define("lux-card", LuxCard);
31
+ export { LuxCard };
@@ -0,0 +1,63 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { escapeHtml, gradientCss, cssBlock } from "utils";
3
+
4
+ class SampledGradientList extends GradientElement {
5
+ mount() {
6
+ this.innerHTML = `
7
+ <div class="d-flex justify-content-between align-items-center mt-4 mb-2">
8
+ <h3 class="h6 mb-0"><i class="bi bi-layers me-1 text-lux-cyan"></i> Sampled gradients</h3>
9
+ <span class="small text-lux-muted">Each drawn line creates one editable gradient.</span>
10
+ </div>
11
+ <div class="gradient-stack"></div>
12
+ `;
13
+ this.refs = { stack: this.querySelector(".gradient-stack") };
14
+ this.concern.on(this, "click", event => this.onClick(event));
15
+ this.watchState(state => this.render(state));
16
+ }
17
+
18
+ render(state) {
19
+ if (!state.lines.length) {
20
+ this.refs.stack.innerHTML = `<div class="empty-state"><i class="bi bi-pencil me-1"></i> Upload an image, then drag across it to create the first sampled gradient line.</div>`;
21
+ return;
22
+ }
23
+ this.refs.stack.innerHTML = state.lines.map((line, index) => {
24
+ const css = gradientCss(line.stops, line.direction);
25
+ const selected = line.id === state.selectedLineId;
26
+ return `
27
+ <article class="gradient-chip ${selected ? "selected" : ""}" data-line-id="${line.id}">
28
+ <div class="p-3">
29
+ <div class="preview mb-2" style="background: ${css}"></div>
30
+ <div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
31
+ <button class="btn btn-link p-0 text-decoration-none text-start ${selected ? "text-lux-gold" : "text-light"}" type="button" data-action="select-line" data-line-id="${line.id}">
32
+ <strong>${escapeHtml(line.name ?? `Gradient ${index + 1}`)}</strong>
33
+ <span class="small text-lux-muted ms-2">${line.stops.length} stops · ${line.direction}</span>
34
+ </button>
35
+ <div class="d-flex gap-2">
36
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="copy-line" data-line-id="${line.id}"><i class="bi bi-clipboard"></i></button>
37
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="add-line" data-line-id="${line.id}"><i class="bi bi-plus-lg"></i></button>
38
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="delete-line" data-line-id="${line.id}"><i class="bi bi-trash3"></i></button>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </article>
43
+ `;
44
+ }).join("");
45
+ }
46
+
47
+ onClick(event) {
48
+ const button = event.target.closest("[data-action]");
49
+ if (!button) return;
50
+ const action = button.dataset.action;
51
+ const lineId = button.dataset.lineId;
52
+ if (action === "select-line") this.app.selectLine(lineId, null, null);
53
+ if (action === "copy-line") {
54
+ const line = this.state.lines.find(item => item.id === lineId);
55
+ if (line) this.app.copyText(cssBlock(line, this.state.domain), "Copied gradient CSS.");
56
+ }
57
+ if (action === "add-line") this.app.addLineToLibrary(lineId);
58
+ if (action === "delete-line") this.app.deleteLine(lineId);
59
+ }
60
+ }
61
+
62
+ customElements.define("sampled-gradient-list", SampledGradientList);
63
+ export { SampledGradientList };
@@ -0,0 +1,100 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { normalizeAngle, directionToAngle, freshStops } from "utils";
3
+
4
+ class SamplerToolbar extends GradientElement {
5
+ mount() {
6
+ this.innerHTML = `
7
+ <div class="row g-3 align-items-end mb-3">
8
+ <div class="col-md-auto">
9
+ <label class="btn btn-lux rounded-pill px-3 mb-0">
10
+ <i class="bi bi-image me-1"></i>
11
+ Upload image
12
+ <input class="visually-hidden file-input" type="file" accept="image/*">
13
+ </label>
14
+ </div>
15
+ <div class="col-md">
16
+ <label class="form-label d-flex justify-content-between mb-1">
17
+ <span>Sample resolution</span>
18
+ <strong class="sample-count-label text-lux-gold">5 stops</strong>
19
+ </label>
20
+ <input class="form-range sample-count" type="range" min="2" max="16" step="1" value="5">
21
+ </div>
22
+ <div class="col-md-4 col-lg-3">
23
+ <label class="form-label mb-1">Gradient angle</label>
24
+ <div class="input-group input-group-sm mb-2">
25
+ <input class="form-control angle-input" type="number" min="0" max="359" step="1" value="90">
26
+ <span class="input-group-text">deg</span>
27
+ </div>
28
+ <input class="form-range angle-range" type="range" min="0" max="359" step="1" value="90">
29
+ </div>
30
+ <div class="col-md-auto">
31
+ <button class="btn btn-soft rounded-pill clear-lines" type="button">
32
+ <i class="bi bi-trash3 me-1"></i> Clear lines
33
+ </button>
34
+ </div>
35
+ </div>
36
+ `;
37
+
38
+ this.refs = {
39
+ file: this.querySelector(".file-input"),
40
+ sample: this.querySelector(".sample-count"),
41
+ sampleLabel: this.querySelector(".sample-count-label"),
42
+ angleInput: this.querySelector(".angle-input"),
43
+ angleRange: this.querySelector(".angle-range"),
44
+ clear: this.querySelector(".clear-lines"),
45
+ };
46
+
47
+ this.concern.on(this.refs.file, "change", event => {
48
+ const [file] = event.target.files ?? [];
49
+ if (file) this.app.loadFile(file);
50
+ });
51
+
52
+ this.concern.on(this.refs.sample, "input", event => {
53
+ const value = Number(event.target.value);
54
+ this.commit(draft => {
55
+ draft.sampleCount = value;
56
+ const selected = this.app.selectedLine(draft);
57
+ if (!selected) return;
58
+ selected.stops = freshStops(value);
59
+ draft.selectedStopId = selected.stops[0]?.id ?? null;
60
+ this.app.resampleLine(selected);
61
+ });
62
+ });
63
+
64
+ const setGradientAngle = event => {
65
+ const angle = normalizeAngle(event.target.value);
66
+ this.commit(draft => {
67
+ draft.direction = `${angle}deg`;
68
+ const selected = this.app.selectedLine(draft);
69
+ if (selected) selected.direction = draft.direction;
70
+ });
71
+ };
72
+
73
+ this.concern.on(this.refs.angleInput, "input", setGradientAngle);
74
+ this.concern.on(this.refs.angleRange, "input", setGradientAngle);
75
+
76
+ this.concern.on(this.refs.clear, "click", () => {
77
+ this.commit(draft => {
78
+ draft.lines = [];
79
+ draft.selectedLineId = null;
80
+ draft.selectedEndpoint = null;
81
+ draft.selectedStopId = null;
82
+ });
83
+ });
84
+
85
+ this.watchState(state => this.render(state));
86
+ }
87
+
88
+ openFileDialog() { this.refs.file.click(); }
89
+
90
+ render(state) {
91
+ const angle = directionToAngle(state.direction);
92
+ this.refs.sample.value = state.sampleCount;
93
+ this.refs.sampleLabel.textContent = `${state.sampleCount} stop${state.sampleCount === 1 ? "" : "s"}`;
94
+ this.refs.angleInput.value = angle;
95
+ this.refs.angleRange.value = angle;
96
+ }
97
+ }
98
+
99
+ customElements.define("sampler-toolbar", SamplerToolbar);
100
+ export { SamplerToolbar };
@@ -0,0 +1,19 @@
1
+ import { GradientElement } from "gradient-element";
2
+
3
+ class ToastZone extends GradientElement {
4
+ mount() {
5
+ this.innerHTML = `<div class="toast-lite"><i class="bi bi-check2-circle me-2 text-lux-gold"></i><span></span></div>`;
6
+ this.refs = { toast: this.querySelector(".toast-lite"), message: this.querySelector("span") };
7
+ this.timer = null;
8
+ }
9
+
10
+ show(message) {
11
+ this.refs.message.textContent = message;
12
+ this.refs.toast.classList.add("show");
13
+ clearTimeout(this.timer);
14
+ this.timer = setTimeout(() => this.refs.toast.classList.remove("show"), 1800);
15
+ }
16
+ }
17
+
18
+ customElements.define("toast-zone", ToastZone);
19
+ export { ToastZone };
package/index.html ADDED
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <!--
3
+ Image Line Gradient Sampler
4
+ The visible body is intentionally a readable application tree of web components.
5
+ Every custom element extends ReactiveHTMLElement via GradientElement.
6
+ -->
7
+ <html lang="en" data-bs-theme="dark">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Image Line Gradient Sampler</title>
12
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
13
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
14
+ <link rel="stylesheet" href="./app.css">
15
+
16
+ <script type="importmap">
17
+ {
18
+ "imports": {
19
+ "reactive-framework": "./reactive-framework.js",
20
+ "utils": "./utils.js",
21
+ "gradient-element": "./components/gradient-element.js"
22
+ }
23
+ }
24
+ </script>
25
+ </head>
26
+ <body>
27
+ <gradient-lab-app>
28
+ <app-shell>
29
+ <app-hero></app-hero>
30
+
31
+ <gradient-workbench>
32
+ <lux-card title="Image sampler" icon="bi-bounding-box-circles" subtitle="Draw on the image. Drag endpoint handles or tiny sample dots to reposition the sampling path.">
33
+ <sampler-toolbar></sampler-toolbar>
34
+ <image-sampling-stage></image-sampling-stage>
35
+ <sampled-gradient-list></sampled-gradient-list>
36
+ </lux-card>
37
+
38
+ <lux-card title="Gradient editor" icon="bi-sliders2" subtitle="Adjust stops, angles, and colors before adding the gradient to the class library.">
39
+ <gradient-editor></gradient-editor>
40
+ </lux-card>
41
+ </gradient-workbench>
42
+
43
+ <lux-card title="Gradient library" icon="bi-collection" subtitle="Curate chosen gradients into copyable class names.">
44
+ <library-controls></library-controls>
45
+ <gradient-library></gradient-library>
46
+ <library-code></library-code>
47
+ </lux-card>
48
+
49
+ <toast-zone></toast-zone>
50
+ </app-shell>
51
+ </gradient-lab-app>
52
+
53
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
54
+ <script type="module" src="./app.js"></script>
55
+ </body>
56
+ </html>