lowlander 0.5.0 → 0.6.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 (41) hide show
  1. package/README.md +43 -67
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +39 -22
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/crud.d.ts +16 -0
  6. package/build/dashboard/client/crud.js +525 -0
  7. package/build/dashboard/client/crud.js.map +1 -0
  8. package/build/dashboard/client/main.js +238 -246
  9. package/build/dashboard/client/main.js.map +1 -1
  10. package/build/dashboard/dashboard.html +8 -8
  11. package/build/dashboard/server.d.ts +20 -9
  12. package/build/dashboard/server.d.ts.map +1 -1
  13. package/build/dashboard/server.js +139 -3
  14. package/build/dashboard/server.js.map +1 -1
  15. package/build/examples/helloworld/.edinburgh/commit_worker.log +1809 -0
  16. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  17. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  18. package/build/examples/helloworld/client/assets/style.css +0 -45
  19. package/build/examples/helloworld/client/index.html +2 -13
  20. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  21. package/build/examples/helloworld/client/js/base.js +8 -217
  22. package/build/examples/helloworld/client/js/base.js.map +1 -1
  23. package/build/examples/helloworld/server/api.d.ts +4 -0
  24. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  25. package/build/examples/helloworld/server/api.js +10 -0
  26. package/build/examples/helloworld/server/api.js.map +1 -1
  27. package/build/server/server.d.ts +3 -3
  28. package/build/server/server.d.ts.map +1 -1
  29. package/build/server/server.js +44 -5
  30. package/build/server/server.js.map +1 -1
  31. package/build/tsconfig.client.tsbuildinfo +1 -1
  32. package/build/tsconfig.server.tsbuildinfo +1 -1
  33. package/client/client.ts +41 -23
  34. package/dashboard/build-bundle.ts +8 -2
  35. package/dashboard/client/crud.ts +634 -0
  36. package/dashboard/client/main.ts +234 -246
  37. package/dashboard/server.ts +149 -5
  38. package/package.json +9 -5
  39. package/server/server.ts +43 -8
  40. package/skill/SKILL.md +30 -47
  41. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -1,7 +1,10 @@
1
1
  import A from 'aberdeen';
2
- import { current as route, go } from 'aberdeen/route';
2
+ import * as route from 'aberdeen/route';
3
+ import * as S from 'staffa';
4
+ import type { Slot } from 'staffa';
3
5
  import { Connection, type ClientProxyObject } from 'lowlander/client';
4
6
  import type { _dashboard } from './shim-server.js';
7
+ import { openCreateModal, openEditModal, openDeleteConfirm } from './crud.js';
5
8
 
6
9
  type ServerExports = { _dashboard: typeof _dashboard };
7
10
  type API = ClientProxyObject<ServerExports>;
@@ -33,32 +36,31 @@ const $state = A.proxy({
33
36
  authed: false,
34
37
  indexRefreshKey: 0,
35
38
  connecting: false,
39
+ connection: undefined as Connection<ServerExports> | undefined,
36
40
  });
37
-
38
- let connection: Connection<ServerExports> | undefined;
39
41
  let api: API | undefined;
40
42
  let authProxy: ReturnType<API['_dashboard']> | undefined;
41
43
 
42
- // URL-backed state helpers (reactive via A.route.current.search)
44
+ // URL-backed state reactive via aberdeen/route
43
45
  const url = {
44
- get debug() { return route.search.debug === '1'; },
46
+ get debug() { return route.current.search.debug === '1'; },
45
47
  set debug(v: boolean) { setSearch('debug', v ? '1' : ''); },
46
- get model() { return route.search.model || ''; },
48
+ get model() { return route.current.search.model || ''; },
47
49
  set model(v: string) { setSearch('model', v); },
48
- get index() { return route.search.index || ''; },
50
+ get index() { return route.current.search.index || ''; },
49
51
  set index(v: string) { setSearch('index', v); },
50
- get search() { return route.search.search || ''; },
52
+ get search() { return route.current.search.search || ''; },
51
53
  set search(v: string) { setSearch('search', v); },
52
- get reverse() { return route.search.reverse === '1'; },
54
+ get reverse() { return route.current.search.reverse === '1'; },
53
55
  set reverse(v: boolean) { setSearch('reverse', v ? '1' : ''); },
54
- get limit() { return parseInt(route.search.limit || '10', 10) || 10; },
56
+ get limit() { return parseInt(route.current.search.limit || '10', 10) || 10; },
55
57
  set limit(v: number) { setSearch('limit', v === 10 ? '' : String(v)); },
56
- get pk() { return route.search.pk || ''; },
58
+ get pk() { return route.current.search.pk || ''; },
57
59
  set pk(v: string) { setSearch('pk', v); },
58
60
  };
