tablinum 0.0.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.
Files changed (75) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +571 -0
  4. package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +498 -0
  5. package/.context/notes.md +0 -0
  6. package/.context/plans/add-changesets-to-douala-v4.md +48 -0
  7. package/.context/plans/dexie-js-style-query-language-for-localstr.md +115 -0
  8. package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +336 -0
  9. package/.context/plans/implementation-plan-localstr-v0-2.md +263 -0
  10. package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +71 -0
  11. package/.context/plans/revise-localstr-prd-v0-2.md +132 -0
  12. package/.context/plans/svelte-5-runes-bindings-for-localstr.md +233 -0
  13. package/.context/todos.md +0 -0
  14. package/.github/workflows/release.yml +36 -0
  15. package/.oxlintrc.json +8 -0
  16. package/README.md +1 -0
  17. package/bun.lock +705 -0
  18. package/examples/svelte/bun.lock +261 -0
  19. package/examples/svelte/package.json +21 -0
  20. package/examples/svelte/src/app.html +11 -0
  21. package/examples/svelte/src/lib/db.ts +44 -0
  22. package/examples/svelte/src/routes/+page.svelte +322 -0
  23. package/examples/svelte/svelte.config.js +16 -0
  24. package/examples/svelte/tsconfig.json +6 -0
  25. package/examples/svelte/vite.config.ts +6 -0
  26. package/examples/vanilla/app.ts +219 -0
  27. package/examples/vanilla/index.html +144 -0
  28. package/examples/vanilla/serve.ts +42 -0
  29. package/package.json +46 -0
  30. package/prds/localstr-v0.2.md +221 -0
  31. package/prek.toml +10 -0
  32. package/scripts/validate.ts +392 -0
  33. package/src/crud/collection-handle.ts +189 -0
  34. package/src/crud/query-builder.ts +414 -0
  35. package/src/crud/watch.ts +78 -0
  36. package/src/db/create-localstr.ts +217 -0
  37. package/src/db/database-handle.ts +16 -0
  38. package/src/db/identity.ts +49 -0
  39. package/src/errors.ts +37 -0
  40. package/src/index.ts +32 -0
  41. package/src/main.ts +10 -0
  42. package/src/schema/collection.ts +53 -0
  43. package/src/schema/field.ts +25 -0
  44. package/src/schema/types.ts +19 -0
  45. package/src/schema/validate.ts +111 -0
  46. package/src/storage/events-store.ts +24 -0
  47. package/src/storage/giftwraps-store.ts +23 -0
  48. package/src/storage/idb.ts +244 -0
  49. package/src/storage/lww.ts +17 -0
  50. package/src/storage/records-store.ts +76 -0
  51. package/src/svelte/collection.svelte.ts +87 -0
  52. package/src/svelte/database.svelte.ts +83 -0
  53. package/src/svelte/index.svelte.ts +52 -0
  54. package/src/svelte/live-query.svelte.ts +29 -0
  55. package/src/svelte/query.svelte.ts +101 -0
  56. package/src/sync/gift-wrap.ts +33 -0
  57. package/src/sync/negentropy.ts +83 -0
  58. package/src/sync/publish-queue.ts +61 -0
  59. package/src/sync/relay.ts +239 -0
  60. package/src/sync/sync-service.ts +183 -0
  61. package/src/sync/sync-status.ts +17 -0
  62. package/src/utils/uuid.ts +22 -0
  63. package/src/vendor/negentropy.js +616 -0
  64. package/tests/db/create-localstr.test.ts +174 -0
  65. package/tests/db/identity.test.ts +33 -0
  66. package/tests/main.test.ts +9 -0
  67. package/tests/schema/collection.test.ts +27 -0
  68. package/tests/schema/field.test.ts +41 -0
  69. package/tests/schema/validate.test.ts +85 -0
  70. package/tests/setup.ts +1 -0
  71. package/tests/storage/idb.test.ts +144 -0
  72. package/tests/storage/lww.test.ts +33 -0
  73. package/tests/sync/gift-wrap.test.ts +56 -0
  74. package/tsconfig.json +18 -0
  75. package/vitest.config.ts +8 -0
