@pramen/admin 0.0.20 → 0.0.22

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.
@@ -0,0 +1,68 @@
1
+ /* AUTO-GENERATED by @tools/sync-tokens — DO NOT EDIT BY HAND. */
2
+ /* Source of truth: the API → packages/tokens/src/tokens.json. */
3
+ /* Regenerate: bun --filter @tools/sync-tokens start */
4
+
5
+ :root {
6
+ --color-accent-green-light: #b1fed4;
7
+ --color-accent-mint: #dff6ec;
8
+ --color-accent-pink: #ff69b4;
9
+ --color-accent-strong: #4bcf9f;
10
+ --color-accent-yellow: #ffcc6a;
11
+ --color-border: #eceae1;
12
+ --color-brand-green: #75e7b8;
13
+ --color-brand-primary: #0d0d0d;
14
+ --color-brand-secondary: #6eddb1;
15
+ --color-danger: #dc2626;
16
+ --color-danger-fg: #ffffff;
17
+ --color-fg: #0d0d0d;
18
+ --color-fg-inverted: #ffffff;
19
+ --color-fg-muted: #7d786f;
20
+ --color-fg-subtle: #aba89c;
21
+ --color-neutral-600: #333333;
22
+ --color-ring: #0d0d0d;
23
+ --color-success: #4bcf9f;
24
+ --color-success-fg: #0d0d0d;
25
+ --color-surface: #ffffff;
26
+ --color-surface-card: #f7f6f2;
27
+ --color-surface-inverted: #242423;
28
+ --color-surface-muted: #eceae1;
29
+ --color-surface-placeholder: #333333;
30
+ --font-mono: 'GT America Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
31
+ --font-sans: 'GT America', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
32
+ --font-size-body: 1rem;
33
+ --font-size-callout: 0.9375rem;
34
+ --font-size-caption: 0.6875rem;
35
+ --font-size-compact: 0.8125rem;
36
+ --font-size-display: 1.75rem;
37
+ --font-size-heading1: 1.75rem;
38
+ --font-size-heading2: 1.375rem;
39
+ --font-size-heading3: 1.25rem;
40
+ --font-size-heading4: 1.125rem;
41
+ --font-size-heading5: 1rem;
42
+ --font-size-headline: 1.375rem;
43
+ --font-size-label: 0.75rem;
44
+ --font-size-micro: 0.625rem;
45
+ --font-size-subtitle: 1.125rem;
46
+ --font-size-title: 1.25rem;
47
+ --line-height-heading1: 30px;
48
+ --line-height-heading2: 24px;
49
+ --line-height-heading3: 22px;
50
+ --line-height-heading4: 22px;
51
+ --line-height-heading5: 20px;
52
+ --radius-2xl: 1rem;
53
+ --radius-lg: 0.5rem;
54
+ --radius-md: 0.375rem;
55
+ --radius-panel: 1.1rem;
56
+ --radius-sm: 0.125rem;
57
+ --radius-xl: 0.75rem;
58
+ --space-lg: 1.5rem;
59
+ --space-md: 1rem;
60
+ --space-nav-x: 0.8125rem;
61
+ --space-sm: 0.5rem;
62
+ --space-xl: 2rem;
63
+ --space-xs: 0.25rem;
64
+ --tracking-normal: -0.13px;
65
+ --tracking-tight: -0.2px;
66
+ --tracking-wide: -0.56px;
67
+ --tracking-wider: -0.6px;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/admin",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "pramen admin dashboard — a Preact single-page browser client for the pramen admin HTTP API (tenants, schema, and generic row browse/edit).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -21,6 +21,15 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "preact": "^10.25.0"
24
+ "@podoba/react": "^0.0.3",
25
+ "@podoba/tokens": "^0.0.3",
26
+ "@podoba/tailwind": "^0.0.3",
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "tailwindcss": "^3.4.19"
25
34
  }
26
35
  }
package/scripts/build.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // mangles pure re-export barrels; app code bundles fine). Pass --watch to rebuild
4
4
  // on change and serve a preview on http://localhost:5174.