59
61
 
60
62
  function setSearch(k: string, v: string) {
61
- const s = route.search;
63
+ const s = route.current.search;
62
64
  if (v === '' || v === undefined) delete s[k];
63
65
  else s[k] = v;
64
66
  }
@@ -66,80 +68,47 @@ function setSearch(k: string, v: string) {
66
68
  function effectiveIndex() { return url.index || '(primary)'; }
67
69
  function effectivePk(): string { return url.pk === '-' ? '' : url.pk; }
68
70
 
69
- let tableClass = '';
70
-
71
- function styles() {
72
- A.setSpacingCssVars();
73
-
74
- A.cssVars.bg = '#0f1117';
75
- A.cssVars.panel = '#181b24';
76
- A.cssVars.panel2 = '#1f2330';
77
- A.cssVars.fg = '#e5e7eb';
78
- A.cssVars.muted = '#94a3b8';
79
- A.cssVars.accent = '#60a5fa';
80
- A.cssVars.accent2 = '#34d399';
81
- A.cssVars.danger = '#f87171';
82
- A.cssVars.border = '#2a2f3d';
83
-
84
- A.insertGlobalCss({
85
- 'html,body': 'm:0 p:0 bg:$bg fg:$fg font-family: -apple-system, system-ui, sans-serif; font-size:14px; h:100vh',
86
- 'body': 'display:flex flex-direction:column',
87
- 'a': 'color:$accent text-decoration:none cursor:pointer',
88
- 'a:hover': 'text-decoration:underline',
89
- 'input,select,textarea,button': 'font-family:inherit font-size:inherit',
90
- 'input[type=text],input[type=password],input:not([type]),select,textarea':
91
- 'bg:$panel2 fg:$fg border: 1px solid $border; r:4px p: 6px 8px; outline:none box-sizing:border-box w:100%',
92
- 'input:focus,textarea:focus,select:focus': 'border-color:$accent',
93
- 'button': 'bg:$accent fg:#0b1220 border:0 r:4px p: 6px 12px; cursor:pointer font-weight:600',
94
- 'button:hover': 'opacity:0.9',
95
- 'button.ghost': 'bg:transparent fg:$accent border: 1px solid $border;',
96
- 'code,pre': 'font-family: ui-monospace, Menlo, Consolas, monospace; font-size:12.5px',
97
- 'pre': 'bg:#0b0d14 p:$3 r:6px overflow:auto m:0',
98
- '.tag': 'display:inline-block bg:$panel2 fg:$muted p: 1px 6px; r:3px font-size:11px ml:$1',
99
- '.sidebarSel': 'bg:#1e3251 box-shadow: inset 3px 0 0 $accent;',
100
- '.row': 'display:flex gap:$2 align-items:center',
101
- '.col': 'display:flex flex-direction:column gap:$2',
102
- 'ul': 'margin-block-start:0 margin-block-end:0',
103
- });
71
+ // Table styling — Staffa provides the base (border-collapse, th/td padding/borders,
72
+ // thead stronger border). We only add size, cursor, hover, and selection states.
73
+ const tableClass = A.insertCss({
74
+ '&': 'w:100% font-size:12.5px',
75
+ '& tbody tr': 'cursor:default',
76
+ '& tbody tr:hover': 'background: color-mix(in srgb, $s-fg 5%, transparent);',
77
+ '& tbody tr.selectable': 'cursor:pointer',
78
+ '& tbody tr.selected': 'background: color-mix(in srgb, $s-accent 15%, transparent);',
79
+ '& tbody tr.selected td:first-child': 'border-left: 3px solid $s-accent; padding-left: 5px;',
80
+ });
104
81
 
105
- tableClass = A.insertCss({
106
- '&': 'w:100% border-collapse:collapse font-size:12.5px',
107
- '& th,& td': 'text-align:left p: 4px 8px; border-bottom: 1px solid $border; vertical-align:top',
108
- '& th': 'bg:$panel2 fg:$muted font-weight:600',
109
- '& tbody tr': 'cursor:default',
110
- '& tbody tr:hover': 'bg:#ffffff08',
111
- '& tbody tr.selectable': 'cursor:pointer',
112
- '& tbody tr.selected': 'bg:#1e3251',
113
- '& tbody tr.selected td:first-child': 'border-left: 3px solid $accent; padding-left: 5px;',
114
- });
115
- }
82
+ // ─── Login ───────────────────────────────────────────────────────────────────
116
83
 
117
84
  function login() {
118
- A(() => {
119
- if ($state.connecting) {
120
- A('div', 'display:flex align-items:center justify-content:center h:100vh fg:$muted', '#Connecting…');
121
- return;
122
- }
123
- A('div', 'display:flex align-items:center justify-content:center h:100vh', () => {
124
- A('form', 'bg:$panel p:$4 r:8px w:360px border: 1px solid $border; display:flex flex-direction:column gap:$3',
125
- 'submit=', (e: Event) => { e.preventDefault(); attemptLogin(); }, () => {
126
- A('h2#Lowlander Dashboard', 'm:0');
127
- A('label', () => {
128
- A('span#WebSocket URL', 'fg:$muted display:block mb:$1');
129
- A('input type=text bind=', A.ref($state, 'wsUrl'));
130
- });
131
- A('label', () => {
132
- A('span#Password', 'fg:$muted display:block mb:$1');
133
- A('input type=password bind=', A.ref($state, 'password'), 'autofocus=');
134
- });
135
- A(() => {
136
- if ($state.loginError) A('div', 'fg:$danger', () => A('text=', $state.loginError));
137
- });
138
- A('button type=submit', () => {
139
- A(() => A($state.connected && !$state.authed ? 'text=Checking…' : 'text="Log in"'));
85
+ S.main({
86
+ title: 'Lowlander Dashboard',
87
+ maxWidth: '24rem',
88
+ content: () => {
89
+ A(() => {
90
+ if ($state.connecting && !$state.connection?.getError()) {
91
+ A('p', 'text-align:center fg:$s-fg-muted', '#Connecting…');
92
+ return;
93
+ }
94
+ S.form({
95
+ submit: () => attemptLogin(),
96
+ content: () => {
97
+ S.textline({ label: 'WebSocket URL', bind: A.ref($state, 'wsUrl') });
98
+ S.textline({ label: 'Password', type: 'password', bind: A.ref($state, 'password'), inputAttrs: 'autofocus=autofocus' });
99
+ A(() => {
100
+ const err = $state.connection?.getError() || $state.loginError;
101
+ if (err) A('p', 'fg:$s-danger m:0', 'text=', err);
102
+ });
103
+ },
104
+ actions: () => S.button({
105
+ content: $state.connected && !$state.authed ? 'Checking…' : 'Log in',
106
+ type: 'submit',
107
+ disabled: $state.connected && !$state.authed,
108
+ }),
140
109
  });
141
110
  });
142
- });
111
+ },
143
112
  });
144
113
  }
145
114
 
@@ -148,10 +117,10 @@ async function attemptLogin() {
148
117
  $state.loginError = '';
149
118
  saveSettings({ wsUrl: $state.wsUrl, password: $state.password });
150
119
  try {
151
- if (connection) (connection as any).ws?.close?.();
120
+ if ($state.connection) ($state.connection as any).ws?.close?.();
152
121
  } catch {}
153
- connection = new Connection<ServerExports>($state.wsUrl);
154
- api = connection.api;
122
+ $state.connection = new Connection<ServerExports>($state.wsUrl);
123
+ api = $state.connection.api;
155
124
  $state.connected = false;
156
125
  $state.authed = false;
157
126
  const proxy = api._dashboard($state.password);
@@ -166,7 +135,7 @@ async function attemptLogin() {
166
135
  $state.loginError = err?.message || 'Login failed';
167
136
  $state.connected = false;
168
137
  $state.authed = false;
169
- connection = undefined;
138
+ $state.connection = undefined;
170
139
  api = undefined;
171
140
  authProxy = undefined;
172
141
  }
@@ -177,124 +146,126 @@ function logout() {
177
146
  $state.connected = false;
178
147
  $state.password = '';
179
148
  saveSettings({ wsUrl: $state.wsUrl, password: '' });
180
- try { (connection as any)?.ws?.close?.(); } catch {}
181
- connection = undefined;
149
+ try { ($state.connection as any)?.ws?.close?.(); } catch {}
150
+ $state.connection = undefined;
182
151
  api = undefined;
183
152
  authProxy = undefined;
184
153
  }
185
154
 
186
- function sidebar() {
187
- A('aside', 'w:280px bg:$panel border-right: 1px solid $border; display:flex flex-direction:column overflow:hidden', () => {
188
- A('div', 'p:$3 border-bottom: 1px solid $border; display:flex justify-content:space-between align-items:center', () => {
189
- A('strong#Lowlander');
190
- A('button.ghost#Logout', 'click=', () => logout());
191
- });
192
- A('div', 'overflow:auto flex:1 p:$2', () => {
193
- if (!authProxy) return;
194
- sidebarModels();
195
- A('div', 'border-top: 1px solid $border; mt:$2 pt:$2', () => {
196
- A('div', 'p: 6px 8px; r:4px cursor:pointer',
197
- 'click=', () => { go({ path: route.path, search: { debug: '1' } }); },
198
- () => {
199
- A(() => A('.sidebarSel=', url.debug));
200
- A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
201
- A('span#⬡ WarpSocket debug');
202
- });
203
- });
204
- });
205
- });
206
- });
207
- }
155
+ // ─── Dashboard (authenticated) ───────────────────────────────────────────────
208
156
 
209
- function sidebarModels() {
157
+ // Sidebar nav: reactive model list. Rendered as a Content function so drawMenu
158
+ // calls it inside a reactive scope; models.busy/.value/.error drive re-renders.
159
+ const navModels: Slot = () => {
210
160
  const models = authProxy!.serverProxy.listModels();
211
161
  A(() => {
212
- if (models.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
213
- if (models.error) { A('div', 'fg:$danger', 'text=', models.error.message); return; }
214
- const list = models.value || [];
215
- for (const m of list) {
216
- A('div', 'p: 6px 8px; r:4px cursor:pointer',
217
- 'click=', () => { go({ path: route.path, search: { model: m.tableName } }); },
218
- () => {
219
- A(() => A('.sidebarSel=', !url.debug && url.model === m.tableName));
220
- A('div', 'display:flex justify-content:space-between align-items:baseline', () => {
221
- A('span', 'text=', m.tableName);
222
- A('span.tag', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
223
- });
224
- });
162
+ if (models.busy) {
163
+ A('p', 'p:$2 fg:$s-fg-muted font-size:0.9em m:0', '#Loading…');
164
+ return;
165
+ }
166
+ if (models.error) {
167
+ A('p', 'p:$2 fg:$s-danger font-size:0.9em m:0', 'text=', models.error.message);
168
+ return;
169
+ }
170
+ for (const m of (models.value ?? [])) {
171
+ A('button.s-menu-item type=button', 'justify-content:space-between', () => {
172
+ A(() => { if (!url.debug && url.model === m.tableName) A('aria-current=page'); });
173
+ A('click=', () => route.go({ path: route.current.path, search: { model: m.tableName } }));
174
+ A('span', 'text=', m.tableName);
175
+ A('span', 'font-size:0.78em opacity:0.65', 'text=', `${m.fieldCount}f ${m.indexCount}i ${m.streamTypeCount}s`);
176
+ });
225
177
  }
226
178
  });
227
- }
179
+ };
228
180
 
181
+ const navDebug: Slot = () => {
182
+ A('button.s-menu-item type=button', () => {
183
+ A(() => { if (url.debug) A('aria-current=page'); });
184
+ A('click=', () => route.go({ path: route.current.path, search: { debug: '1' } }));
185
+ A('span.s-menu-icon aria-hidden=true #⬡');
186
+ A('#WarpSocket debug');
187
+ });
188
+ };
229
189
 
230
- function mainArea() {
231
- A('main', 'flex:1 overflow:auto p:$4', () => {
232
- if (!authProxy) return;
233
- if (url.debug) debugView();
234
- else if (url.model) modelDetail();
235
- else A('div', 'fg:$muted p:$4 text-align:center', '#Select a model from the sidebar');
190
+ function dashboard() {
191
+ S.main({
192
+ icon: '⬡',
193
+ title: 'Lowlander',
194
+ menu: () => S.button({ content: 'Logout', attrs: '.neutral .outlined .small', click: logout }),
195
+ nav: { items: [navModels, { separator: true }, navDebug] },
196
+ content: () => {
197
+ if (url.debug) debugView();
198
+ else if (url.model) modelDetail();
199
+ else A('p', 'fg:$s-fg-muted text-align:center mt:$4', '#Select a model from the sidebar');
200
+ },
236
201
  });
237
202
  }
238
203
 
204
+ // ─── Model detail ─────────────────────────────────────────────────────────────
205
+
239
206
  function modelDetail() {
240
207
  const name = url.model;
241
208
  const info = authProxy!.serverProxy.getModel(name);
242
209
  A(() => {
243
- if (info.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
244
- if (info.error) { A('div', 'fg:$danger', 'text=', info.error.message); return; }
210
+ if (info.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
211
+ if (info.error) { A('p', 'fg:$s-danger', 'text=', info.error.message); return; }
245
212
  const m = info.value;
246
213
  if (!m) return;
247
214
 
248
- A('h2', 'm:0 mb:$2', 'text=', m.tableName);
249
-
250
- A('section', 'mb:$4', () => {
251
- A('h3#Fields', 'm:0 mb:$2 fg:$muted font-weight:600');
252
- A('table', tableClass, () => {
253
- A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
254
- A('tbody', () => {
255
- for (const f of m.fields) {
256
- A('tr', () => {
257
- A('td', () => A('code', 'text=', f.name));
258
- A('td', 'fg:$accent2', 'text=', f.type.display);
259
- A('td', () => { const lm = f.type.linkedModel; if (lm) modelLink(lm); });
260
- A('td', 'text=', f.hasDefault ? '' : '');
261
- A('td', 'fg:$muted', 'text=', f.description || '');
262
- });
263
- }
215
+ S.box({
216
+ header: m.tableName,
217
+ content: () => {
218
+ A('table', tableClass, () => {
219
+ A('thead tr', () => { A('th#Name'); A('th#Type'); A('th#Linked'); A('th#Default'); A('th#Description'); });
220
+ A('tbody', () => {
221
+ for (const f of m.fields) {
222
+ A('tr', () => {
223
+ A('td', () => A('code', 'text=', f.name));
224
+ A('td', 'fg:$s-success', 'text=', f.type.display);
225
+ A('td', () => { const lm = f.type.linkedModel; if (lm) modelLink(lm); });
226
+ A('td', 'text=', f.hasDefault ? '✓' : '');
227
+ A('td', 'fg:$s-fg-muted', 'text=', f.description || '');
228
+ });
229
+ }
230
+ });
264
231
  });
265
- });
232
+ },
266
233
  });
267
234
 
268
- A('section', 'mb:$4', () => {
269
- A('h3#Indexes', 'm:0 mb:$2 fg:$muted font-weight:600');
270
- indexesTable(m);
235
+ S.box({
236
+ header: 'Indexes',
237
+ content: () => indexesTable(m),
271
238
  });
272
239
 
273
240
  A(() => {
274
241
  const cur = effectiveIndex();
275
242
  const idx = m.indexes.find((i: any) => i.name === cur);
276
243
  if (!idx) return;
277
- A('section', 'mb:$4', () => {
278
- A('h3', 'm:0 mb:$2 fg:$muted font-weight:600', 'text=', `Data for ${cur}`);
279
- indexBrowser(m, idx);
244
+ S.box({
245
+ header: () => {
246
+ A('span', 'flex:1', 'text=', `Data · ${cur}`);
247
+ S.button({ content: '+ New', attrs: '.outlined .small', click: () => {
248
+ openCreateModal(authProxy!, m.tableName, m.fields, () => { $state.indexRefreshKey++; });
249
+ }});
250
+ },
251
+ content: () => indexBrowser(m, idx),
280
252
  });
281
253
  });
282
254
 
283
- if (m.streamTypes.length) A('section', 'mb:$4', () => {
284
- A('h3#Stream types', 'm:0 mb:$2 fg:$muted font-weight:600');
285
- streamTypesTable(m);
255
+ if (m.streamTypes.length) S.box({
256
+ header: 'Stream types',
257
+ content: () => streamTypesTable(m),
286
258
  });
287
259
  });
288
260
  }
289
261
 
290
- // Render a clickable link to another model
291
262
  function modelLink(modelName: string, pk?: any, display?: string) {
292
263
  A('a', 'click=', (e: Event) => {
293
264
  e.preventDefault();
294
265
  e.stopPropagation();
295
266
  const search: Record<string, string | number> = { model: modelName };
296
267
  if (pk !== undefined) { search.search = jsonStringify(pk); search.limit = 1; }
297
- go({ path: route.path, search });
268
+ route.go({ path: route.current.path, search });
298
269
  }, 'text=', display ?? modelName);
299
270
  }
300
271
 
@@ -316,7 +287,7 @@ function indexesTable(m: any) {
316
287
  }, () => {
317
288
  A(() => A('.selected=', effectiveIndex() === idx.name));
318
289
  A('td', () => A('code', 'text=', idx.name));
319
- A('td', 'fg:$accent2', 'text=', idx.info.kind);
290
+ A('td', 'fg:$s-success', 'text=', idx.info.kind);
320
291
  A('td', 'text=', idx.info.fields.join(', ') || '(computed)');
321
292
  });
322
293
  }
@@ -351,19 +322,19 @@ function streamTypesTable(m: any) {
351
322
  }
352
323
 
353
324
  function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
354
- if (sel === true) { A('span', 'fg:$muted', '#(scalar)'); return; }
325
+ if (sel === true) { A('span', 'fg:$s-fg-muted', '#(scalar)'); return; }
355
326
  if (!sel || typeof sel !== 'object') { A('span', 'text=', String(sel)); return; }
356
327
  for (const [k, v] of Object.entries(sel)) {
357
- if (v === true) {
358
- A('div', 'text=', k);
359
- } else if (typeof v === 'number') {
328
+ if (typeof v === 'number') {
360
329
  const linked = fieldByName[k]?.type?.linkedModel;
361
- A(`div #${k}→`, () => {
330
+ A(`div#${k}→`, () => {
362
331
  if (linked) modelLink(linked, undefined, v.toString());
363
332
  else A('#?');
364
333
  });
334
+ } else if (v === false) {
335
+ A(`div#${k}*`);
365
336
  } else {
366
- A(`div #${k}={...}`);
337
+ A(`div#${k}`);
367
338
  }
368
339
  }
369
340
  }
@@ -371,80 +342,101 @@ function streamFieldsInline(sel: any, fieldByName: Record<string, any>) {
371
342
  function streamLiveCell(modelName: string, streamTypeId: number) {
372
343
  A(() => {
373
344
  const pkRaw = effectivePk();
374
- if (!pkRaw) { A('span', 'fg:$muted', '#(select a row)'); return; }
345
+ if (!pkRaw) { A('span', 'fg:$s-fg-muted', '#(select a row)'); return; }
375
346
  let pk: any;
376
347
  try { pk = JSON.parse(pkRaw); } catch { pk = pkRaw; }
377
348
  const $stream = authProxy!.serverProxy.streamRecord(modelName, streamTypeId, pk);
378
349
  A(() => {
379
- if ($stream.busy) { A('span', 'fg:$muted', '#…'); return; }
380
- if ($stream.error) { A('span', 'fg:$danger', 'text=', $stream.error.message); return; }
350
+ if ($stream.busy) { A('span', 'fg:$s-fg-muted', '#…'); return; }
351
+ if ($stream.error) { A('span', 'fg:$s-danger', 'text=', $stream.error.message); return; }
381
352
  A.dump($stream.value);
382
353
  });
383
354
  });
384
355
  }
385
356
 
357
+ // ─── Index browser ────────────────────────────────────────────────────────────
358
+
386
359
  function indexBrowser(m: any, idx: any) {
387
360
  const modelName = m.tableName;
388
361
  const indexName = idx.name;
389
362
 
390
- A('div', 'display:flex flex-direction:column', () => {
391
- A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center mb:$2', () => {
392
- A('input type=text flex:1 min-w:200px placeholder=search',
393
- 'value=', A.peek(() => url.search),
394
- 'input=', (e: any) => { url.search = e.target.value; url.limit = 10; });
395
- A('label', 'display:flex align-items:center gap:$1 white-space:nowrap', () => {
396
- A('input type=checkbox', 'checked=', url.reverse, 'change=', (e: any) => url.reverse = e.target.checked);
397
- A('span', 'fg:$muted', '#reverse');
398
- });
399
- A('button.ghost', 'p: 2px 8px; font-size:11px', '#↺ refresh',
400
- 'click=', () => $state.indexRefreshKey++);
363
+ const searchBind = {
364
+ get value() { return url.search; },
365
+ set value(v: string | number) { url.search = String(v); url.limit = 10; },
366
+ };
367
+ const reverseBind = {
368
+ get value() { return url.reverse; },
369
+ set value(v: any) { url.reverse = Boolean(v); },
370
+ };
371
+
372
+ A('div', 'display:flex flex-direction:column gap:$3', () => {
373
+ A('div', 'display:flex flex-wrap:wrap gap:$2 align-items:center', () => {
374
+ S.textline({ placeholder: 'search', attrs: 'flex:1 min-w:180px', bind: searchBind });
375
+ S.checkbox({ label: 'reverse', bind: reverseBind });
376
+ S.button({ content: '↺', attrs: '.outlined .small', ariaLabel: 'Refresh',
377
+ click: () => $state.indexRefreshKey++ });
401
378
  });
402
379
 
403
380
  A(() => {
404
381
  void $state.indexRefreshKey;
405
- const opts = {
406
- search: parseMaybe(url.search),
407
- reverse: url.reverse,
408
- limit: url.limit,
409
- };
382
+ const opts = { search: parseMaybe(url.search), reverse: url.reverse, limit: url.limit };
410
383
  const rows = authProxy!.serverProxy.findRecords(modelName, indexName, opts);
411
384
  A(() => {
412
- if (rows.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
413
- if (rows.error) { A('div', 'fg:$danger', 'text=', rows.error.message); return; }
385
+ if (rows.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
386
+ if (rows.error) { A('p', 'fg:$s-danger', 'text=', rows.error.message); return; }
414
387
  const r = rows.value;
415
388
  if (!r) return;
416
- // Auto-select first row when no explicit selection
417
389
  if (!url.pk && r.rows.length > 0) {
418
390
  url.pk = jsonStringify(r.rows[0].pk);
419
391
  return;
420
392
  }
421
- A('div', 'fg:$muted mb:$2 font-size:12px', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
422
- if (!r.rows.length) { A('div', 'fg:$muted', '#(empty)'); return; }
393
+ A('p', 'fg:$s-fg-muted font-size:0.85em m:0', 'text=', `${r.rows.length} rows (scanned ${r.scanned})`);
394
+ if (!r.rows.length) { A('p', 'fg:$s-fg-muted m:0', '#(empty)'); return; }
423
395
  A('div', 'overflow:auto', () => {
424
396
  A('table', tableClass, () => {
425
397
  const cols = Object.keys(r.rows[0].values);
426
- A('thead tr', () => { for (const c of cols) A('th', 'text=', c); });
398
+ A('thead tr', () => {
399
+ for (const c of cols) A('th', 'text=', c);
400
+ A('th', 'w:1px');
401
+ });
427
402
  A('tbody', () => {
428
403
  for (const row of r.rows) {
429
404
  const pkStr = jsonStringify(row.pk);
430
405
  A('tr.selectable', 'click=', () => { url.pk = (effectivePk() === pkStr) ? '-' : pkStr; }, () => {
431
- A(() => A('.selected=', effectivePk() === pkStr));
432
- for (const c of cols) {
433
- A('td', () => A.dump(wrapForDump((row.values as any)[c])));
434
- }
406
+ A(() => A('.selected=', effectivePk() === pkStr));
407
+ for (const c of cols) {
408
+ A('td', () => A.dump(wrapForDump((row.values as any)[c])));
409
+ }
410
+ A('td', 'text-align:right white-space:nowrap', () => {
411
+ S.button({ content: '✎', attrs: '.outlined .small mr:$1',
412
+ ariaLabel: 'Edit', click: (e) => {
413
+ e.stopPropagation();
414
+ openEditModal(authProxy!, modelName, m.fields, row.pk, row.values, () => {
415
+ $state.indexRefreshKey++;
416
+ });
417
+ },
418
+ });
419
+ S.button({ content: '✕', attrs: '.danger .outlined .small',
420
+ ariaLabel: 'Delete', click: (e) => {
421
+ e.stopPropagation();
422
+ openDeleteConfirm(authProxy!, modelName, row.pk, pkStr, () => {
423
+ $state.indexRefreshKey++;
424
+ if (effectivePk() === pkStr) url.pk = '-';
425
+ });
426
+ },
427
+ });
435
428
  });
429
+ });
436
430
  }
437
431
  });
438
432
  });
439
433
  });
440
- // 'more' button
441
434
  if (r.rows.length >= url.limit) {
442
- A('div', 'mt:$2', () => {
443
- A('button.ghost', 'p: 2px 8px; font-size:11px', 'click=', () => {
444
- const cur = url.limit;
445
- url.limit = Math.min(cur * 5, cur + 250);
446
- }, () => A('span', 'text=', `+ more (currently limit ${url.limit})`));
447
- });
435
+ A(() => S.button({
436
+ content: `+ more (currently limit ${url.limit})`,
437
+ attrs: '.outlined .small',
438
+ click: () => { const cur = url.limit; url.limit = Math.min(cur * 5, cur + 250); },
439
+ }));
448
440
  }
449
441
  });
450
442
  });
@@ -472,47 +464,47 @@ function parseMaybe(s: string): any {
472
464
  return s;
473
465
  }
474
466
 
467
+ // ─── Debug view ───────────────────────────────────────────────────────────────
468
+
475
469
  function debugView() {
476
- const $mode = A.proxy({v: 'channels' as 'channels' | 'sockets' | 'workers' | 'kv'});
477
- A('h2#WarpSocket debug', 'm:0 mb:$3');
478
- A('div', '.row mb:$3', () => {
479
- for (const t of ['channels','sockets','workers','kv'] as const) {
480
- A('button click=', () => $mode.v = t, () => {
481
- A(() => A($mode.v === t ? 'bg:$accent fg:#0b1220' : 'bg:$panel2 fg:$fg border: 1px solid $border;'));
482
- A('text=', t);
483
- });
484
- }
485
- });
486
- A(() => {
487
- const debugInfo = authProxy!.serverProxy.getDebugState($mode.v);
488
- A(() => {
489
- if (debugInfo.busy) { A('div', 'fg:$muted', '#Loading…'); return; }
490
- if (debugInfo.error) { A('div', 'fg:$danger', 'text=', debugInfo.error.message); return; }
491
- const data = A.unproxy(debugInfo.value) as undefined | Record<string, Record<string, any>>;
492
- if (!data) return;
493
- const keySet = new Set<string>();
494
- for (const obj of Object.values(data)) {
495
- if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
496
- for (const k of Object.keys(obj)) keySet.add(k);
497
- }
498
- const cols = [...keySet];
499
- A('div', 'overflow:auto', () => {
500
- A('table', tableClass, () => {
501
- A('thead tr', () => {
502
- A('th##');
503
- for (const k of cols) A('th', 'text=', k);
504
- });
505
- A('tbody', () => {
506
- for (const [idx, obj] of Object.entries(data)) {
507
- A('tr', () => {
508
- A('td', () => A('code', 'text=', idx));
509
- for (const k of cols) A('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
470
+ const $mode = A.proxy({ value: 'channels' as string });
471
+ S.tabs({
472
+ bind: $mode,
473
+ tabs: (['channels', 'sockets', 'workers', 'kv'] as const).map(t => ({
474
+ id: t,
475
+ label: t,
476
+ content: () => {
477
+ const debugInfo = authProxy!.serverProxy.getDebugState(t);
478
+ A(() => {
479
+ if (debugInfo.busy) { A('p', 'fg:$s-fg-muted', '#Loading…'); return; }
480
+ if (debugInfo.error) { A('p', 'fg:$s-danger', 'text=', debugInfo.error.message); return; }
481
+ const data = A.unproxy(debugInfo.value) as undefined | Record<string, Record<string, any>>;
482
+ if (!data) return;
483
+ const keySet = new Set<string>();
484
+ for (const obj of Object.values(data)) {
485
+ if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
486
+ for (const k of Object.keys(obj)) keySet.add(k);
487
+ }
488
+ const cols = [...keySet];
489
+ A('div', 'overflow:auto', () => {
490
+ A('table', tableClass, () => {
491
+ A('thead tr', () => {
492
+ A('th##');
493
+ for (const k of cols) A('th', 'text=', k);
510
494
  });
511
- }
495
+ A('tbody', () => {
496
+ for (const [idx, obj] of Object.entries(data)) {
497
+ A('tr', () => {
498
+ A('td', () => A('code', 'text=', idx));
499
+ for (const k of cols) A('td', 'font-size:12px font-family:monospace', 'text=', debugCellText(obj?.[k]));
500
+ });
501
+ }
502
+ });
503
+ });
512
504
  });
513
505
  });
514
- });
515
- });
506
+ },
507
+ })),
516
508
  });
517
509
  }
518
510
 
@@ -521,7 +513,6 @@ function debugCellText(value: any): string {
521
513
  if (value instanceof Uint8Array) return escapeBytes([...value]);
522
514
  if (typeof value === 'object') {
523
515
  if (value.type === 'Buffer' && Array.isArray(value.data)) return escapeBytes(value.data);
524
- // Buffer-like (proxied Uint8Array): sequential integer keys
525
516
  const keys = Object.keys(value);
526
517
  if (!Array.isArray(value) && keys.length > 0 && keys.length <= 4096 && keys.every((k, i) => k === String(i))) {
527
518
  const arr: number[] = [];
@@ -551,14 +542,11 @@ function escapeBytes(bytes: number[]): string {
551
542
  return s;
552
543
  }
553
544
 
554
- styles();
545
+ // ─── Mount ────────────────────────────────────────────────────────────────────
555
546
 
556
547
  A.mount(document.body, () => {
557
548
  if (!$state.authed) { login(); return; }
558
- A('div', 'display:flex flex-direction:row h:100vh w:100vw overflow:hidden', () => {
559
- sidebar();
560
- mainArea();
561
- });
549
+ dashboard();
562
550
  });
563
551
 
564
552
  if ($state.password) {