tsquare 0.2.0 → 0.3.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.
@@ -5,84 +5,177 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>tsquare Playground</title>
7
7
  <link rel="icon" type="image/png" href="/mark.png">
8
+ <script>
9
+ // Apply the saved or system theme before first paint, so there's no flash.
10
+ try { document.documentElement.dataset.theme = localStorage.getItem("wf.theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); } catch {}
11
+ </script>
8
12
  <style>
9
- :root {
13
+ :root, :root[data-theme="light"] {
10
14
  --bg: #f4f5f7;
11
15
  --panel: #ffffff;
12
16
  --ink: #1f2328;
13
17
  --text: #3d434b;
14
- --muted: #7a818b;
18
+ --muted: #6f7782;
15
19
  --line: #dfe2e6;
16
20
  --line-strong: #c7cbd1;
17
21
  --fill: #eef0f3;
18
- --accent: #2b3036;
19
- --on-accent: #ffffff;
22
+ --stage: #e6e8eb;
23
+ --primary: #1f6fd6; /* tsquare blue, deep enough for white text (4.9:1) */
24
+ --on-primary: #ffffff;
25
+ --accent-soft: rgba(59, 153, 251, 0.16);
20
26
  --error: #c2362b;
21
27
  --error-bg: #fcefed;
22
28
  --ok: #2f7d4f;
29
+ --shadow: 0 12px 32px -12px rgba(15, 23, 42, 0.28);
30
+ --editor-bg: #ffffff;
31
+ --editor-ink: #1f2328;
32
+ /* translucent: CodeMirror draws selections behind the text, so an opaque current-line color would hide them */
33
+ --editor-active: rgba(31, 111, 214, 0.045);
34
+ --editor-selection: rgba(31, 111, 214, 0.26);
35
+ --syn-component: #0b3d91;
36
+ --syn-string: #17754a;
37
+ --syn-prop: #6d28d9;
38
+ --syn-number: #b45309;
39
+ --syn-word: #0e7490;
40
+ --syn-color: #be185d;
41
+ --syn-comment: #8b95a5;
42
+ --syn-punct: #5b6472;
23
43
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
24
44
  --sans: Inter, ui-sans-serif, -apple-system, "Segoe UI", sans-serif;
45
+ color-scheme: light;
46
+ }
47
+ :root[data-theme="dark"] {
48
+ --bg: #0e131a;
49
+ --panel: #151c25;
50
+ --ink: #e6ebf2;
51
+ --text: #c3cad4;
52
+ --muted: #8b95a5;
53
+ --line: #253041;
54
+ --line-strong: #344155;
55
+ --fill: #1c2532;
56
+ --stage: #0a0e13;
57
+ --primary: #2f7fe6;
58
+ --on-primary: #ffffff;
59
+ --accent-soft: rgba(59, 153, 251, 0.22);
60
+ --error: #ff7b70;
61
+ --error-bg: #3a1d1c;
62
+ --ok: #5cc98a;
63
+ --shadow: 0 16px 40px -12px rgba(0, 0, 0, 0.7);
64
+ --editor-bg: #111821;
65
+ --editor-ink: #e6ebf2;
66
+ --editor-active: rgba(255, 255, 255, 0.035);
67
+ --editor-selection: rgba(80, 150, 255, 0.36);
68
+ --syn-component: #8ab4ff;
69
+ --syn-string: #7ee2a8;
70
+ --syn-prop: #c4a7ff;
71
+ --syn-number: #f5b971;
72
+ --syn-word: #67d4e8;
73
+ --syn-color: #f49ac1;
74
+ --syn-comment: #6f7a8a;
75
+ --syn-punct: #9aa4b2;
76
+ color-scheme: dark;
25
77
  }
26
78
  * { box-sizing: border-box; }
27
79
  html, body { height: 100%; margin: 0; }
28
80
  body { background: var(--bg); color: var(--ink); font: 14px/1.45 var(--sans); display: flex; flex-direction: column; }
29
- button, select { font: inherit; }
81
+ button, select { font: inherit; color: inherit; }
82
+ [data-icon] { display: inline-flex; }
83
+ [data-icon] svg { width: 16px; height: 16px; }
30
84
 
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; }
85
+ header { display: flex; align-items: center; gap: 20px; padding: 0 12px 0 16px; height: 52px; background: var(--panel); border-bottom: 1px solid var(--line); flex-shrink: 0; }
32
86
  header h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; }
33
87
  header h1 img { width: 26px; height: 26px; border-radius: 6px; }
88
+ header .brand { display: flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; border-radius: 6px; }
89
+ header .brand:hover { opacity: .8; }
90
+ header .spacer { flex: 1; }
34
91
  nav { display: flex; gap: 4px; }
35
92
  nav button { border: 0; background: none; padding: 6px 12px; border-radius: 6px; color: var(--muted); cursor: pointer; font-weight: 500; }
36
93
  nav button:hover { color: var(--ink); background: var(--fill); }
37
94
  nav button[aria-selected="true"] { color: var(--ink); background: var(--fill); }
38
95
 
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; }
96
+ .btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line-strong); background: var(--panel); padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; }
40
97
  .btn:hover { border-color: var(--muted); }
