tsquare 0.1.0 → 0.2.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 +15 -3
- package/assets/mark.png +0 -0
- package/bin/tsquare.js +2 -6
- package/dist/catalog.d.ts +1021 -0
- package/dist/chunks/chunk-L6GTIZQH.js +2029 -0
- package/{src/cli.ts → dist/cli.js} +28 -24
- package/dist/colors.d.ts +29 -0
- package/dist/compile.d.ts +24 -0
- package/dist/components.d.ts +83 -0
- package/dist/icons.d.ts +12 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +38 -0
- package/dist/layout.d.ts +66 -0
- package/dist/playground/index.d.ts +27 -0
- package/dist/playground/index.html +501 -0
- package/dist/playground/index.js +376 -0
- package/dist/playground/reference.d.ts +11 -0
- package/dist/print.d.ts +2 -0
- package/dist/prompt-example.d.ts +3 -0
- package/dist/prompt.d.ts +22 -0
- package/dist/render.d.ts +20 -0
- package/dist/schema.d.ts +33 -0
- package/dist/suggest.d.ts +5 -0
- package/dist/text.d.ts +45 -0
- package/examples/notes-mobile.tsq +84 -0
- package/examples/notes-web.tsq +54 -0
- package/package.json +23 -7
- package/skill/tsquare/SKILL.md +3 -3
- package/skill/tsquare/reference.md +2 -2
- package/src/catalog.ts +0 -329
- package/src/colors.ts +0 -82
- package/src/compile.ts +0 -64
- package/src/components.tsx +0 -1057
- package/src/icons.ts +0 -54
- package/src/index.ts +0 -11
- package/src/layout.ts +0 -63
- package/src/print.ts +0 -73
- package/src/prompt-example.ts +0 -43
- package/src/prompt.ts +0 -121
- package/src/render.ts +0 -178
- package/src/schema.ts +0 -43
- package/src/suggest.ts +0 -34
- package/src/text.ts +0 -333
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>tsquare Playground</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/mark.png">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f4f5f7;
|
|
11
|
+
--panel: #ffffff;
|
|
12
|
+
--ink: #1f2328;
|
|
13
|
+
--text: #3d434b;
|
|
14
|
+
--muted: #7a818b;
|
|
15
|
+
--line: #dfe2e6;
|
|
16
|
+
--line-strong: #c7cbd1;
|
|
17
|
+
--fill: #eef0f3;
|
|
18
|
+
--accent: #2b3036;
|
|
19
|
+
--on-accent: #ffffff;
|
|
20
|
+
--error: #c2362b;
|
|
21
|
+
--error-bg: #fcefed;
|
|
22
|
+
--ok: #2f7d4f;
|
|
23
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
24
|
+
--sans: Inter, ui-sans-serif, -apple-system, "Segoe UI", sans-serif;
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
html, body { height: 100%; margin: 0; }
|
|
28
|
+
body { background: var(--bg); color: var(--ink); font: 14px/1.45 var(--sans); display: flex; flex-direction: column; }
|
|
29
|
+
button, select { font: inherit; }
|
|
30
|
+
|
|
31
|
+
header { display: flex; align-items: center; gap: 24px; padding: 0 16px; height: 52px; background: var(--panel); border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
|
32
|
+
header h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; }
|
|
33
|
+
header h1 img { width: 26px; height: 26px; border-radius: 6px; }
|
|
34
|
+
nav { display: flex; gap: 4px; }
|
|
35
|
+
nav button { border: 0; background: none; padding: 6px 12px; border-radius: 6px; color: var(--muted); cursor: pointer; font-weight: 500; }
|
|
36
|
+
nav button:hover { color: var(--ink); background: var(--fill); }
|
|
37
|
+
nav button[aria-selected="true"] { color: var(--ink); background: var(--fill); }
|
|
38
|
+
|
|
39
|
+
.btn { border: 1px solid var(--line-strong); background: var(--panel); color: var(--ink); padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
|
40
|
+
.btn:hover { border-color: var(--muted); }
|
|
41
|
+
.btn:disabled { opacity: .45; cursor: default; }
|
|
42
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
43
|
+
select.btn { padding-right: 6px; }
|
|
44
|
+
|
|
45
|
+
main { flex: 1; min-height: 0; display: flex; }
|
|
46
|
+
.view { flex: 1; min-width: 0; display: none; }
|
|
47
|
+
.view.active { display: flex; }
|
|
48
|
+
|
|
49
|
+
/* Editor */
|
|
50
|
+
#editor-view { gap: 0; }
|
|
51
|
+
.pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
52
|
+
.editor-pane { width: 44%; min-width: 320px; background: var(--panel); border-right: 1px solid var(--line); }
|
|
53
|
+
.toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); flex-shrink: 0; flex-wrap: wrap; }
|
|
54
|
+
.toolbar .grow { flex: 1; }
|
|
55
|
+
.code { flex: 1; min-height: 0; display: flex; overflow: auto; font: 13px/20px var(--mono); }
|
|
56
|
+
.gutter { padding: 12px 0; text-align: right; color: var(--muted); user-select: none; flex-shrink: 0; min-width: 44px; background: var(--panel); position: sticky; left: 0; }
|
|
57
|
+
.gutter div { padding: 0 10px 0 8px; height: 20px; }
|
|
58
|
+
.gutter div.bad { color: var(--error); background: var(--error-bg); font-weight: 600; }
|
|
59
|
+
textarea { flex: 1; border: 0; outline: none; resize: none; padding: 12px 12px 12px 4px; font: inherit; color: var(--ink); background: transparent; white-space: pre; overflow: hidden; tab-size: 2; min-width: 0; }
|
|
60
|
+
.issues { border-top: 1px solid var(--line); max-height: 32%; overflow: auto; flex-shrink: 0; }
|
|
61
|
+
.issues:empty { display: none; }
|
|
62
|
+
.issue { display: flex; gap: 10px; padding: 7px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; }
|
|
63
|
+
.issue:hover { background: var(--error-bg); }
|
|
64
|
+
.issue .ln { color: var(--error); font: 600 12px/20px var(--mono); flex-shrink: 0; min-width: 52px; }
|
|
65
|
+
|
|
66
|
+
.preview-pane { flex: 1; }
|
|
67
|
+
.status { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
|
68
|
+
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
69
|
+
.status.ok .dot { background: var(--ok); }
|
|
70
|
+
.status.bad { color: var(--error); }
|
|
71
|
+
.status.bad .dot { background: var(--error); }
|
|
72
|
+
.stage { flex: 1; min-height: 0; overflow: auto; padding: 16px; display: flex; }
|
|
73
|
+
.stage img { margin: auto; display: block; transition: opacity .15s; }
|
|
74
|
+
.stage.fit img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
75
|
+
.stage.stale img { opacity: .35; }
|
|
76
|
+
.stage .empty { margin: auto; color: var(--muted); }
|
|
77
|
+
|
|
78
|
+
/* Components */
|
|
79
|
+
#components-view { min-height: 0; }
|
|
80
|
+
.toc { width: 200px; flex-shrink: 0; overflow: auto; padding: 16px 8px 32px 16px; border-right: 1px solid var(--line); background: var(--panel); }
|
|
81
|
+
.toc h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 16px 0 4px; font-weight: 600; }
|
|
82
|
+
.toc h3:first-child { margin-top: 0; }
|
|
83
|
+
.toc a { display: block; padding: 3px 8px; margin-left: -8px; border-radius: 5px; color: var(--text); text-decoration: none; }
|
|
84
|
+
.toc a:hover { background: var(--fill); color: var(--ink); }
|
|
85
|
+
.docs { flex: 1; overflow: auto; padding: 24px 32px 80px; scroll-padding-top: 16px; }
|
|
86
|
+
.docs-intro { max-width: 760px; color: var(--text); margin-bottom: 24px; }
|
|
87
|
+
.docs-intro code, .comp code, .prompt-help code { font: 12.5px var(--mono); background: var(--fill); padding: 1px 5px; border-radius: 4px; }
|
|
88
|
+
.group-title { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 40px 0 12px; font-weight: 600; }
|
|
89
|
+
.comp { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 20px; overflow: hidden; }
|
|
90
|
+
.comp-head { display: flex; align-items: baseline; gap: 12px; padding: 16px 20px 0; }
|
|
91
|
+
.comp-head h2 { margin: 0; font-size: 18px; font-weight: 650; }
|
|
92
|
+
.comp-head h2 code { background: none; padding: 0; font-size: 16px; color: var(--muted); font-weight: 500; }
|
|
93
|
+
.comp-head .grow { flex: 1; }
|
|
94
|
+
.comp > p { margin: 4px 20px 12px; color: var(--text); }
|
|
95
|
+
.comp-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); border-top: 1px solid var(--line); }
|
|
96
|
+
.comp-body > div { min-width: 0; }
|
|
97
|
+
.shot { background: var(--bg); display: flex; align-items: center; justify-content: center; padding: 12px; border-right: 1px solid var(--line); }
|
|
98
|
+
.shot img { max-width: 100%; max-height: 420px; }
|
|
99
|
+
.props { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
100
|
+
.props th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; padding: 10px 14px 6px; }
|
|
101
|
+
.props td { padding: 7px 14px; border-top: 1px solid var(--line); vertical-align: top; }
|
|
102
|
+
.props td:first-child { font: 600 12.5px/20px var(--mono); white-space: nowrap; }
|
|
103
|
+
.props .type { font: 12px/18px var(--mono); color: var(--text); word-break: break-word; }
|
|
104
|
+
.props .desc { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
|
|
105
|
+
.tag { display: inline-block; font: 600 10px/1 var(--sans); text-transform: uppercase; letter-spacing: .04em; padding: 3px 5px; border-radius: 4px; background: var(--fill); color: var(--text); margin-left: 6px; vertical-align: 2px; }
|
|
106
|
+
pre.src { margin: 0; padding: 12px 14px; border-top: 1px solid var(--line); background: var(--panel); font: 12px/18px var(--mono); overflow: auto; max-height: 260px; color: var(--text); }
|
|
107
|
+
|
|
108
|
+
/* Prompt */
|
|
109
|
+
#prompt-view { flex-direction: column; min-height: 0; }
|
|
110
|
+
.prompt-help { padding: 16px 24px; background: var(--panel); border-bottom: 1px solid var(--line); display: flex; gap: 24px; align-items: center; flex-wrap: wrap; }
|
|
111
|
+
.prompt-help ol { margin: 0; padding-left: 20px; color: var(--text); }
|
|
112
|
+
.prompt-help .grow { flex: 1; }
|
|
113
|
+
#prompt-text { flex: 1; margin: 0; overflow: auto; padding: 20px 24px 60px; font: 12.5px/19px var(--mono); white-space: pre-wrap; color: var(--text); max-width: 980px; }
|
|
114
|
+
|
|
115
|
+
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--accent); color: var(--on-accent); padding: 8px 14px; border-radius: 8px; font-size: 13px; opacity: 0; transition: .2s; pointer-events: none; }
|
|
116
|
+
.toast.show { opacity: 1; transform: translateX(-50%); }
|
|
117
|
+
|
|
118
|
+
@media (max-width: 860px) {
|
|
119
|
+
#editor-view.active { flex-direction: column; }
|
|
120
|
+
.editor-pane { width: auto; min-width: 0; height: 50%; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
121
|
+
.toc { display: none; }
|
|
122
|
+
.docs { padding: 16px 16px 60px; }
|
|
123
|
+
.comp-body { grid-template-columns: 1fr; }
|
|
124
|
+
.shot { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
125
|
+
header { gap: 12px; }
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<header>
|
|
131
|
+
<h1><img src="/mark.png" alt="">tsquare</h1>
|
|
132
|
+
<nav role="tablist">
|
|
133
|
+
<button role="tab" data-view="editor" aria-selected="true">Editor</button>
|
|
134
|
+
<button role="tab" data-view="components" aria-selected="false">Components</button>
|
|
135
|
+
<button role="tab" data-view="prompt" aria-selected="false">Prompt</button>
|
|
136
|
+
</nav>
|
|
137
|
+
</header>
|
|
138
|
+
|
|
139
|
+
<main>
|
|
140
|
+
<section id="editor-view" class="view active">
|
|
141
|
+
<div class="pane editor-pane">
|
|
142
|
+
<div class="toolbar">
|
|
143
|
+
<select id="examples" class="btn" aria-label="Load an example"><option value="">Load example…</option></select>
|
|
144
|
+
<span class="grow"></span>
|
|
145
|
+
<span class="status" id="status"><span class="dot"></span><span id="status-text">Rendering…</span></span>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="code" id="code">
|
|
148
|
+
<div class="gutter" id="gutter"></div>
|
|
149
|
+
<textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Wireframe text"></textarea>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="issues" id="issues"></div>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="pane preview-pane">
|
|
154
|
+
<div class="toolbar">
|
|
155
|
+
<button class="btn" id="zoom">Actual size</button>
|
|
156
|
+
<span class="grow"></span>
|
|
157
|
+
<button class="btn" id="copy-svg" disabled>Copy SVG</button>
|
|
158
|
+
<button class="btn" id="dl-svg" disabled>Download SVG</button>
|
|
159
|
+
<button class="btn primary" id="dl-png" disabled>Download PNG</button>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="stage fit" id="stage"><div class="empty">Nothing rendered yet</div></div>
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<section id="components-view" class="view">
|
|
166
|
+
<div class="toc" id="toc"></div>
|
|
167
|
+
<div class="docs" id="docs">
|
|
168
|
+
<div class="docs-intro">
|
|
169
|
+
Every component, its props, and an example you can open in the editor. One element per line, children indented two spaces.
|
|
170
|
+
A <code>"quoted string"</code> fills the prop marked <span class="tag">main</span>. Enum values and booleans can be written bare
|
|
171
|
+
(<code>button primary lg</code>, <code>checkbox checked</code>, <code>toggle off</code>); anything else is <code>key=value</code>.
|
|
172
|
+
Lists use commas, and items can contain spaces: <code>[All notes, Pinned]</code>. Quote an item that contains a comma:
|
|
173
|
+
<code>["$1,200", "Smith, J"]</code>. Objects are <code>{key=value key=value}</code>.
|
|
174
|
+
</div>
|
|
175
|
+
<div id="docs-body" class="empty">Loading…</div>
|
|
176
|
+
</div>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<section id="prompt-view" class="view">
|
|
180
|
+
<div class="prompt-help">
|
|
181
|
+
<ol>
|
|
182
|
+
<li>Copy this system prompt into Claude (or another model).</li>
|
|
183
|
+
<li>Ask for a wireframe, e.g. “a settings screen for a notes app”.</li>
|
|
184
|
+
<li>Paste the reply into the <strong>Editor</strong>. Code fences are fine; errors show by line and can be pasted back to the model.</li>
|
|
185
|
+
</ol>
|
|
186
|
+
<span class="grow"></span>
|
|
187
|
+
<button class="btn primary" id="copy-prompt">Copy prompt</button>
|
|
188
|
+
</div>
|
|
189
|
+
<pre id="prompt-text">Loading…</pre>
|
|
190
|
+
</section>
|
|
191
|
+
</main>
|
|
192
|
+
|
|
193
|
+
<div class="toast" id="toast"></div>
|
|
194
|
+
|
|
195
|
+
<script>
|
|
196
|
+
const $ = (id) => document.getElementById(id);
|
|
197
|
+
const store = {
|
|
198
|
+
get(k) { try { return localStorage.getItem(k); } catch { return null; } },
|
|
199
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch {} },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
function toast(msg) {
|
|
203
|
+
const t = $("toast");
|
|
204
|
+
t.textContent = msg;
|
|
205
|
+
t.classList.add("show");
|
|
206
|
+
clearTimeout(toast.timer);
|
|
207
|
+
toast.timer = setTimeout(() => t.classList.remove("show"), 1600);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function copy(text, msg) {
|
|
211
|
+
try { await navigator.clipboard.writeText(text); toast(msg); }
|
|
212
|
+
catch { toast("Couldn't copy: the browser blocked clipboard access"); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function download(blob, name) {
|
|
216
|
+
const a = document.createElement("a");
|
|
217
|
+
a.href = URL.createObjectURL(blob);
|
|
218
|
+
a.download = name;
|
|
219
|
+
a.click();
|
|
220
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function fileName(ext) {
|
|
224
|
+
const m = source.value.match(/^\s*board\b[^"\n]*"([^"]+)"/m);
|
|
225
|
+
const base = (m ? m[1] : "wireframe").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wireframe";
|
|
226
|
+
return `${base}.${ext}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Tabs ────────────────────────────────────────────────────────────────
|
|
230
|
+
function showView(name) {
|
|
231
|
+
document.querySelectorAll("nav button").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.view === name)));
|
|
232
|
+
document.querySelectorAll(".view").forEach((v) => v.classList.toggle("active", v.id === `${name}-view`));
|
|
233
|
+
store.set("wf.view", name);
|
|
234
|
+
if (name === "components") loadReference();
|
|
235
|
+
if (name === "prompt") loadPrompt();
|
|
236
|
+
}
|
|
237
|
+
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showView(b.dataset.view)));
|
|
238
|
+
|
|
239
|
+
// ── Editor ──────────────────────────────────────────────────────────────
|
|
240
|
+
const source = $("source");
|
|
241
|
+
const gutter = $("gutter");
|
|
242
|
+
let lastSvg = null;
|
|
243
|
+
let badLines = new Set();
|
|
244
|
+
let seq = 0;
|
|
245
|
+
|
|
246
|
+
function paintGutter() {
|
|
247
|
+
const n = source.value.split("\n").length;
|
|
248
|
+
let html = "";
|
|
249
|
+
for (let i = 1; i <= n; i++) html += `<div${badLines.has(i) ? ' class="bad"' : ""}>${i}</div>`;
|
|
250
|
+
gutter.innerHTML = html;
|
|
251
|
+
// the textarea grows with its content; .code scrolls both together
|
|
252
|
+
source.style.height = `${n * 20 + 24}px`;
|
|
253
|
+
source.style.width = "0";
|
|
254
|
+
source.style.width = `${Math.max(source.scrollWidth, source.parentElement.clientWidth - gutter.offsetWidth)}px`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function setStatus(kind, text) {
|
|
258
|
+
$("status").className = `status ${kind}`;
|
|
259
|
+
$("status-text").textContent = text;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function showSvg(svg) {
|
|
263
|
+
const stage = $("stage");
|
|
264
|
+
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
|
265
|
+
let img = stage.querySelector("img");
|
|
266
|
+
if (!img) { stage.innerHTML = ""; img = document.createElement("img"); img.alt = "Rendered wireframe"; stage.appendChild(img); }
|
|
267
|
+
const old = img.src;
|
|
268
|
+
img.src = url;
|
|
269
|
+
if (old.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(old), 1000);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showIssues(issues) {
|
|
273
|
+
badLines = new Set(issues.map((i) => i.line));
|
|
274
|
+
const box = $("issues");
|
|
275
|
+
box.innerHTML = "";
|
|
276
|
+
for (const i of issues) {
|
|
277
|
+
const row = document.createElement("div");
|
|
278
|
+
row.className = "issue";
|
|
279
|
+
row.innerHTML = `<span class="ln">line ${i.line}</span><span></span>`;
|
|
280
|
+
row.lastChild.textContent = i.message;
|
|
281
|
+
row.addEventListener("click", () => goToLine(i.line));
|
|
282
|
+
box.appendChild(row);
|
|
283
|
+
}
|
|
284
|
+
paintGutter();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function goToLine(n) {
|
|
288
|
+
const lines = source.value.split("\n");
|
|
289
|
+
const start = lines.slice(0, n - 1).reduce((a, l) => a + l.length + 1, 0);
|
|
290
|
+
source.focus();
|
|
291
|
+
source.setSelectionRange(start, start + (lines[n - 1] ?? "").length);
|
|
292
|
+
$("code").scrollTop = Math.max(0, (n - 4) * 20);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function render() {
|
|
296
|
+
const mine = ++seq;
|
|
297
|
+
const text = source.value;
|
|
298
|
+
store.set("wf.source", text);
|
|
299
|
+
if (!text.trim()) {
|
|
300
|
+
showIssues([]);
|
|
301
|
+
setStatus("", "Empty");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
setStatus("", "Rendering…");
|
|
305
|
+
try {
|
|
306
|
+
const res = await fetch("/api/render", { method: "POST", body: text });
|
|
307
|
+
const data = await res.json();
|
|
308
|
+
if (mine !== seq) return; // a newer edit is already rendering
|
|
309
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
310
|
+
showIssues(data.issues);
|
|
311
|
+
const stale = !data.svg;
|
|
312
|
+
if (data.svg) { lastSvg = data.svg; showSvg(data.svg); }
|
|
313
|
+
$("stage").classList.toggle("stale", stale && !!lastSvg);
|
|
314
|
+
["copy-svg", "dl-svg", "dl-png"].forEach((id) => ($(id).disabled = stale));
|
|
315
|
+
if (stale) setStatus("bad", `${data.issues.length} problem${data.issues.length === 1 ? "" : "s"}${lastSvg ? " · showing last valid render" : ""}`);
|
|
316
|
+
else setStatus("ok", "Valid");
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (mine !== seq) return;
|
|
319
|
+
setStatus("bad", `Render failed: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let timer;
|
|
324
|
+
source.addEventListener("input", () => { paintGutter(); clearTimeout(timer); timer = setTimeout(render, 250); });
|
|
325
|
+
source.addEventListener("keydown", (e) => {
|
|
326
|
+
if (e.key === "Tab" && !e.metaKey && !e.ctrlKey) {
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
const { selectionStart: s, selectionEnd: end, value } = source;
|
|
329
|
+
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
|
|
330
|
+
if (e.shiftKey) {
|
|
331
|
+
if (value.startsWith(" ", lineStart)) {
|
|
332
|
+
source.setRangeText("", lineStart, lineStart + 2, "preserve");
|
|
333
|
+
source.setSelectionRange(Math.max(lineStart, s - 2), Math.max(lineStart, end - 2));
|
|
334
|
+
}
|
|
335
|
+
} else if (s === end) {
|
|
336
|
+
source.setRangeText(" ", s, end, "end");
|
|
337
|
+
} else {
|
|
338
|
+
// indent every selected line
|
|
339
|
+
const block = value.slice(lineStart, end);
|
|
340
|
+
const out = block.replace(/^/gm, " ");
|
|
341
|
+
source.setRangeText(out, lineStart, end, "select");
|
|
342
|
+
}
|
|
343
|
+
source.dispatchEvent(new Event("input"));
|
|
344
|
+
} else if (e.key === "Enter") {
|
|
345
|
+
// keep the current line's indentation
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
const s = source.selectionStart;
|
|
348
|
+
const lineStart = source.value.lastIndexOf("\n", s - 1) + 1;
|
|
349
|
+
const indent = source.value.slice(lineStart).match(/^ */)[0];
|
|
350
|
+
source.setRangeText("\n" + indent, s, source.selectionEnd, "end");
|
|
351
|
+
source.dispatchEvent(new Event("input"));
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
// Pasting a whole model reply: keep just the fenced block, drop the prose around it.
|
|
355
|
+
source.addEventListener("paste", (e) => {
|
|
356
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
357
|
+
const m = text.match(/```[^\n]*\n([\s\S]*?)```/);
|
|
358
|
+
if (!m || text.trim() === m[0].trim()) return;
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
source.setRangeText(m[1], source.selectionStart, source.selectionEnd, "end");
|
|
361
|
+
source.dispatchEvent(new Event("input"));
|
|
362
|
+
toast("Pasted the wireframe from the code block");
|
|
363
|
+
});
|
|
364
|
+
window.addEventListener("resize", paintGutter);
|
|
365
|
+
|
|
366
|
+
$("zoom").addEventListener("click", () => {
|
|
367
|
+
const fit = $("stage").classList.toggle("fit");
|
|
368
|
+
$("zoom").textContent = fit ? "Actual size" : "Fit";
|
|
369
|
+
});
|
|
370
|
+
$("copy-svg").addEventListener("click", () => lastSvg && copy(lastSvg, "SVG copied"));
|
|
371
|
+
$("dl-svg").addEventListener("click", () => lastSvg && download(new Blob([lastSvg], { type: "image/svg+xml" }), fileName("svg")));
|
|
372
|
+
$("dl-png").addEventListener("click", async () => {
|
|
373
|
+
const res = await fetch("/api/png?scale=2", { method: "POST", body: source.value });
|
|
374
|
+
if (!res.ok) return toast("Fix the problems first");
|
|
375
|
+
download(await res.blob(), fileName("png"));
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
function loadIntoEditor(text) {
|
|
379
|
+
source.value = text.replace(/\s+$/, "") + "\n";
|
|
380
|
+
paintGutter();
|
|
381
|
+
render();
|
|
382
|
+
showView("editor");
|
|
383
|
+
$("code").scrollTop = 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let examples = [];
|
|
387
|
+
fetch("/api/examples").then((r) => r.json()).then((list) => {
|
|
388
|
+
examples = list;
|
|
389
|
+
for (const e of list) $("examples").add(new Option(e.name, e.name));
|
|
390
|
+
if (!source.value) loadIntoEditor(list[0]?.source ?? 'board "Untitled"\n screen phone "Home"\n heading "Hello"\n');
|
|
391
|
+
});
|
|
392
|
+
$("examples").addEventListener("change", (e) => {
|
|
393
|
+
const ex = examples.find((x) => x.name === e.target.value);
|
|
394
|
+
if (ex && (source.value.trim() === "" || confirm("Replace the editor contents with this example?"))) loadIntoEditor(ex.source);
|
|
395
|
+
e.target.value = "";
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ── Components ──────────────────────────────────────────────────────────
|
|
399
|
+
let referenceLoaded = false;
|
|
400
|
+
async function loadReference() {
|
|
401
|
+
if (referenceLoaded) return;
|
|
402
|
+
referenceLoaded = true;
|
|
403
|
+
const { groups, components } = await (await fetch("/api/reference")).json();
|
|
404
|
+
const byName = Object.fromEntries(components.map((c) => [c.name, c]));
|
|
405
|
+
const body = $("docs-body");
|
|
406
|
+
body.className = "";
|
|
407
|
+
body.innerHTML = "";
|
|
408
|
+
const toc = $("toc");
|
|
409
|
+
for (const g of groups) {
|
|
410
|
+
const h = document.createElement("h3");
|
|
411
|
+
h.textContent = g.name;
|
|
412
|
+
toc.appendChild(h);
|
|
413
|
+
const gt = document.createElement("div");
|
|
414
|
+
gt.className = "group-title";
|
|
415
|
+
gt.textContent = g.name;
|
|
416
|
+
body.appendChild(gt);
|
|
417
|
+
for (const name of g.components) {
|
|
418
|
+
const c = byName[name];
|
|
419
|
+
const a = document.createElement("a");
|
|
420
|
+
a.href = `#c-${name}`;
|
|
421
|
+
a.textContent = name;
|
|
422
|
+
a.addEventListener("click", (e) => { e.preventDefault(); $(`c-${name}`).scrollIntoView({ behavior: "smooth" }); });
|
|
423
|
+
toc.appendChild(a);
|
|
424
|
+
body.appendChild(componentCard(c));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function el(tag, cls, text) {
|
|
430
|
+
const e = document.createElement(tag);
|
|
431
|
+
if (cls) e.className = cls;
|
|
432
|
+
if (text != null) e.textContent = text;
|
|
433
|
+
return e;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function componentCard(c) {
|
|
437
|
+
const card = el("article", "comp");
|
|
438
|
+
card.id = `c-${c.name}`;
|
|
439
|
+
const head = el("div", "comp-head");
|
|
440
|
+
const h = el("h2", null, c.name + " ");
|
|
441
|
+
h.appendChild(el("code", null, c.name.toLowerCase()));
|
|
442
|
+
head.append(h, el("span", "grow"));
|
|
443
|
+
const open = el("button", "btn", "Open in editor");
|
|
444
|
+
open.addEventListener("click", () => {
|
|
445
|
+
if (source.value.trim() && !confirm("Replace the editor contents with this example?")) return;
|
|
446
|
+
loadIntoEditor(c.example);
|
|
447
|
+
});
|
|
448
|
+
head.appendChild(open);
|
|
449
|
+
card.append(head, el("p", null, c.description));
|
|
450
|
+
|
|
451
|
+
const bodyEl = el("div", "comp-body");
|
|
452
|
+
const shot = el("div", "shot");
|
|
453
|
+
const img = el("img");
|
|
454
|
+
img.alt = `${c.name} example`;
|
|
455
|
+
img.loading = "lazy";
|
|
456
|
+
img.src = URL.createObjectURL(new Blob([c.svg], { type: "image/svg+xml" }));
|
|
457
|
+
shot.appendChild(img);
|
|
458
|
+
|
|
459
|
+
const right = el("div");
|
|
460
|
+
if (c.props.length) {
|
|
461
|
+
const table = el("table", "props");
|
|
462
|
+
table.innerHTML = "<thead><tr><th>Prop</th><th>Values</th></tr></thead>";
|
|
463
|
+
const tb = el("tbody");
|
|
464
|
+
for (const p of c.props) {
|
|
465
|
+
const tr = el("tr");
|
|
466
|
+
const name = el("td", null, p.name);
|
|
467
|
+
if (p.main) name.appendChild(el("span", "tag", "main"));
|
|
468
|
+
if (p.required) name.appendChild(el("span", "tag", "required"));
|
|
469
|
+
const val = el("td");
|
|
470
|
+
val.appendChild(el("div", "type", p.type));
|
|
471
|
+
if (p.description) val.appendChild(el("div", "desc", p.description));
|
|
472
|
+
tr.append(name, val);
|
|
473
|
+
tb.appendChild(tr);
|
|
474
|
+
}
|
|
475
|
+
table.appendChild(tb);
|
|
476
|
+
right.appendChild(table);
|
|
477
|
+
} else {
|
|
478
|
+
right.appendChild(el("p", "desc", "No props."));
|
|
479
|
+
}
|
|
480
|
+
right.appendChild(el("pre", "src", c.example));
|
|
481
|
+
bodyEl.append(shot, right);
|
|
482
|
+
card.appendChild(bodyEl);
|
|
483
|
+
return card;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Prompt ──────────────────────────────────────────────────────────────
|
|
487
|
+
let promptText = null;
|
|
488
|
+
async function loadPrompt() {
|
|
489
|
+
if (promptText) return;
|
|
490
|
+
promptText = await (await fetch("/api/prompt")).text();
|
|
491
|
+
$("prompt-text").textContent = promptText;
|
|
492
|
+
}
|
|
493
|
+
$("copy-prompt").addEventListener("click", async () => { await loadPrompt(); copy(promptText, "Prompt copied"); });
|
|
494
|
+
|
|
495
|
+
// ── Start ───────────────────────────────────────────────────────────────
|
|
496
|
+
const saved = store.get("wf.source");
|
|
497
|
+
if (saved && saved.trim()) { source.value = saved; paintGutter(); render(); }
|
|
498
|
+
showView(store.get("wf.view") || "editor");
|
|
499
|
+
</script>
|
|
500
|
+
</body>
|
|
501
|
+
</html>
|