modelroom 0.0.3 → 0.0.5
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/CHANGELOG.md +14 -0
- package/dist/lib/opencode.d.ts +3 -5
- package/dist/lib/opencode.d.ts.map +1 -1
- package/dist/lib/opencode.js +2 -32
- package/dist/lib/opencode.js.map +1 -1
- package/dist/lib/parts.d.ts +40 -0
- package/dist/lib/parts.d.ts.map +1 -0
- package/dist/lib/parts.js +158 -0
- package/dist/lib/parts.js.map +1 -0
- package/dist/lib/server.d.ts.map +1 -1
- package/dist/lib/server.js +2 -1
- package/dist/lib/server.js.map +1 -1
- package/package.json +2 -2
- package/ui/assets/app.js +41 -37
- package/ui/dashboard.html +13 -321
- package/ui/kit.html +10 -1
- package/ui/modelroom.css +1 -1
- package/ui/room.html +9 -0
- package/ui/settings.html +12 -114
package/ui/dashboard.html
CHANGED
|
@@ -1,332 +1,24 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<!-- modelroom — dashboard: every room, narrowed by what the records say
|
|
2
|
+
<!-- modelroom — dashboard: every room, narrowed by what the records say.
|
|
3
|
+
The shell is static; the app lives in web/ and is built to /ui/assets/app.js. -->
|
|
3
4
|
<html lang="en">
|
|
4
5
|
<head>
|
|
5
6
|
<meta charset="utf-8">
|
|
6
7
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
8
|
<title>modelroom</title>
|
|
8
9
|
<link rel="stylesheet" href="/ui/modelroom.css">
|
|
9
|
-
</head>
|
|
10
|
-
<body class="min-h-dvh bg-base-200 text-base-content font-sans">
|
|
11
|
-
|
|
12
|
-
<div class="navbar bg-base-100 border-b border-base-300 sticky top-0 z-20 gap-2">
|
|
13
|
-
<div class="flex-none gap-3">
|
|
14
|
-
<span class="text-lg font-semibold tracking-tight">modelroom</span>
|
|
15
|
-
<span class="badge badge-soft badge-neutral" id="count">—</span>
|
|
16
|
-
</div>
|
|
17
|
-
<div class="flex-1">
|
|
18
|
-
<label class="input input-sm w-full max-w-xl flex items-center gap-2">
|
|
19
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4 opacity-50"><path fill-rule="evenodd" d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z" clip-rule="evenodd"/></svg>
|
|
20
|
-
<input id="q" type="search" class="grow" placeholder="Search rooms, docs, tags… ( / )" autocomplete="off">
|
|
21
|
-
</label>
|
|
22
|
-
</div>
|
|
23
|
-
<div class="flex-none gap-2">
|
|
24
|
-
<span class="badge" id="status" data-phase="idle"><span class="loading loading-dots loading-xs hidden" id="spinner"></span><span id="statusText">ready</span></span>
|
|
25
|
-
<a class="btn btn-ghost btn-sm" href="/settings">Settings</a>
|
|
26
|
-
<button class="btn btn-ghost btn-sm" id="refresh">Refresh</button>
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
|
|
30
|
-
<main class="mx-auto w-full max-w-[1600px] p-5">
|
|
31
|
-
<section id="filters" class="mb-4 flex flex-wrap items-start gap-5"></section>
|
|
32
|
-
<div id="active" class="mb-3 flex flex-wrap items-center gap-2"></div>
|
|
33
|
-
|
|
34
|
-
<div id="grid" class="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(240px,1fr))]"></div>
|
|
35
|
-
<div id="empty" class="hidden">
|
|
36
|
-
<div class="card bg-base-100 border border-base-300">
|
|
37
|
-
<div class="card-body">
|
|
38
|
-
<h2 class="card-title text-base" id="emptyTitle">No rooms yet</h2>
|
|
39
|
-
<p class="text-sm opacity-70" id="emptyText">A room is one screen: <code>rooms/<name>/</code> with <code>BRIEF.md</code>,
|
|
40
|
-
<code>SPEC.md</code>, <code>BEHAVIOUR.md</code> and an artifact.</p>
|
|
41
|
-
<div class="mockup-code mt-2 text-xs" id="emptyHint">
|
|
42
|
-
<pre data-prefix="$"><code>npx modelroom init my-first-room</code></pre>
|
|
43
|
-
<pre data-prefix="$"><code>npx modelroom serve</code></pre>
|
|
44
|
-
</div>
|
|
45
|
-
<button class="btn btn-sm btn-ghost justify-start hidden" id="clear">Clear search and filters</button>
|
|
46
|
-
</div>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
</main>
|
|
50
|
-
|
|
51
|
-
<footer class="mx-auto w-full max-w-[1600px] px-5 pb-8 text-xs opacity-60 mr-prose">
|
|
52
|
-
Agent-agnostic: modelroom drives the coding agent you configure. Artifacts are served unmodified (no injection).
|
|
53
|
-
</footer>
|
|
54
|
-
|
|
55
10
|
<script>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const when = (ms) => new Date(ms || Date.now()).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
64
|
-
|
|
65
|
-
function statusBadge(status) {
|
|
66
|
-
const s = String(status || '').toLowerCase();
|
|
67
|
-
if (!s) return '';
|
|
68
|
-
const cls = /adopt|approv|done|complete|accept/.test(s) ? 'badge-success'
|
|
69
|
-
: /review/.test(s) ? 'badge-warning'
|
|
70
|
-
: /draft|wip|todo/.test(s) ? 'badge-ghost' : 'badge-neutral';
|
|
71
|
-
return `<span class="badge badge-sm ${cls}">${esc(status)}</span>`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function hitFor(name) {
|
|
75
|
-
return (state.hits || []).find((h) => h.name === name) || null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function highlight(text) {
|
|
79
|
-
const safe = esc(text);
|
|
80
|
-
const tokens = state.q.trim().split(/\s+/).filter(Boolean);
|
|
81
|
-
if (!tokens.length) return safe;
|
|
82
|
-
const re = new RegExp('(' + tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi');
|
|
83
|
-
return safe.replace(re, '<mark class="bg-warning/40 rounded px-0.5">$1</mark>');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function card(room) {
|
|
87
|
-
const hit = hitFor(room.name);
|
|
88
|
-
const thumb = room.hasThumb
|
|
89
|
-
? `<img class="mr-card-artifact" src="${room.thumbUrl}?v=${room.updatedAt}" alt="">`
|
|
90
|
-
: `<div class="mr-card-artifact flex items-center justify-center text-xs opacity-60">${room.hasArtifact ? 'no thumbnail yet' : 'no artifact yet'}</div>`;
|
|
91
|
-
const tags = (room.tags || []).slice(0, 4)
|
|
92
|
-
.map((t) => `<span class="badge badge-xs badge-soft">${esc(t)}</span>`).join('');
|
|
93
|
-
const snippet = hit?.snippets?.[0];
|
|
94
|
-
return `
|
|
95
|
-
<a class="card bg-base-100 border border-base-300 shadow-sm transition hover:shadow-md hover:-translate-y-0.5 ${room.deprecated ? 'opacity-60' : ''}" href="/room/${encodeURIComponent(room.name)}">
|
|
96
|
-
<figure class="bg-base-200">${thumb}</figure>
|
|
97
|
-
<div class="card-body p-3 gap-1">
|
|
98
|
-
<div class="flex items-center gap-2">
|
|
99
|
-
<span class="font-medium truncate">${esc(room.title || room.name)}</span>
|
|
100
|
-
${room.version !== null && room.version !== undefined ? `<span class="badge badge-xs badge-ghost font-mono">v${esc(room.version)}</span>` : ''}
|
|
101
|
-
${room.running ? '<span class="badge badge-sm badge-info gap-1"><span class="loading loading-ring loading-xs"></span>running</span>' : ''}
|
|
102
|
-
</div>
|
|
103
|
-
<div class="text-xs opacity-60 font-mono truncate">rooms/${esc(room.name)}</div>
|
|
104
|
-
${room.summary ? `<p class="text-xs opacity-70 [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical] overflow-hidden">${esc(room.summary)}</p>` : ''}
|
|
105
|
-
${snippet ? `<p class="text-xs opacity-80 bg-base-200/60 rounded px-2 py-1">${highlight(snippet.text)}</p>` : ''}
|
|
106
|
-
<div class="flex items-center gap-1 flex-wrap mt-1">
|
|
107
|
-
${statusBadge(room.status)}
|
|
108
|
-
${room.deprecated ? '<span class="badge badge-sm badge-error badge-soft">archived</span>' : ''}
|
|
109
|
-
${tags}
|
|
110
|
-
${room.behaviourIds?.length ? `<span class="badge badge-sm badge-ghost">${room.behaviourIds.length} behaviour${room.behaviourIds.length > 1 ? 's' : ''}</span>` : ''}
|
|
111
|
-
<span class="text-xs opacity-50 ml-auto">${when(room.updatedAt)}</span>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
</a>`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function matchesFilters(room) {
|
|
118
|
-
for (const [key, wanted] of Object.entries(state.filters)) {
|
|
119
|
-
if (!wanted || !wanted.length) continue;
|
|
120
|
-
const raw = room.fields?.[key];
|
|
121
|
-
const have = (Array.isArray(raw) ? raw : [raw]).filter((v) => v !== null && v !== undefined).map(String);
|
|
122
|
-
if (!have.some((v) => wanted.includes(v))) return false;
|
|
123
|
-
}
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function visibleRooms() {
|
|
128
|
-
let list = state.rooms.filter((r) => state.archived || !r.deprecated);
|
|
129
|
-
if (state.q) {
|
|
130
|
-
const order = new Map((state.hits || []).map((h, i) => [h.name, i]));
|
|
131
|
-
list = list.filter((r) => order.has(r.name)).sort((a, b) => order.get(a.name) - order.get(b.name));
|
|
132
|
-
} else {
|
|
133
|
-
list = list.slice().sort((a, b) => b.updatedAt - a.updatedAt);
|
|
134
|
-
}
|
|
135
|
-
return list.filter(matchesFilters);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function renderFacet(facet) {
|
|
139
|
-
const selected = state.filters[facet.key] || [];
|
|
140
|
-
const name = `f-${facet.key}`;
|
|
141
|
-
let body = '';
|
|
142
|
-
|
|
143
|
-
if (facet.control === 'checkboxes') {
|
|
144
|
-
body = facet.values.map((v) => `
|
|
145
|
-
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
|
146
|
-
<input type="checkbox" class="checkbox checkbox-xs" data-facet="${esc(facet.key)}" value="${esc(v.value)}" ${selected.includes(v.value) ? 'checked' : ''}>
|
|
147
|
-
<span class="truncate">${esc(v.value)}</span>
|
|
148
|
-
<span class="text-xs opacity-50 ml-auto">${v.count}</span>
|
|
149
|
-
</label>`).join('');
|
|
150
|
-
} else if (facet.control === 'radio') {
|
|
151
|
-
body = `
|
|
152
|
-
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
|
153
|
-
<input type="radio" class="radio radio-xs" name="${esc(name)}" data-facet="${esc(facet.key)}" value="" ${selected.length ? '' : 'checked'}>
|
|
154
|
-
<span class="opacity-60">any</span>
|
|
155
|
-
</label>` + facet.values.map((v) => `
|
|
156
|
-
<label class="flex items-center gap-2 cursor-pointer text-sm">
|
|
157
|
-
<input type="radio" class="radio radio-xs" name="${esc(name)}" data-facet="${esc(facet.key)}" value="${esc(v.value)}" ${selected.includes(v.value) ? 'checked' : ''}>
|
|
158
|
-
<span class="truncate">${esc(v.value)}</span>
|
|
159
|
-
<span class="text-xs opacity-50 ml-auto">${v.count}</span>
|
|
160
|
-
</label>`).join('');
|
|
161
|
-
} else {
|
|
162
|
-
body = `
|
|
163
|
-
<select class="select select-sm w-full" data-facet="${esc(facet.key)}">
|
|
164
|
-
<option value="">any</option>
|
|
165
|
-
${facet.values.map((v) => `<option value="${esc(v.value)}" ${selected.includes(v.value) ? 'selected' : ''}>${esc(v.value)} (${v.count})</option>`).join('')}
|
|
166
|
-
</select>`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return `
|
|
170
|
-
<fieldset class="min-w-[9rem] max-w-[16rem]">
|
|
171
|
-
<legend class="text-[11px] uppercase tracking-wide opacity-60 mb-1">${esc(facet.key)}</legend>
|
|
172
|
-
<div class="flex flex-col gap-1">${body}</div>
|
|
173
|
-
</fieldset>`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function renderFilters() {
|
|
177
|
-
const archived = state.rooms.filter((r) => r.deprecated).length;
|
|
178
|
-
const archivedToggle = archived
|
|
179
|
-
? `<label class="flex items-center gap-2 cursor-pointer text-sm ml-auto">
|
|
180
|
-
<input type="checkbox" class="toggle toggle-xs" id="archived" ${state.archived ? 'checked' : ''}>
|
|
181
|
-
<span class="opacity-60">archived (${archived})</span>
|
|
182
|
-
</label>`
|
|
183
|
-
: '';
|
|
184
|
-
$('filters').innerHTML = state.facets.map(renderFacet).join('') + archivedToggle;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function renderActive() {
|
|
188
|
-
const chips = [];
|
|
189
|
-
for (const [key, values] of Object.entries(state.filters)) {
|
|
190
|
-
for (const value of values) {
|
|
191
|
-
chips.push(`<button class="btn btn-xs btn-ghost gap-1" data-clear="${esc(key)}" data-value="${esc(value)}">${esc(key)}: ${esc(value)} <span class="opacity-50">×</span></button>`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (state.q) chips.unshift(`<span class="badge badge-sm badge-soft">search: ${esc(state.q)}</span>`);
|
|
195
|
-
if (chips.length > 1) chips.push('<button class="btn btn-xs btn-link" id="clearAll">clear all</button>');
|
|
196
|
-
$('active').innerHTML = chips.join('');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function render() {
|
|
200
|
-
const rooms = visibleRooms();
|
|
201
|
-
$('grid').innerHTML = rooms.map(card).join('');
|
|
202
|
-
|
|
203
|
-
const total = state.rooms.filter((r) => state.archived || !r.deprecated).length;
|
|
204
|
-
$('count').textContent = rooms.length === total ? `rooms ${total}` : `${rooms.length} / ${total}`;
|
|
205
|
-
|
|
206
|
-
const empty = rooms.length === 0;
|
|
207
|
-
$('empty').classList.toggle('hidden', !empty);
|
|
208
|
-
if (empty) {
|
|
209
|
-
const filtered = Boolean(state.q) || Object.values(state.filters).some((v) => v.length);
|
|
210
|
-
$('emptyTitle').textContent = filtered ? 'Nothing matches' : 'No rooms yet';
|
|
211
|
-
$('emptyText').innerHTML = filtered
|
|
212
|
-
? 'Loosen a filter, or search for something else.'
|
|
213
|
-
: 'A room is one screen: <code>rooms/<name>/</code> with <code>BRIEF.md</code>, <code>SPEC.md</code>, <code>BEHAVIOUR.md</code> and an artifact.';
|
|
214
|
-
$('emptyHint').classList.toggle('hidden', filtered);
|
|
215
|
-
$('clear').classList.toggle('hidden', !filtered);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function syncUrl() {
|
|
220
|
-
const p = new URLSearchParams();
|
|
221
|
-
if (state.q) p.set('q', state.q);
|
|
222
|
-
for (const [key, values] of Object.entries(state.filters)) if (values.length) p.set(`f.${key}`, values.join('|'));
|
|
223
|
-
if (state.archived) p.set('archived', '1');
|
|
224
|
-
const qs = p.toString();
|
|
225
|
-
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function readUrl() {
|
|
229
|
-
const p = new URLSearchParams(location.search);
|
|
230
|
-
state.q = p.get('q') || '';
|
|
231
|
-
state.archived = p.get('archived') === '1';
|
|
232
|
-
state.filters = {};
|
|
233
|
-
for (const [key, value] of p) {
|
|
234
|
-
if (!key.startsWith('f.') || !value) continue;
|
|
235
|
-
state.filters[key.slice(2)] = value.split('|').filter(Boolean);
|
|
236
|
-
}
|
|
237
|
-
$('q').value = state.q;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function runSearch() {
|
|
241
|
-
const my = ++seq;
|
|
242
|
-
if (!state.q.trim()) { state.hits = null; render(); return; }
|
|
243
|
-
const data = await (await fetch(`/api/search?q=${encodeURIComponent(state.q)}`)).json();
|
|
244
|
-
if (my !== seq) return;
|
|
245
|
-
state.hits = data.hits || [];
|
|
246
|
-
render();
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function load() {
|
|
250
|
-
const data = await (await fetch('/api/rooms')).json();
|
|
251
|
-
state.rooms = data.rooms || [];
|
|
252
|
-
state.facets = data.facets || [];
|
|
253
|
-
renderFilters();
|
|
254
|
-
renderActive();
|
|
255
|
-
if (state.q.trim()) await runSearch(); else render();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
$('q').addEventListener('input', (e) => {
|
|
259
|
-
state.q = e.target.value;
|
|
260
|
-
clearTimeout(timer);
|
|
261
|
-
timer = setTimeout(() => { syncUrl(); runSearch(); }, 220);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
$('filters').addEventListener('change', (e) => {
|
|
265
|
-
const input = e.target.closest('[data-facet]');
|
|
266
|
-
if (input) {
|
|
267
|
-
const key = input.dataset.facet;
|
|
268
|
-
if (input.type === 'checkbox') {
|
|
269
|
-
const set = new Set(state.filters[key] || []);
|
|
270
|
-
if (input.checked) set.add(input.value); else set.delete(input.value);
|
|
271
|
-
state.filters[key] = [...set];
|
|
272
|
-
} else {
|
|
273
|
-
state.filters[key] = input.value ? [input.value] : [];
|
|
274
|
-
}
|
|
275
|
-
if (!state.filters[key].length) delete state.filters[key];
|
|
276
|
-
syncUrl();
|
|
277
|
-
renderActive();
|
|
278
|
-
render();
|
|
279
|
-
}
|
|
280
|
-
if (e.target.id === 'archived') {
|
|
281
|
-
state.archived = e.target.checked;
|
|
282
|
-
syncUrl();
|
|
283
|
-
renderActive();
|
|
284
|
-
render();
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
$('active').addEventListener('click', (e) => {
|
|
289
|
-
const chip = e.target.closest('[data-clear]');
|
|
290
|
-
if (chip) {
|
|
291
|
-
const key = chip.dataset.clear;
|
|
292
|
-
state.filters[key] = (state.filters[key] || []).filter((v) => v !== chip.dataset.value);
|
|
293
|
-
if (!state.filters[key].length) delete state.filters[key];
|
|
294
|
-
renderFilters(); syncUrl(); renderActive(); render();
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (e.target.id === 'clearAll') {
|
|
298
|
-
state.filters = {}; state.q = ''; $('q').value = '';
|
|
299
|
-
state.hits = null; renderFilters(); syncUrl(); renderActive(); render();
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
$('clear').addEventListener('click', () => {
|
|
304
|
-
state.filters = {}; state.q = ''; $('q').value = '';
|
|
305
|
-
state.hits = null; renderFilters(); syncUrl(); renderActive(); render();
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
$('refresh').addEventListener('click', load);
|
|
309
|
-
|
|
310
|
-
document.addEventListener('keydown', (e) => {
|
|
311
|
-
if (e.key === '/' && document.activeElement !== $('q')) { e.preventDefault(); $('q').focus(); }
|
|
312
|
-
if (e.key === 'Escape' && document.activeElement === $('q')) { $('q').blur(); }
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
function setStatus(phase, message) {
|
|
316
|
-
$('status').dataset.phase = phase;
|
|
317
|
-
$('statusText').textContent = message;
|
|
318
|
-
const busy = phase === 'chat' || phase === 'rendering';
|
|
319
|
-
$('spinner').classList.toggle('hidden', !busy);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const es = new EventSource('/events');
|
|
323
|
-
const refreshSoon = () => { clearTimeout(timer); timer = setTimeout(load, 350); };
|
|
324
|
-
es.addEventListener('status', (e) => { const d = JSON.parse(e.data); setStatus(d.phase, d.message); });
|
|
325
|
-
['change', 'chat-start', 'chat-done', 'render-done', 'doc-saved'].forEach((ev) => es.addEventListener(ev, refreshSoon));
|
|
326
|
-
es.addEventListener('error', () => setStatus('error', 'disconnected — retrying'));
|
|
327
|
-
|
|
328
|
-
readUrl();
|
|
329
|
-
load();
|
|
11
|
+
// Theme before paint: the bundle is React, but the first frame should not flash.
|
|
12
|
+
try {
|
|
13
|
+
var stored = localStorage.getItem('modelroom.theme');
|
|
14
|
+
document.documentElement.dataset.theme = stored
|
|
15
|
+
? JSON.parse(stored)
|
|
16
|
+
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
17
|
+
} catch (error) { /* the default is light */ }
|
|
330
18
|
</script>
|
|
19
|
+
</head>
|
|
20
|
+
<body class="bg-background text-foreground font-sans">
|
|
21
|
+
<div id="root" data-app="dashboard"></div>
|
|
22
|
+
<script type="module" src="/ui/assets/app.js"></script>
|
|
331
23
|
</body>
|
|
332
24
|
</html>
|
package/ui/kit.html
CHANGED
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
9
9
|
<title>Workshop — modelroom</title>
|
|
10
10
|
<link rel="stylesheet" href="/ui/modelroom.css">
|
|
11
|
+
<script>
|
|
12
|
+
// Theme before paint: the bundle is React, but the first frame should not flash.
|
|
13
|
+
try {
|
|
14
|
+
var stored = localStorage.getItem('modelroom.theme');
|
|
15
|
+
document.documentElement.dataset.theme = stored
|
|
16
|
+
? JSON.parse(stored)
|
|
17
|
+
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
18
|
+
} catch (error) { /* the default is light */ }
|
|
19
|
+
</script>
|
|
11
20
|
</head>
|
|
12
|
-
<body class="
|
|
21
|
+
<body class="bg-muted/30 text-foreground font-sans">
|
|
13
22
|
<div id="root" data-app="kit"></div>
|
|
14
23
|
<script type="module" src="/ui/assets/app.js"></script>
|
|
15
24
|
</body>
|