create-syncular-app 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,14 +18,13 @@ bunx create-syncular-app my-app --template web
18
18
  |---|---|
19
19
  | `minimal` | Server + a terminal two-client convergence demo (no browser) — migrations + manifest + `generate` wiring. Copy-evolved from `examples/quickstart`. The smallest honest starting point. |
20
20
  | `web` | Hono server + WebSocket realtime + a single-pane browser todo app whose whole client core runs in a Web Worker on OPFS. Derived from `apps/demo`, slimmed to one pane (no conflict simulator, no blob attachments) — the minimal browser app a real user starts from. |
21
+ | `tauri` | One React codebase, web + desktop (RFC 0002 §4.1): the `web` template's server plus a shared React tree behind the `__TAURI_INTERNALS__` engine seam (`src/frontend/engine.ts`) — worker core on OPFS in the browser, native Rust core in a `src-tauri/` host (`tauri-plugin-syncular` from crates.io, `native-transport`). Derived from `bindings/tauri/example` + the [web+desktop guide](../../apps/docs/src/content/guide-web-desktop.md). |
21
22
 
22
- > **Third-template candidate: `react`.** [`apps/demo-react`](../../apps/demo-react)
23
- > is the ready-made source for a hooks-based template — `@syncular/react`
24
- > (`SyncProvider` + `useQuery` + `useRawSql` + `useMutation` + `useSyncStatus` +
25
- > `useWindow`) over the same worker + OPFS core, with the named-query read
26
- > tier wired. Slim it the way `web` slims `apps/demo` (drop the three-list
27
- > seed to one, keep one hook of each kind) and add it here when a React
28
- > template is wanted. Not built yet — noted so the shape is on record.
23
+ > **Next-template candidate: `react` (web-only).** [`apps/demo-react`](../../apps/demo-react)
24
+ > is the ready-made source for a hooks-based web-only template — the `tauri`
25
+ > template already carries the hook surface for the two-host story. Slim
26
+ > demo-react the way `web` slims `apps/demo` when a web-only React template
27
+ > is wanted. Not built yet noted so the shape is on record.
29
28
 
30
29
  Each template ships its own `README.md` (run steps, what to edit first),
31
30
  `.gitignore` (as `gitignore` — see below), a working `tsconfig.json`, and a
@@ -80,3 +79,12 @@ The in-tree template `*.test.ts` files are excluded from the root `bun test`
80
79
  sweep (`--path-ignore-patterns '**/create-app/template/**'` in the root `test`
81
80
  script) — they can only resolve their deps inside a scaffolded, linked copy,
82
81
  which the tier test above provides.
82
+
83
+ The `tauri` template's desktop half (`src-tauri/`) is deliberately outside
84
+ both tiers: compiling it needs the Rust toolchain, the Tauri system
85
+ libraries, and the crates.io registry. Its compile proof is a local
86
+ `cargo check` in a scaffolded app (documented in the template README) plus
87
+ the tauri-bindings CI job, which builds the same plugin wiring from the
88
+ in-tree path dep — the honest scoping the tauri/RN binding gates already
89
+ use. The webview side (engine seam, React tree, `build-frontend.ts`) rides
90
+ the always-run tier like any other template file.
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `create-syncular-app [project-name] [--template <minimal|web>] [--local]`
3
+ * `create-syncular-app [project-name] [--template <minimal|web|tauri>]
4
+ * [--local]`
4
5
  *
5
6
  * Runnable as `bun create syncular-app my-app` or `bunx create-syncular-app
6
7
  * my-app`. Prompts for anything a flag did not supply. Scaffolds one of the
7
- * two templates (see `./scaffold`).
8
+ * templates (see `./scaffold`).
8
9
  */
9
10
  import { relative } from 'node:path';
10
11
  import { createInterface } from 'node:readline/promises';
11
- import { CLI_BIN, CREATE_BIN, PRODUCT_NAME } from './constants.js';
12
+ import { CREATE_BIN, PRODUCT_NAME } from './constants.js';
12
13
  import { isTemplateName, scaffoldApp, TEMPLATES, } from './scaffold.js';