5
5
 
6
- import { rm, mkdir, writeFile } from "node:fs/promises";
6
+ import { rm, mkdir, writeFile, copyFile } from "node:fs/promises";
7
7
 
8
8
  const root = new URL("..", import.meta.url).pathname;
9
9
  const dist = `${root}dist`;
@@ -16,6 +16,8 @@ const html = (jsName: string) => `<!doctype html>
16
16
  <meta charset="utf-8" />
17
17
  <meta name="viewport" content="width=device-width, initial-scale=1" />
18
18
  <title>pramen · admin</title>
19
+ <link rel="stylesheet" href="./tokens.css" />
20
+ <link rel="stylesheet" href="./app.css" />
19
21
  </head>
20
22
  <body>
21
23
  <div id="app"></div>
@@ -24,7 +26,18 @@ const html = (jsName: string) => `<!doctype html>
24
26
  </html>
25
27
  `;
26
28
 
29
+ // Design system = podoba. Compile Tailwind (podoba preset) → dist/app.css and
30
+ // copy the podoba token CSS vars → dist/tokens.css; index.html <link>s both.
31
+ async function styles(): Promise<void> {
32
+ await copyFile(`${root}node_modules/@podoba/tokens/src/variables.css`, `${dist}/tokens.css`);
33
+ const args = ["tailwindcss", "-c", `${root}tailwind.config.ts`, "-i", `${root}src/app.css`, "-o", `${dist}/app.css`];
34
+ if (!watch) args.push("--minify");
35
+ const proc = Bun.spawn(["bunx", ...args], { cwd: root, stdout: "inherit", stderr: "inherit" });
36
+ if ((await proc.exited) !== 0) throw new Error("tailwind build failed");
37
+ }
38
+
27
39
  async function build(): Promise<void> {
40
+ await styles();
28
41
  const out = await Bun.build({
29
42
  entrypoints: [entry],
30
43
  outdir: dist,
package/src/app.css ADDED
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
package/src/app.tsx CHANGED
@@ -3,12 +3,16 @@
3
3
  // over the /admin/data primitives. Editing is a robust JSON textarea (no schema
4
4
  // guessing). All API failures render verbatim (error + code) in a banner.
5
5
 
6
- import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
6
+ import { Button, Input } from "@podoba/react";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
8
  import { api, ApiError, type Config, type Row, type SchemaResult } from "./api";
8
9
 
9
10
  const LS = { base: "pramen.admin.baseUrl", token: "pramen.admin.token", tenant: "pramen.admin.tenant" };
10
11
  const PAGE = 25;
11
12
 
13
+ // Tokenized bare control (podoba filled-field skin) for the native inputs/selects.
14
+ const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
15
+
12
16
  const useLocal = (key: string, initial: string): [string, (v: string) => void] => {
13
17
  const [v, setV] = useState(() => localStorage.getItem(key) ?? initial);
14
18
  const set = useCallback(
@@ -22,19 +26,19 @@ const useLocal = (key: string, initial: string): [string, (v: string) => void] =
22
26
  };
23
27
 
24
28
  function renderCell(v: unknown) {
25
- if (v === null || v === undefined) return <span class="cell null">null</span>;
26
- if (typeof v === "boolean") return <span class="cell bool">{String(v)}</span>;
29
+ if (v === null || v === undefined) return <span className="italic text-fg-subtle">null</span>;
30
+ if (typeof v === "boolean") return <span className="text-accent-strong">{String(v)}</span>;
27
31
  if (typeof v === "object") {
28
32
  const s = JSON.stringify(v);
29
33
  return (
30
- <span class="cell json" title={s}>
34
+ <span className="block max-w-[280px] truncate font-mono text-xs text-fg-muted" title={s}>
31
35
  {s}
32
36
  </span>
33
37
  );
34
38
  }
35
39
  const s = String(v);
36
40
  return (
37
- <span class="cell" title={s}>
41
+ <span className="block max-w-[280px] truncate" title={s}>
38
42
  {s}
39
43
  </span>
40
44
  );
@@ -109,7 +113,7 @@ export function App() {
109
113
  }, [cfg, tenant, table, offset, token, report]);
110
114
 
111
115
  // tenants + schema react to config/tenant; rows react to table/page.
112
- const debounce = useRef<ReturnType<typeof setTimeout>>();
116
+ const debounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
113
117
  useEffect(() => {
114
118
  clearTimeout(debounce.current);
115
119
  debounce.current = setTimeout(() => {
@@ -208,132 +212,129 @@ export function App() {
208
212
 
209
213
  return (
210
214
  <>
211
- <div class="bar">
212
- <div class="brand">
213
- <span class="mark">pramen</span>
214
- <span class="sub">admin</span>
215
+ <div className="flex flex-wrap items-end gap-4 border-b border-border bg-surface px-7 py-4">
216
+ <div className="flex items-baseline gap-1.5">
217
+ <span className="text-[15px] font-bold text-fg">pramen</span>
218
+ <span className="text-fg-subtle">admin</span>
215
219
  </div>
216
- <div class="field url">
217
- <label>base url</label>
218
- <input value={baseUrl} onInput={(e) => setBaseUrl((e.target as HTMLInputElement).value)} placeholder="http://localhost:8787" spellcheck={false} />
220
+ <div className="min-w-[220px] flex-1">
221
+ <Input label="base url" value={baseUrl} onChange={setBaseUrl} placeholder="http://localhost:8787" spellCheck="false" />
219
222
  </div>
220
- <div class="field token">
221
- <label>admin token</label>
222
- <input type="password" value={token} onInput={(e) => setToken((e.target as HTMLInputElement).value)} placeholder="paste an admin JWT (roles include &quot;admin&quot;)" spellcheck={false} />
223
+ <div className="min-w-[260px] flex-1">
224
+ <Input label="admin token" type="password" value={token} onChange={setToken} placeholder='paste an admin JWT (roles include "admin")' spellCheck="false" />
223
225
  </div>
224
- <div class="hint">Must be an admin token — every endpoint checks roles for &quot;admin&quot;.</div>
226
+ <div className="w-full text-xs text-fg-subtle">Must be an admin token — every endpoint checks roles for &quot;admin&quot;.</div>
225
227
  </div>
226
228
 
227
- <div class="layout">
228
- <aside class="side">
229
- <p class="sec-title">Tenant</p>
230
- <div class="tenant-row">
229
+ <div className="grid grid-cols-[260px_1fr] gap-5 px-7 py-5 max-[820px]:grid-cols-1">
230
+ <aside className="flex flex-col gap-2">
231
+ <p className="text-sm text-fg-subtle">Tenant</p>
232
+ <div className="flex items-center gap-1.5">
231
233
  {tenants.length > 0 ? (
232
- <select value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant((e.target as HTMLSelectElement).value)}>
234
+ <select className={CONTROL} value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant(e.target.value)}>
233
235
  {!tenants.includes(tenant) && <option value="">{tenant} (manual)</option>}
234
236
  {tenants.map((t) => (
235
- <option key={t} value={t}>
236
- {t}
237
- </option>
237
+ <option key={t} value={t}>{t}</option>
238
238
  ))}
239
239
  </select>
240
240
  ) : (
241
- <input value={tenant} onInput={(e) => setTenant((e.target as HTMLInputElement).value)} placeholder="main" spellcheck={false} />
241
+ <input className={CONTROL} value={tenant} onChange={(e) => setTenant(e.target.value)} placeholder="main" spellCheck={false} />
242
242
  )}
243
- <button class="ghost sm" title="Edit tenant name manually" onClick={() => {
243
+ <Button variant="ghost" size="sm" className="shrink-0" onPress={() => {
244
244
  const next = prompt("Tenant name", tenant);
245
245
  if (next != null && next.trim()) setTenant(next.trim());
246
- }}>edit</button>
246
+ }}>edit</Button>
247
247
  </div>
248
248
 
249
- <p class="sec-title">Schema</p>
249
+ <p className="mt-3 text-sm text-fg-subtle">Schema</p>
250
250
  {schema ? (
251
- <div class={`hashchip${schema.hash ? "" : " none"}`} title="applied schema hash">
252
- <span class="dot" />
251
+ <div className={`flex items-center gap-2 rounded-full border border-border px-3 py-1 font-mono text-xs ${schema.hash ? "text-fg-muted" : "text-fg-subtle"}`} title="applied schema hash">
252
+ <span className={`h-2 w-2 rounded-full ${schema.hash ? "bg-brand-green" : "bg-fg-subtle"}`} />
253
253
  {schema.hash ? schema.hash.slice(0, 12) : "no schema hash"}
254
254
  </div>
255
255
  ) : (
256
- <div class="muted" style="margin-bottom:14px">{token ? "—" : "enter a token"}</div>
256
+ <div className="mb-3.5 text-fg-subtle">{token ? "—" : "enter a token"}</div>
257
257
  )}
258
258
 
259
- <p class="sec-title">Tables{tableNames.length ? ` · ${tableNames.length}` : ""}</p>
259
+ <p className="mt-3 text-sm text-fg-subtle">Tables{tableNames.length ? ` · ${tableNames.length}` : ""}</p>
260
260
  {tableNames.length > 0 ? (
261
- <ul class="tables">
261
+ <ul className="flex flex-col gap-1">
262
262
  {tableNames.map((t) => (
263
263
  <li key={t}>
264
- <button class={t === table ? "active" : ""} onClick={() => setTable(t)}>
265
- <span class="tname">{t}</span>
266
- <span class="cols">{schema!.tables[t].length} cols</span>
264
+ <button
265
+ className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${t === table ? "border-fg bg-surface-card text-fg" : "border-transparent text-fg-muted hover:bg-surface-card"}`}
266
+ onClick={() => setTable(t)}
267
+ >
268
+ <span className="font-medium">{t}</span>
269
+ <span className="text-xs text-fg-subtle">{schema!.tables[t].length} cols</span>
267
270
  </button>