@@ -0,0 +1,322 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from "svelte";
3
+ import { initDb, type AppSchema, type TodoRecord } from "$lib/db";
4
+ import type { Database, Collection, LiveQuery } from "localstr/svelte";
5
+
6
+ let db: Database<AppSchema> | null = $state(null);
7
+ let todos: Collection<AppSchema["todos"]> | null = $state(null);
8
+ let incomplete: LiveQuery<TodoRecord> | null = $state(null);
9
+ let done: LiveQuery<TodoRecord> | null = $state(null);
10
+
11
+ let title = $state("");
12
+ let importKeyHex = $state("");
13
+ let loading = $state(true);
14
+ let initError = $state<string | null>(null);
15
+ let imported = $state(false);
16
+ let keyCopied = $state(false);
17
+
18
+ async function init() {
19
+ try {
20
+ const result = await initDb();
21
+ db = result.db;
22
+ imported = result.imported;
23
+ todos = db.collection("todos");
24
+ incomplete = todos.where("done").equals(false).live();
25
+ done = todos.where("done").equals(true).live();
26
+ loading = false;
27
+ } catch (e: unknown) {
28
+ initError = e instanceof Error ? e.message : String(e);
29
+ loading = false;
30
+ }
31
+ }
32
+
33
+ init();
34
+
35
+ async function addTodo(e: SubmitEvent) {
36
+ e.preventDefault();
37
+ if (!todos || !title.trim()) return;
38
+ await todos.add({ title: title.trim(), done: false, priority: 1 });
39
+ title = "";
40
+ }
41
+
42
+ async function toggle(id: string, currentDone: boolean) {
43
+ if (!todos) return;
44
+ await todos.update(id, { done: !currentDone });
45
+ }
46
+
47
+ async function remove(id: string) {
48
+ if (!todos) return;
49
+ await todos.delete(id);
50
+ }
51
+
52
+ async function sync() {
53
+ if (!db) return;
54
+ await db.sync();
55
+ }
56
+
57
+ async function rebuild() {
58
+ if (!db) return;
59
+ await db.rebuild();
60
+ }
61
+
62
+ function copyKey() {
63
+ if (!db) return;
64
+ navigator.clipboard.writeText(db.exportKey()).then(
65
+ () => {
66
+ keyCopied = true;
67
+ setTimeout(() => (keyCopied = false), 2000);
68
+ },
69
+ () => {},
70
+ );
71
+ }
72
+
73
+ function importKey() {
74
+ const hex = importKeyHex.trim().toLowerCase();
75
+ if (hex.length !== 64) return;
76
+ if (!/^[0-9a-f]+$/.test(hex)) return;
77
+ window.location.search = `?key=${hex}`;
78
+ }
79
+
80
+ onDestroy(() => {
81
+ incomplete?.destroy();
82
+ done?.destroy();
83
+ db?.close();
84
+ });
85
+ </script>
86
+
87
+ <svelte:head>
88
+ <title>localstr Svelte Demo</title>
89
+ </svelte:head>
90
+
91
+ <main>
92
+ <h1>localstr Svelte Demo</h1>
93
+
94
+ {#if loading}
95
+ <p>Initializing database...</p>
96
+ {:else if initError}
97
+ <p class="error">Failed to initialize: {initError}</p>
98
+ {:else if todos && db}
99
+ <div class="key-section">
100
+ <label>Current key:</label>
101
+ <div class="key-display">{db.exportKey()}</div>
102
+ {#if imported}
103
+ <p class="imported-badge">Imported from URL</p>
104
+ {/if}
105
+ <div class="key-import">
106
+ <input
107
+ bind:value={importKeyHex}
108
+ placeholder="Paste hex private key to import..."
109
+ />
110
+ <button onclick={importKey}>Import Key</button>
111
+ </div>
112
+ </div>
113
+
114
+ {#if db.status === "syncing"}
115
+ <p class="sync-status">Syncing...</p>
116
+ {/if}
117
+
118
+ {#if todos.error}
119
+ <p class="error">{todos.error.message}</p>
120
+ {/if}
121
+
122
+ <form onsubmit={addTodo}>
123
+ <input bind:value={title} placeholder="Add a todo..." autofocus />
124
+ <button type="submit">Add</button>
125
+ </form>
126
+
127
+ <p class="count">{todos.items.length} total</p>
128
+
129
+ <h2>Todo ({incomplete?.items.length ?? 0})</h2>
130
+ <ul>
131
+ {#each incomplete?.items ?? [] as todo}
132
+ <li>
133
+ <label>
134
+ <input
135
+ type="checkbox"
136
+ checked={false}
137
+ onchange={() => toggle(todo.id, todo.done)}
138
+ />
139
+ <span>{todo.title}</span>
140
+ </label>
141
+ <button class="delete" onclick={() => remove(todo.id)}
142
+ >✕</button
143
+ >
144
+ </li>
145
+ {/each}
146
+ </ul>
147
+
148
+ <h2>Done ({done?.items.length ?? 0})</h2>
149
+ <ul>
150
+ {#each done?.items ?? [] as todo}
151
+ <li class="done">
152
+ <label>
153
+ <input
154
+ type="checkbox"
155
+ checked={true}
156
+ onchange={() => toggle(todo.id, todo.done)}
157
+ />
158
+ <span>{todo.title}</span>
159
+ </label>
160
+ <button class="delete" onclick={() => remove(todo.id)}
161
+ >✕</button
162
+ >
163
+ </li>
164
+ {/each}
165
+ </ul>
166
+
167
+ <div class="actions">
168
+ <button onclick={sync}>Sync</button>
169
+ <button onclick={rebuild}>Rebuild</button>
170
+ <button onclick={copyKey}
171
+ >{keyCopied ? "Copied!" : "Copy Key"}</button
172
+ >
173
+ </div>
174
+ {/if}
175
+ </main>
176
+
177
+ <style>
178
+ * {
179
+ box-sizing: border-box;
180
+ margin: 0;
181
+ padding: 0;
182
+ }
183
+
184
+ main {
185
+ font-family: system-ui, sans-serif;
186
+ max-width: 600px;
187
+ margin: 2rem auto;
188
+ padding: 0 1rem;
189
+ }
190
+
191
+ h1 {
192
+ margin-bottom: 1rem;
193
+ }
194
+
195
+ h2 {
196
+ margin: 1rem 0 0.5rem;
197
+ font-size: 1rem;
198
+ color: #666;
199
+ }
200
+
201
+ form {
202
+ display: flex;
203
+ gap: 0.5rem;
204
+ margin-bottom: 1rem;
205
+ }
206
+
207
+ input[type="text"] {
208
+ flex: 1;
209
+ padding: 0.5rem;
210
+ font-size: 1rem;
211
+ border: 1px solid #ccc;
212
+ border-radius: 4px;
213
+ }
214
+
215
+ input[type="checkbox"] {
216
+ flex: none;
217
+ }
218
+
219
+ button {
220
+ padding: 0.5rem 1rem;
221
+ font-size: 1rem;
222
+ cursor: pointer;
223
+ border: 1px solid #ccc;
224
+ border-radius: 4px;
225
+ background: #f5f5f5;
226
+ }
227
+
228
+ button:hover {
229
+ background: #e0e0e0;
230
+ }
231
+
232
+ ul {
233
+ list-style: none;
234
+ }
235
+
236
+ li {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 0.5rem;
240
+ padding: 0.5rem 0;
241
+ border-bottom: 1px solid #eee;
242
+ }
243
+
244
+ li label {
245
+ flex: 1;
246
+ display: flex;
247
+ align-items: center;
248
+ gap: 0.5rem;
249
+ cursor: pointer;
250
+ }
251
+
252
+ li.done span {
253
+ opacity: 0.5;
254
+ text-decoration: line-through;
255
+ }
256
+
257
+ .delete {
258
+ padding: 0.25rem 0.5rem;
259
+ font-size: 0.875rem;
260
+ }
261
+
262
+ .count {
263
+ color: #666;
264
+ margin-bottom: 0.5rem;
265
+ }
266
+
267
+ .actions {
268
+ display: flex;
269
+ gap: 0.5rem;
270
+ margin-top: 1.5rem;
271
+ flex-wrap: wrap;
272
+ }
273
+
274
+ .error {
275
+ color: #c00;
276
+ margin-bottom: 1rem;
277
+ }
278
+
279
+ .sync-status {
280
+ color: #06c;
281
+ margin-bottom: 0.5rem;
282
+ }
283
+
284
+ .key-section {
285
+ margin-bottom: 1rem;
286
+ padding: 0.75rem;
287
+ background: #f9f9f9;
288
+ border: 1px solid #ddd;
289
+ border-radius: 4px;
290
+ }
291
+
292
+ .key-section label {
293
+ display: block;
294
+ font-size: 0.85rem;
295
+ color: #666;
296
+ margin-bottom: 0.25rem;
297
+ }
298
+
299
+ .key-display {
300
+ font-family: monospace;
301
+ font-size: 0.8rem;
302
+ word-break: break-all;
303
+ color: #333;
304
+ margin-bottom: 0.5rem;
305
+ }
306
+
307
+ .key-import {
308
+ display: flex;
309
+ gap: 0.5rem;
310
+ }
311
+
312
+ .key-import input {
313
+ font-family: monospace;
314
+ font-size: 0.8rem;
315
+ }
316
+
317
+ .imported-badge {
318
+ font-size: 0.8rem;
319
+ color: #06c;
320
+ margin-bottom: 0.5rem;
321
+ }
322
+ </style>
@@ -0,0 +1,16 @@
1
+ import adapter from "@sveltejs/adapter-static";
2
+
3
+ /** @type {import('@sveltejs/kit').Config} */
4
+ const config = {
5
+ kit: {
6
+ adapter: adapter({
7
+ fallback: "index.html",
8
+ }),
9
+ alias: {
10
+ "localstr/svelte": "../../src/svelte/index.svelte.ts",
11
+ localstr: "../../src/index.ts",
12
+ },
13
+ },
14
+ };
15
+
16
+ export default config;
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowImportingTsExtensions": true
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ import { sveltekit } from "@sveltejs/kit/vite";
2
+ import { defineConfig } from "vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [sveltekit()],
6
+ });
@@ -0,0 +1,219 @@
1
+ import { Effect, Stream } from "effect";
2
+ import { field, collection, createLocalstr } from "../../src/index.ts";
3
+
4
+ // Read key from URL if present: ?key=<hex>
5
+ function getKeyFromUrl(): Uint8Array | undefined {
6
+ const params = new URLSearchParams(window.location.search);
7
+ const hex = params.get("key");
8
+ if (!hex || hex.length !== 64) return undefined;
9
+ const bytes = new Uint8Array(32);
10
+ for (let i = 0; i < 32; i++) {
11
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
12
+ }
13
+ return bytes;
14
+ }
15
+
16
+ const log = (msg: string) => {
17
+ const el = document.getElementById("log")!;
18
+ el.textContent += msg + "\n";
19
+ el.scrollTop = el.scrollHeight;
20
+ };
21
+
22
+ const app = Effect.gen(function* () {
23
+ const todos = collection(
24
+ "todos",
25
+ {
26
+ title: field.string(),
27
+ done: field.boolean(),
28
+ priority: field.number(),
29
+ },
30
+ { indices: ["done", "priority"] },
31
+ );
32
+
33
+ const importedKey = getKeyFromUrl();
34
+ // Use a unique DB name per key so imported keys get a fresh DB
35
+ const dbSuffix = importedKey ? "-imported" : "";
36
+
37
+ const db = yield* createLocalstr({
38
+ schema: { todos },
39
+ relays: ["wss://relay.nostr.place"],
40
+ dbName: `localstr-demo${dbSuffix}`,
41
+ privateKey: importedKey,
42
+ onSyncError: (err) => {
43
+ log(`Sync error: ${err.message}`);
44
+ },
45
+ });
46
+
47
+ const col = db.collection("todos");
48
+ const fullKey = db.exportKey();
49
+
50
+ // Show key in UI
51
+ document.getElementById("key-display")!.textContent = fullKey;
52
+ if (importedKey) {
53
+ log("Imported key from URL");
54
+ }
55
+
56
+ // Wire up UI
57
+ const form = document.getElementById("add-form") as HTMLFormElement;
58
+ form.addEventListener("submit", (e) => {
59
+ e.preventDefault();
60
+ const titleInput = document.getElementById("title") as HTMLInputElement;
61
+ const title = titleInput.value.trim();
62
+ if (!title) return;
63
+
64
+ Effect.runPromise(
65
+ Effect.gen(function* () {
66
+ const id = yield* col.add({ title, done: false, priority: 1 } as any);
67
+ log(`Added: "${title}" (${id})`);
68
+ titleInput.value = "";
69
+ yield* renderTodos();
70
+ }),
71
+ ).catch((err) => {
72
+ log(`Error: ${err}`);
73
+ if (err?.cause) log(`Cause: ${err.cause}`);
74
+ console.error(err);
75
+ });
76
+ });
77
+
78
+ const renderTodos = () =>
79
+ Effect.gen(function* () {
80
+ const all = yield* col.where("done").equals(false).sortBy("priority").get();
81
+ const done = yield* col.where("done").equals(true).get();
82
+ const count = yield* col.count();
83
+
84
+ const list = document.getElementById("todos")!;
85
+ list.innerHTML = "";
86
+
87
+ for (const todo of all) {
88
+ const li = document.createElement("li");
89
+ li.innerHTML = `
90
+ <span>${todo.title}</span>
91
+ <button data-id="${todo.id}" data-action="done">✓</button>
92
+ <button data-id="${todo.id}" data-action="delete">✕</button>
93
+ `;
94
+ list.appendChild(li);
95
+ }
96
+
97
+ for (const todo of done) {
98
+ const li = document.createElement("li");
99
+ li.style.opacity = "0.5";
100
+ li.style.textDecoration = "line-through";
101
+ li.innerHTML = `
102
+ <span>${todo.title}</span>
103
+ <button data-id="${todo.id}" data-action="delete">✕</button>
104
+ `;
105
+ list.appendChild(li);
106
+ }
107
+
108
+ document.getElementById("count")!.textContent = `${count} total`;
109
+ });
110
+
111
+ // Handle todo actions (done/delete)
112
+ document.getElementById("todos")!.addEventListener("click", (e) => {
113
+ const btn = (e.target as HTMLElement).closest("button");
114
+ if (!btn) return;
115
+ const id = btn.dataset.id!;
116
+ const action = btn.dataset.action!;
117
+
118
+ const effect =
119
+ action === "done"
120
+ ? Effect.gen(function* () {
121
+ yield* col.update(id, { done: true } as any);
122
+ log(`Completed: ${id}`);
123
+ yield* renderTodos();
124
+ })
125
+ : Effect.gen(function* () {
126
+ yield* col.delete(id);
127
+ log(`Deleted: ${id}`);
128
+ yield* renderTodos();
129
+ });
130
+
131
+ Effect.runPromise(effect).catch((err) => {
132
+ log(`Error: ${err}`);
133
+ if (err?.cause) log(`Cause: ${err.cause}`);
134
+ console.error(err);
135
+ });
136
+ });
137
+
138
+ // Sync button
139
+ document.getElementById("sync-btn")!.addEventListener("click", () => {
140
+ log("Syncing...");
141
+ Effect.runPromise(
142
+ Effect.gen(function* () {
143
+ yield* db.sync();
144
+ log("Sync complete");
145
+ yield* renderTodos();
146
+ }),
147
+ ).catch((err) => {
148
+ log(`Sync error: ${err}`);
149
+ if (err?.cause) log(`Cause: ${err.cause}`);
150
+ console.error(err);
151
+ });
152
+ });
153
+
154
+ // Rebuild button
155
+ document.getElementById("rebuild-btn")!.addEventListener("click", () => {
156
+ Effect.runPromise(
157
+ Effect.gen(function* () {
158
+ yield* db.rebuild();
159
+ log("Rebuilt records from events");
160
+ yield* renderTodos();
161
+ }),
162
+ ).catch((err) => {
163
+ log(`Error: ${err}`);
164
+ if (err?.cause) log(`Cause: ${err.cause}`);
165
+ console.error(err);
166
+ });
167
+ });
168
+
169
+ // Copy key button
170
+ document.getElementById("copy-btn")!.addEventListener("click", () => {
171
+ navigator.clipboard.writeText(fullKey).then(
172
+ () => log("Key copied to clipboard"),
173
+ () => log(`Key: ${fullKey}`),
174
+ );
175
+ });
176
+
177
+ // Import key button — reload page with key in URL
178
+ document.getElementById("import-btn")!.addEventListener("click", () => {
179
+ const input = document.getElementById("import-key") as HTMLInputElement;
180
+ const hex = input.value.trim();
181
+ if (hex.length !== 64) {
182
+ log("Error: key must be 64 hex characters");
183
+ return;
184
+ }
185
+ if (!/^[0-9a-f]+$/i.test(hex)) {
186
+ log("Error: key must be valid hex");
187
+ return;
188
+ }
189
+ window.location.search = `?key=${hex.toLowerCase()}`;
190
+ });
191
+
192
+ log("localstr initialized");
193
+ log(`Key: ${fullKey.slice(0, 16)}...`);
194
+
195
+ yield* renderTodos();
196
+
197
+ // Watch for changes (local + remote) and re-render automatically
198
+ yield* col.watch().pipe(
199
+ Stream.runForEach((todos) =>
200
+ Effect.gen(function* () {
201
+ console.log("[localstr:watch] todos changed:", todos);
202
+ yield* renderTodos();
203
+ }).pipe(
204
+ Effect.catch((_e) =>
205
+ Effect.sync(() => console.error("[localstr:watch] render error:", _e)),
206
+ ),
207
+ ),
208
+ ),
209
+ );
210
+ });
211
+
212
+ Effect.runPromise(Effect.scoped(app)).catch((err) => {
213
+ document.getElementById("log")!.textContent += `Fatal: ${err}\n`;
214
+ if (err?.cause) {
215
+ document.getElementById("log")!.textContent += `Cause: ${err.cause}\n`;
216
+ console.error("Cause:", err.cause);
217
+ }
218
+ console.error(err);
219
+ });
@@ -0,0 +1,144 @@
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>localstr demo</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ margin: 0;
11
+ padding: 0;
12
+ }
13
+ body {
14
+ font-family: system-ui, sans-serif;
15
+ max-width: 600px;
16
+ margin: 2rem auto;
17
+ padding: 0 1rem;
18
+ }
19
+ h1 {
20
+ margin-bottom: 1rem;
21
+ }
22
+ form {
23
+ display: flex;
24
+ gap: 0.5rem;
25
+ margin-bottom: 1rem;
26
+ }
27
+ input {
28
+ flex: 1;
29
+ padding: 0.5rem;
30
+ font-size: 1rem;
31
+ border: 1px solid #ccc;
32
+ border-radius: 4px;
33
+ }
34
+ button {
35
+ padding: 0.5rem 1rem;
36
+ font-size: 1rem;
37
+ cursor: pointer;
38
+ border: 1px solid #ccc;
39
+ border-radius: 4px;
40
+ background: #f5f5f5;
41
+ }
42
+ button:hover {
43
+ background: #e0e0e0;
44
+ }
45
+ ul {
46
+ list-style: none;
47
+ margin-bottom: 1rem;
48
+ }
49
+ li {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 0.5rem;
53
+ padding: 0.5rem 0;
54
+ border-bottom: 1px solid #eee;
55
+ }
56
+ li span {
57
+ flex: 1;
58
+ }
59
+ li button {
60
+ padding: 0.25rem 0.5rem;
61
+ font-size: 0.875rem;
62
+ }
63
+ #count {
64
+ color: #666;
65
+ margin-bottom: 1rem;
66
+ }
67
+ .actions {
68
+ display: flex;
69
+ gap: 0.5rem;
70
+ margin-bottom: 1rem;
71
+ flex-wrap: wrap;
72
+ }
73
+ #log {
74
+ background: #1a1a1a;
75
+ color: #0f0;
76
+ padding: 1rem;
77
+ border-radius: 4px;
78
+ font-family: monospace;
79
+ font-size: 0.8rem;
80
+ white-space: pre-wrap;
81
+ max-height: 200px;
82
+ overflow-y: auto;
83
+ }
84
+ .key-section {
85
+ margin-bottom: 1rem;
86
+ padding: 0.75rem;
87
+ background: #f9f9f9;
88
+ border: 1px solid #ddd;
89
+ border-radius: 4px;
90
+ }
91
+ .key-section label {
92
+ display: block;
93
+ font-size: 0.85rem;
94
+ color: #666;
95
+ margin-bottom: 0.25rem;
96
+ }
97
+ .key-display {
98
+ font-family: monospace;
99
+ font-size: 0.8rem;
100
+ word-break: break-all;
101
+ color: #333;
102
+ margin-bottom: 0.5rem;
103
+ }
104
+ .key-import {
105
+ display: flex;
106
+ gap: 0.5rem;
107
+ }
108
+ .key-import input {
109
+ font-family: monospace;
110
+ font-size: 0.8rem;
111
+ }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <h1>localstr demo</h1>
116
+
117
+ <div class="key-section">
118
+ <label>Current key:</label>
119
+ <div class="key-display" id="key-display">loading...</div>
120
+ <div class="key-import">
121
+ <input id="import-key" type="text" placeholder="Paste hex private key to import..." />
122
+ <button id="import-btn">Import Key</button>
123
+ </div>
124
+ </div>
125
+
126
+ <form id="add-form">
127
+ <input id="title" type="text" placeholder="Add a todo..." autofocus />
128
+ <button type="submit">Add</button>
129
+ </form>
130
+
131
+ <div id="count"></div>
132
+ <ul id="todos"></ul>
133
+
134
+ <div class="actions">
135
+ <button id="sync-btn">Sync</button>
136
+ <button id="rebuild-btn">Rebuild</button>
137
+ <button id="copy-btn">Copy Key</button>
138
+ </div>
139
+
140
+ <div id="log"></div>
141
+
142
+ <script type="module" src="./app.js"></script>
143
+ </body>
144
+ </html>