13
14
  function usage() {
14
15
  return `usage: ${CREATE_BIN} [project-name] [options]
@@ -19,6 +20,9 @@ templates:
19
20
  minimal server + a terminal two-client convergence demo (no browser)
20
21
  web Hono server + WebSocket + a single-pane browser todo app
21
22
  (worker core on OPFS)
23
+ tauri one React codebase, web + desktop: the web template's server
24
+ plus a src-tauri host running the native core
25
+ (tauri-plugin-syncular from crates.io)
22
26
 
23
27
  options:
24
28
  --template <name> one of: ${TEMPLATES.join(', ')} (prompts if omitted)
@@ -19,7 +19,7 @@ export declare const PRODUCT_NAME = "syncular";
19
19
  * documentation, not a gate — but it keeps the "what does a template pull in"
20
20
  * question answerable from one spot.
21
21
  */
22
- export declare const WORKSPACE_PACKAGES: readonly ["@syncular/core", "@syncular/server", "@syncular/server-hono", "@syncular/client", "@syncular/typegen"];
22
+ export declare const WORKSPACE_PACKAGES: readonly ["@syncular/core", "@syncular/server", "@syncular/server-hono", "@syncular/client", "@syncular/react", "@syncular/tauri", "@syncular/typegen"];
23
23
  export declare const PUBLISHED_DEPENDENCY_RANGE: string;
24
24
  /** Placeholder tokens substituted verbatim during scaffold (dumb + greppable). */
25
25
  export declare const PLACEHOLDER: {
package/dist/constants.js CHANGED
@@ -25,6 +25,8 @@ export const WORKSPACE_PACKAGES = [
25
25
  `${PACKAGE_SCOPE}/server`,
26
26
  `${PACKAGE_SCOPE}/server-hono`,
27
27
  `${PACKAGE_SCOPE}/client`,
28
+ `${PACKAGE_SCOPE}/react`,
29
+ `${PACKAGE_SCOPE}/tauri`,
28
30
  `${PACKAGE_SCOPE}/typegen`,
29
31
  ];
30
32
  /**
@@ -1,5 +1,5 @@
1
- /** The two templates this rung ships. */
2
- export declare const TEMPLATES: readonly ["minimal", "web"];
1
+ /** The templates this rung ships. */
2
+ export declare const TEMPLATES: readonly ["minimal", "web", "tauri"];
3
3
  export type TemplateName = (typeof TEMPLATES)[number];
4
4
  export declare function isTemplateName(value: string): value is TemplateName;
5
5
  /** Absolute path to `template/` inside this package (works for src or dist). */
package/dist/scaffold.js CHANGED
@@ -7,8 +7,8 @@ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSyn
7
7
  import { basename, join, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { PACKAGE_SCOPE, PLACEHOLDER, PUBLISHED_DEPENDENCY_RANGE, } from './constants.js';
10
- /** The two templates this rung ships. */
11
- export const TEMPLATES = ['minimal', 'web'];
10
+ /** The templates this rung ships. */
11
+ export const TEMPLATES = ['minimal', 'web', 'tauri'];
12
12
  export function isTemplateName(value) {
13
13
  return TEMPLATES.includes(value);
14
14
  }
@@ -66,7 +66,12 @@ function substitutePlaceholders(body, projectName) {
66
66
  * skipped per-template) — substitution stays dumb and auditable rather than
67
67
  * scanning every file. Paths are relative + POSIX-joined below.
68
68
  */
69
- const SUBSTITUTE_FILES = ['README.md', 'src/frontend/index.html'];
69
+ const SUBSTITUTE_FILES = [
70
+ 'README.md',
71
+ 'src/frontend/index.html',
72
+ 'src/frontend/main.tsx',
73
+ 'src-tauri/tauri.conf.json',
74
+ ];
70
75
  function directoryIsEmpty(path) {
71
76
  return readdirSync(path).length === 0;
72
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-syncular-app",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Scaffold a new Syncular app: `create-syncular-app`",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -49,6 +49,6 @@
49
49
  "!dist/**/*.test.d.ts"
50
50
  ],
51
51
  "devDependencies": {
52
- "@syncular/typegen": "0.2.1"
52
+ "@syncular/typegen": "0.4.1"
53
53
  }
54
54
  }
package/src/cli.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `create-syncular-app [project-name] [--template <minimal|web>] [--local]`
3
+ * `create-syncular-app [project-name] [--template <minimal|web|tauri>]
4
+ * [--local]`
4
5
  *
5
6
  * Runnable as `bun create syncular-app my-app` or `bunx create-syncular-app
6
7
  * my-app`. Prompts for anything a flag did not supply. Scaffolds one of the
7
- * two templates (see `./scaffold`).
8
+ * templates (see `./scaffold`).
8
9
  */
9
10
  import { relative } from 'node:path';
10
11
  import { createInterface } from 'node:readline/promises';
11
- import { CLI_BIN, CREATE_BIN, PRODUCT_NAME } from './constants';
12
+ import { CREATE_BIN, PRODUCT_NAME } from './constants';
12
13
  import {
13
14
  isTemplateName,
14
15
  scaffoldApp,
@@ -32,6 +33,9 @@ templates:
32
33
  minimal server + a terminal two-client convergence demo (no browser)
33
34
  web Hono server + WebSocket + a single-pane browser todo app
34
35
  (worker core on OPFS)
36
+ tauri one React codebase, web + desktop: the web template's server
37
+ plus a src-tauri host running the native core
38
+ (tauri-plugin-syncular from crates.io)
35
39
 
36
40
  options:
37
41
  --template <name> one of: ${TEMPLATES.join(', ')} (prompts if omitted)
package/src/constants.ts CHANGED
@@ -31,6 +31,8 @@ export const WORKSPACE_PACKAGES = [
31
31
  `${PACKAGE_SCOPE}/server`,
32
32
  `${PACKAGE_SCOPE}/server-hono`,
33
33
  `${PACKAGE_SCOPE}/client`,
34
+ `${PACKAGE_SCOPE}/react`,
35
+ `${PACKAGE_SCOPE}/tauri`,
34
36
  `${PACKAGE_SCOPE}/typegen`,
35
37
  ] as const;
36
38
 
package/src/scaffold.ts CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  PUBLISHED_DEPENDENCY_RANGE,
20
20
  } from './constants';
21
21
 
22
- /** The two templates this rung ships. */
23
- export const TEMPLATES = ['minimal', 'web'] as const;
22
+ /** The templates this rung ships. */
23
+ export const TEMPLATES = ['minimal', 'web', 'tauri'] as const;
24
24
  export type TemplateName = (typeof TEMPLATES)[number];
25
25
 
26
26
  export function isTemplateName(value: string): value is TemplateName {
@@ -106,7 +106,12 @@ export interface ScaffoldResult {
106
106
  * skipped per-template) — substitution stays dumb and auditable rather than
107
107
  * scanning every file. Paths are relative + POSIX-joined below.
108
108
  */
109
- const SUBSTITUTE_FILES = ['README.md', 'src/frontend/index.html'] as const;
109
+ const SUBSTITUTE_FILES = [
110
+ 'README.md',
111
+ 'src/frontend/index.html',
112
+ 'src/frontend/main.tsx',
113
+ 'src-tauri/tauri.conf.json',
114
+ ] as const;
110
115
 
111
116
  function directoryIsEmpty(path: string): boolean {
112
117
  return readdirSync(path).length === 0;
@@ -0,0 +1,91 @@
1
+ # __PROJECT_NAME__
2
+
3
+ A [syncular](https://github.com/bkniffler/syncular) "one codebase, web +
4
+ desktop" app: one React tree that runs in the browser (the whole client core
5
+ in a Web Worker on OPFS) **and** in a Tauri window (a native Rust core with a
6
+ real file database in the host process). Every hook, component, and query is
7
+ identical — the only host-aware code is the ~50-line engine seam in
8
+ `src/frontend/engine.ts`.
9
+
10
+ ## Run the web half
11
+
12
+ ```sh
13
+ bun install
14
+ bun run generate # syncular.json + migrations → src/syncular.generated.ts
15
+ bun run dev # http://localhost:8787 — sync server + web app
16
+ ```
17
+
18
+ Open the URL, add todos, open a second window to watch them converge over the
19
+ realtime socket. `bun test` runs a server-level convergence smoke test.
20
+
21
+ ## Run the desktop half
22
+
23
+ With the dev server still running (the native core syncs against it):
24
+
25
+ ```sh
26
+ cd src-tauri
27
+ cargo tauri dev
28
+ ```
29
+
30
+ `cargo tauri dev` builds the frontend (`bun run build-frontend` → `dist/`),
31
+ compiles the Rust host, and opens the window. Edit todos in the browser and
32
+ the desktop window side by side — same server, two radically different hosts,
33
+ converging live.
34
+
35
+ Prerequisites: [Rust](https://rustup.rs) and the
36
+ [Tauri v2 system dependencies](https://v2.tauri.app/start/prerequisites/);
37
+ `cargo install tauri-cli` for the `cargo tauri` subcommand. The
38
+ `tauri-plugin-syncular` dependency comes from crates.io.
39
+
40
+ > `dist/` ships with a placeholder page so a direct `cargo build` works
41
+ > before the first frontend build (`tauri::generate_context!` hard-fails on
42
+ > a missing `frontendDist` directory). Any `cargo tauri dev`/`build`
43
+ > replaces it via `beforeDevCommand`.
44
+
45
+ ## Layout
46
+
47
+ | File | What it is |
48
+ |---|---|
49
+ | `syncular.json` + `migrations/` | The schema manifest and SQL — typegen's input |
50
+ | `src/syncular.generated.ts` | Generated by `syncular generate` (committed) |
51
+ | `src/server.ts` | Sync server + WebSocket + the web frontend, one Bun process |
52
+ | `src/frontend/engine.ts` | **The seam**: picks worker core vs native core |
53
+ | `src/frontend/main.tsx` | The shared React tree (host-agnostic) |
54
+ | `src/frontend/worker.ts` | The sync worker (web half only) |
55
+ | `build-frontend.ts` | Bundles the frontend to `dist/` for the Tauri window |
56
+ | `src-tauri/` | The desktop host: plugin registration, config, capability |
57
+ | `src/smoke.test.ts` | Server-level convergence smoke test (`bun test`) |
58
+
59
+ ## How the seam works
60
+
61
+ `createEngine()` detects a Tauri webview (`__TAURI_INTERNALS__`) and
62
+ dynamic-imports either `createTauriSyncClient` (`@syncular/tauri` — an RPC
63
+ bridge to the native core the plugin hosts) or `createSyncClientHandle`
64
+ (`@syncular/client` — the worker core on OPFS). Both satisfy the one
65
+ structural interface the hooks target, `SyncClientLike`, so a single
66
+ `<SyncProvider>` serves both hosts. The dynamic imports keep each host's
67
+ machinery out of the other's bundle.
68
+
69
+ On desktop, the plugin owns the database path and the transport — see
70
+ `src-tauri/src/lib.rs` (`SyncularConfig { base_url, db_path, .. }`) and the
71
+ `syncular:default` permission in `src-tauri/capabilities/syncular.json`.
72
+
73
+ ## What to edit first
74
+
75
+ 1. **`src/server.ts` → `resolveScopes`** — the whole authorization story. The
76
+ starter returns `['*']`; a real one returns the scope values the
77
+ authenticated actor may see.
78
+ 2. **`src/server.ts` → `authenticate`** — plug in your real session/token
79
+ check; return `{ actorId, partition }` or `null` for a 401.
80
+ 3. **`src-tauri/src/lib.rs` → `base_url`** — point the native core at your
81
+ deployed sync endpoint (rotating auth goes through `client.setHeaders`).
82
+ 4. **`src/frontend/main.tsx`** — the UI. Everything under `<SyncProvider>` is
83
+ shared code; grow it into your app.
84
+ 5. **`migrations/` + `syncular.json`** — add tables and scopes, then
85
+ `bun run generate`.
86
+
87
+ ## Next
88
+
89
+ - One codebase, web + desktop guide: https://syncular.dev/guide-web-desktop/
90
+ - Tauri plugin reference: https://syncular.dev/platform-tauri/
91
+ - React hooks: https://syncular.dev/platform-react/
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Bundle the React frontend to `dist/` — the `frontendDist` the Tauri window
3
+ * loads (`src-tauri/tauri.conf.json` runs this as `beforeDevCommand` /
4
+ * `beforeBuildCommand`).
5
+ *
6
+ * Dependency-light on purpose: no Vite, no webpack. Bun's bundler transpiles
7
+ * the TSX (automatic JSX runtime) and bundles React + the syncular packages
8
+ * into one `dist/app.js`; `index.html` is copied verbatim (it references
9
+ * `/app.js`). `@tauri-apps/api` is bundled too, so the `@syncular/tauri`
10
+ * bridge's dynamic import resolves inside the webview.
11
+ *
12
+ * The web half never runs this — `src/server.ts` builds its own bundles at
13
+ * startup (including the worker; the Tauri webview has no worker to build).
14
+ *
15
+ * Run: `bun run build-frontend`.
16
+ */
17
+ import { copyFile, mkdir, rm } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+
20
+ const root = import.meta.dir;
21
+ const frontend = join(root, 'src', 'frontend');
22
+ const dist = join(root, 'dist');
23
+
24
+ await rm(dist, { recursive: true, force: true });
25
+ await mkdir(dist, { recursive: true });
26
+
27
+ const build = await Bun.build({
28
+ entrypoints: [join(frontend, 'main.tsx')],
29
+ outdir: dist,
30
+ target: 'browser',
31
+ // Workspace packages resolve their `bun` condition (TS source) so the
32
+ // build works without a compiled dist (the published `browser` condition
33
+ // points at compiled output for external bundlers).
34
+ conditions: ['bun'],
35
+ minify: true,
36
+ sourcemap: 'linked',
37
+ naming: { entry: 'app.js' },
38
+ define: { 'process.env.NODE_ENV': '"production"' },
39
+ });
40
+
41
+ if (!build.success) {
42
+ for (const log of build.logs) console.error(log);
43
+ throw new Error('frontend build failed');
44
+ }
45
+
46
+ await copyFile(join(frontend, 'index.html'), join(dist, 'index.html'));
47
+ console.log(`frontend built → ${dist}`);
@@ -0,0 +1,11 @@
1
+ node_modules/
2
+ *.log
3
+ .DS_Store
4
+ *.sqlite
5
+ *.sqlite-*
6
+ # built frontend (materialized by `bun run build-frontend`; a placeholder
7
+ # ships with the scaffold so a direct `cargo build` works before the first
8
+ # frontend build)
9
+ dist/
10
+ src-tauri/target/
11
+ src-tauri/gen/
@@ -0,0 +1,11 @@
1
+ -- Your schema, one table to start. typegen reads this to derive the schema
2
+ -- SHAPE (column types, primary key); the server manages its own sync_* tables
3
+ -- and never runs this migration.
4
+ CREATE TABLE todos (
5
+ id TEXT PRIMARY KEY,
6
+ list_id TEXT NOT NULL,
7
+ title TEXT NOT NULL,
8
+ done BOOLEAN NOT NULL,
9
+ position INTEGER NOT NULL,
10
+ updated_at_ms INTEGER NOT NULL
11
+ );
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun run src/server.ts",
7
+ "build-frontend": "bun run build-frontend.ts",
8
+ "generate": "syncular generate --manifest-dir .",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
13
+ "@syncular/core": "workspace:*",
14
+ "@syncular/server": "workspace:*",
15
+ "@syncular/server-hono": "workspace:*",
16
+ "@syncular/client": "workspace:*",
17
+ "@syncular/react": "workspace:*",
18
+ "@syncular/tauri": "workspace:*",
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@syncular/typegen": "workspace:*",
24
+ "@tauri-apps/api": "^2.0.0",
25
+ "@types/bun": "latest",
26
+ "@types/react": "^18.3.0",
27
+ "@types/react-dom": "^18.3.0",
28
+ "typescript": "^5.9.0"
29
+ }
30
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The engine seam — the ONE file that knows about hosts.
3
+ *
4
+ * Everything in `@syncular/react` targets one structural interface,
5
+ * `SyncClientLike`. The browser worker handle implements it, the Tauri
6
+ * bridge implements it, and `SyncProvider` normalizes either one — the
7
+ * hooks never see the difference. This file picks the client:
8
+ *
9
+ * - Inside a Tauri webview (Tauri v2 injects `__TAURI_INTERNALS__`), the
10
+ * NATIVE Rust core runs in the host process — the webview is a thin RPC
11
+ * proxy. The plugin owns the database path and the transport
12
+ * (see `src-tauri/src/lib.rs`).
13
+ * - In a plain browser, the whole TypeScript core runs in a Web Worker on a
14
+ * persistent OPFS database; the first tab leads, further tabs follow over
15
+ * a BroadcastChannel.
16
+ *
17
+ * The dynamic imports keep each host's machinery out of the other's bundle:
18
+ * the web build never ships the Tauri bridge, and the Tauri webview never
19
+ * loads sqlite-wasm.
20
+ */
21
+ import type { SyncClientLike } from '@syncular/react';
22
+ import { schema } from '../syncular.generated';
23
+
24
+ /**
25
+ * What the app gets back: the hook surface (`SyncClientLike`) plus the
26
+ * lifecycle both concrete clients share — enough for the boot sequence in
27
+ * `main.tsx`, still fully host-agnostic.
28
+ */
29
+ export interface Engine extends SyncClientLike {
30
+ subscribe(input: {
31
+ readonly id: string;
32
+ readonly table: string;
33
+ readonly scopes: Record<string, readonly string[]>;
34
+ }): Promise<unknown>;
35
+ connectRealtime(): Promise<void>;
36
+ close(): Promise<void>;
37
+ }
38
+
39
+ /** Tauri v2 injects this into every webview it hosts. */
40
+ const isTauri = () => '__TAURI_INTERNALS__' in window;
41
+
42
+ /** A stable per-browser client id (the native side persists its own db). */
43
+ function clientId(): string {
44
+ const KEY = 'syncular-client-id';
45
+ const existing = localStorage.getItem(KEY);
46
+ if (existing !== null) return existing;
47
+ const id = crypto.randomUUID();
48
+ localStorage.setItem(KEY, id);
49
+ return id;
50
+ }
51
+
52
+ export async function createEngine(): Promise<Engine> {
53
+ if (isTauri()) {
54
+ // Desktop: the native Rust core in the Tauri process.
55
+ const { createTauriSyncClient } = await import('@syncular/tauri');
56
+ return createTauriSyncClient({ clientId: clientId(), schema });
57
+ }
58
+ // Web: the whole core in a worker, persisted on OPFS.
59
+ const { createSyncClientHandle } = await import('@syncular/client');
60
+ const WS = location.protocol === 'https:' ? 'wss' : 'ws';
61
+ return createSyncClientHandle({
62
+ worker: () => new Worker('/worker.js', { type: 'module' }),
63
+ schema,
64
+ clientId: clientId(),
65
+ database: { mode: 'persistent', name: 'app' },
66
+ endpoints: {
67
+ syncUrl: '/sync',
68
+ segmentsUrl: '/segments',
69
+ realtimeUrl: `${WS}://${location.host}/realtime?clientId={clientId}`,
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,33 @@
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.0" />
6
+ <title>__PROJECT_NAME__</title>
7
+ <style>
8
+ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
9
+ body { margin: 0; padding: 1.5rem; max-width: 40rem; margin-inline: auto; }
10
+ header { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
11
+ header h1 { font-size: 1.15rem; margin: 0; }
12
+ .status { margin-left: auto; display: flex; gap: 0.4rem; }
13
+ .badge { font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px; border: 1px solid currentColor; opacity: 0.85; }
14
+ .badge.ok { color: #16a34a; }
15
+ .badge.warn { color: #d97706; }
16
+ input, button { font: inherit; }
17
+ form.add { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
18
+ form.add input { flex: 1; padding: 0.4rem 0.5rem; }
19
+ ul.todos { list-style: none; padding: 0; margin: 0; }
20
+ ul.todos li { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.25rem; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
21
+ ul.todos li.done .title { text-decoration: line-through; opacity: 0.55; }
22
+ li .title { flex: 1; }
23
+ button.del { cursor: pointer; }
24
+ .empty { opacity: 0.6; font-size: 0.9rem; padding: 0.5rem 0; }
25
+ .error { color: #dc2626; font-size: 0.85rem; padding: 0.35rem 0; }
26
+ footer { margin-top: 1.25rem; font-size: 0.78rem; opacity: 0.65; line-height: 1.5; }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ <script type="module" src="/app.js"></script>
32
+ </body>
33
+ </html>