268
271
  </li>
269
272
  ))}
270
273
  </ul>
271
274
  ) : (
272
- <div class="muted">{token ? "no tables" : ""}</div>
275
+ <div className="text-fg-subtle">{token ? "no tables" : ""}</div>
273
276
  )}
274
277
  </aside>
275
278
 
276
- <main class="main">
279
+ <main className="min-w-0">
277
280
  {err && (
278
- <div class="banner">
281
+ <div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-danger bg-surface-card px-4 py-2.5 text-sm text-danger">
279
282
  <div>
280
- {err.message} <code>{err.code}</code>
283
+ {err.message} <code className="font-mono">{err.code}</code>
281
284
  </div>
282
- <button class="ghost sm" onClick={() => setErr(null)}>dismiss</button>
285
+ <Button variant="ghost" size="sm" onPress={() => setErr(null)}>dismiss</Button>
283
286
  </div>
284
287
  )}
285
288
 
286
289
  {!table ? (
287
- <div class="placeholder">
288
- <div class="big">{token ? "select a table" : "paste an admin token to begin"}</div>
289
- <div>The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code style="color:var(--amber)">main</code>.</div>
290
+ <div className="rounded-panel border border-border bg-surface-card px-8 py-12 text-center">
291
+ <div className="mb-2 text-xl text-fg">{token ? "select a table" : "paste an admin token to begin"}</div>
292
+ <div className="text-fg-muted">The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code className="font-mono text-accent-strong">main</code>.</div>
290
293
  </div>
291
294
  ) : (
292
295
  <>
293
- <div class="main-head">
294
- <h2>{table}</h2>
295
- <span class="countchip">{count == null ? "…" : `${count} row${count === 1 ? "" : "s"}`}</span>
296
- <span class="spacer" />
297
- <button class="ghost sm" onClick={() => void loadRows()} disabled={loading}>refresh</button>
298
- <button class="primary" onClick={openCreate}>+ new row</button>
296
+ <div className="mb-3 flex items-center gap-3">
297
+ <h2 className="m-0 text-2xl font-normal text-fg">{table}</h2>
298
+ <span className="rounded-full border border-border px-2.5 py-0.5 text-[11px] text-fg-muted">{count == null ? "…" : `${count} row${count === 1 ? "" : "s"}`}</span>
299
+ <span className="flex-1" />
300
+ <Button variant="ghost" size="sm" onPress={() => void loadRows()} isDisabled={loading}>refresh</Button>
301
+ <Button size="sm" onPress={openCreate}>+ new row</Button>
299
302
  </div>
300
303
 
301
- <div class="toolbar">
302
- <div class="pager">
303
- <button class="sm" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(0, offset - PAGE))}>‹ prev</button>
304
- <span>
305
- page {page}
306
- {totalPages != null ? ` / ${totalPages}` : ""}
307
- </span>
308
- <button class="sm" disabled={loading || (count != null && offset + PAGE >= count)} onClick={() => setOffset(offset + PAGE)}>next ›</button>
309
- </div>
304
+ <div className="mb-3 flex items-center gap-2">
305
+ <Button variant="secondary" size="sm" isDisabled={offset === 0 || loading} onPress={() => setOffset(Math.max(0, offset - PAGE))}>‹ prev</Button>
306
+ <span className="text-sm text-fg-muted">
307
+ page {page}
308
+ {totalPages != null ? ` / ${totalPages}` : ""}
309
+ </span>
310
+ <Button variant="secondary" size="sm" isDisabled={loading || (count != null && offset + PAGE >= count)} onPress={() => setOffset(offset + PAGE)}>next ›</Button>
310
311
  </div>
