@milesroxas/create-design-hub 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +14 -2
- package/index.mjs +154 -0
- package/lib.mjs +65 -0
- package/package.json +37 -3
- package/setup/.fallowrc.jsonc +6 -0
- package/setup/app.html +861 -0
- package/setup/lib.mjs +147 -0
- package/setup/lib.test.mjs +96 -0
- package/setup/server.mjs +444 -0
- package/setup/server.test.mjs +99 -0
- package/setup/slug.mjs +15 -0
- package/template/.claude/launch.json +18 -0
- package/template/.claude/settings.json +35 -0
- package/template/.design-hub/checks.json +9 -0
- package/template/.env.example +28 -0
- package/template/.impeccable/config.json +16 -0
- package/template/AGENTS.md +133 -0
- package/template/CLAUDE.md +1 -0
- package/template/DESIGN.md +126 -0
- package/template/Design Hub.command +11 -0
- package/template/PRODUCT.md +65 -0
- package/template/README.md +20 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +41 -0
- package/template/api/review.ts +5 -0
- package/template/docs/.obsidian/app.json +6 -0
- package/template/docs/.obsidian/appearance.json +1 -0
- package/template/docs/.obsidian/core-plugins.json +32 -0
- package/template/docs/README.md +19 -0
- package/template/docs/sprints/sample/guide.md +69 -0
- package/template/kit/AppShell.tsx +149 -0
- package/template/kit/SiteShell.tsx +75 -0
- package/template/kit/app-shell.css +70 -0
- package/template/kit/icons.ts +11 -0
- package/template/kit/shell.ts +59 -0
- package/template/kit/site-shell.css +27 -0
- package/template/kit/tokens.css +249 -0
- package/template/package.json +38 -0
- package/template/plans/README.md +11 -0
- package/template/prototypes/sample-list/README.md +51 -0
- package/template/prototypes/sample-list/fixtures.json +87 -0
- package/template/prototypes/sample-list/index.tsx +122 -0
- package/template/prototypes/sample-list/styles.css +25 -0
- package/template/proxy.ts +4 -0
- package/template/sketches/paper/README.md +3 -0
- package/template/tsconfig.json +10 -0
- package/template/vercel.json +49 -0
package/setup/app.html
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
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
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>Design Hub</title>
|
|
8
|
+
<style>
|
|
9
|
+
/* The hub's neutral system look (design-hub's shell/system.css), restated here
|
|
10
|
+
because this page runs before anything is installed: pure greys, hue only
|
|
11
|
+
for status, the system font until Geist is on disk.
|
|
12
|
+
One scale for each thing: type 12/13/14/24, space 4/8/12/16/24/32,
|
|
13
|
+
controls 40px tall, radius 8 on controls and 12 on cards. */
|
|
14
|
+
:root {
|
|
15
|
+
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
16
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
17
|
+
|
|
18
|
+
--text: oklch(0.205 0 0);
|
|
19
|
+
--text-2: oklch(0.44 0 0);
|
|
20
|
+
--text-3: oklch(0.56 0 0);
|
|
21
|
+
--bg: oklch(0.97 0 0);
|
|
22
|
+
--surface: oklch(1 0 0);
|
|
23
|
+
--sunken: oklch(0.965 0 0);
|
|
24
|
+
--hover: oklch(0.955 0 0);
|
|
25
|
+
--border: oklch(0 0 0 / 0.08);
|
|
26
|
+
--border-strong: oklch(0 0 0 / 0.15);
|
|
27
|
+
--accent: oklch(0.205 0 0);
|
|
28
|
+
--on-accent: oklch(0.985 0 0);
|
|
29
|
+
--disabled-bg: oklch(0.93 0 0);
|
|
30
|
+
--disabled-text: oklch(0.62 0 0);
|
|
31
|
+
--ok: oklch(0.627 0.194 149.214);
|
|
32
|
+
--warn: oklch(0.58 0.15 58);
|
|
33
|
+
--error: oklch(0.577 0.245 27.325);
|
|
34
|
+
--error-tint: oklch(0.97 0.015 27);
|
|
35
|
+
--ring: oklch(0.556 0 0 / 0.45);
|
|
36
|
+
--panel-shadow: 0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04), 0 8px 24px -8px oklch(0 0 0 / 0.08);
|
|
37
|
+
|
|
38
|
+
--text-xs: 12px;
|
|
39
|
+
--text-sm: 13px;
|
|
40
|
+
--text-md: 14px;
|
|
41
|
+
--text-xl: 24px;
|
|
42
|
+
|
|
43
|
+
--s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px;
|
|
44
|
+
--control: 40px;
|
|
45
|
+
--r-control: 8px;
|
|
46
|
+
--r-card: 12px;
|
|
47
|
+
--r-panel: 16px;
|
|
48
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
49
|
+
--swap-from: 0.9;
|
|
50
|
+
}
|
|
51
|
+
@media (prefers-color-scheme: dark) {
|
|
52
|
+
:root {
|
|
53
|
+
--text: oklch(0.97 0 0);
|
|
54
|
+
--text-2: oklch(0.76 0 0);
|
|
55
|
+
--text-3: oklch(0.62 0 0);
|
|
56
|
+
--bg: oklch(0.155 0 0);
|
|
57
|
+
--surface: oklch(0.2 0 0);
|
|
58
|
+
--sunken: oklch(0.175 0 0);
|
|
59
|
+
--hover: oklch(0.235 0 0);
|
|
60
|
+
/* Solid in the dark: alpha borders glow. */
|
|
61
|
+
--border: oklch(0.27 0 0);
|
|
62
|
+
--border-strong: oklch(0.34 0 0);
|
|
63
|
+
--accent: oklch(0.97 0 0);
|
|
64
|
+
--on-accent: oklch(0.2 0 0);
|
|
65
|
+
--disabled-bg: oklch(0.26 0 0);
|
|
66
|
+
--disabled-text: oklch(0.52 0 0);
|
|
67
|
+
--warn: oklch(0.75 0.14 70);
|
|
68
|
+
--error: oklch(0.7 0.19 25);
|
|
69
|
+
--error-tint: oklch(0.24 0.04 25);
|
|
70
|
+
--panel-shadow: 0 0 0 1px oklch(0.27 0 0), 0 8px 24px -8px oklch(0 0 0 / 0.5);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
* { box-sizing: border-box; }
|
|
75
|
+
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
|
76
|
+
body {
|
|
77
|
+
margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: var(--s6) var(--s4);
|
|
78
|
+
background: var(--bg); color: var(--text); font: var(--text-md)/1.5 var(--font);
|
|
79
|
+
}
|
|
80
|
+
::selection { background: oklch(0.556 0 0 / 0.25); }
|
|
81
|
+
/* biome-ignore lint/complexity/noImportantStyles: `hidden` must beat every component's display. */
|
|
82
|
+
[hidden] { display: none !important; }
|
|
83
|
+
code { font-family: var(--mono); font-size: 0.93em; }
|
|
84
|
+
|
|
85
|
+
/* ---------- frame ---------- */
|
|
86
|
+
|
|
87
|
+
main { width: 100%; max-width: 480px; display: grid; gap: var(--s4); }
|
|
88
|
+
.top { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--s1); height: 24px; }
|
|
89
|
+
.brand { display: flex; align-items: center; gap: var(--s2); font-size: var(--text-sm); font-weight: 500; }
|
|
90
|
+
.brand svg { width: 16px; height: 16px; }
|
|
91
|
+
.stepper { font-size: var(--text-xs); color: var(--text-3); font-variant-numeric: tabular-nums; }
|
|
92
|
+
.panel { background: var(--surface); border-radius: var(--r-panel); box-shadow: var(--panel-shadow); padding: var(--s6); }
|
|
93
|
+
.foot { margin: 0; padding: 0 var(--s1); font-size: var(--text-xs); color: var(--text-3); text-align: center; text-wrap: balance; }
|
|
94
|
+
|
|
95
|
+
/* Every screen is a column with one gap; no element carries its own margin. */
|
|
96
|
+
section { display: flex; flex-direction: column; gap: var(--s5); }
|
|
97
|
+
/* A new screen settles in from just below. Seen a handful of times, once
|
|
98
|
+
per setup, so it can carry a little motion; never on a step's own updates. */
|
|
99
|
+
section {
|
|
100
|
+
transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
|
|
101
|
+
@starting-style { opacity: 0; transform: translateY(6px); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.head { display: grid; gap: var(--s2); }
|
|
105
|
+
h1:focus { outline: none; }
|
|
106
|
+
h1 { font-size: var(--text-xl); line-height: 1.2; font-weight: 600; letter-spacing: -0.02em; margin: 0; text-wrap: balance; }
|
|
107
|
+
.lede { color: var(--text-2); margin: 0; max-width: 60ch; text-wrap: pretty; }
|
|
108
|
+
h2 { font-size: var(--text-sm); font-weight: 500; color: var(--text-2); margin: 0; }
|
|
109
|
+
|
|
110
|
+
/* ---------- fields ---------- */
|
|
111
|
+
|
|
112
|
+
form, .fields { display: flex; flex-direction: column; gap: var(--s5); }
|
|
113
|
+
.field { display: grid; gap: var(--s2); }
|
|
114
|
+
.field > label, .label { font-size: var(--text-sm); font-weight: 500; }
|
|
115
|
+
.optional { font-weight: 400; color: var(--text-3); }
|
|
116
|
+
input[type=text], input[type=password] {
|
|
117
|
+
width: 100%; height: var(--control); padding: 0 var(--s3); border-radius: var(--r-control);
|
|
118
|
+
border: 1px solid var(--border-strong); background: var(--surface); color: var(--text);
|
|
119
|
+
font: inherit; outline: none;
|
|
120
|
+
transition: border-color 150ms ease, box-shadow 150ms ease;
|
|
121
|
+
}
|
|
122
|
+
input::placeholder { color: var(--text-3); }
|
|
123
|
+
@media (hover: hover) and (pointer: fine) { input[type=text]:hover, input[type=password]:hover { border-color: var(--text-3); } }
|
|
124
|
+
input:focus-visible { border-color: var(--text-2); box-shadow: 0 0 0 3px var(--ring); }
|
|
125
|
+
input[aria-invalid=true] { border-color: var(--error); }
|
|
126
|
+
input[aria-invalid=true]:focus-visible { box-shadow: 0 0 0 3px color-mix(in oklch, var(--error) 30%, transparent); }
|
|
127
|
+
.hint { font-size: var(--text-xs); color: var(--text-3); margin: 0; }
|
|
128
|
+
.error-text { display: flex; gap: 6px; align-items: center; font-size: var(--text-xs); color: var(--error); margin: 0; }
|
|
129
|
+
.error-text:empty { display: none; }
|
|
130
|
+
.error-text::before { content: '!'; flex: none; width: 14px; height: 14px; border-radius: 50%; background: var(--error); color: var(--surface); font: 700 10px/14px var(--font); text-align: center; }
|
|
131
|
+
|
|
132
|
+
/* The save path reads as a field: same height, border and radius as an input. */
|
|
133
|
+
.where {
|
|
134
|
+
display: flex; align-items: center; gap: var(--s2); height: var(--control); padding: 0 var(--s1) 0 var(--s3);
|
|
135
|
+
border-radius: var(--r-control); background: var(--sunken); box-shadow: inset 0 0 0 1px var(--border);
|
|
136
|
+
}
|
|
137
|
+
.where code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); color: var(--text-2); }
|
|
138
|
+
|
|
139
|
+
.check { display: flex; gap: var(--s2); align-items: center; font-weight: 400; cursor: pointer; width: fit-content; }
|
|
140
|
+
.check input { appearance: none; margin: 0; flex: none; width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--border-strong); background: var(--surface); display: grid; place-items: center; cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease; }
|
|
141
|
+
.check input:checked { background: var(--accent); border-color: var(--accent); }
|
|
142
|
+
.check input:checked::after { content: ''; width: 8px; height: 4px; border-left: 1.5px solid var(--on-accent); border-bottom: 1.5px solid var(--on-accent); transform: translateY(-1px) rotate(-45deg); }
|
|
143
|
+
.check input:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
|
|
144
|
+
|
|
145
|
+
/* ---------- buttons ---------- */
|
|
146
|
+
|
|
147
|
+
button {
|
|
148
|
+
font: inherit; font-weight: 500; cursor: pointer; border: 0; border-radius: var(--r-control);
|
|
149
|
+
height: var(--control); padding: 0 var(--s4); display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
|
|
150
|
+
color: var(--text); background: none;
|
|
151
|
+
transition: transform 140ms var(--ease-out), background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
|
|
152
|
+
-webkit-tap-highlight-color: transparent; user-select: none;
|
|
153
|
+
}
|
|
154
|
+
button:active:not(:disabled) { transform: scale(0.97); }
|
|
155
|
+
button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
|
|
156
|
+
button:disabled { cursor: default; }
|
|
157
|
+
.primary { background: var(--accent); color: var(--on-accent); }
|
|
158
|
+
.primary:disabled { background: var(--disabled-bg); color: var(--disabled-text); }
|
|
159
|
+
.secondary { background: var(--surface); box-shadow: inset 0 0 0 1px var(--border-strong); }
|
|
160
|
+
.secondary:focus-visible { box-shadow: inset 0 0 0 1px var(--border-strong), 0 0 0 3px var(--ring); }
|
|
161
|
+
.secondary:disabled { color: var(--disabled-text); }
|
|
162
|
+
/* Small: sits inside a 40px field with 4px inset, so 32px tall and radius 8 - 4 + 2. */
|
|
163
|
+
.small { height: 32px; padding: 0 var(--s3); border-radius: 6px; font-size: var(--text-sm); }
|
|
164
|
+
.quiet { color: var(--text-2); }
|
|
165
|
+
@media (hover: hover) and (pointer: fine) {
|
|
166
|
+
.primary:hover:not(:disabled) { background: color-mix(in oklch, var(--accent), var(--surface) 14%); }
|
|
167
|
+
.secondary:hover:not(:disabled), .quiet:hover:not(:disabled) { background: var(--hover); color: var(--text); }
|
|
168
|
+
}
|
|
169
|
+
.actions { display: flex; gap: var(--s2); }
|
|
170
|
+
.actions .primary { flex: 1; }
|
|
171
|
+
|
|
172
|
+
/* ---------- status icons ---------- */
|
|
173
|
+
|
|
174
|
+
/* One spinner everywhere: a faint track and a head in the text color. */
|
|
175
|
+
.spin { flex: none; width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--border-strong); border-top-color: currentColor; animation: spin 650ms linear infinite; }
|
|
176
|
+
@keyframes spin { to { rotate: 1turn; } }
|
|
177
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
|
|
178
|
+
.tick, .cross { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; color: white; }
|
|
179
|
+
.tick { background: var(--ok); }
|
|
180
|
+
.cross { background: var(--error); }
|
|
181
|
+
.tick svg, .cross svg { width: 10px; height: 10px; }
|
|
182
|
+
.card-ok { color: var(--ok); }
|
|
183
|
+
/* Icons that swap in place: every child sits in one cell and only the one
|
|
184
|
+
matching the row's state shows, so nothing around it moves. */
|
|
185
|
+
.swap { display: grid; place-items: center; }
|
|
186
|
+
.swap > * { grid-area: 1 / 1; opacity: 0; transform: scale(var(--swap-from)); transition: opacity 160ms var(--ease-out), transform 160ms var(--ease-out); }
|
|
187
|
+
[data-status=waiting] .dot, [data-status=running] .spin, [data-status=done] .tick, [data-status=error] .cross,
|
|
188
|
+
.card:not([data-state]) .card-go, .card[data-state=busy] .spin, .card[data-state=done] .card-ok { opacity: 1; transform: none; }
|
|
189
|
+
|
|
190
|
+
/* ---------- choice cards ---------- */
|
|
191
|
+
|
|
192
|
+
.choices { display: grid; gap: var(--s2); }
|
|
193
|
+
.choice {
|
|
194
|
+
display: grid; grid-template-columns: 16px 1fr; gap: var(--s3); align-items: start;
|
|
195
|
+
padding: var(--s4); border-radius: var(--r-card); background: var(--surface);
|
|
196
|
+
box-shadow: inset 0 0 0 1px var(--border-strong); cursor: pointer;
|
|
197
|
+
transition: box-shadow 150ms ease, background-color 150ms ease;
|
|
198
|
+
}
|
|
199
|
+
@media (hover: hover) and (pointer: fine) { .choice:not(:has(input:disabled)):hover { background: var(--hover); } }
|
|
200
|
+
.choice:has(input:checked) { box-shadow: inset 0 0 0 1.5px var(--text); }
|
|
201
|
+
.choice:has(input:disabled) { cursor: default; }
|
|
202
|
+
.choice:has(input:focus-visible) { box-shadow: inset 0 0 0 1.5px var(--text), 0 0 0 3px var(--ring); }
|
|
203
|
+
.choice input { appearance: none; margin: 2px 0 0; width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--border-strong); background: var(--surface); display: grid; place-items: center; cursor: inherit; transition: border-color 150ms ease; }
|
|
204
|
+
.choice input:checked { border: 1.5px solid var(--text); }
|
|
205
|
+
.choice input:checked::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--text); }
|
|
206
|
+
.choice input:focus-visible { outline: none; }
|
|
207
|
+
.choice-body { display: grid; gap: 2px; }
|
|
208
|
+
.choice-title { font-weight: 500; }
|
|
209
|
+
.choice:has(input:disabled) .choice-title { color: var(--disabled-text); }
|
|
210
|
+
.choice-desc { color: var(--text-2); font-size: var(--text-sm); }
|
|
211
|
+
.choice-extra { margin-top: var(--s3); font-size: var(--text-sm); color: var(--text-2); }
|
|
212
|
+
.choice-extra:empty { display: none; }
|
|
213
|
+
|
|
214
|
+
/* Sits inside a choice card, so no box of its own: a divider and type carry it. */
|
|
215
|
+
.device { display: grid; gap: var(--s1); padding-top: var(--s3); border-top: 1px solid var(--border); }
|
|
216
|
+
.device-code { font: 600 20px/1.2 var(--mono); letter-spacing: 0.08em; color: var(--text); }
|
|
217
|
+
.device p { margin: 0; font-size: var(--text-xs); color: var(--text-3); }
|
|
218
|
+
|
|
219
|
+
/* ---------- disclosure ---------- */
|
|
220
|
+
|
|
221
|
+
details > summary { cursor: pointer; width: fit-content; color: var(--text-2); font-size: var(--text-sm); font-weight: 500; list-style: none; display: inline-flex; align-items: center; gap: var(--s2); border-radius: 4px; transition: color 150ms ease; }
|
|
222
|
+
details > summary::-webkit-details-marker { display: none; }
|
|
223
|
+
details > summary::before { content: ''; width: 5px; height: 5px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateX(-1px) rotate(-45deg); transition: transform 160ms var(--ease-out); }
|
|
224
|
+
details[open] > summary::before { transform: translateY(-1px) rotate(45deg); }
|
|
225
|
+
details > summary:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
|
|
226
|
+
@media (hover: hover) and (pointer: fine) { details > summary:hover { color: var(--text); } }
|
|
227
|
+
.more { border-top: 1px solid var(--border); padding-top: var(--s5); }
|
|
228
|
+
.more .fields { padding-top: var(--s5); gap: var(--s4); }
|
|
229
|
+
|
|
230
|
+
/* ---------- progress ---------- */
|
|
231
|
+
|
|
232
|
+
/* One card, a row per step. Each row swaps its icon in place; nothing moves. */
|
|
233
|
+
.steps { list-style: none; margin: 0; padding: 0; border-radius: var(--r-card); box-shadow: inset 0 0 0 1px var(--border); }
|
|
234
|
+
.steps li { display: grid; grid-template-columns: 20px 1fr; gap: var(--s3); align-items: center; min-height: 52px; padding: var(--s3) var(--s4); color: var(--text-3); transition: color 200ms ease; }
|
|
235
|
+
.steps li + li { border-top: 1px solid var(--border); }
|
|
236
|
+
.steps li[data-status=running], .steps li[data-status=done], .steps li[data-status=error] { color: var(--text); }
|
|
237
|
+
.steps li[data-status=skipped] { display: none; }
|
|
238
|
+
.steps li[data-status=skipped] + li { border-top: 0; }
|
|
239
|
+
.step-text { display: grid; }
|
|
240
|
+
.step-sub { font-size: var(--text-xs); color: var(--text-3); }
|
|
241
|
+
.icon { width: 20px; height: 20px; }
|
|
242
|
+
|
|
243
|
+
/* ---------- problems ---------- */
|
|
244
|
+
|
|
245
|
+
.problem { display: grid; grid-template-columns: 16px 1fr; gap: var(--s3); padding: var(--s4); border-radius: var(--r-card); background: var(--error-tint); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--error) 22%, transparent); }
|
|
246
|
+
.problem::before { content: '!'; width: 16px; height: 16px; margin-top: 2px; border-radius: 50%; background: var(--error); color: var(--surface); font: 700 11px/16px var(--font); text-align: center; }
|
|
247
|
+
.problem-body { display: grid; gap: var(--s1); min-width: 0; }
|
|
248
|
+
.problem strong { font-weight: 500; }
|
|
249
|
+
.problem p { margin: 0; color: var(--text-2); font-size: var(--text-sm); }
|
|
250
|
+
.problem details { margin-top: var(--s2); }
|
|
251
|
+
.problem summary { font-size: var(--text-xs); }
|
|
252
|
+
pre { margin: var(--s2) 0 0; padding: var(--s3); border-radius: var(--r-control); background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); font: var(--text-xs)/1.5 var(--mono); color: var(--text-2); white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow: auto; }
|
|
253
|
+
|
|
254
|
+
.warnings { margin: 0; padding: var(--s3) var(--s4) var(--s3) calc(var(--s4) + 16px); border-radius: var(--r-card); background: color-mix(in oklch, var(--warn) 8%, var(--surface)); color: var(--text-2); font-size: var(--text-sm); display: grid; gap: var(--s1); }
|
|
255
|
+
.warnings li::marker { color: var(--warn); }
|
|
256
|
+
.warnings:empty { display: none; }
|
|
257
|
+
|
|
258
|
+
/* ---------- ready and launcher ---------- */
|
|
259
|
+
|
|
260
|
+
.status { display: inline-flex; align-items: center; gap: var(--s2); font-size: var(--text-sm); color: var(--text-2); }
|
|
261
|
+
.status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); transition: background-color 200ms ease, box-shadow 200ms ease; }
|
|
262
|
+
.status[data-running=true]::before { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok) 22%, transparent); }
|
|
263
|
+
.status code { color: var(--text); }
|
|
264
|
+
|
|
265
|
+
.done-mark { width: 32px; height: 32px; border-radius: 50%; background: var(--ok); color: white; display: grid; place-items: center; margin-bottom: var(--s2);
|
|
266
|
+
transition: transform 320ms var(--ease-out), opacity 220ms var(--ease-out);
|
|
267
|
+
@starting-style { transform: scale(0.85); opacity: 0; }
|
|
268
|
+
}
|
|
269
|
+
.done-mark svg { width: 14px; height: 14px; }
|
|
270
|
+
.meta { display: grid; grid-template-columns: auto 1fr; gap: var(--s1) var(--s4); margin: 0; font-size: var(--text-sm); }
|
|
271
|
+
.meta dt { color: var(--text-3); }
|
|
272
|
+
.meta dd { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-family: var(--mono); font-size: var(--text-sm); }
|
|
273
|
+
|
|
274
|
+
/* Next steps: a stack of full-width pressable cards. At this width a row of
|
|
275
|
+
three would squeeze each description to a few words; a stack keeps every
|
|
276
|
+
line readable and every card the same shape. */
|
|
277
|
+
.next { display: grid; gap: var(--s3); border-top: 1px solid var(--border); padding-top: var(--s5); }
|
|
278
|
+
.cards { display: grid; gap: var(--s2); }
|
|
279
|
+
.card {
|
|
280
|
+
display: grid; grid-template-columns: 36px 1fr 16px; gap: var(--s3); align-items: center; justify-content: stretch;
|
|
281
|
+
width: 100%; height: auto; min-height: 64px; padding: var(--s3) var(--s4) var(--s3) var(--s3); text-align: left;
|
|
282
|
+
border-radius: var(--r-card); background: var(--surface); box-shadow: inset 0 0 0 1px var(--border-strong); font-weight: 400;
|
|
283
|
+
}
|
|
284
|
+
.card:active:not(:disabled) { transform: scale(0.985); }
|
|
285
|
+
.card:focus-visible { box-shadow: inset 0 0 0 1px var(--border-strong), 0 0 0 3px var(--ring); }
|
|
286
|
+
/* 36px tile with 12px card padding: radius 12 - 12 + 8 keeps the corners concentric-looking. */
|
|
287
|
+
.card-icon { width: 36px; height: 36px; border-radius: var(--r-control); background: var(--sunken); box-shadow: inset 0 0 0 1px var(--border); display: grid; place-items: center; color: var(--text-2); transition: color 150ms ease; }
|
|
288
|
+
.card-icon svg { width: 18px; height: 18px; }
|
|
289
|
+
.card-text { display: grid; gap: 2px; min-width: 0; }
|
|
290
|
+
.card-title { font-weight: 500; color: var(--text); }
|
|
291
|
+
.card-desc { font-size: var(--text-sm); color: var(--text-2); text-wrap: pretty; }
|
|
292
|
+
.card-end { width: 16px; height: 16px; color: var(--text-3); }
|
|
293
|
+
.card-end svg { width: 16px; height: 16px; }
|
|
294
|
+
@media (hover: hover) and (pointer: fine) {
|
|
295
|
+
.card:hover:not(:disabled) { background: var(--hover); }
|
|
296
|
+
.card:hover:not(:disabled) .card-icon { color: var(--text); }
|
|
297
|
+
.card:hover:not(:disabled) .card-go { transform: translateX(2px); color: var(--text); }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.loading { display: flex; align-items: center; gap: var(--s3); color: var(--text-2); }
|
|
301
|
+
|
|
302
|
+
@media (max-width: 520px) {
|
|
303
|
+
.panel { padding: var(--s5); }
|
|
304
|
+
}
|
|
305
|
+
@media (prefers-reduced-motion: reduce) {
|
|
306
|
+
section, .done-mark { transition: opacity 160ms ease; @starting-style { opacity: 0; transform: none; } }
|
|
307
|
+
:root { --swap-from: 1; }
|
|
308
|
+
.spin { animation-duration: 1.6s; }
|
|
309
|
+
button:active:not(:disabled) { transform: none; }
|
|
310
|
+
}
|
|
311
|
+
</style>
|
|
312
|
+
</head>
|
|
313
|
+
<body>
|
|
314
|
+
<main>
|
|
315
|
+
<header class="top">
|
|
316
|
+
<div class="brand">
|
|
317
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="1" y="1" width="6" height="6" rx="1.5" fill="currentColor"/><rect x="9.75" y="1.75" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="1.75" y="9.75" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="9.75" y="9.75" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.5"/></svg>
|
|
318
|
+
Design Hub
|
|
319
|
+
</div>
|
|
320
|
+
<span class="stepper" id="stepper" aria-live="polite"></span>
|
|
321
|
+
</header>
|
|
322
|
+
|
|
323
|
+
<div class="panel">
|
|
324
|
+
<section id="loading">
|
|
325
|
+
<div class="loading"><span class="spin" aria-hidden="true"></span><span id="loading-text">Checking this computer…</span></div>
|
|
326
|
+
</section>
|
|
327
|
+
|
|
328
|
+
<!-- A project that is already set up: this page is its launcher. -->
|
|
329
|
+
<section id="launch" hidden>
|
|
330
|
+
<div class="head">
|
|
331
|
+
<h1 tabindex="-1" data-bind="here.name">Your project</h1>
|
|
332
|
+
<div class="status" id="launch-status"></div>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="actions">
|
|
335
|
+
<button type="button" class="primary" id="launch-open">Open Design Hub</button>
|
|
336
|
+
<button type="button" class="secondary" id="launch-stop" hidden>Stop</button>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="problem" id="launch-problem" hidden></div>
|
|
339
|
+
<div class="next">
|
|
340
|
+
<h2>Keep going</h2>
|
|
341
|
+
<div class="cards" data-cards></div>
|
|
342
|
+
</div>
|
|
343
|
+
</section>
|
|
344
|
+
|
|
345
|
+
<!-- Apple's developer tools are missing: nothing works without git. -->
|
|
346
|
+
<section id="devtools" hidden>
|
|
347
|
+
<div class="head">
|
|
348
|
+
<h1 tabindex="-1">One thing first</h1>
|
|
349
|
+
<p class="lede">Design Hub needs Apple’s free developer tools. Your Mac installs them in a few minutes.</p>
|
|
350
|
+
</div>
|
|
351
|
+
<p class="hint" id="devtools-hint" hidden>Follow the window your Mac opened. When it finishes, choose Check again.</p>
|
|
352
|
+
<div class="actions">
|
|
353
|
+
<button type="button" class="secondary" id="devtools-check">Check again</button>
|
|
354
|
+
<button type="button" class="primary" id="devtools-install">Install developer tools</button>
|
|
355
|
+
</div>
|
|
356
|
+
</section>
|
|
357
|
+
|
|
358
|
+
<!-- Step 1: name and place. -->
|
|
359
|
+
<section id="name" hidden>
|
|
360
|
+
<div class="head">
|
|
361
|
+
<h1 tabindex="-1">Start a design project</h1>
|
|
362
|
+
<p class="lede">Name it and Design Hub does the rest: copies the template, installs what it needs and picks a port nothing else uses.</p>
|
|
363
|
+
</div>
|
|
364
|
+
<form id="name-form" novalidate>
|
|
365
|
+
<div class="field">
|
|
366
|
+
<label for="project-name">Project name</label>
|
|
367
|
+
<input type="text" id="project-name" placeholder="Acme checkout" autocomplete="off" spellcheck="false" data-autofocus aria-describedby="name-error">
|
|
368
|
+
<p class="error-text" id="name-error" role="alert"></p>
|
|
369
|
+
</div>
|
|
370
|
+
<div class="field">
|
|
371
|
+
<span class="label">Saved to</span>
|
|
372
|
+
<div class="where">
|
|
373
|
+
<code id="where-path"></code>
|
|
374
|
+
<button type="button" class="small quiet" id="pick-folder" aria-label="Change where the project is saved">Change…</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="actions"><button class="primary" type="submit">Continue</button></div>
|
|
378
|
+
</form>
|
|
379
|
+
</section>
|
|
380
|
+
|
|
381
|
+
<!-- Step 2: where it lives, and the options an agent would ask about. -->
|
|
382
|
+
<section id="save" hidden>
|
|
383
|
+
<div class="head">
|
|
384
|
+
<h1 tabindex="-1" id="save-title">Where should it live?</h1>
|
|
385
|
+
<p class="lede" id="save-lede">Start on this computer. You can add GitHub later.</p>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="choices" role="radiogroup" aria-label="Where to save">
|
|
388
|
+
<label class="choice">
|
|
389
|
+
<input type="radio" name="visibility" value="local" checked>
|
|
390
|
+
<div class="choice-body">
|
|
391
|
+
<div class="choice-title">This computer</div>
|
|
392
|
+
<div class="choice-desc">Only on your Mac. The simplest start.</div>
|
|
393
|
+
</div>
|
|
394
|
+
</label>
|
|
395
|
+
<label class="choice" id="github-choice">
|
|
396
|
+
<input type="radio" name="visibility" value="private">
|
|
397
|
+
<div class="choice-body">
|
|
398
|
+
<div class="choice-title">This computer and GitHub</div>
|
|
399
|
+
<div class="choice-desc">A private repo on your GitHub account, so the team can work on it.</div>
|
|
400
|
+
<div class="choice-extra" id="github-extra"></div>
|
|
401
|
+
</div>
|
|
402
|
+
</label>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<details class="more">
|
|
406
|
+
<summary>More options</summary>
|
|
407
|
+
<div class="fields">
|
|
408
|
+
<div class="field">
|
|
409
|
+
<label for="typesafe-key">TypeSafe API key <span class="optional">Optional</span></label>
|
|
410
|
+
<input type="password" id="typesafe-key" autocomplete="off">
|
|
411
|
+
<p class="hint">Turns on the automatic checks. Saved only in this project, never in git.</p>
|
|
412
|
+
<p class="error-text" data-error="typesafeKey"></p>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="field">
|
|
415
|
+
<label for="conductor-branch">Conductor branch <span class="optional">Optional</span></label>
|
|
416
|
+
<input type="text" id="conductor-branch" autocomplete="off" spellcheck="false">
|
|
417
|
+
<p class="hint">Leave empty if you don’t use Conductor.</p>
|
|
418
|
+
<p class="error-text" data-error="conductorBranch"></p>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="field" data-github-only>
|
|
421
|
+
<label for="owner">GitHub owner <span class="optional">Optional</span></label>
|
|
422
|
+
<input type="text" id="owner" placeholder="Your account" autocomplete="off" spellcheck="false">
|
|
423
|
+
<p class="hint">An organization name, to create the repo there instead.</p>
|
|
424
|
+
<p class="error-text" data-error="owner"></p>
|
|
425
|
+
</div>
|
|
426
|
+
<label class="check" data-github-only><input type="checkbox" id="public"> Make the GitHub repo public</label>
|
|
427
|
+
</div>
|
|
428
|
+
</details>
|
|
429
|
+
|
|
430
|
+
<div class="actions">
|
|
431
|
+
<button type="button" class="secondary" id="save-back">Back</button>
|
|
432
|
+
<button type="button" class="primary" id="create">Create project</button>
|
|
433
|
+
</div>
|
|
434
|
+
</section>
|
|
435
|
+
|
|
436
|
+
<!-- Step 3: progress. -->
|
|
437
|
+
<section id="progress" hidden>
|
|
438
|
+
<div class="head">
|
|
439
|
+
<h1 tabindex="-1" id="progress-title">Setting up</h1>
|
|
440
|
+
<p class="lede">Keep this page open. It takes a minute or two.</p>
|
|
441
|
+
</div>
|
|
442
|
+
<ol class="steps">
|
|
443
|
+
<li data-step="copy" data-status="waiting"><span class="icon swap" data-icon></span><span class="step-text">Copy the template</span></li>
|
|
444
|
+
<li data-step="install" data-status="waiting"><span class="icon swap" data-icon></span><span class="step-text">Install<span class="step-sub">The longest step, about a minute</span></span></li>
|
|
445
|
+
<li data-step="port" data-status="waiting"><span class="icon swap" data-icon></span><span class="step-text">Pick a free port</span></li>
|
|
446
|
+
</ol>
|
|
447
|
+
<div class="problem" id="progress-problem" hidden></div>
|
|
448
|
+
<div class="actions" id="progress-actions" hidden>
|
|
449
|
+
<button type="button" class="secondary" id="progress-back">Back</button>
|
|
450
|
+
<button type="button" class="primary" id="progress-retry">Try again</button>
|
|
451
|
+
</div>
|
|
452
|
+
</section>
|
|
453
|
+
|
|
454
|
+
<!-- Step 4: ready. -->
|
|
455
|
+
<section id="done" hidden>
|
|
456
|
+
<div class="head">
|
|
457
|
+
<div class="done-mark" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 10 10" fill="none"><path d="M2 5.2 4.1 7.2 8 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
|
458
|
+
<h1 tabindex="-1" id="done-title">Your project is ready</h1>
|
|
459
|
+
<dl class="meta">
|
|
460
|
+
<dt>Folder</dt><dd id="done-folder"></dd>
|
|
461
|
+
<dt>Address</dt><dd id="done-address"></dd>
|
|
462
|
+
</dl>
|
|
463
|
+
</div>
|
|
464
|
+
<ul class="warnings" id="done-warnings"></ul>
|
|
465
|
+
<button type="button" class="primary" id="done-open">Open Design Hub</button>
|
|
466
|
+
<div class="problem" id="done-problem" hidden></div>
|
|
467
|
+
<div class="next">
|
|
468
|
+
<h2>Next steps</h2>
|
|
469
|
+
<div class="cards" data-cards></div>
|
|
470
|
+
</div>
|
|
471
|
+
</section>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<p class="foot">Keep the Terminal window open while you work. Closing it stops Design Hub.</p>
|
|
475
|
+
</main>
|
|
476
|
+
|
|
477
|
+
<template id="step-icon">
|
|
478
|
+
<span class="dot"></span><span class="spin"></span>
|
|
479
|
+
<span class="tick"><svg aria-hidden="true" viewBox="0 0 10 10" fill="none"><path d="M2 5.2 4.1 7.2 8 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
|
480
|
+
<span class="cross"><svg aria-hidden="true" viewBox="0 0 10 10" fill="none"><path d="m3 3 4 4m0-4-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
|
481
|
+
</template>
|
|
482
|
+
|
|
483
|
+
<!-- One stroke style for every card icon: 18px box, 1.5 stroke, round joins. -->
|
|
484
|
+
<template id="next-card">
|
|
485
|
+
<button type="button" class="card">
|
|
486
|
+
<span class="card-icon" aria-hidden="true"></span>
|
|
487
|
+
<span class="card-text"><span class="card-title"></span><span class="card-desc"></span></span>
|
|
488
|
+
<span class="card-end swap" aria-hidden="true">
|
|
489
|
+
<svg aria-hidden="true" class="card-go" viewBox="0 0 16 16" fill="none"><path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
490
|
+
<span class="spin"></span>
|
|
491
|
+
<svg aria-hidden="true" class="card-ok" viewBox="0 0 16 16" fill="none"><path d="m3.5 8.3 3 2.9L12.5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
492
|
+
</span>
|
|
493
|
+
</button>
|
|
494
|
+
</template>
|
|
495
|
+
|
|
496
|
+
<script type="module">
|
|
497
|
+
import { slugify } from '/slug.mjs';
|
|
498
|
+
|
|
499
|
+
const TOKEN = '__TOKEN__';
|
|
500
|
+
const $ = (s, root = document) => root.querySelector(s);
|
|
501
|
+
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
|
502
|
+
|
|
503
|
+
// Every action is a POST carrying the page's token; the server refuses any other.
|
|
504
|
+
const post = (path, body) => fetch(path, {
|
|
505
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-setup-token': TOKEN }, body: JSON.stringify(body),
|
|
506
|
+
});
|
|
507
|
+
const api = async (path, body = {}) => (await post(path, body)).json();
|
|
508
|
+
|
|
509
|
+
let state;
|
|
510
|
+
const form = { name: '', parent: '', parentDisplay: '' };
|
|
511
|
+
let project = null; // the project the done or launch screen acts on
|
|
512
|
+
|
|
513
|
+
// The wizard's own screens get a counter; the others stand alone.
|
|
514
|
+
const STEPS = { create: ['name', 'save', 'progress'], clone: ['save', 'progress'] };
|
|
515
|
+
function show(id) {
|
|
516
|
+
for (const s of $$('.panel > section')) s.hidden = s.id !== id;
|
|
517
|
+
const order = STEPS[state?.mode] ?? [];
|
|
518
|
+
const i = order.indexOf(id);
|
|
519
|
+
$('#stepper').textContent = i < 0 ? '' : `Step ${i + 1} of ${order.length}`;
|
|
520
|
+
// Move focus to the new screen's field, or else its heading, so screen
|
|
521
|
+
// readers announce the change without a ring flashing on a button.
|
|
522
|
+
const focus = $(`#${id} [data-autofocus]`) ?? $(`#${id} h1`);
|
|
523
|
+
focus?.focus({ preventScroll: true });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
for (const icon of $$('[data-icon]')) icon.append($('#step-icon').content.cloneNode(true));
|
|
527
|
+
|
|
528
|
+
const el = (tag, className, text) => {
|
|
529
|
+
const n = document.createElement(tag);
|
|
530
|
+
if (className) n.className = className;
|
|
531
|
+
if (text) n.textContent = text;
|
|
532
|
+
return n;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
function problem(box, { message, fix, detail }) {
|
|
536
|
+
const body = el('div', 'problem-body');
|
|
537
|
+
body.append(el('strong', '', message));
|
|
538
|
+
if (fix) body.append(el('p', '', fix));
|
|
539
|
+
if (detail) {
|
|
540
|
+
const d = el('details');
|
|
541
|
+
d.append(el('summary', '', 'Details for a developer'), el('pre', '', detail));
|
|
542
|
+
body.append(d);
|
|
543
|
+
}
|
|
544
|
+
box.replaceChildren(body);
|
|
545
|
+
box.setAttribute('role', 'alert');
|
|
546
|
+
box.hidden = false;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Starting the hub can take a while, and a tab opened after a long wait gets
|
|
550
|
+
// blocked as a popup, so open the tab on the click and fill it in later.
|
|
551
|
+
async function openHub(p, path, problemEl, busy) {
|
|
552
|
+
problemEl.hidden = true;
|
|
553
|
+
const tab = window.open('about:blank', '_blank');
|
|
554
|
+
busy(true);
|
|
555
|
+
const res = await api('/api/start', { path: p.path });
|
|
556
|
+
busy(false);
|
|
557
|
+
if (!res.ok) { tab?.close(); problem(problemEl, res); return null; }
|
|
558
|
+
const url = res.project.url + path;
|
|
559
|
+
if (tab) tab.location = url; else window.open(url, '_blank');
|
|
560
|
+
return res.project;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function buttonBusy(button, label) {
|
|
564
|
+
const idle = button.textContent;
|
|
565
|
+
return (on) => {
|
|
566
|
+
button.disabled = on;
|
|
567
|
+
button.replaceChildren(...(on ? [el('span', 'spin'), label] : [idle]));
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------- next-step cards ----------
|
|
572
|
+
|
|
573
|
+
const ICONS = {
|
|
574
|
+
sample: '<svg aria-hidden="true" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="2.25" y="3" width="13.5" height="12" rx="2"/><path d="M2.25 6.75h13.5M5.25 9.75h4.5M5.25 12h7.5" stroke-linecap="round"/></svg>',
|
|
575
|
+
claude: '<svg aria-hidden="true" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2.25v3M9 12.75v3M2.25 9h3M12.75 9h3M4.2 4.2l2.1 2.1M11.7 11.7l2.1 2.1M4.2 13.8l2.1-2.1M11.7 6.3l2.1-2.1"/></svg>',
|
|
576
|
+
folder: '<svg aria-hidden="true" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.25 5.25a1.5 1.5 0 0 1 1.5-1.5h3l1.5 1.875h6a1.5 1.5 0 0 1 1.5 1.5v6.375a1.5 1.5 0 0 1-1.5 1.5H3.75a1.5 1.5 0 0 1-1.5-1.5Z"/></svg>',
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
function card({ icon, title, desc, run }) {
|
|
580
|
+
const node = $('#next-card').content.firstElementChild.cloneNode(true);
|
|
581
|
+
$('.card-icon', node).innerHTML = ICONS[icon];
|
|
582
|
+
$('.card-title', node).textContent = title;
|
|
583
|
+
const descEl = $('.card-desc', node);
|
|
584
|
+
descEl.textContent = desc;
|
|
585
|
+
let reset;
|
|
586
|
+
node.onclick = async () => {
|
|
587
|
+
if (node.dataset.state === 'busy') return;
|
|
588
|
+
clearTimeout(reset);
|
|
589
|
+
node.dataset.state = 'busy';
|
|
590
|
+
node.setAttribute('aria-busy', 'true');
|
|
591
|
+
const after = await run().catch(() => null);
|
|
592
|
+
node.removeAttribute('aria-busy');
|
|
593
|
+
if (!after) { delete node.dataset.state; return; }
|
|
594
|
+
// Confirm what happened in the card itself, then settle back.
|
|
595
|
+
node.dataset.state = 'done';
|
|
596
|
+
descEl.textContent = after;
|
|
597
|
+
reset = setTimeout(() => { delete node.dataset.state; descEl.textContent = desc; }, 4000);
|
|
598
|
+
};
|
|
599
|
+
return node;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderCards(section, problemEl) {
|
|
603
|
+
$('[data-cards]', section).replaceChildren(
|
|
604
|
+
card({
|
|
605
|
+
icon: 'sample', title: 'Explore the sample',
|
|
606
|
+
desc: 'Click through a finished prototype and its walkthrough.',
|
|
607
|
+
run: async () => {
|
|
608
|
+
const p = await openHub(project, 'sample-list/', problemEl, () => {});
|
|
609
|
+
if (!p) return null;
|
|
610
|
+
project = p;
|
|
611
|
+
if (section.id === 'launch') renderLaunch(p, false);
|
|
612
|
+
return 'Opened in a new tab.';
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
card({
|
|
616
|
+
icon: 'claude', title: 'Work with Claude',
|
|
617
|
+
desc: 'Opens Claude and copies this folder’s path for you to paste.',
|
|
618
|
+
run: async () => {
|
|
619
|
+
const res = await api('/api/claude', { path: project.path });
|
|
620
|
+
if (!res.ok) return null;
|
|
621
|
+
return res.app ? 'Path copied. Paste it in Claude Code to choose this folder.' : 'Claude isn’t installed yet. Its download page is open.';
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
card({
|
|
625
|
+
icon: 'folder', title: 'Come back later',
|
|
626
|
+
desc: 'Design Hub.command in the project folder reopens this page.',
|
|
627
|
+
run: async () => {
|
|
628
|
+
const res = await api('/api/reveal', { path: project.path });
|
|
629
|
+
return res.ok ? 'Shown in Finder.' : null;
|
|
630
|
+
},
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------- boot ----------
|
|
636
|
+
|
|
637
|
+
async function boot() {
|
|
638
|
+
state = await api('/api/state');
|
|
639
|
+
for (const n of $$('[data-bind]')) n.textContent = n.dataset.bind.split('.').reduce((o, k) => o?.[k], state);
|
|
640
|
+
if (state.mode === 'project') return renderLaunch(state.here);
|
|
641
|
+
if (!state.tools.git) return show('devtools');
|
|
642
|
+
if (state.mode === 'clone') {
|
|
643
|
+
$('#save-title').textContent = `Set up ${state.here.name}`;
|
|
644
|
+
$('#save-lede').textContent = 'This folder is already a copy of the template. Setup picks a free port and installs what it needs.';
|
|
645
|
+
$('#save-back').hidden = true;
|
|
646
|
+
$('#github-choice').hidden = true;
|
|
647
|
+
for (const n of $$('[data-github-only], [data-copy-only]')) n.hidden = true;
|
|
648
|
+
$('#create').textContent = 'Set up';
|
|
649
|
+
$('#progress [data-step=copy]').dataset.status = 'skipped';
|
|
650
|
+
$('#progress-title').textContent = `Setting up ${state.here.name}`;
|
|
651
|
+
return show('save');
|
|
652
|
+
}
|
|
653
|
+
form.parent = state.defaults.parent;
|
|
654
|
+
form.parentDisplay = state.defaults.parentDisplay;
|
|
655
|
+
renderWhere();
|
|
656
|
+
renderGithub();
|
|
657
|
+
show('name');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---------- launcher ----------
|
|
661
|
+
|
|
662
|
+
function renderLaunch(p, reshow = true) {
|
|
663
|
+
project = p;
|
|
664
|
+
const status = $('#launch-status');
|
|
665
|
+
status.dataset.running = p.running;
|
|
666
|
+
status.replaceChildren(p.running ? 'Running at ' : 'Not running');
|
|
667
|
+
if (p.running) status.append(el('code', '', p.url.replace('http://', '').replace(/\/$/, '')));
|
|
668
|
+
$('#launch-stop').hidden = !p.running;
|
|
669
|
+
if (!reshow) return;
|
|
670
|
+
renderCards($('#launch'), $('#launch-problem'));
|
|
671
|
+
show('launch');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
$('#launch-open').onclick = async () => {
|
|
675
|
+
const button = $('#launch-open');
|
|
676
|
+
const p = await openHub(project, '', $('#launch-problem'), buttonBusy(button, project.running ? 'Opening…' : 'Starting…'));
|
|
677
|
+
if (p) renderLaunch(p, false);
|
|
678
|
+
};
|
|
679
|
+
$('#launch-stop').onclick = async () => {
|
|
680
|
+
const res = await api('/api/stop', { path: project.path });
|
|
681
|
+
if (res.ok) renderLaunch(res.project, false);
|
|
682
|
+
$('#launch-open').focus();
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
// ---------- devtools ----------
|
|
686
|
+
|
|
687
|
+
$('#devtools-install').onclick = () => { api('/api/devtools', {}); $('#devtools-hint').hidden = false; };
|
|
688
|
+
$('#devtools-check').onclick = boot;
|
|
689
|
+
|
|
690
|
+
// ---------- step 1 ----------
|
|
691
|
+
|
|
692
|
+
function renderWhere() {
|
|
693
|
+
const slug = slugify($('#project-name').value) || 'your-project';
|
|
694
|
+
$('#where-path').textContent = `${form.parentDisplay}/${slug}`;
|
|
695
|
+
}
|
|
696
|
+
$('#project-name').addEventListener('input', () => {
|
|
697
|
+
$('#project-name').removeAttribute('aria-invalid');
|
|
698
|
+
$('#name-error').textContent = '';
|
|
699
|
+
renderWhere();
|
|
700
|
+
});
|
|
701
|
+
$('#pick-folder').onclick = async () => {
|
|
702
|
+
const res = await api('/api/pick-folder', {});
|
|
703
|
+
if (res.path) { form.parent = res.path; form.parentDisplay = res.display; renderWhere(); }
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
$('#name-form').addEventListener('submit', async (e) => {
|
|
707
|
+
e.preventDefault();
|
|
708
|
+
const name = $('#project-name').value;
|
|
709
|
+
const res = await api('/api/path', { name, parent: form.parent });
|
|
710
|
+
if (!res.ok) {
|
|
711
|
+
$('#project-name').setAttribute('aria-invalid', 'true');
|
|
712
|
+
$('#name-error').textContent = res.error;
|
|
713
|
+
return $('#project-name').focus();
|
|
714
|
+
}
|
|
715
|
+
form.name = name;
|
|
716
|
+
$('#progress-title').textContent = `Setting up ${slugify(name)}`;
|
|
717
|
+
show('save');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// ---------- step 2 ----------
|
|
721
|
+
|
|
722
|
+
let polling = null;
|
|
723
|
+
function renderGithub() {
|
|
724
|
+
const gh = state.tools.gh;
|
|
725
|
+
const radio = $('#github-choice input');
|
|
726
|
+
const extra = $('#github-extra');
|
|
727
|
+
extra.replaceChildren();
|
|
728
|
+
radio.disabled = !gh.installed;
|
|
729
|
+
if (!gh.installed) {
|
|
730
|
+
extra.textContent = 'Needs the GitHub CLI on this computer. Ask a developer to install it, or start on this computer.';
|
|
731
|
+
} else if (!gh.login) {
|
|
732
|
+
const b = el('button', 'secondary small', 'Sign in to GitHub');
|
|
733
|
+
b.type = 'button';
|
|
734
|
+
b.onclick = signIn;
|
|
735
|
+
extra.append(b);
|
|
736
|
+
} else {
|
|
737
|
+
extra.textContent = `Signed in as ${gh.login}.`;
|
|
738
|
+
}
|
|
739
|
+
syncCreate();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function signIn(e) {
|
|
743
|
+
e.preventDefault();
|
|
744
|
+
const b = e.currentTarget;
|
|
745
|
+
buttonBusy(b, 'Opening GitHub…')(true);
|
|
746
|
+
const res = await api('/api/github', {});
|
|
747
|
+
const extra = $('#github-extra');
|
|
748
|
+
extra.replaceChildren();
|
|
749
|
+
if (res.error) { extra.textContent = res.error; return; }
|
|
750
|
+
if (res.code) {
|
|
751
|
+
const box = el('div', 'device');
|
|
752
|
+
box.append(
|
|
753
|
+
el('strong', 'label', 'Enter this code on the GitHub page'),
|
|
754
|
+
el('div', 'device-code', res.code),
|
|
755
|
+
el('p', '', 'It’s already copied. This updates on its own when you finish.'),
|
|
756
|
+
);
|
|
757
|
+
extra.append(box);
|
|
758
|
+
}
|
|
759
|
+
clearInterval(polling);
|
|
760
|
+
polling = setInterval(async () => {
|
|
761
|
+
const next = await api('/api/state');
|
|
762
|
+
if (next.tools.gh.login) { clearInterval(polling); state = next; renderGithub(); }
|
|
763
|
+
}, 2000);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function visibility() {
|
|
767
|
+
const v = $('input[name=visibility]:checked').value;
|
|
768
|
+
return v === 'local' ? 'local' : $('#public').checked ? 'public' : 'private';
|
|
769
|
+
}
|
|
770
|
+
function syncCreate() {
|
|
771
|
+
const github = $('input[name=visibility]:checked').value !== 'local';
|
|
772
|
+
for (const n of $$('[data-github-only]')) if (state.mode !== 'clone') n.hidden = !github;
|
|
773
|
+
$('#create').disabled = github && !state.tools.gh.login;
|
|
774
|
+
}
|
|
775
|
+
for (const r of $$('input[name=visibility]')) r.addEventListener('change', syncCreate);
|
|
776
|
+
$('#save-back').onclick = () => show('name');
|
|
777
|
+
$('#create').onclick = run;
|
|
778
|
+
|
|
779
|
+
// ---------- step 3 ----------
|
|
780
|
+
|
|
781
|
+
function request() {
|
|
782
|
+
return {
|
|
783
|
+
name: form.name,
|
|
784
|
+
parent: form.parent,
|
|
785
|
+
visibility: state.mode === 'clone' ? 'local' : visibility(),
|
|
786
|
+
owner: $('#owner').value,
|
|
787
|
+
typesafeKey: $('#typesafe-key').value,
|
|
788
|
+
conductorBranch: $('#conductor-branch').value,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function run() {
|
|
793
|
+
for (const n of $$('[data-error]')) n.textContent = '';
|
|
794
|
+
for (const li of $$('.steps li')) if (li.dataset.status !== 'skipped') li.dataset.status = 'waiting';
|
|
795
|
+
$('#progress-problem').hidden = true;
|
|
796
|
+
$('#progress-actions').hidden = true;
|
|
797
|
+
show('progress');
|
|
798
|
+
|
|
799
|
+
const res = await post('/api/create', request());
|
|
800
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
801
|
+
let buffer = '';
|
|
802
|
+
for (;;) {
|
|
803
|
+
const { value, done } = await reader.read();
|
|
804
|
+
if (done) break;
|
|
805
|
+
buffer += value;
|
|
806
|
+
const lines = buffer.split('\n');
|
|
807
|
+
buffer = lines.pop();
|
|
808
|
+
for (const line of lines) if (line.trim()) handle(JSON.parse(line));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function handle(event) {
|
|
813
|
+
if (event.step === 'invalid') {
|
|
814
|
+
for (const [field, msg] of Object.entries(event.errors)) {
|
|
815
|
+
const n = $(`[data-error=${field}]`);
|
|
816
|
+
if (n) n.textContent = msg;
|
|
817
|
+
}
|
|
818
|
+
if (event.errors.name || event.errors.parent) {
|
|
819
|
+
$('#name-error').textContent = event.errors.name ?? event.errors.parent;
|
|
820
|
+
return show('name');
|
|
821
|
+
}
|
|
822
|
+
$('details.more').open = true;
|
|
823
|
+
return show('save');
|
|
824
|
+
}
|
|
825
|
+
if (event.step === 'ready') return renderDone(event);
|
|
826
|
+
const li = $(`.steps [data-step=${event.step}]`);
|
|
827
|
+
if (li) li.dataset.status = event.status;
|
|
828
|
+
if (event.status === 'error') {
|
|
829
|
+
problem($('#progress-problem'), event);
|
|
830
|
+
$('#progress-actions').hidden = false;
|
|
831
|
+
$('#progress-back').hidden = state.mode === 'clone';
|
|
832
|
+
$('#progress-problem').scrollIntoView({ block: 'nearest' });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
$('#progress-retry').onclick = run;
|
|
836
|
+
$('#progress-back').onclick = () => show('save');
|
|
837
|
+
|
|
838
|
+
// ---------- step 4 ----------
|
|
839
|
+
|
|
840
|
+
function renderDone({ project: p, warnings }) {
|
|
841
|
+
project = p;
|
|
842
|
+
$('#done-title').textContent = `${p.name} is ready`;
|
|
843
|
+
$('#done-folder').textContent = p.display;
|
|
844
|
+
$('#done-folder').title = p.display;
|
|
845
|
+
$('#done-address').textContent = `localhost:${p.port}`;
|
|
846
|
+
$('#done-warnings').replaceChildren(...warnings.map((w) => el('li', '', w)));
|
|
847
|
+
renderCards($('#done'), $('#done-problem'));
|
|
848
|
+
show('done');
|
|
849
|
+
}
|
|
850
|
+
$('#done-open').onclick = async () => {
|
|
851
|
+
const p = await openHub(project, '', $('#done-problem'), buttonBusy($('#done-open'), project.running ? 'Opening…' : 'Starting…'));
|
|
852
|
+
if (p) project = p;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
boot().catch(() => {
|
|
856
|
+
$('#loading .spin').hidden = true;
|
|
857
|
+
$('#loading-text').textContent = 'Design Hub stopped. Double-click Design Hub.command to start it again.';
|
|
858
|
+
});
|
|
859
|
+
</script>
|
|
860
|
+
</body>
|
|
861
|
+
</html>
|