41
98
  .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; }
99
+ .btn.primary { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
100
+ .btn.icon-only { padding: 5px 7px; }
101
+ .btn.quiet { border-color: transparent; background: none; }
102
+ .btn.quiet:hover { background: var(--fill); }
44
103
 
45
104
  main { flex: 1; min-height: 0; display: flex; }
46
105
  .view { flex: 1; min-width: 0; display: none; }
47
106
  .view.active { display: flex; }
48
107
 
49
- /* Editor */
50
- #editor-view { gap: 0; }
108
+ /* ── Editor view ── */
51
109
  .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; }
110
+ .editor-pane { width: var(--split, 42%); min-width: 280px; background: var(--editor-bg); }
111
+ .splitter { width: 6px; margin: 0 -3px; cursor: col-resize; position: relative; z-index: 2; flex-shrink: 0; }
112
+ .splitter::after { content: ""; position: absolute; inset: 0 2px; background: var(--line); transition: background .15s; }
113
+ .splitter:hover::after, .splitter.dragging::after { background: var(--primary); }
114
+ .toolbar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); flex-shrink: 0; background: var(--panel); min-height: 47px; min-width: 0; }
54
115
  .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; }
116
+ .doc-title { font-weight: 600; font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
117
+ .code { flex: 1; min-height: 0; overflow: hidden; }
118
+ .issues { border-top: 1px solid var(--line); max-height: 30%; overflow: auto; flex-shrink: 0; background: var(--panel); }
61
119
  .issues:empty { display: none; }
62
- .issue { display: flex; gap: 10px; padding: 7px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; }
120
+ .issue { display: flex; gap: 10px; padding: 7px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; width: 100%; text-align: left; background: none; border-left: 0; border-right: 0; border-top: 0; }
63
121
  .issue:hover { background: var(--error-bg); }
64
122
  .issue .ln { color: var(--error); font: 600 12px/20px var(--mono); flex-shrink: 0; min-width: 52px; }
65
123
 
66
- .preview-pane { flex: 1; }
67
- .status { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
124
+ .preview-pane { flex: 1; container-type: inline-size; }
125
+ /* Narrow preview: long button labels collapse to their icons (SVG/PNG keep their text) */
126
+ @container (max-width: 620px) { .label-wide { display: none; } .btn:has(.label-wide) { padding: 5px 7px; } }
127
+ @container (max-width: 470px) { .zoom-level { display: none; } }
128
+ .status { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
68
129
  .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
69
130
  .status.ok .dot { background: var(--ok); }
70
131
  .status.bad { color: var(--error); }
71
132
  .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 */
133
+ .zoom-level { min-width: 52px; justify-content: center; font-variant-numeric: tabular-nums; }
134
+ .screens { display: flex; gap: 4px; padding: 6px 10px; border-bottom: 1px solid var(--line); background: var(--panel); overflow-x: auto; scrollbar-width: none; flex-shrink: 0; }
135
+ .screens::-webkit-scrollbar { display: none; }
136
+ .screens:empty { display: none; }
137
+ .screens button { border: 1px solid var(--line); background: none; border-radius: 999px; padding: 3px 10px; font-size: 12.5px; color: var(--text); cursor: pointer; white-space: nowrap; }
138
+ .screens button:hover { border-color: var(--line-strong); }
139
+ .screens button[aria-pressed="true"] { background: var(--accent-soft); border-color: transparent; color: var(--ink); font-weight: 600; }
140
+ .stage { flex: 1; min-height: 0; overflow: hidden; position: relative; background: var(--stage); cursor: grab; touch-action: none; }
141
+ .stage.panning { cursor: grabbing; }
142
+ .stage .canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
143
+ .stage .canvas img { display: block; user-select: none; -webkit-user-drag: none; transition: opacity .15s; }
144
+ .stage.stale .canvas img { opacity: .35; }
145
+ .stage .empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
146
+ .stage.animate .canvas { transition: transform .25s ease; }
147
+ .preview-pane:fullscreen { background: var(--bg); }
148
+
149
+ /* ── Menu (share links) ── */
150
+ .menu-wrap { position: relative; }
151
+ .menu { position: absolute; right: 0; top: calc(100% + 6px); min-width: 250px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); padding: 6px; z-index: 20; display: none; }
152
+ .menu.open { display: block; }
153
+ .menu button { display: flex; align-items: flex-start; gap: 10px; width: 100%; text-align: left; border: 0; background: none; padding: 8px 10px; border-radius: 7px; cursor: pointer; }
154
+ .menu button:hover, .menu button:focus-visible { background: var(--fill); outline: none; }
155
+ .menu button small { display: block; color: var(--muted); font-size: 12px; }
156
+ .menu hr { border: 0; border-top: 1px solid var(--line); margin: 6px 4px; }
157
+
158
+ /* ── Examples gallery ── */
159
+ dialog { border: 1px solid var(--line); border-radius: 14px; padding: 0; background: var(--panel); color: var(--ink); width: min(920px, calc(100vw - 32px)); max-height: calc(100vh - 64px); box-shadow: var(--shadow); }
160
+ dialog::backdrop { background: rgba(10, 14, 20, .45); }
161
+ .dialog-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--panel); }
162
+ .dialog-head h2 { margin: 0; font-size: 16px; }
163
+ .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; padding: 16px; }
164
+ .card { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); cursor: pointer; padding: 0; text-align: left; overflow: hidden; display: flex; flex-direction: column; }
165
+ .card:hover, .card:focus-visible { border-color: var(--primary); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
166
+ .card .thumb { height: 140px; background: var(--stage); display: flex; align-items: center; justify-content: center; padding: 8px; overflow: hidden; }
167
+ .card .thumb img { max-width: 100%; max-height: 124px; object-fit: contain; }
168
+ .card .thumb.blank { color: var(--muted); font-size: 13px; }
169
+ .card .name { padding: 9px 12px; font-weight: 600; font-size: 13px; }
170
+
171
+ /* ── Components view ── */
79
172
  #components-view { min-height: 0; }
80
173
  .toc { width: 200px; flex-shrink: 0; overflow: auto; padding: 16px 8px 32px 16px; border-right: 1px solid var(--line); background: var(--panel); }
81
174
  .toc h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 16px 0 4px; font-weight: 600; }
82
175
  .toc h3:first-child { margin-top: 0; }
83
176
  .toc a { display: block; padding: 3px 8px; margin-left: -8px; border-radius: 5px; color: var(--text); text-decoration: none; }
84
177
  .toc a:hover { background: var(--fill); color: var(--ink); }
85
- .docs { flex: 1; overflow: auto; padding: 24px 32px 80px; scroll-padding-top: 16px; }
178
+ .docs { flex: 1; overflow: auto; padding: 24px 32px 80px; }
86
179
  .docs-intro { max-width: 760px; color: var(--text); margin-bottom: 24px; }
87
180
  .docs-intro code, .comp code, .prompt-help code { font: 12.5px var(--mono); background: var(--fill); padding: 1px 5px; border-radius: 4px; }
88
181
  .group-title { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 40px 0 12px; font-weight: 600; }
@@ -94,7 +187,7 @@
94
187
  .comp > p { margin: 4px 20px 12px; color: var(--text); }
95
188
  .comp-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); border-top: 1px solid var(--line); }
96
189
  .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); }
190
+ .shot { background: var(--stage); display: flex; align-items: center; justify-content: center; padding: 12px; border-right: 1px solid var(--line); }
98
191
  .shot img { max-width: 100%; max-height: 420px; }
99
192
  .props { width: 100%; border-collapse: collapse; font-size: 13px; }