311
312
 
312
313
  {loading && !rows ? (
313
- <div class="loading">loading…</div>
314
+ <div className="py-12 text-center text-fg-subtle">loading…</div>
314
315
  ) : rows && rows.length === 0 ? (
315
- <div class="empty">no rows on this page</div>
316
+ <div className="py-12 text-center text-fg-subtle">no rows on this page</div>
316
317
  ) : rows ? (
317
- <div class="tablewrap">
318
- <table class="grid">
318
+ <div className="overflow-x-auto rounded-panel border border-border">
319
+ <table className="w-full border-collapse text-sm">
319
320
  <thead>
320
321
  <tr>
321
322
  {columns.map((c) => (
322
- <th key={c}>{c}</th>
323
+ <th key={c} className="whitespace-nowrap border-b border-border bg-surface-card px-3 py-2 text-left font-medium text-fg-muted">{c}</th>
323
324
  ))}
324
- <th style="text-align:right">actions</th>
325
+ <th className="border-b border-border bg-surface-card px-3 py-2 text-right font-medium text-fg-muted">actions</th>
325
326
  </tr>
326
327
  </thead>
327
328
  <tbody>
328
329
  {rows.map((row, i) => (
329
- <tr key={(row.id as string) ?? i}>
330
+ <tr key={(row.id as string) ?? i} className="hover:bg-surface-card">
330
331
  {columns.map((c) => (
331
- <td key={c}>{renderCell(row[c])}</td>
332
+ <td key={c} className="border-b border-border px-3 py-2 align-top">{renderCell(row[c])}</td>
332
333
  ))}
333
- <td>
334
- <div class="rowactions">
335
- <button class="ghost sm" onClick={() => void openEdit(row)}>edit</button>
336
- <button class="danger sm" onClick={() => void del(row)} disabled={busy}>del</button>
334
+ <td className="border-b border-border px-3 py-2 align-top">
335
+ <div className="flex justify-end gap-1">
336
+ <Button variant="ghost" size="sm" onPress={() => void openEdit(row)}>edit</Button>
337
+ <Button variant="ghost" size="sm" className="text-danger" onPress={() => void del(row)} isDisabled={busy}>del</Button>
337
338
  </div>
338
339
  </td>
339
340
  </tr>
@@ -348,29 +349,30 @@ export function App() {
348
349
  </div>
349
350
 
350
351
  {editor && (
351
- <div class="scrim" onClick={(e) => e.target === e.currentTarget && setEditor(null)}>
352
- <div class="modal">
353
- <div class="modal-head">
354
- <h3>{editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`}</h3>
355
- <span class="spacer" />
356
- <button class="ghost sm" onClick={() => setEditor(null)}>✕</button>
352
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={(e) => e.target === e.currentTarget && setEditor(null)}>
353
+ <div className="flex max-h-[86vh] w-full max-w-[680px] flex-col overflow-hidden rounded-panel border border-border bg-surface-card shadow-[0_24px_60px_rgba(30,20,10,0.12)]">
354
+ <div className="flex items-center gap-2 border-b border-border px-6 py-4">
355
+ <h3 className="m-0 text-lg font-normal text-fg">{editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`}</h3>
356
+ <span className="flex-1" />
357
+ <Button variant="ghost" size="sm" onPress={() => setEditor(null)}>✕</Button>
357
358
  </div>
358
- <div class="modal-body">
359
- <label>row json {editor.mode === "edit" ? "(only changed fields are sent as the patch)" : "(sent as values)"}</label>
359
+ <div className="flex flex-col gap-2 overflow-auto px-6 py-4">
360
+ <label className="text-sm font-medium text-fg">row json {editor.mode === "edit" ? "(only changed fields are sent as the patch)" : "(sent as values)"}</label>
360
361
  <textarea
362
+ className="min-h-[280px] w-full resize-y rounded-lg border border-border bg-surface-card px-4 py-3 font-mono text-[13px] text-fg outline-none focus:border-brand-green"
361
363
  value={editor.text}
362
- spellcheck={false}
363
- onInput={(e) => setEditor({ ...editor, text: (e.target as HTMLTextAreaElement).value })}
364
+ spellCheck={false}
365
+ onChange={(e) => setEditor({ ...editor, text: e.target.value })}
364
366
  />
365
- {editErr && <div class="jsonerr" style="margin-top:8px">{editErr}</div>}
367
+ {editErr && <div className="mt-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-[13px] text-danger">{editErr}</div>}
366
368
  </div>
367
- <div class="modal-foot">
368
- <span class="muted">{editor.mode === "edit" ? "id is never modified" : "id is assigned by the server unless provided"}</span>
369
- <span class="spacer" />
370
- <button class="ghost" onClick={() => setEditor(null)}>cancel</button>
371
- <button class="primary" onClick={() => void saveEditor()} disabled={busy}>
369
+ <div className="flex items-center gap-2 border-t border-border px-6 py-4">
370
+ <span className="text-fg-subtle">{editor.mode === "edit" ? "id is never modified" : "id is assigned by the server unless provided"}</span>
371
+ <span className="flex-1" />
372
+ <Button variant="ghost" onPress={() => setEditor(null)}>cancel</Button>
373
+ <Button onPress={() => void saveEditor()} isDisabled={busy}>
372
374
  {busy ? "saving…" : editor.mode === "create" ? "create" : "save changes"}
373
- </button>
375
+ </Button>
374
376
  </div>
375
377
  </div>
376
378
  </div>
package/src/main.tsx CHANGED
@@ -1,11 +1,12 @@
1
- // SPA entry: inject the inline stylesheet and mount the admin dashboard.
1
+ // SPA entry: mount the admin dashboard. Styling is podoba @podoba/tokens/variables.css
2
+ // + the compiled Tailwind (podoba preset) are <link>ed by index.html (see scripts/build.ts).
2
3
 
3
- import { render } from "preact";
4
+ import { StrictMode } from "react";
5
+ import { createRoot } from "react-dom/client";
4
6
  import { App } from "./app";
5
- import { css } from "./styles";
6
7
 
7
- const style = document.createElement("style");
8
- style.textContent = css;
9
- document.head.appendChild(style);
10
-
11
- render(<App />, document.getElementById("app")!);
8
+ createRoot(document.getElementById("app")!).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );