@designtools/shadows 0.1.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.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@designtools/shadows",
3
+ "version": "0.1.0",
4
+ "description": "Visual shadow editing CLI — scan, preview, and edit box-shadow values in your project",
5
+ "type": "module",
6
+ "license": "CC-BY-NC-4.0",
7
+ "author": "Andrew Flett",
8
+ "engines": { "node": ">=18" },
9
+ "scripts": {
10
+ "dev": "tsx src/cli.ts",
11
+ "dev:client": "vite dev src/client",
12
+ "build": "vite build src/client && tsup",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "bin": { "designtools-shadows": "dist/cli.js" },
16
+ "files": ["dist", "src/client", "src/inject"],
17
+ "dependencies": {
18
+ "@designtools/core": "*",
19
+ "@radix-ui/react-icons": "^1.3.2",
20
+ "@tailwindcss/vite": "^4.1.0",
21
+ "@vitejs/plugin-react": "^4.4.0",
22
+ "express": "^5.1.0",
23
+ "http-proxy": "^1.18.1",
24
+ "http-proxy-middleware": "^3.0.3",
25
+ "open": "^10.1.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0",
28
+ "tailwindcss": "^4.1.0",
29
+ "vite": "^6.3.0",
30
+ "ws": "^8.18.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/express": "^5.0.0",
34
+ "@types/http-proxy": "^1.17.17",
35
+ "@types/node": "^22.15.0",
36
+ "@types/react": "^19.0.0",
37
+ "@types/react-dom": "^19.0.0",
38
+ "@types/ws": "^8.5.0",
39
+ "tsup": "^8.0.0",
40
+ "tsx": "^4.19.0",
41
+ "typescript": "^5.7.0"
42
+ }
43
+ }
@@ -0,0 +1,109 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { ShadowIcon } from "@radix-ui/react-icons";
3
+ import { ToolChrome } from "@designtools/core/client/components/tool-chrome";
4
+ import {
5
+ sendToIframe,
6
+ onIframeMessage,
7
+ type ElementData,
8
+ } from "@designtools/core/client/lib/iframe-bridge";
9
+ import { ShadowEditorPanel } from "./components/shadow-editor-panel.js";
10
+
11
+ export interface ShadowsScanData {
12
+ framework: { name: string; cssFiles: string[] };
13
+ styling: { type: string; cssFiles: string[] };
14
+ shadows: {
15
+ shadows: any[];
16
+ cssFilePath: string;
17
+ stylingType: string;
18
+ };
19
+ routes: { routes: { urlPath: string; filePath: string }[] };
20
+ }
21
+
22
+ export function App() {
23
+ const [scanData, setScanData] = useState<ShadowsScanData | null>(null);
24
+ const [selectedElement, setSelectedElement] = useState<ElementData | null>(null);
25
+ const [selectionMode, setSelectionMode] = useState(false);
26
+ const [theme, setTheme] = useState<"light" | "dark">("light");
27
+ const [viewportWidth, setViewportWidth] = useState<number | "fill">("fill");
28
+ const [iframePath, setIframePath] = useState("/");
29
+ const iframeRef = useRef<HTMLIFrameElement>(null);
30
+
31
+ useEffect(() => {
32
+ fetch("/scan/all")
33
+ .then((r) => r.json())
34
+ .then(setScanData)
35
+ .catch(console.error);
36
+ }, []);
37
+
38
+ useEffect(() => {
39
+ return onIframeMessage((msg) => {
40
+ if (msg.type === "tool:elementSelected") {
41
+ setSelectedElement(msg.data);
42
+ }
43
+ if (msg.type === "tool:injectedReady" && iframeRef.current) {
44
+ if (selectionMode) {
45
+ sendToIframe(iframeRef.current, {
46
+ type: "tool:enterSelectionMode",
47
+ });
48
+ }
49
+ }
50
+ });
51
+ }, [selectionMode]);
52
+
53
+ useEffect(() => {
54
+ if (!iframeRef.current) return;
55
+ sendToIframe(iframeRef.current, {
56
+ type: selectionMode
57
+ ? "tool:enterSelectionMode"
58
+ : "tool:exitSelectionMode",
59
+ });
60
+ }, [selectionMode]);
61
+
62
+ const toggleTheme = useCallback(() => {
63
+ const newTheme = theme === "light" ? "dark" : "light";
64
+ setTheme(newTheme);
65
+ if (iframeRef.current) {
66
+ sendToIframe(iframeRef.current, {
67
+ type: "tool:setTheme",
68
+ theme: newTheme,
69
+ });
70
+ }
71
+ }, [theme]);
72
+
73
+ const previewShadow = useCallback(
74
+ (variableName: string, value: string) => {
75
+ if (iframeRef.current) {
76
+ sendToIframe(iframeRef.current, {
77
+ type: "tool:setProperty",
78
+ token: variableName,
79
+ value,
80
+ });
81
+ }
82
+ },
83
+ []
84
+ );
85
+
86
+ return (
87
+ <ToolChrome
88
+ toolName="Shadows"
89
+ toolIcon={<ShadowIcon style={{ width: 15, height: 15 }} />}
90
+ routes={scanData?.routes.routes || []}
91
+ selectionMode={selectionMode}
92
+ onToggleSelectionMode={() => setSelectionMode((s) => !s)}
93
+ theme={theme}
94
+ onToggleTheme={toggleTheme}
95
+ viewportWidth={viewportWidth}
96
+ onViewportWidthChange={setViewportWidth}
97
+ iframePath={iframePath}
98
+ onIframePathChange={setIframePath}
99
+ iframeRef={iframeRef}
100
+ editorPanel={
101
+ <ShadowEditorPanel
102
+ scanData={scanData}
103
+ selectedElement={selectedElement}
104
+ onPreviewShadow={previewShadow}
105
+ />
106
+ }
107
+ />
108
+ );
109
+ }
@@ -0,0 +1,281 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ PlusIcon,
4
+ TrashIcon,
5
+ MoveIcon,
6
+ } from "@radix-ui/react-icons";
7
+
8
+ interface ShadowLayer {
9
+ offsetX: string;
10
+ offsetY: string;
11
+ blur: string;
12
+ spread: string;
13
+ color: string;
14
+ inset: boolean;
15
+ }
16
+
17
+ interface ShadowControlsProps {
18
+ shadow: { value: string; layers: ShadowLayer[] };
19
+ onPreview: (value: string) => void;
20
+ onSave: (value: string) => void;
21
+ }
22
+
23
+ export function ShadowControls({
24
+ shadow,
25
+ onPreview,
26
+ onSave,
27
+ }: ShadowControlsProps) {
28
+ const [layers, setLayers] = useState<ShadowLayer[]>(() =>
29
+ shadow.layers.length > 0
30
+ ? shadow.layers
31
+ : [{ offsetX: "0", offsetY: "4px", blur: "6px", spread: "-1px", color: "rgb(0 0 0 / 0.1)", inset: false }]
32
+ );
33
+
34
+ const formatValue = useCallback((layers: ShadowLayer[]) => {
35
+ if (layers.length === 0) return "none";
36
+ return layers
37
+ .map((l) => {
38
+ const parts: string[] = [];
39
+ if (l.inset) parts.push("inset");
40
+ parts.push(l.offsetX, l.offsetY, l.blur, l.spread, l.color);
41
+ return parts.join(" ");
42
+ })
43
+ .join(", ");
44
+ }, []);
45
+
46
+ const updateLayer = useCallback(
47
+ (index: number, update: Partial<ShadowLayer>) => {
48
+ const newLayers = layers.map((l, i) =>
49
+ i === index ? { ...l, ...update } : l
50
+ );
51
+ setLayers(newLayers);
52
+ onPreview(formatValue(newLayers));
53
+ },
54
+ [layers, onPreview, formatValue]
55
+ );
56
+
57
+ const addLayer = () => {
58
+ const newLayers = [
59
+ ...layers,
60
+ { offsetX: "0", offsetY: "4px", blur: "8px", spread: "0", color: "rgb(0 0 0 / 0.1)", inset: false },
61
+ ];
62
+ setLayers(newLayers);
63
+ onPreview(formatValue(newLayers));
64
+ };
65
+
66
+ const removeLayer = (index: number) => {
67
+ const newLayers = layers.filter((_, i) => i !== index);
68
+ setLayers(newLayers);
69
+ onPreview(formatValue(newLayers));
70
+ };
71
+
72
+ return (
73
+ <div className="flex flex-col gap-3">
74
+ {/* Preview */}
75
+ <div
76
+ className="flex items-center justify-center p-4 rounded-lg"
77
+ style={{
78
+ background: "var(--studio-bg)",
79
+ border: "1px solid var(--studio-border-subtle)",
80
+ }}
81
+ >
82
+ <div
83
+ className="w-20 h-20 rounded-lg"
84
+ style={{
85
+ background: "white",
86
+ boxShadow: formatValue(layers),
87
+ }}
88
+ />
89
+ </div>
90
+
91
+ {/* Layers */}
92
+ {layers.map((layer, i) => (
93
+ <LayerEditor
94
+ key={i}
95
+ layer={layer}
96
+ index={i}
97
+ total={layers.length}
98
+ onChange={(update) => updateLayer(i, update)}
99
+ onRemove={() => removeLayer(i)}
100
+ />
101
+ ))}
102
+
103
+ {/* Add layer button */}
104
+ <button
105
+ onClick={addLayer}
106
+ className="flex items-center justify-center gap-1 py-1.5 rounded text-[11px] cursor-pointer"
107
+ style={{
108
+ background: "var(--studio-input-bg)",
109
+ border: "1px solid var(--studio-border-subtle)",
110
+ color: "var(--studio-text-muted)",
111
+ }}
112
+ >
113
+ <PlusIcon style={{ width: 12, height: 12 }} />
114
+ Add Layer
115
+ </button>
116
+
117
+ {/* Save */}
118
+ <button
119
+ onClick={() => onSave(formatValue(layers))}
120
+ className="w-full py-1.5 rounded text-[11px] font-medium cursor-pointer"
121
+ style={{
122
+ background: "var(--studio-accent)",
123
+ color: "white",
124
+ border: "none",
125
+ }}
126
+ >
127
+ Save to File
128
+ </button>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function LayerEditor({
134
+ layer,
135
+ index,
136
+ total,
137
+ onChange,
138
+ onRemove,
139
+ }: {
140
+ layer: ShadowLayer;
141
+ index: number;
142
+ total: number;
143
+ onChange: (update: Partial<ShadowLayer>) => void;
144
+ onRemove: () => void;
145
+ }) {
146
+ const parseNum = (v: string): number => parseFloat(v) || 0;
147
+
148
+ return (
149
+ <div
150
+ className="rounded-lg p-2.5"
151
+ style={{
152
+ background: "var(--studio-input-bg)",
153
+ border: "1px solid var(--studio-border-subtle)",
154
+ }}
155
+ >
156
+ {/* Layer header */}
157
+ <div className="flex items-center justify-between mb-2">
158
+ <span
159
+ className="text-[9px] font-semibold uppercase tracking-wide"
160
+ style={{ color: "var(--studio-text-dimmed)" }}
161
+ >
162
+ Layer {index + 1}
163
+ </span>
164
+ <div className="flex items-center gap-1">
165
+ <button
166
+ onClick={() => onChange({ inset: !layer.inset })}
167
+ className={`studio-bp-btn ${layer.inset ? "active" : ""}`}
168
+ style={{ fontSize: 9, padding: "1px 5px" }}
169
+ >
170
+ inset
171
+ </button>
172
+ {total > 1 && (
173
+ <button
174
+ onClick={onRemove}
175
+ className="studio-icon-btn"
176
+ style={{ width: 20, height: 20 }}
177
+ >
178
+ <TrashIcon style={{ width: 10, height: 10 }} />
179
+ </button>
180
+ )}
181
+ </div>
182
+ </div>
183
+
184
+ {/* Controls grid */}
185
+ <div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
186
+ <SliderField
187
+ label="X Offset"
188
+ value={parseNum(layer.offsetX)}
189
+ min={-50}
190
+ max={50}
191
+ unit="px"
192
+ onChange={(v) => onChange({ offsetX: `${v}px` })}
193
+ />
194
+ <SliderField
195
+ label="Y Offset"
196
+ value={parseNum(layer.offsetY)}
197
+ min={-50}
198
+ max={50}
199
+ unit="px"
200
+ onChange={(v) => onChange({ offsetY: `${v}px` })}
201
+ />
202
+ <SliderField
203
+ label="Blur"
204
+ value={parseNum(layer.blur)}
205
+ min={0}
206
+ max={100}
207
+ unit="px"
208
+ onChange={(v) => onChange({ blur: `${v}px` })}
209
+ />
210
+ <SliderField
211
+ label="Spread"
212
+ value={parseNum(layer.spread)}
213
+ min={-50}
214
+ max={50}
215
+ unit="px"
216
+ onChange={(v) => onChange({ spread: `${v}px` })}
217
+ />
218
+ </div>
219
+
220
+ {/* Color */}
221
+ <div className="mt-1.5">
222
+ <div
223
+ className="text-[9px] font-medium mb-0.5"
224
+ style={{ color: "var(--studio-text-dimmed)" }}
225
+ >
226
+ Color
227
+ </div>
228
+ <input
229
+ type="text"
230
+ value={layer.color}
231
+ onChange={(e) => onChange({ color: e.target.value })}
232
+ className="studio-input w-full"
233
+ style={{ fontSize: 10 }}
234
+ />
235
+ </div>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ function SliderField({
241
+ label,
242
+ value,
243
+ min,
244
+ max,
245
+ unit,
246
+ onChange,
247
+ }: {
248
+ label: string;
249
+ value: number;
250
+ min: number;
251
+ max: number;
252
+ unit: string;
253
+ onChange: (v: number) => void;
254
+ }) {
255
+ return (
256
+ <div>
257
+ <div className="flex items-center justify-between">
258
+ <span
259
+ className="text-[9px] font-medium"
260
+ style={{ color: "var(--studio-text-dimmed)" }}
261
+ >
262
+ {label}
263
+ </span>
264
+ <span
265
+ className="text-[9px] font-mono"
266
+ style={{ color: "var(--studio-text-muted)" }}
267
+ >
268
+ {value}{unit}
269
+ </span>
270
+ </div>
271
+ <input
272
+ type="range"
273
+ min={min}
274
+ max={max}
275
+ value={value}
276
+ onChange={(e) => onChange(parseInt(e.target.value, 10))}
277
+ className="w-full"
278
+ />
279
+ </div>
280
+ );
281
+ }
@@ -0,0 +1,137 @@
1
+ import { useState } from "react";
2
+ import {
3
+ Cross2Icon,
4
+ ShadowIcon,
5
+ MixerHorizontalIcon,
6
+ GridIcon,
7
+ ChevronRightIcon,
8
+ ChevronDownIcon,
9
+ } from "@radix-ui/react-icons";
10
+ import type { ShadowsScanData } from "../app.js";
11
+ import type { ElementData } from "@designtools/core/client/lib/iframe-bridge";
12
+ import { ShadowList } from "./shadow-list.js";
13
+ import { ShadowOverview } from "./shadow-overview.js";
14
+
15
+ type ViewMode = "list" | "overview";
16
+
17
+ interface ShadowEditorPanelProps {
18
+ scanData: ShadowsScanData | null;
19
+ selectedElement: ElementData | null;
20
+ onPreviewShadow: (variableName: string, value: string) => void;
21
+ }
22
+
23
+ export function ShadowEditorPanel({
24
+ scanData,
25
+ selectedElement,
26
+ onPreviewShadow,
27
+ }: ShadowEditorPanelProps) {
28
+ const [viewMode, setViewMode] = useState<ViewMode>("list");
29
+
30
+ if (!scanData) {
31
+ return (
32
+ <div
33
+ className="flex flex-col border-l studio-scrollbar"
34
+ style={{
35
+ width: 340,
36
+ minWidth: 340,
37
+ background: "var(--studio-surface)",
38
+ borderColor: "var(--studio-border)",
39
+ }}
40
+ >
41
+ <div
42
+ className="px-4 py-3 text-[11px]"
43
+ style={{ color: "var(--studio-text-dimmed)" }}
44
+ >
45
+ Scanning project...
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ const modeConfig: Record<ViewMode, { icon: any; label: string }> = {
52
+ list: { icon: MixerHorizontalIcon, label: "Edit" },
53
+ overview: { icon: GridIcon, label: "Overview" },
54
+ };
55
+
56
+ return (
57
+ <div
58
+ className="flex flex-col border-l studio-scrollbar overflow-y-auto"
59
+ style={{
60
+ width: 340,
61
+ minWidth: 340,
62
+ background: "var(--studio-surface)",
63
+ borderColor: "var(--studio-border)",
64
+ }}
65
+ >
66
+ {/* Header */}
67
+ <div
68
+ className="flex items-center gap-2 px-4 py-3 border-b shrink-0"
69
+ style={{ borderColor: "var(--studio-border)" }}
70
+ >
71
+ <div
72
+ className="w-5 h-5 rounded flex items-center justify-center shrink-0"
73
+ style={{ background: "var(--studio-accent-muted)" }}
74
+ >
75
+ <ShadowIcon
76
+ style={{ width: 12, height: 12, color: "var(--studio-accent)" }}
77
+ />
78
+ </div>
79
+ <div className="flex-1 min-w-0">
80
+ <span
81
+ className="text-[12px] font-semibold"
82
+ style={{ color: "var(--studio-text)" }}
83
+ >
84
+ Shadows
85
+ </span>
86
+ <div
87
+ className="text-[10px]"
88
+ style={{ color: "var(--studio-text-dimmed)" }}
89
+ >
90
+ {scanData.shadows.shadows.length} shadow
91
+ {scanData.shadows.shadows.length !== 1 ? "s" : ""} found
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ {/* View mode switcher */}
97
+ <div
98
+ className="px-4 py-2.5 border-b shrink-0"
99
+ style={{ borderColor: "var(--studio-border)" }}
100
+ >
101
+ <div className="studio-segmented" style={{ width: "100%" }}>
102
+ {(Object.keys(modeConfig) as ViewMode[]).map((mode) => {
103
+ const cfg = modeConfig[mode];
104
+ return (
105
+ <button
106
+ key={mode}
107
+ onClick={() => setViewMode(mode)}
108
+ className={viewMode === mode ? "active" : ""}
109
+ style={{ flex: 1 }}
110
+ >
111
+ <cfg.icon style={{ width: 12, height: 12 }} />
112
+ {cfg.label}
113
+ </button>
114
+ );
115
+ })}
116
+ </div>
117
+ </div>
118
+
119
+ {/* Content */}
120
+ <div className="flex-1 overflow-y-auto studio-scrollbar">
121
+ {viewMode === "list" && (
122
+ <ShadowList
123
+ shadows={scanData.shadows.shadows}
124
+ cssFilePath={scanData.shadows.cssFilePath}
125
+ stylingType={scanData.shadows.stylingType}
126
+ onPreviewShadow={onPreviewShadow}
127
+ />
128
+ )}
129
+ {viewMode === "overview" && (
130
+ <ShadowOverview
131
+ shadows={scanData.shadows.shadows}
132
+ />
133
+ )}
134
+ </div>
135
+ </div>
136
+ );
137
+ }