100
193
  .props th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; padding: 10px 14px 6px; }
@@ -105,60 +198,86 @@
105
198
  .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
199
  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
200
 
108
- /* Prompt */
201
+ /* ── Prompt view ── */
109
202
  #prompt-view { flex-direction: column; min-height: 0; }
110
203
  .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
204
  .prompt-help ol { margin: 0; padding-left: 20px; color: var(--text); }
112
205
  .prompt-help .grow { flex: 1; }
113
206
  #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
207
 
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%); }
208
+ /* ── Toast ── */
209
+ .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--ink); color: var(--panel); padding: 8px 8px 8px 14px; border-radius: 10px; font-size: 13px; opacity: 0; transition: .2s; pointer-events: none; display: flex; align-items: center; gap: 12px; z-index: 50; box-shadow: var(--shadow); }
210
+ .toast.show { opacity: 1; transform: translateX(-50%); pointer-events: auto; }
211
+ .toast button { border: 0; background: rgba(127, 127, 127, .25); color: inherit; padding: 4px 10px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 12.5px; }
212
+ .toast button:empty { display: none; }
213
+ .toast:not(:has(button:not(:empty))) { padding-right: 14px; }
117
214
 
118
215
  @media (max-width: 860px) {
119
216
  #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); }
217
+ .editor-pane { width: auto; min-width: 0; height: 48%; border-bottom: 1px solid var(--line); }
218
+ .splitter { display: none; }
121
219
  .toc { display: none; }
122
220
  .docs { padding: 16px 16px 60px; }
123
221
  .comp-body { grid-template-columns: 1fr; }
124
222
  .shot { border-right: 0; border-bottom: 1px solid var(--line); }
125
- header { gap: 12px; }
223
+ header { gap: 10px; }
224
+ }
225
+ @media (max-width: 480px) {
226
+ header { gap: 6px; padding: 0 8px 0 12px; }
227
+ header h1 { font-size: 0; } /* keep the logo mark, drop the word */
228
+ nav button { padding: 6px 8px; }
126
229
  }
127
230
  </style>
128
231
  </head>
129
232
  <body>
130
233
  <header>
131
- <h1><img src="/mark.png" alt="">tsquare</h1>
234
+ <h1><a class="brand" id="brand" href="https://tsquare.dev"><img src="/mark.png" alt="">tsquare</a></h1>
132
235
  <nav role="tablist">
133
236
  <button role="tab" data-view="editor" aria-selected="true">Editor</button>
134
237
  <button role="tab" data-view="components" aria-selected="false">Components</button>
135
238
  <button role="tab" data-view="prompt" aria-selected="false">Prompt</button>
136
239
  </nav>
240
+ <span class="spacer"></span>
241
+ <button class="btn quiet icon-only" id="theme" type="button" aria-label="Switch to dark mode" title="Switch theme"><span data-icon="moon"></span></button>
137
242
  </header>
138
243
 
139
244
  <main>
140
245
  <section id="editor-view" class="view active">
141
246
  <div class="pane editor-pane">
142
247
  <div class="toolbar">
143
- <select id="examples" class="btn" aria-label="Load an example"><option value="">Load example…</option></select>
248
+ <button class="btn" id="open-examples" type="button"><span data-icon="layout-grid"></span>Examples</button>
249
+ <span class="doc-title" id="doc-title" title="The board's title"></span>
144
250
  <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>
251
+ <span class="status" id="status"><span class="dot"></span><span id="status-text">Loading…</span></span>
150
252
  </div>
253
+ <div class="code" id="code"></div>
151
254
  <div class="issues" id="issues"></div>
152
255
  </div>
153
- <div class="pane preview-pane">
256
+ <div class="splitter" id="splitter" role="separator" aria-orientation="vertical" aria-label="Resize editor" tabindex="0"></div>
257
+ <div class="pane preview-pane" id="preview-pane">
154
258
  <div class="toolbar">
