pixl-mcp 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/README.md +91 -0
- package/dist/design.js +128 -0
- package/dist/design.js.map +1 -0
- package/dist/flow.js +52 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/linter/contrast.js +46 -0
- package/dist/linter/contrast.js.map +1 -0
- package/dist/linter/index.js +190 -0
- package/dist/linter/index.js.map +1 -0
- package/dist/linter/linter.test.js +99 -0
- package/dist/linter/linter.test.js.map +1 -0
- package/dist/mcp/tools.js +129 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/presets/catalog.js +162 -0
- package/dist/presets/catalog.js.map +1 -0
- package/dist/presets/contract.js +140 -0
- package/dist/presets/contract.js.map +1 -0
- package/dist/presets/types.js +2 -0
- package/dist/presets/types.js.map +1 -0
- package/dist/preview-server/index.js +143 -0
- package/dist/preview-server/index.js.map +1 -0
- package/dist/screens.js +52 -0
- package/dist/screens.js.map +1 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/workspace/index.js +149 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/schemas.js +48 -0
- package/dist/workspace/schemas.js.map +1 -0
- package/package.json +47 -0
- package/preview-app/index.html +12 -0
- package/preview-app/src/App.tsx +53 -0
- package/preview-app/src/FlowCanvas.tsx +208 -0
- package/preview-app/src/Gallery.tsx +35 -0
- package/preview-app/src/api.ts +51 -0
- package/preview-app/src/main.tsx +10 -0
- package/preview-app/src/styles.css +173 -0
- package/preview-app/src/useLiveReload.ts +17 -0
- package/preview-app/src/vite-env.d.ts +1 -0
- package/preview-app/tsconfig.json +15 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
BackgroundVariant,
|
|
6
|
+
Controls,
|
|
7
|
+
Handle,
|
|
8
|
+
Position,
|
|
9
|
+
MarkerType,
|
|
10
|
+
addEdge,
|
|
11
|
+
useNodesState,
|
|
12
|
+
useEdgesState,
|
|
13
|
+
type Node,
|
|
14
|
+
type Edge,
|
|
15
|
+
type Connection,
|
|
16
|
+
type NodeProps,
|
|
17
|
+
} from "@xyflow/react";
|
|
18
|
+
import dagre from "dagre";
|
|
19
|
+
import "@xyflow/react/dist/style.css";
|
|
20
|
+
import { api, type Flow, type Screen } from "./api";
|
|
21
|
+
|
|
22
|
+
// Thumbnail geometry: a full 390×844 screen scaled down into a node.
|
|
23
|
+
const SCALE = 0.28;
|
|
24
|
+
const THUMB_W = Math.round(390 * SCALE); // 109
|
|
25
|
+
const THUMB_H = Math.round(844 * SCALE); // 236
|
|
26
|
+
const NODE_W = THUMB_W + 20;
|
|
27
|
+
const NODE_H = THUMB_H + 34;
|
|
28
|
+
|
|
29
|
+
type ScreenData = { label: string; html: string };
|
|
30
|
+
type ScreenNodeType = Node<ScreenData, "screen">;
|
|
31
|
+
|
|
32
|
+
/** A node = a live thumbnail of the screen plus its name, with edge handles. */
|
|
33
|
+
function ScreenNode({ data }: NodeProps<ScreenNodeType>) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flow-node">
|
|
36
|
+
<Handle type="target" position={Position.Left} />
|
|
37
|
+
<div className="flow-thumb" style={{ width: THUMB_W, height: THUMB_H }}>
|
|
38
|
+
{data.html ? (
|
|
39
|
+
<iframe
|
|
40
|
+
title={data.label}
|
|
41
|
+
srcDoc={data.html}
|
|
42
|
+
sandbox="allow-scripts"
|
|
43
|
+
width={390}
|
|
44
|
+
height={844}
|
|
45
|
+
style={{ transform: `scale(${SCALE})`, transformOrigin: "top left", border: 0 }}
|
|
46
|
+
/>
|
|
47
|
+
) : (
|
|
48
|
+
<div className="flow-thumb-empty">not generated yet</div>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flow-node-label">{data.label}</div>
|
|
52
|
+
<Handle type="source" position={Position.Right} />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nodeTypes = { screen: ScreenNode };
|
|
58
|
+
|
|
59
|
+
/** dagre left-to-right auto-layout; returns nodes with fresh positions. */
|
|
60
|
+
function autoLayout(nodes: ScreenNodeType[], edges: Edge[]): ScreenNodeType[] {
|
|
61
|
+
const g = new dagre.graphlib.Graph();
|
|
62
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
63
|
+
g.setGraph({ rankdir: "LR", nodesep: 50, ranksep: 90 });
|
|
64
|
+
nodes.forEach((n) => g.setNode(n.id, { width: NODE_W, height: NODE_H }));
|
|
65
|
+
edges.forEach((e) => g.setEdge(e.source, e.target));
|
|
66
|
+
dagre.layout(g);
|
|
67
|
+
return nodes.map((n) => {
|
|
68
|
+
const p = g.node(n.id);
|
|
69
|
+
return { ...n, position: { x: p.x - NODE_W / 2, y: p.y - NODE_H / 2 } };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const edgeOf = (e: { id: string; source: string; target: string; label?: string }): Edge => ({
|
|
74
|
+
id: e.id,
|
|
75
|
+
source: e.source,
|
|
76
|
+
target: e.target,
|
|
77
|
+
label: e.label || undefined,
|
|
78
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
79
|
+
labelStyle: { fill: "#e7e9ee", fontSize: 11 },
|
|
80
|
+
labelBgStyle: { fill: "#1d2027" },
|
|
81
|
+
style: { stroke: "#6366f1" },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Draggable user-journey canvas (React Flow). Renders flow.json, persists node
|
|
86
|
+
* drags + edge edits back through /api/flow, and offers dagre auto-layout. It
|
|
87
|
+
* skips the reload it triggers itself so a save doesn't yank the canvas.
|
|
88
|
+
*/
|
|
89
|
+
export function FlowCanvas({ screens, version }: { screens: Screen[]; version: number }) {
|
|
90
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<ScreenNodeType>([]);
|
|
91
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
92
|
+
const justSaved = useRef(false);
|
|
93
|
+
const dragging = useRef(false);
|
|
94
|
+
|
|
95
|
+
const htmlById = useMemo(() => new Map(screens.map((s) => [s.id, s.html])), [screens]);
|
|
96
|
+
|
|
97
|
+
const persist = useCallback((nds: ScreenNodeType[], eds: Edge[]) => {
|
|
98
|
+
justSaved.current = true;
|
|
99
|
+
const flow: Flow = {
|
|
100
|
+
nodes: nds.map((n) => ({
|
|
101
|
+
id: n.id,
|
|
102
|
+
screenId: n.id,
|
|
103
|
+
label: n.data.label,
|
|
104
|
+
x: Math.round(n.position.x),
|
|
105
|
+
y: Math.round(n.position.y),
|
|
106
|
+
})),
|
|
107
|
+
edges: eds.map((e, i) => ({
|
|
108
|
+
id: e.id || `e${i}`,
|
|
109
|
+
source: e.source,
|
|
110
|
+
target: e.target,
|
|
111
|
+
label: typeof e.label === "string" ? e.label : "",
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
api.saveFlow(flow).catch(() => undefined);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const load = useCallback(async () => {
|
|
118
|
+
if (justSaved.current) {
|
|
119
|
+
justSaved.current = false; // this reload is the echo of our own save
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (dragging.current) return;
|
|
123
|
+
const flow = await api.flow();
|
|
124
|
+
setNodes(
|
|
125
|
+
flow.nodes.map((n) => ({
|
|
126
|
+
id: n.id,
|
|
127
|
+
type: "screen",
|
|
128
|
+
position: { x: n.x, y: n.y },
|
|
129
|
+
data: { label: n.label, html: htmlById.get(n.screenId) ?? "" },
|
|
130
|
+
})),
|
|
131
|
+
);
|
|
132
|
+
setEdges(flow.edges.map(edgeOf));
|
|
133
|
+
}, [htmlById, setNodes, setEdges]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
load();
|
|
137
|
+
}, [load, version]);
|
|
138
|
+
|
|
139
|
+
const onConnect = useCallback(
|
|
140
|
+
(c: Connection) => {
|
|
141
|
+
setEdges((eds) => {
|
|
142
|
+
const next = addEdge({ ...edgeOf({ id: `e${eds.length}`, source: c.source!, target: c.target! }) }, eds);
|
|
143
|
+
persist(nodes, next);
|
|
144
|
+
return next;
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
[nodes, persist, setEdges],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const onEdgeDoubleClick = useCallback(
|
|
151
|
+
(_: unknown, edge: Edge) => {
|
|
152
|
+
const label = window.prompt("Transition label", typeof edge.label === "string" ? edge.label : "");
|
|
153
|
+
if (label === null) return;
|
|
154
|
+
setEdges((eds) => {
|
|
155
|
+
const next = eds.map((e) => (e.id === edge.id ? { ...e, label: label || undefined } : e));
|
|
156
|
+
persist(nodes, next);
|
|
157
|
+
return next;
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
[nodes, persist, setEdges],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const relayout = useCallback(() => {
|
|
164
|
+
const laid = autoLayout(nodes, edges);
|
|
165
|
+
setNodes(laid);
|
|
166
|
+
persist(laid, edges);
|
|
167
|
+
}, [nodes, edges, setNodes, persist]);
|
|
168
|
+
|
|
169
|
+
if (nodes.length === 0) {
|
|
170
|
+
return (
|
|
171
|
+
<div className="placeholder">
|
|
172
|
+
<p>No journey yet.</p>
|
|
173
|
+
<p className="dim">
|
|
174
|
+
Ask your assistant to plan screens (pixl_plan_screens) or set the flow (pixl_set_flow) —
|
|
175
|
+
the journey appears here as a draggable map.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="flow-canvas">
|
|
183
|
+
<div className="flow-toolbar">
|
|
184
|
+
<button onClick={relayout}>Auto-layout</button>
|
|
185
|
+
<span className="dim">Drag nodes to rearrange · drag handle→handle to link · double-click an arrow to label it</span>
|
|
186
|
+
</div>
|
|
187
|
+
<ReactFlow
|
|
188
|
+
nodes={nodes}
|
|
189
|
+
edges={edges}
|
|
190
|
+
nodeTypes={nodeTypes}
|
|
191
|
+
onNodesChange={onNodesChange}
|
|
192
|
+
onEdgesChange={onEdgesChange}
|
|
193
|
+
onConnect={onConnect}
|
|
194
|
+
onEdgeDoubleClick={onEdgeDoubleClick}
|
|
195
|
+
onNodeDragStart={() => (dragging.current = true)}
|
|
196
|
+
onNodeDragStop={() => {
|
|
197
|
+
dragging.current = false;
|
|
198
|
+
persist(nodes, edges);
|
|
199
|
+
}}
|
|
200
|
+
fitView
|
|
201
|
+
proOptions={{ hideAttribution: true }}
|
|
202
|
+
>
|
|
203
|
+
<Background variant={BackgroundVariant.Dots} gap={22} size={1.5} color="#2a2e38" />
|
|
204
|
+
<Controls />
|
|
205
|
+
</ReactFlow>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Screen } from "./api";
|
|
2
|
+
|
|
3
|
+
/** Renders each screen inside a 390×844 phone frame via a sandboxed iframe. */
|
|
4
|
+
export function Gallery({ screens }: { screens: Screen[] }) {
|
|
5
|
+
if (screens.length === 0) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="placeholder">
|
|
8
|
+
<p>No screens yet.</p>
|
|
9
|
+
<p className="dim">
|
|
10
|
+
Pick a design system, then ask your assistant to generate screens — they show up here
|
|
11
|
+
live.
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="gallery">
|
|
19
|
+
{screens.map((s) => (
|
|
20
|
+
<figure key={s.id} className="phone">
|
|
21
|
+
<div className="phone-frame">
|
|
22
|
+
<iframe
|
|
23
|
+
title={s.name}
|
|
24
|
+
srcDoc={s.html}
|
|
25
|
+
sandbox="allow-scripts"
|
|
26
|
+
width={390}
|
|
27
|
+
height={844}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
<figcaption>{s.name}</figcaption>
|
|
31
|
+
</figure>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface Screen {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
html: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FlowNode {
|
|
9
|
+
id: string;
|
|
10
|
+
screenId: string;
|
|
11
|
+
label: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
export interface FlowEdge {
|
|
16
|
+
id: string;
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Flow {
|
|
22
|
+
nodes: FlowNode[];
|
|
23
|
+
edges: FlowEdge[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Status {
|
|
27
|
+
root: string;
|
|
28
|
+
initialized: boolean;
|
|
29
|
+
designSystem: string | null;
|
|
30
|
+
screenCount: number;
|
|
31
|
+
flowNodeCount: number;
|
|
32
|
+
previewUrl: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getJson<T>(url: string): Promise<T> {
|
|
36
|
+
const res = await fetch(url);
|
|
37
|
+
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const api = {
|
|
42
|
+
status: () => getJson<Status>("/api/status"),
|
|
43
|
+
screens: () => getJson<{ screens: Screen[] }>("/api/screens"),
|
|
44
|
+
flow: () => getJson<Flow>("/api/flow"),
|
|
45
|
+
saveFlow: (flow: Flow) =>
|
|
46
|
+
fetch("/api/flow", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify(flow),
|
|
50
|
+
}).then((r) => r.json()),
|
|
51
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0b0c0f;
|
|
3
|
+
--panel: #14161b;
|
|
4
|
+
--line: #23262e;
|
|
5
|
+
--text: #e7e9ee;
|
|
6
|
+
--dim: #8b909c;
|
|
7
|
+
--accent: #6366f1;
|
|
8
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
background: var(--bg);
|
|
17
|
+
color: var(--text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.app {
|
|
21
|
+
min-height: 100vh;
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.topbar {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 24px;
|
|
30
|
+
padding: 12px 20px;
|
|
31
|
+
border-bottom: 1px solid var(--line);
|
|
32
|
+
background: var(--panel);
|
|
33
|
+
position: sticky;
|
|
34
|
+
top: 0;
|
|
35
|
+
z-index: 10;
|
|
36
|
+
}
|
|
37
|
+
.brand {
|
|
38
|
+
font-weight: 700;
|
|
39
|
+
font-size: 18px;
|
|
40
|
+
letter-spacing: -0.02em;
|
|
41
|
+
}
|
|
42
|
+
.brand .dot {
|
|
43
|
+
color: var(--accent);
|
|
44
|
+
}
|
|
45
|
+
.tabs {
|
|
46
|
+
display: flex;
|
|
47
|
+
gap: 4px;
|
|
48
|
+
}
|
|
49
|
+
.tabs button {
|
|
50
|
+
background: transparent;
|
|
51
|
+
border: 1px solid transparent;
|
|
52
|
+
color: var(--dim);
|
|
53
|
+
padding: 6px 12px;
|
|
54
|
+
border-radius: 8px;
|
|
55
|
+
font-size: 13px;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
.tabs button.active {
|
|
59
|
+
color: var(--text);
|
|
60
|
+
background: #1d2027;
|
|
61
|
+
border-color: var(--line);
|
|
62
|
+
}
|
|
63
|
+
.meta {
|
|
64
|
+
margin-left: auto;
|
|
65
|
+
color: var(--dim);
|
|
66
|
+
font-size: 12px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.content {
|
|
70
|
+
flex: 1;
|
|
71
|
+
padding: 28px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.gallery {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
gap: 28px;
|
|
78
|
+
}
|
|
79
|
+
.phone figcaption {
|
|
80
|
+
margin-top: 10px;
|
|
81
|
+
text-align: center;
|
|
82
|
+
color: var(--dim);
|
|
83
|
+
font-size: 12px;
|
|
84
|
+
}
|
|
85
|
+
.phone-frame {
|
|
86
|
+
width: 390px;
|
|
87
|
+
height: 844px;
|
|
88
|
+
border-radius: 40px;
|
|
89
|
+
border: 10px solid #000;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
background: #fff;
|
|
92
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
93
|
+
}
|
|
94
|
+
.phone-frame iframe {
|
|
95
|
+
border: 0;
|
|
96
|
+
display: block;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* --- flow canvas --- */
|
|
100
|
+
.flow-canvas {
|
|
101
|
+
height: calc(100vh - 130px);
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
gap: 10px;
|
|
105
|
+
}
|
|
106
|
+
.flow-toolbar {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 14px;
|
|
110
|
+
}
|
|
111
|
+
.flow-toolbar button {
|
|
112
|
+
background: #1d2027;
|
|
113
|
+
color: var(--text);
|
|
114
|
+
border: 1px solid var(--line);
|
|
115
|
+
padding: 6px 12px;
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
font-size: 13px;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
.flow-toolbar button:hover {
|
|
121
|
+
border-color: var(--accent);
|
|
122
|
+
}
|
|
123
|
+
.flow-canvas .react-flow {
|
|
124
|
+
flex: 1;
|
|
125
|
+
border: 1px solid var(--line);
|
|
126
|
+
border-radius: 12px;
|
|
127
|
+
background: var(--panel);
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
.flow-node {
|
|
131
|
+
width: max-content;
|
|
132
|
+
padding: 8px 8px 6px;
|
|
133
|
+
background: #14161b;
|
|
134
|
+
border: 1px solid var(--line);
|
|
135
|
+
border-radius: 12px;
|
|
136
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
137
|
+
}
|
|
138
|
+
.flow-thumb {
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
border-radius: 6px;
|
|
141
|
+
background: #fff;
|
|
142
|
+
}
|
|
143
|
+
.flow-thumb iframe {
|
|
144
|
+
pointer-events: none;
|
|
145
|
+
}
|
|
146
|
+
.flow-thumb-empty {
|
|
147
|
+
width: 100%;
|
|
148
|
+
height: 100%;
|
|
149
|
+
display: grid;
|
|
150
|
+
place-items: center;
|
|
151
|
+
background: #1d2027;
|
|
152
|
+
color: var(--dim);
|
|
153
|
+
font-size: 11px;
|
|
154
|
+
}
|
|
155
|
+
.flow-node-label {
|
|
156
|
+
margin-top: 6px;
|
|
157
|
+
text-align: center;
|
|
158
|
+
color: var(--text);
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.placeholder {
|
|
164
|
+
max-width: 460px;
|
|
165
|
+
margin: 80px auto;
|
|
166
|
+
text-align: center;
|
|
167
|
+
color: var(--text);
|
|
168
|
+
}
|
|
169
|
+
.placeholder .dim,
|
|
170
|
+
.dim {
|
|
171
|
+
color: var(--dim);
|
|
172
|
+
font-size: 13px;
|
|
173
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/** Subscribes to the Pixl reload channel; calls onReload when files change. */
|
|
4
|
+
export function useLiveReload(onReload: () => void) {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
7
|
+
const ws = new WebSocket(`${proto}://${location.host}/pixl-ws`);
|
|
8
|
+
ws.onmessage = (e) => {
|
|
9
|
+
try {
|
|
10
|
+
if (JSON.parse(e.data).type === "reload") onReload();
|
|
11
|
+
} catch {
|
|
12
|
+
/* ignore */
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
return () => ws.close();
|
|
16
|
+
}, [onReload]);
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|