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,224 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { clamp, uid, round, directionToAngle, escapeHtml, sortStops, gradientCss, cssBlock } from "utils";
3
+
4
+ class GradientEditor extends GradientElement {
5
+ constructor() {
6
+ super();
7
+ this.editorDrag = null;
8
+ }
9
+
10
+ mount() {
11
+ this.concern.on(this, "pointerdown", event => this.onPointerDown(event));
12
+ this.concern.on(window, "pointermove", event => this.onPointerMove(event));
13
+ this.concern.on(window, "pointerup", () => { this.editorDrag = null; });
14
+ this.concern.on(this, "dblclick", event => this.onDoubleClick(event));
15
+ this.concern.on(this, "click", event => this.onClick(event));
16
+ this.concern.on(this, "input", event => this.onInput(event));
17
+ this.concern.on(this, "change", event => this.onInput(event));
18
+ this.concern.on(window, "keydown", event => this.onKeyDown(event));
19
+ this.watchState(state => this.render(state));
20
+ }
21
+
22
+ render(state) {
23
+ const line = this.app.selectedLine(state);
24
+ if (!line) {
25
+ this.innerHTML = `
26
+ <div class="empty-state">
27
+ <div class="h5 mb-2"><i class="bi bi-cursor me-1"></i> No gradient selected</div>
28
+ <p class="mb-0">Draw a line over the image or select a sampled gradient. The editor will show a large preview, a draggable stop bar, individual stop controls, and copyable CSS.</p>
29
+ </div>
30
+ `;
31
+ return;
32
+ }
33
+
34
+ const css = gradientCss(line.stops, line.direction);
35
+ const block = cssBlock(line, state.domain);
36
+ this.innerHTML = `
37
+ <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
38
+ <div>
39
+ <div class="fw-semibold">${escapeHtml(line.name)}</div>
40
+ <div class="small text-lux-muted">${line.stops.length} stops · ${line.direction}</div>
41
+ </div>
42
+ <span class="badge rounded-pill text-bg-dark border">${directionToAngle(line.direction)}°</span>
43
+ </div>
44
+
45
+ <div class="gradient-display mb-3" style="background: ${css}"></div>
46
+
47
+ <div class="d-flex justify-content-between align-items-center mb-2">
48
+ <label class="form-label mb-0">Stop bar</label>
49
+ <span class="small text-lux-muted">Double-click to add a stop.</span>
50
+ </div>
51
+ <div class="gradient-track mb-3" data-role="editor-track" data-line-id="${line.id}" style="background: ${css}">
52
+ ${line.stops.map(stop => `
53
+ <button class="editor-stop ${stop.id === state.selectedStopId ? "selected" : ""}" type="button" aria-label="Gradient stop ${round(stop.pos, 1)}%" data-role="editor-stop" data-line-id="${line.id}" data-stop-id="${stop.id}" style="left: ${stop.pos}%; background: ${stop.color}"></button>
54
+ `).join("")}
55
+ </div>
56
+
57
+ <div class="d-flex gap-2 mb-3">
58
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="delete-stop" ${line.stops.length <= 2 || !state.selectedStopId ? "disabled" : ""}>
59
+ <i class="bi bi-x-lg me-1"></i> Delete selected stop
60
+ </button>
61
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="resample-selected" ${!state.image ? "disabled" : ""}>
62
+ <i class="bi bi-arrow-clockwise me-1"></i> Resample
63
+ </button>
64
+ </div>
65
+
66
+ <div class="vstack gap-2 mb-3">
67
+ ${line.stops.map((stop, index) => `
68
+ <div class="stop-row ${stop.id === state.selectedStopId ? "selected" : ""}" data-stop-id="${stop.id}">
69
+ <div class="row g-2 align-items-center">
70
+ <div class="col-auto"><span class="small text-lux-muted">${index + 1}</span></div>
71
+ <div class="col-auto"><input class="form-control form-control-color" type="color" value="${stop.color}" data-action="stop-color" data-line-id="${line.id}" data-stop-id="${stop.id}" title="Choose color"></div>
72
+ <div class="col"><input class="form-control form-control-sm" value="${stop.color}" data-action="stop-color-text" data-line-id="${line.id}" data-stop-id="${stop.id}" spellcheck="false"></div>
73
+ <div class="col-4"><div class="input-group input-group-sm"><input class="form-control" type="number" min="0" max="100" step="0.1" value="${round(stop.pos, 1)}" data-action="stop-pos" data-line-id="${line.id}" data-stop-id="${stop.id}"><span class="input-group-text">%</span></div></div>
74
+ </div>
75
+ </div>
76
+ `).join("")}
77
+ </div>
78
+
79
+ <div class="d-grid gap-2 mb-3">
80
+ <button class="btn btn-lux rounded-pill" type="button" data-action="add-selected-library"><i class="bi bi-plus-circle me-1"></i> Add to library</button>
81
+ <button class="btn btn-soft rounded-pill" type="button" data-action="copy-selected"><i class="bi bi-clipboard me-1"></i> Copy CSS</button>
82
+ <button class="btn btn-soft rounded-pill" type="button" data-action="delete-selected-line"><i class="bi bi-trash3 me-1"></i> Delete sampled line</button>
83
+ </div>
84
+
85
+ <button class="btn btn-soft btn-sm rounded-pill mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#selectedCodeCollapse" aria-expanded="false" aria-controls="selectedCodeCollapse">
86
+ <i class="bi bi-code-square me-1"></i> Show CSS
87
+ </button>
88
+ <div class="collapse" id="selectedCodeCollapse">
89
+ <textarea class="form-control codebox" readonly>${escapeHtml(block)}</textarea>
90
+ </div>
91
+ `;
92
+ }
93
+
94
+ onPointerDown(event) {
95
+ const stopButton = event.target.closest("[data-role='editor-stop']");
96
+ if (!stopButton) return;
97
+ event.preventDefault();
98
+ this.editorDrag = { lineId: stopButton.dataset.lineId, stopId: stopButton.dataset.stopId };
99
+ this.app.selectLine(stopButton.dataset.lineId, null, stopButton.dataset.stopId);
100
+ }
101
+
102
+ onPointerMove(event) {
103
+ if (!this.editorDrag) return;
104
+ const track = this.querySelector(`[data-role='editor-track'][data-line-id='${this.editorDrag.lineId}']`);
105
+ if (!track) return;
106
+ const rect = track.getBoundingClientRect();
107
+ const percent = round(clamp((event.clientX - rect.left) / rect.width, 0, 1) * 100, 1);
108
+ this.commit(draft => {
109
+ const line = draft.lines.find(item => item.id === this.editorDrag.lineId);
110
+ const stop = line?.stops.find(item => item.id === this.editorDrag.stopId);
111
+ if (!line || !stop) return;
112
+ stop.pos = percent;
113
+ sortStops(line.stops);
114
+ this.app.resampleLine(line);
115
+ draft.selectedStopId = stop.id;
116
+ });
117
+ }
118
+
119
+ onDoubleClick(event) {
120
+ const track = event.target.closest("[data-role='editor-track']");
121
+ if (!track) return;
122
+ const rect = track.getBoundingClientRect();
123
+ const percent = round(clamp((event.clientX - rect.left) / rect.width, 0, 1) * 100, 1);
124
+ const lineId = track.dataset.lineId;
125
+ this.commit(draft => {
126
+ const line = draft.lines.find(item => item.id === lineId);
127
+ if (!line) return;
128
+ const point = this.app.stage().stopToView(line, percent);
129
+ const stop = {
130
+ id: uid("stop"),
131
+ pos: percent,
132
+ color: this.app.sampleCanvas(point.x, point.y),
133
+ };
134
+ line.stops.push(stop);
135
+ sortStops(line.stops);
136
+ draft.selectedStopId = stop.id;
137
+ });
138
+ }
139
+
140
+ onClick(event) {
141
+ const button = event.target.closest("[data-action]");
142
+ if (!button) return;
143
+ const action = button.dataset.action;
144
+ if (action === "delete-stop") this.deleteSelectedStop();
145
+ if (action === "resample-selected") this.resampleSelectedLine();
146
+ if (action === "add-selected-library") {
147
+ const line = this.app.selectedLine();
148
+ if (line) this.app.addLineToLibrary(line.id);
149
+ }
150
+ if (action === "copy-selected") {
151
+ const line = this.app.selectedLine();
152
+ if (line) this.app.copyText(cssBlock(line, this.state.domain), "Copied selected CSS.");
153
+ }
154
+ if (action === "delete-selected-line") {
155
+ const line = this.app.selectedLine();
156
+ if (line) this.app.deleteLine(line.id);
157
+ }
158
+ }
159
+
160
+ onInput(event) {
161
+ const action = event.target.dataset?.action;
162
+ if (!action) return;
163
+ if (action === "stop-color" || action === "stop-color-text") {
164
+ const value = event.target.value.trim();
165
+ if (!/^#[0-9a-fA-F]{6}$/.test(value)) return;
166
+ this.updateStop(event.target.dataset.lineId, event.target.dataset.stopId, stop => {
167
+ stop.color = value.toLowerCase();
168
+ });
169
+ }
170
+ if (action === "stop-pos") {
171
+ const value = round(clamp(Number(event.target.value), 0, 100), 1);
172
+ this.updateStop(event.target.dataset.lineId, event.target.dataset.stopId, stop => {
173
+ stop.pos = value;
174
+ }, { resample: true });
175
+ }
176
+ }
177
+
178
+ onKeyDown(event) {
179
+ const active = document.activeElement;
180
+ const typing = active && ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
181
+ if (typing) return;
182
+ if (event.key === "Delete" || event.key === "Backspace") {
183
+ const line = this.app.selectedLine();
184
+ if (line) this.app.deleteLine(line.id);
185
+ }
186
+ }
187
+
188
+ updateStop(lineId, stopId, fn, options = {}) {
189
+ this.commit(draft => {
190
+ const line = draft.lines.find(item => item.id === lineId);
191
+ const stop = line?.stops.find(item => item.id === stopId);
192
+ if (!line || !stop) return;
193
+ fn(stop, line);
194
+ sortStops(line.stops);
195
+ if (options.resample) this.app.resampleLine(line);
196
+ draft.selectedLineId = line.id;
197
+ draft.selectedStopId = stop.id;
198
+ draft.selectedEndpoint = null;
199
+ });
200
+ }
201
+
202
+ deleteSelectedStop() {
203
+ this.commit(draft => {
204
+ const line = this.app.selectedLine(draft);
205
+ if (!line || line.stops.length <= 2 || !draft.selectedStopId) return;
206
+ const index = line.stops.findIndex(stop => stop.id === draft.selectedStopId);
207
+ if (index < 0) return;
208
+ line.stops.splice(index, 1);
209
+ const next = line.stops[Math.min(index, line.stops.length - 1)] ?? line.stops[0];
210
+ draft.selectedStopId = next?.id ?? null;
211
+ });
212
+ }
213
+
214
+ resampleSelectedLine() {
215
+ this.commit(draft => {
216
+ const line = this.app.selectedLine(draft);
217
+ if (!line) return;
218
+ this.app.resampleLine(line);
219
+ });
220
+ }
221
+ }
222
+
223
+ customElements.define("gradient-editor", GradientEditor);
224
+ export { GradientEditor };
@@ -0,0 +1,8 @@
1
+ import { ReactiveHTMLElement } from "reactive-framework";
2
+
3
+ export class GradientElement extends ReactiveHTMLElement {
4
+ get app() { return this.closest("gradient-lab-app"); }
5
+ get state() { return this.app.state.value; }
6
+ commit(mutator) { this.app.commit(mutator); }
7
+ watchState(fn) { return this.subscribe(this.app.state, fn); }
8
+ }
@@ -0,0 +1,120 @@
1
+ import { ReactiveHTMLElement } from "reactive-framework";
2
+ import { uid, copyLine, copyLibraryItem, freshStops } from "utils";
3
+
4
+ class GradientLabApp extends ReactiveHTMLElement {
5
+ constructor() {
6
+ super();
7
+ this.state = this.signal("state", {
8
+ image: null,
9
+ imageName: "",
10
+ sampleCount: 5,
11
+ direction: "90deg",
12
+ domain: window.location.host || "gradientlab.local",
13
+ prefix: "gr-",
14
+ selectedLineId: null,
15
+ selectedEndpoint: null,
16
+ selectedStopId: null,
17
+ lines: [],
18
+ library: [],
19
+ });
20
+ }
21
+
22
+ mount() {
23
+ window.GradientLab = this;
24
+ }
25
+
26
+ commit(mutator) {
27
+ const current = this.state.value;
28
+ const draft = {
29
+ ...current,
30
+ lines: current.lines.map(copyLine),
31
+ library: current.library.map(copyLibraryItem),
32
+ };
33
+ mutator(draft);
34
+ this.state.value = draft;
35
+ }
36
+
37
+ selectedLine(state = this.state.value) {
38
+ return state.lines.find(line => line.id === state.selectedLineId) ?? null;
39
+ }
40
+
41
+ stage() { return this.querySelector("image-sampling-stage"); }
42
+ toolbar() { return this.querySelector("sampler-toolbar"); }
43
+ toastZone() { return this.querySelector("toast-zone"); }
44
+
45
+ openFileDialog() { this.toolbar()?.openFileDialog(); }
46
+ resampleLine(line) { this.stage()?.resampleLine(line); }
47
+ sampleCanvas(x, y) { return this.stage()?.sampleCanvas(x, y) ?? "#000000"; }
48
+
49
+ loadFile(file) {
50
+ const url = URL.createObjectURL(file);
51
+ const image = new Image();
52
+ image.onload = () => {
53
+ this.commit(draft => {
54
+ draft.image = image;
55
+ draft.imageName = file.name;
56
+ draft.lines = [];
57
+ draft.selectedLineId = null;
58
+ draft.selectedEndpoint = null;
59
+ draft.selectedStopId = null;
60
+ });
61
+ URL.revokeObjectURL(url);
62
+ this.toast(`Loaded ${file.name}`);
63
+ };
64
+ image.onerror = () => {
65
+ URL.revokeObjectURL(url);
66
+ this.toast("Could not load that image.");
67
+ };
68
+ image.src = url;
69
+ }
70
+
71
+ selectLine(lineId, endpoint = null, stopId = null) {
72
+ this.commit(draft => {
73
+ const line = draft.lines.find(item => item.id === lineId);
74
+ if (!line) return;
75
+ draft.selectedLineId = line.id;
76
+ draft.selectedEndpoint = endpoint;
77
+ draft.selectedStopId = stopId ?? line.stops[0]?.id ?? null;
78
+ draft.direction = line.direction;
79
+ });
80
+ }
81
+
82
+ deleteLine(lineId) {
83
+ this.commit(draft => {
84
+ const index = draft.lines.findIndex(line => line.id === lineId);
85
+ if (index < 0) return;
86
+ draft.lines.splice(index, 1);
87
+ const next = draft.lines[Math.min(index, draft.lines.length - 1)] ?? null;
88
+ draft.selectedLineId = next?.id ?? null;
89
+ draft.selectedEndpoint = null;
90
+ draft.selectedStopId = next?.stops[0]?.id ?? null;
91
+ });
92
+ }
93
+
94
+ addLineToLibrary(lineId) {
95
+ this.commit(draft => {
96
+ const line = draft.lines.find(item => item.id === lineId);
97
+ if (!line) return;
98
+ draft.library.push({
99
+ id: uid("lib"),
100
+ direction: line.direction,
101
+ stops: line.stops.map(stop => ({ ...stop, id: uid("libstop") })),
102
+ });
103
+ });
104
+ this.toast("Added gradient to library.");
105
+ }
106
+
107
+ async copyText(text, message) {
108
+ try {
109
+ await navigator.clipboard.writeText(text);
110
+ this.toast(message);
111
+ } catch {
112
+ this.toast("Clipboard access was blocked by the browser.");
113
+ }
114
+ }
115
+
116
+ toast(message) { this.toastZone()?.show(message); }
117
+ }
118
+
119
+ customElements.define("gradient-lab-app", GradientLabApp);
120
+ export { GradientLabApp };
@@ -0,0 +1,60 @@
1
+ import { GradientElement } from "gradient-element";
2
+ import { escapeHtml, gradientCss } from "utils";
3
+
4
+ class GradientLibrary extends GradientElement {
5
+ mount() {
6
+ this.innerHTML = `<div class="library-grid mb-3"></div>`;
7
+ this.refs = { grid: this.querySelector(".library-grid") };
8
+ this.concern.on(this, "click", event => this.onClick(event));
9
+ this.watchState(state => this.render(state));
10
+ }
11
+
12
+ render(state) {
13
+ if (!state.library.length) {
14
+ this.refs.grid.innerHTML = `<div class="empty-state"><i class="bi bi-collection me-1"></i> The library is empty. Add a selected gradient to create copyable ${escapeHtml(state.prefix)}1, ${escapeHtml(state.prefix)}2, ${escapeHtml(state.prefix)}3 classes.</div>`;
15
+ return;
16
+ }
17
+ this.refs.grid.innerHTML = state.library.map((item, index) => {
18
+ const className = `${state.prefix}${index + 1}`;
19
+ const css = gradientCss(item.stops, item.direction);
20
+ return `
21
+ <article class="library-item">
22
+ <div class="library-well mb-2" style="background: ${css}"></div>
23
+ <div class="d-flex align-items-center justify-content-between gap-2">
24
+ <div>
25
+ <code class="text-lux-gold">.${escapeHtml(className)}</code>
26
+ <div class="small text-lux-muted">${item.stops.length} stops · ${item.direction}</div>
27
+ </div>
28
+ <div class="d-flex gap-2">
29
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="copy-library-item" data-library-index="${index}"><i class="bi bi-clipboard"></i></button>
30
+ <button class="btn btn-soft btn-sm rounded-pill" type="button" data-action="remove-library-item" data-library-index="${index}"><i class="bi bi-trash3"></i></button>
31
+ </div>
32
+ </div>
33
+ </article>
34
+ `;
35
+ }).join("");
36
+ }
37
+
38
+ onClick(event) {
39
+ const button = event.target.closest("[data-action]");
40
+ if (!button) return;
41
+ const index = Number(button.dataset.libraryIndex);
42
+ if (button.dataset.action === "remove-library-item") {
43
+ this.commit(draft => {
44
+ if (index < 0 || index >= draft.library.length) return;
45
+ draft.library.splice(index, 1);
46
+ });
47
+ }
48
+ if (button.dataset.action === "copy-library-item") {
49
+ const item = this.state.library[index];
50
+ if (!item) return;
51
+ const className = `.${this.state.prefix}${index + 1}`.replace("..", ".");
52
+ this.app.copyText(`${className} {
53
+ background: ${gradientCss(item.stops, item.direction)};
54
+ }`, `Copied ${className}.`);
55
+ }
56
+ }
57
+ }
58
+
59
+ customElements.define("gradient-library", GradientLibrary);
60
+ export { GradientLibrary };
@@ -0,0 +1,18 @@
1
+ import { GradientElement } from "gradient-element";
2
+
3
+ class GradientWorkbench extends GradientElement {
4
+ mount() {
5
+ const cards = [...this.children];
6
+ this.innerHTML = `<div class="row g-4 align-items-stretch"></div>`;
7
+ const row = this.firstElementChild;
8
+ cards.forEach((card, index) => {
9
+ const col = document.createElement("div");
10
+ col.className = index === 0 ? "col-12 col-xl-8" : "col-12 col-xl-4";
11
+ col.append(card);
12
+ row.append(col);
13
+ });
14
+ }
15
+ }
16
+
17
+ customElements.define("gradient-workbench", GradientWorkbench);
18
+ export { GradientWorkbench };