155
- <button class="btn" id="zoom">Actual size</button>
259
+ <button class="btn icon-only" id="zoom-out" type="button" aria-label="Zoom out" title="Zoom out"><span data-icon="zoom-out"></span></button>
260
+ <button class="btn zoom-level" id="zoom-fit" type="button" title="Fit to the window">100%</button>
261
+ <button class="btn icon-only" id="zoom-in" type="button" aria-label="Zoom in" title="Zoom in"><span data-icon="zoom-in"></span></button>
156
262
  <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>
263
+ <div class="menu-wrap">
264
+ <button class="btn" id="share" type="button" aria-haspopup="menu" aria-expanded="false" title="Copy a link" disabled><span data-icon="link"></span><span class="label-wide">Copy link</span></button>
265
+ <div class="menu" id="share-menu" role="menu">
266
+ <button role="menuitem" data-copy="share"><span data-icon="share-2"></span><span>Share link<small>Opens this wireframe in the playground</small></span></button>
267
+ <hr>
268
+ <button role="menuitem" data-copy="svg"><span data-icon="image"></span><span>Image link (SVG)<small>Embed anywhere an image works</small></span></button>
269
+ <button role="menuitem" data-copy="png"><span data-icon="image"></span><span>Image link (PNG)<small>For tools that don't show SVG</small></span></button>
270
+ <button role="menuitem" data-copy="markdown"><span data-icon="file-text"></span><span>Markdown<small>For READMEs and docs</small></span></button>
271
+ <button role="menuitem" data-copy="html"><span data-icon="code"></span><span>HTML<small>An &lt;img&gt; tag</small></span></button>
272
+ </div>
273
+ </div>
274
+ <button class="btn" id="copy-svg" type="button" disabled title="Copy SVG"><span data-icon="copy"></span><span class="label-wide">Copy SVG</span></button>
275
+ <button class="btn" id="dl-svg" type="button" disabled title="Download SVG"><span data-icon="download"></span>SVG</button>
276
+ <button class="btn" id="dl-png" type="button" disabled title="Download PNG"><span data-icon="download"></span>PNG</button>
277
+ <button class="btn icon-only" id="fullscreen" type="button" aria-label="Full screen" title="Full screen"><span data-icon="maximize"></span></button>
160
278
  </div>
161
- <div class="stage fit" id="stage"><div class="empty">Nothing rendered yet</div></div>
279
+ <div class="screens" id="screens" aria-label="Zoom to a screen"></div>
280
+ <div class="stage" id="stage"><div class="canvas" id="canvas"></div><div class="empty" id="empty">Nothing rendered yet</div></div>
162
281
  </div>
163
282
  </section>
164
283
 
@@ -172,7 +291,7 @@
172
291
  Lists use commas, and items can contain spaces: <code>[All notes, Pinned]</code>. Quote an item that contains a comma:
173
292
  <code>["$1,200", "Smith, J"]</code>. Objects are <code>{key=value key=value}</code>.
174
293
  </div>
175
- <div id="docs-body" class="empty">Loading…</div>
294
+ <div id="docs-body">Loading…</div>
176
295
  </div>
177
296
  </section>
178
297
 
@@ -184,318 +303,23 @@
184
303
  <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
304
  </ol>
186
305
  <span class="grow"></span>
187
- <button class="btn primary" id="copy-prompt">Copy prompt</button>
306
+ <button class="btn primary" id="copy-prompt" type="button"><span data-icon="copy"></span>Copy prompt</button>
188
307
  </div>
189
308
  <pre id="prompt-text">Loading…</pre>
190
309
  </section>
191
310
  </main>
192
311
 
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
- }
312
+ <dialog id="examples-dialog" aria-labelledby="examples-title">
313
+ <div class="dialog-head">
314
+ <h2 id="examples-title">Examples</h2>
315
+ <span class="grow" style="flex:1"></span>
316
+ <button class="btn quiet icon-only" type="button" id="close-examples" aria-label="Close"><span data-icon="x"></span></button>
317
+ </div>
318
+ <div class="gallery" id="gallery"></div>
319
+ </dialog>
385
320
 
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
- });
321
+ <div class="toast" id="toast" role="status"><span id="toast-text"></span><button type="button" id="toast-action"></button></div>
397
322
 
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>
323
+ <script type="module" src="/playground.js"></script>
500
324
  </body>
501
325
  </html>