jsgui3-server 0.0.140 → 0.0.141
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/.github/agents/jsgui3-server.agent.md +699 -0
- package/.github/instructions/copilot.instructions.md +180 -0
- package/.playwright-mcp/page-2025-11-29T23-39-18-629Z.png +0 -0
- package/.playwright-mcp/page-2025-11-29T23-39-31-903Z.png +0 -0
- package/.playwright-mcp/page-2025-11-30T00-33-56-265Z.png +0 -0
- package/.playwright-mcp/page-2025-11-30T00-34-06-619Z.png +0 -0
- package/docs/agent-development-guide.md +108 -4
- package/docs/api-reference.md +116 -0
- package/docs/controls-development.md +127 -0
- package/docs/css/luxuryObsidianCss.js +1203 -0
- package/docs/css/obsidian-scrollbars.css +370 -0
- package/docs/diagrams/jsgui3-stack.svg +568 -0
- package/docs/guides/JSGUI3_UI_ARCHITECTURE_GUIDE.md +2527 -0
- package/docs/guides/OBSIDIAN_LUXURY_DESIGN_GUIDE.md +847 -0
- package/docs/jsgui3-vs-express-comparison.svg +542 -0
- package/docs/jsgui3-vs-nestjs-comparison.svg +550 -0
- package/docs/publishers-guide.md +76 -0
- package/docs/troubleshooting.md +51 -0
- package/examples/controls/15) window, observable SSE/README.md +125 -0
- package/examples/controls/15) window, observable SSE/check.js +144 -0
- package/examples/controls/15) window, observable SSE/client.js +395 -0
- package/examples/controls/15) window, observable SSE/server.js +111 -0
- package/http/responders/static/Static_Route_HTTP_Responder.js +16 -16
- package/module.js +7 -0
- package/package.json +7 -6
- package/port-utils.js +112 -0
- package/serve-factory.js +27 -5
- package/tests/README.md +40 -26
- package/tests/examples-controls.e2e.test.js +164 -0
- package/tests/observable-sse.test.js +363 -0
- package/tests/port-utils.test.js +114 -0
- package/tests/test-runner.js +13 -12
|
@@ -0,0 +1,2527 @@
|
|
|
1
|
+
# jsgui3 UI Architecture Guide for AI Agents
|
|
2
|
+
|
|
3
|
+
**Target Audience**: AI coding agents working on jsgui3 UIs
|
|
4
|
+
**Scope**: Component-based architecture, control composition, isomorphic patterns, and SSR
|
|
5
|
+
**Last Updated**: November 2025
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Overview](#overview)
|
|
12
|
+
2. [Isomorphic Architecture](#isomorphic-architecture)
|
|
13
|
+
3. [Core Concepts](#core-concepts)
|
|
14
|
+
4. [Control Architecture](#control-architecture)
|
|
15
|
+
5. [Creating Controls](#creating-controls)
|
|
16
|
+
6. [Composition Patterns](#composition-patterns)
|
|
17
|
+
7. [Project Structure](#project-structure)
|
|
18
|
+
8. [Verification Scripts](#verification-scripts)
|
|
19
|
+
9. [Development Server & Detached Mode](#development-server--detached-mode)
|
|
20
|
+
10. [Common Patterns](#common-patterns)
|
|
21
|
+
11. [**Extending jsgui3 (Plugins, Mixins, Extensions)**](#extending-jsgui3-plugins-mixins-extensions)
|
|
22
|
+
12. [Dashboard Server Performance Patterns](#dashboard-server-performance-patterns)
|
|
23
|
+
13. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
|
|
24
|
+
14. [Quick Reference](#quick-reference)
|
|
25
|
+
15. [**Client-Side Activation Flow (CRITICAL)**](#client-side-activation-flow-critical)
|
|
26
|
+
16. [Troubleshooting](#troubleshooting)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Workflow Discovery Quickstart
|
|
31
|
+
|
|
32
|
+
1. Before touching any jsgui3 surface, run `node tools/dev/md-scan.js --dir docs --search "jsgui3 workflow" --json` (tweak the search term: `activation`, `control registration`, `dashboard wiring`, etc.).
|
|
33
|
+
2. Record which sections you rely on inside your session notes (`WORKING_NOTES.md`) so the next agent can pick up the same trail.
|
|
34
|
+
3. If the workflow you find is incomplete, expand this guide (or the relevant workflow doc) and mention the new section in your summary so md-scan keeps surfacing it.
|
|
35
|
+
|
|
36
|
+
> **Tip:** Use `--find-sections "Client-Side Activation"` when you already know the section title you need.
|
|
37
|
+
|
|
38
|
+
## Overview
|
|
39
|
+
|
|
40
|
+
> **Visual Guide**: See [`jsgui3-architecture-diagram.svg`](../diagrams/jsgui3-architecture-diagram.svg) for an illustrated overview of the isomorphic architecture.
|
|
41
|
+
|
|
42
|
+
**jsgui3-html** is an **isomorphic** component library that works on both server and client. Controls are JavaScript classes that build DOM structures programmatically. On the server, they render to HTML strings; on the client, they can activate existing DOM and bind events (called "hydration" in other frameworks).
|
|
43
|
+
|
|
44
|
+
### When to Use jsgui3
|
|
45
|
+
|
|
46
|
+
- Server-rendered pages with structured, reusable components
|
|
47
|
+
- Admin dashboards, data explorers, documentation viewers
|
|
48
|
+
- Pages where SEO and initial load performance matter
|
|
49
|
+
- UIs that need consistent structure across multiple views
|
|
50
|
+
|
|
51
|
+
### Key Characteristics
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
// Server: controls render to HTML strings
|
|
55
|
+
const control = new MyControl({ context, data });
|
|
56
|
+
const html = control.all_html_render(); // → "<div class=\"my-control\">...</div>"
|
|
57
|
+
|
|
58
|
+
// Client: controls activate existing DOM and bind events
|
|
59
|
+
const control = new MyControl({ context, el: existingElement });
|
|
60
|
+
control.activate(); // Binds click handlers, etc.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Isomorphic Architecture
|
|
66
|
+
|
|
67
|
+
### Library Relationship
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
jsgui3-html (core isomorphic library)
|
|
71
|
+
│
|
|
72
|
+
├── Used directly on server for SSR
|
|
73
|
+
│
|
|
74
|
+
└── jsgui3-client (extends jsgui3-html)
|
|
75
|
+
│
|
|
76
|
+
└── Adds browser-specific features:
|
|
77
|
+
• Client_Page_Context
|
|
78
|
+
• activate() lifecycle
|
|
79
|
+
• Event binding
|
|
80
|
+
• DOM activation
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Key insight**: `jsgui3-client` **requires** `jsgui3-html` internally. The core Control API is identical on both server and client.
|
|
84
|
+
|
|
85
|
+
### Same Code, Different Environments
|
|
86
|
+
|
|
87
|
+
Controls using `require("jsgui3-html")` work in both environments:
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
// This control works on BOTH server and client
|
|
91
|
+
const jsgui = require("jsgui3-html");
|
|
92
|
+
|
|
93
|
+
class MyButtonControl extends jsgui.Control {
|
|
94
|
+
constructor(spec = {}) {
|
|
95
|
+
super({ ...spec, tagName: "button", __type_name: "my_button" });
|
|
96
|
+
this.label = spec.label || "Click me";
|
|
97
|
+
if (!spec.el) this.compose();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
compose() {
|
|
101
|
+
this.add(new jsgui.String_Control({ context: this.context, text: this.label }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **Server**: `node` resolves `jsgui3-html` → renders HTML
|
|
107
|
+
- **Client**: Bundler (esbuild) includes `jsgui3-html` → control works in browser
|
|
108
|
+
|
|
109
|
+
### When to Use jsgui3-client
|
|
110
|
+
|
|
111
|
+
Use `require("jsgui3-client")` only when you need **client-specific features**:
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
// Client-only control with activation lifecycle
|
|
115
|
+
const jsgui = require("jsgui3-client");
|
|
116
|
+
|
|
117
|
+
class DocsThemeToggleControl extends jsgui.Control {
|
|
118
|
+
constructor(spec = {}) {
|
|
119
|
+
super({ ...spec, tagName: "button" });
|
|
120
|
+
this.__type_name = "docs_theme_toggle";
|
|
121
|
+
if (!spec.el) this.compose();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Client-side activation - binds events to existing DOM
|
|
125
|
+
activate() {
|
|
126
|
+
if (this.__active) return;
|
|
127
|
+
this.__active = true;
|
|
128
|
+
|
|
129
|
+
const el = this.dom?.el;
|
|
130
|
+
if (!el) return;
|
|
131
|
+
|
|
132
|
+
el.addEventListener("click", (e) => {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
this.toggleTheme();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
toggleTheme() {
|
|
139
|
+
// Client-side theme switching logic
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Bundling for Browser
|
|
145
|
+
|
|
146
|
+
The build script (`scripts/build-docs-viewer-client.js`) uses esbuild to bundle controls:
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
await esbuild.build({
|
|
150
|
+
entryPoints: [entryPoint],
|
|
151
|
+
bundle: true,
|
|
152
|
+
platform: "browser",
|
|
153
|
+
format: "iife",
|
|
154
|
+
// jsgui3-client from npm (v0.0.121+) has browser compatibility fixes
|
|
155
|
+
// No special aliasing needed - just require("jsgui3-client")
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The bundler resolves all `require()` statements and produces a single browser-ready file.
|
|
160
|
+
|
|
161
|
+
### Activation Pattern
|
|
162
|
+
|
|
163
|
+
Server renders HTML with data attributes, client activates:
|
|
164
|
+
|
|
165
|
+
> **Terminology note**: jsgui3 calls this process "activation". Other frameworks (React, Vue, Svelte) call the equivalent process "hydration".
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
// Server-side: render with marker attribute
|
|
169
|
+
class MyControl extends jsgui.Control {
|
|
170
|
+
constructor(spec) {
|
|
171
|
+
super(spec);
|
|
172
|
+
this.dom.attributes["data-jsgui-control"] = "my_control";
|
|
173
|
+
// ...
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Client-side: find and activate marked elements
|
|
178
|
+
const elements = document.querySelectorAll("[data-jsgui-control]");
|
|
179
|
+
elements.forEach(el => {
|
|
180
|
+
const controlType = el.getAttribute("data-jsgui-control");
|
|
181
|
+
const ControlClass = CONTROL_TYPES[controlType];
|
|
182
|
+
|
|
183
|
+
// Create control with existing DOM element (skips compose)
|
|
184
|
+
const control = new ControlClass({ context, el });
|
|
185
|
+
control.activate(); // Bind events
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Project Structure for Isomorphic Apps
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
src/ui/server/myApp/
|
|
193
|
+
├── controls/ # Server-side controls (use jsgui3-html)
|
|
194
|
+
│ ├── MyAppControl.js
|
|
195
|
+
│ └── index.js
|
|
196
|
+
├── client/ # Client-side entry + controls
|
|
197
|
+
│ ├── index.js # Bundle entry point
|
|
198
|
+
│ ├── controls/ # Client controls (use jsgui3-client)
|
|
199
|
+
│ │ └── MyInteractiveControl.js
|
|
200
|
+
│ └── shims/ # Browser shims for Node modules
|
|
201
|
+
└── public/ # Built assets
|
|
202
|
+
└── my-app-client.js # Bundled client code
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Core Concepts
|
|
208
|
+
|
|
209
|
+
### 1. Context
|
|
210
|
+
|
|
211
|
+
Every control requires a **context** object that manages rendering state. Create it once per page render:
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
const jsgui = require("jsgui3-html");
|
|
215
|
+
|
|
216
|
+
// Create context for a page render
|
|
217
|
+
const context = new jsgui.Page_Context();
|
|
218
|
+
|
|
219
|
+
// Pass context to all controls
|
|
220
|
+
const app = new MyAppControl({ context, ...props });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Critical Rule**: Context flows down through composition. Parent controls pass their `this.context` to child controls.
|
|
224
|
+
|
|
225
|
+
### 2. Controls
|
|
226
|
+
|
|
227
|
+
Controls are classes that extend `jsgui.Control`. They:
|
|
228
|
+
- Accept a `spec` object in the constructor
|
|
229
|
+
- Store state as instance properties
|
|
230
|
+
- Build their DOM structure in a `compose()` method
|
|
231
|
+
- Render to HTML via `all_html_render()`
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
class MyControl extends jsgui.Control {
|
|
235
|
+
constructor(spec = {}) {
|
|
236
|
+
super({
|
|
237
|
+
...spec,
|
|
238
|
+
tagName: "div", // Root element tag
|
|
239
|
+
__type_name: "my_control" // Internal type identifier
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Store state from spec
|
|
243
|
+
this.title = spec.title || "Default";
|
|
244
|
+
|
|
245
|
+
// Compose after state is set
|
|
246
|
+
if (!spec.el) {
|
|
247
|
+
this.compose();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
compose() {
|
|
252
|
+
// Build child elements here
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### 3. Adding Text Content
|
|
258
|
+
|
|
259
|
+
jsgui3 **automatically wraps raw strings** when you call `.add()`. Both approaches work:
|
|
260
|
+
|
|
261
|
+
```javascript
|
|
262
|
+
// ✅ Simple - jsgui3 auto-wraps strings
|
|
263
|
+
element.add("Hello World");
|
|
264
|
+
|
|
265
|
+
// ✅ Explicit - useful when you need the control reference
|
|
266
|
+
const StringControl = jsgui.String_Control;
|
|
267
|
+
element.add(new StringControl({ context: this.context, text: "Hello World" }));
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**When to use explicit `String_Control`**:
|
|
271
|
+
- When you need a reference to the text node for later manipulation
|
|
272
|
+
- When building reusable components where explicitness aids clarity
|
|
273
|
+
- Legacy code compatibility (older jsgui patterns)
|
|
274
|
+
|
|
275
|
+
For most cases, just use `.add("text")` directly.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Control Architecture
|
|
280
|
+
|
|
281
|
+
### Inheritance Hierarchy
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
jsgui.Control (base)
|
|
285
|
+
└── BaseAppControl (shared app-level features)
|
|
286
|
+
├── DocAppControl (docs viewer)
|
|
287
|
+
├── ExplorerAppControl (data explorer)
|
|
288
|
+
├── DiagramAtlasAppControl (diagram atlas)
|
|
289
|
+
└── GazetteerAppControl (gazetteer)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### BaseAppControl Pattern
|
|
293
|
+
|
|
294
|
+
Create a shared base class for app-level controls that provides:
|
|
295
|
+
- Common header/footer structure
|
|
296
|
+
- Navigation building
|
|
297
|
+
- Section creation helpers
|
|
298
|
+
|
|
299
|
+
```javascript
|
|
300
|
+
class BaseAppControl extends jsgui.Control {
|
|
301
|
+
constructor(spec = {}) {
|
|
302
|
+
super({
|
|
303
|
+
...spec,
|
|
304
|
+
tagName: "div",
|
|
305
|
+
__type_name: "base_app"
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
this.appName = spec.appName || "App";
|
|
309
|
+
this.appClass = spec.appClass || "app";
|
|
310
|
+
this.title = spec.title || this.appName;
|
|
311
|
+
|
|
312
|
+
this.add_class(this.appClass);
|
|
313
|
+
|
|
314
|
+
// DO NOT call compose() here - subclasses must call it
|
|
315
|
+
// after setting their own properties
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
compose() {
|
|
319
|
+
this.headerContainer = this._buildHeader();
|
|
320
|
+
this.add(this.headerContainer);
|
|
321
|
+
|
|
322
|
+
this.mainContainer = new jsgui.Control({
|
|
323
|
+
context: this.context,
|
|
324
|
+
tagName: "main"
|
|
325
|
+
});
|
|
326
|
+
this.mainContainer.add_class(`${this.appClass}__main`);
|
|
327
|
+
this.add(this.mainContainer);
|
|
328
|
+
|
|
329
|
+
// Hook for subclasses
|
|
330
|
+
this.composeMainContent();
|
|
331
|
+
|
|
332
|
+
this.footerContainer = this._buildFooter();
|
|
333
|
+
this.add(this.footerContainer);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Override in subclasses
|
|
337
|
+
composeMainContent() {}
|
|
338
|
+
|
|
339
|
+
_buildHeader() { /* ... */ }
|
|
340
|
+
_buildFooter() { /* ... */ }
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Critical: Constructor Timing
|
|
345
|
+
|
|
346
|
+
**JavaScript class inheritance runs the parent constructor before the child constructor.**
|
|
347
|
+
|
|
348
|
+
This means properties set in a child constructor are NOT available when the parent constructor runs.
|
|
349
|
+
|
|
350
|
+
```javascript
|
|
351
|
+
// ❌ WRONG - compose() called before child properties exist
|
|
352
|
+
class BaseControl extends jsgui.Control {
|
|
353
|
+
constructor(spec) {
|
|
354
|
+
super(spec);
|
|
355
|
+
this.compose(); // Child's this.data is undefined here!
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
class ChildControl extends BaseControl {
|
|
360
|
+
constructor(spec) {
|
|
361
|
+
super(spec); // Parent's constructor runs FIRST
|
|
362
|
+
this.data = spec.data; // This runs AFTER parent's compose()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```javascript
|
|
368
|
+
// ✅ CORRECT - child calls compose() after setting properties
|
|
369
|
+
class BaseControl extends jsgui.Control {
|
|
370
|
+
constructor(spec) {
|
|
371
|
+
super(spec);
|
|
372
|
+
// DO NOT call compose() here
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
class ChildControl extends BaseControl {
|
|
377
|
+
constructor(spec) {
|
|
378
|
+
super(spec);
|
|
379
|
+
this.data = spec.data; // Set properties first
|
|
380
|
+
|
|
381
|
+
if (!spec.el) {
|
|
382
|
+
this.compose(); // Now compose with all properties available
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Creating Controls
|
|
391
|
+
|
|
392
|
+
### Step 1: Define the Control Class
|
|
393
|
+
|
|
394
|
+
```javascript
|
|
395
|
+
"use strict";
|
|
396
|
+
|
|
397
|
+
const jsgui = require("jsgui3-html");
|
|
398
|
+
const StringControl = jsgui.String_Control;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* PlaceBadgeControl - Reusable badge for place metadata
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* const badge = new PlaceBadgeControl({
|
|
405
|
+
* context,
|
|
406
|
+
* text: "city",
|
|
407
|
+
* variant: "kind"
|
|
408
|
+
* });
|
|
409
|
+
*/
|
|
410
|
+
class PlaceBadgeControl extends jsgui.Control {
|
|
411
|
+
/**
|
|
412
|
+
* @param {Object} spec - Control specification
|
|
413
|
+
* @param {Object} spec.context - jsgui context (required)
|
|
414
|
+
* @param {string} spec.text - Badge text content
|
|
415
|
+
* @param {string} [spec.variant] - Visual variant: "kind", "country", "default"
|
|
416
|
+
*/
|
|
417
|
+
constructor(spec = {}) {
|
|
418
|
+
super({
|
|
419
|
+
...spec,
|
|
420
|
+
tagName: "span",
|
|
421
|
+
__type_name: "place_badge"
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
this.add_class("gazetteer__badge");
|
|
425
|
+
|
|
426
|
+
this.text = spec.text || "";
|
|
427
|
+
this.variant = spec.variant || "default";
|
|
428
|
+
|
|
429
|
+
if (this.variant !== "default") {
|
|
430
|
+
this.add_class(`gazetteer__badge--${this.variant}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!spec.el) {
|
|
434
|
+
this.compose();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
compose() {
|
|
439
|
+
this.add(new StringControl({ context: this.context, text: this.text }));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = { PlaceBadgeControl };
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Step 2: Key Patterns
|
|
447
|
+
|
|
448
|
+
#### Adding CSS Classes
|
|
449
|
+
|
|
450
|
+
```javascript
|
|
451
|
+
// Single class
|
|
452
|
+
control.add_class("my-class");
|
|
453
|
+
|
|
454
|
+
// BEM naming convention
|
|
455
|
+
control.add_class("block__element");
|
|
456
|
+
control.add_class("block__element--modifier");
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
#### Setting Attributes
|
|
460
|
+
|
|
461
|
+
```javascript
|
|
462
|
+
// Standard attributes
|
|
463
|
+
control.dom.attributes.href = "/path";
|
|
464
|
+
control.dom.attributes.type = "submit";
|
|
465
|
+
control.dom.attributes.value = "Search";
|
|
466
|
+
|
|
467
|
+
// Data attributes
|
|
468
|
+
control.dom.attributes["data-role"] = "toolbar";
|
|
469
|
+
control.dom.attributes["data-metric"] = "count";
|
|
470
|
+
|
|
471
|
+
// Boolean attributes
|
|
472
|
+
control.dom.attributes.disabled = "disabled";
|
|
473
|
+
control.dom.attributes.selected = "selected";
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
#### Creating Child Elements
|
|
477
|
+
|
|
478
|
+
```javascript
|
|
479
|
+
compose() {
|
|
480
|
+
// Create a child element
|
|
481
|
+
const header = new jsgui.Control({
|
|
482
|
+
context: this.context,
|
|
483
|
+
tagName: "header"
|
|
484
|
+
});
|
|
485
|
+
header.add_class("my-control__header");
|
|
486
|
+
|
|
487
|
+
// Add text content
|
|
488
|
+
const title = new jsgui.Control({ context: this.context, tagName: "h1" });
|
|
489
|
+
title.add(new StringControl({ context: this.context, text: this.title }));
|
|
490
|
+
header.add(title);
|
|
491
|
+
|
|
492
|
+
// Add to parent
|
|
493
|
+
this.add(header);
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### Building Methods Pattern
|
|
498
|
+
|
|
499
|
+
For complex controls, break composition into private builder methods:
|
|
500
|
+
|
|
501
|
+
```javascript
|
|
502
|
+
compose() {
|
|
503
|
+
const header = this._buildHeader();
|
|
504
|
+
this.add(header);
|
|
505
|
+
|
|
506
|
+
const content = this._buildContent();
|
|
507
|
+
this.add(content);
|
|
508
|
+
|
|
509
|
+
const footer = this._buildFooter();
|
|
510
|
+
this.add(footer);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
_buildHeader() {
|
|
514
|
+
const header = new jsgui.Control({ context: this.context, tagName: "header" });
|
|
515
|
+
// ... build header structure
|
|
516
|
+
return header;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
_buildContent() {
|
|
520
|
+
const content = new jsgui.Control({ context: this.context, tagName: "main" });
|
|
521
|
+
// ... build content structure
|
|
522
|
+
return content;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
_buildFooter() {
|
|
526
|
+
const footer = new jsgui.Control({ context: this.context, tagName: "footer" });
|
|
527
|
+
// ... build footer structure
|
|
528
|
+
return footer;
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Composition Patterns
|
|
535
|
+
|
|
536
|
+
### Pattern 1: Direct Instantiation
|
|
537
|
+
|
|
538
|
+
When a component is simple and self-contained:
|
|
539
|
+
|
|
540
|
+
```javascript
|
|
541
|
+
_buildHeader() {
|
|
542
|
+
const header = new jsgui.Control({ context: this.context, tagName: "header" });
|
|
543
|
+
|
|
544
|
+
// Use a dedicated control
|
|
545
|
+
const toolbar = new DiagramToolbarControl({
|
|
546
|
+
context: this.context,
|
|
547
|
+
snapshotTime: this.generatedAt,
|
|
548
|
+
status: "complete"
|
|
549
|
+
});
|
|
550
|
+
header.add(toolbar);
|
|
551
|
+
|
|
552
|
+
return header;
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Pattern 2: Iterative Composition
|
|
557
|
+
|
|
558
|
+
When rendering lists of similar items:
|
|
559
|
+
|
|
560
|
+
```javascript
|
|
561
|
+
_buildResultsList() {
|
|
562
|
+
const list = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
563
|
+
list.add_class("results-list");
|
|
564
|
+
|
|
565
|
+
for (const item of this.results) {
|
|
566
|
+
const itemControl = new ResultItemControl({
|
|
567
|
+
context: this.context,
|
|
568
|
+
data: item
|
|
569
|
+
});
|
|
570
|
+
list.add(itemControl);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return list;
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Pattern 3: Conditional Composition
|
|
578
|
+
|
|
579
|
+
When structure depends on state:
|
|
580
|
+
|
|
581
|
+
```javascript
|
|
582
|
+
composeMainContent() {
|
|
583
|
+
if (!this.data) {
|
|
584
|
+
// Empty state
|
|
585
|
+
const placeholder = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
586
|
+
placeholder.add_class("placeholder");
|
|
587
|
+
placeholder.add(new StringControl({ context: this.context, text: "No data available" }));
|
|
588
|
+
this.mainContainer.add(placeholder);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Normal state with data
|
|
593
|
+
const content = this._buildDataContent();
|
|
594
|
+
this.mainContainer.add(content);
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Pattern 4: View Type Switching
|
|
599
|
+
|
|
600
|
+
When a single control handles multiple view types:
|
|
601
|
+
|
|
602
|
+
```javascript
|
|
603
|
+
const VIEW_TYPES = Object.freeze({
|
|
604
|
+
SEARCH: "search",
|
|
605
|
+
DETAIL: "detail",
|
|
606
|
+
DASHBOARD: "dashboard"
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
class MyAppControl extends BaseAppControl {
|
|
610
|
+
constructor(spec) {
|
|
611
|
+
super(spec);
|
|
612
|
+
this.viewType = spec.viewType || VIEW_TYPES.SEARCH;
|
|
613
|
+
// ...
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
composeMainContent() {
|
|
617
|
+
switch (this.viewType) {
|
|
618
|
+
case VIEW_TYPES.SEARCH:
|
|
619
|
+
this._composeSearchView();
|
|
620
|
+
break;
|
|
621
|
+
case VIEW_TYPES.DETAIL:
|
|
622
|
+
this._composeDetailView();
|
|
623
|
+
break;
|
|
624
|
+
case VIEW_TYPES.DASHBOARD:
|
|
625
|
+
this._composeDashboardView();
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
MyAppControl.VIEW_TYPES = VIEW_TYPES;
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## Project Structure
|
|
637
|
+
|
|
638
|
+
### Recommended Directory Layout
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
src/ui/
|
|
642
|
+
├── controls/ # Shared/generic controls
|
|
643
|
+
│ ├── Table.js
|
|
644
|
+
│ ├── PagerButton.js
|
|
645
|
+
│ └── checks/ # Check scripts for shared controls
|
|
646
|
+
│ └── Table.check.js
|
|
647
|
+
│
|
|
648
|
+
├── server/
|
|
649
|
+
│ ├── shared/ # Shared app-level infrastructure
|
|
650
|
+
│ │ ├── BaseAppControl.js
|
|
651
|
+
│ │ └── index.js
|
|
652
|
+
│ │
|
|
653
|
+
│ ├── dataExplorer/ # Data Explorer app
|
|
654
|
+
│ │ ├── controls/
|
|
655
|
+
│ │ │ ├── ExplorerAppControl.js
|
|
656
|
+
│ │ │ ├── ExplorerHomeCardControl.js
|
|
657
|
+
│ │ │ ├── ExplorerPaginationControl.js
|
|
658
|
+
│ │ │ └── index.js
|
|
659
|
+
│ │ ├── checks/
|
|
660
|
+
│ │ │ └── ExplorerAppControl.check.js
|
|
661
|
+
│ │ └── dataExplorerServer.js
|
|
662
|
+
│ │
|
|
663
|
+
│ ├── gazetteer/ # Gazetteer app
|
|
664
|
+
│ │ ├── controls/
|
|
665
|
+
│ │ │ ├── GazetteerAppControl.js
|
|
666
|
+
│ │ │ ├── GazetteerSearchFormControl.js
|
|
667
|
+
│ │ │ ├── GazetteerBreadcrumbControl.js
|
|
668
|
+
│ │ │ ├── GazetteerResultItemControl.js
|
|
669
|
+
│ │ │ ├── PlaceBadgeControl.js
|
|
670
|
+
│ │ │ └── index.js
|
|
671
|
+
│ │ └── checks/
|
|
672
|
+
│ │ └── GazetteerAppControl.check.js
|
|
673
|
+
│ │
|
|
674
|
+
│ └── diagramAtlas/ # Diagram Atlas app
|
|
675
|
+
│ ├── controls/
|
|
676
|
+
│ │ ├── DiagramAtlasAppControl.js
|
|
677
|
+
│ │ ├── DiagramToolbarControl.js
|
|
678
|
+
│ │ ├── DiagramDiagnosticsControl.js
|
|
679
|
+
│ │ └── index.js
|
|
680
|
+
│ └── checks/
|
|
681
|
+
│ ├── DiagramAtlasAppControl.check.js
|
|
682
|
+
│ └── DiagramToolbarControl.check.js
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Index Files
|
|
686
|
+
|
|
687
|
+
Each controls directory should have an `index.js` that exports all controls:
|
|
688
|
+
|
|
689
|
+
```javascript
|
|
690
|
+
"use strict";
|
|
691
|
+
|
|
692
|
+
const { GazetteerAppControl, VIEW_TYPES } = require("./GazetteerAppControl");
|
|
693
|
+
const { GazetteerSearchFormControl, KIND_OPTIONS } = require("./GazetteerSearchFormControl");
|
|
694
|
+
const { GazetteerBreadcrumbControl } = require("./GazetteerBreadcrumbControl");
|
|
695
|
+
const { GazetteerResultItemControl } = require("./GazetteerResultItemControl");
|
|
696
|
+
const { PlaceBadgeControl, BADGE_VARIANTS } = require("./PlaceBadgeControl");
|
|
697
|
+
|
|
698
|
+
module.exports = {
|
|
699
|
+
GazetteerAppControl,
|
|
700
|
+
GazetteerSearchFormControl,
|
|
701
|
+
GazetteerBreadcrumbControl,
|
|
702
|
+
GazetteerResultItemControl,
|
|
703
|
+
PlaceBadgeControl,
|
|
704
|
+
VIEW_TYPES,
|
|
705
|
+
KIND_OPTIONS,
|
|
706
|
+
BADGE_VARIANTS
|
|
707
|
+
};
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## Verification Scripts
|
|
713
|
+
|
|
714
|
+
### Purpose
|
|
715
|
+
|
|
716
|
+
Check scripts verify that controls render correctly without running the full application. They:
|
|
717
|
+
- Instantiate controls with sample data
|
|
718
|
+
- Render to HTML
|
|
719
|
+
- Assert expected content/structure exists
|
|
720
|
+
- Provide quick feedback during development
|
|
721
|
+
|
|
722
|
+
### Check Script Template
|
|
723
|
+
|
|
724
|
+
```javascript
|
|
725
|
+
"use strict";
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* MyControl Check Script
|
|
729
|
+
*
|
|
730
|
+
* Run with: node src/ui/server/myApp/checks/MyControl.check.js
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
const jsgui = require("jsgui3-html");
|
|
734
|
+
const { MyControl } = require("../controls/MyControl");
|
|
735
|
+
|
|
736
|
+
// Create context for rendering
|
|
737
|
+
function createContext() {
|
|
738
|
+
return new jsgui.Page_Context();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Sample data
|
|
742
|
+
const SAMPLE_DATA = {
|
|
743
|
+
title: "Test Title",
|
|
744
|
+
items: [
|
|
745
|
+
{ id: 1, name: "Item 1" },
|
|
746
|
+
{ id: 2, name: "Item 2" }
|
|
747
|
+
]
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
console.log("MyControl Verification");
|
|
751
|
+
console.log("========================================\n");
|
|
752
|
+
|
|
753
|
+
let totalPassed = 0;
|
|
754
|
+
let totalFailed = 0;
|
|
755
|
+
|
|
756
|
+
function check(condition, name) {
|
|
757
|
+
if (condition) {
|
|
758
|
+
console.log(` ✅ ${name}`);
|
|
759
|
+
totalPassed++;
|
|
760
|
+
} else {
|
|
761
|
+
console.log(` ❌ ${name}`);
|
|
762
|
+
totalFailed++;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Test 1: Basic rendering
|
|
767
|
+
console.log("📋 Testing basic rendering...");
|
|
768
|
+
{
|
|
769
|
+
const ctx = createContext();
|
|
770
|
+
const control = new MyControl({
|
|
771
|
+
context: ctx,
|
|
772
|
+
title: SAMPLE_DATA.title,
|
|
773
|
+
items: SAMPLE_DATA.items
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const html = control.all_html_render();
|
|
777
|
+
|
|
778
|
+
check(html.includes("my-control"), "has my-control class");
|
|
779
|
+
check(html.includes("Test Title"), "contains title");
|
|
780
|
+
check(html.includes("Item 1"), "contains first item");
|
|
781
|
+
check(html.includes("Item 2"), "contains second item");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Test 2: Empty state
|
|
785
|
+
console.log("\n📋 Testing empty state...");
|
|
786
|
+
{
|
|
787
|
+
const ctx = createContext();
|
|
788
|
+
const control = new MyControl({
|
|
789
|
+
context: ctx,
|
|
790
|
+
title: "Empty",
|
|
791
|
+
items: []
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const html = control.all_html_render();
|
|
795
|
+
|
|
796
|
+
check(html.includes("my-control"), "has my-control class");
|
|
797
|
+
check(html.includes("no-items") || html.includes("No items"), "shows empty state");
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Summary
|
|
801
|
+
console.log("\n========================================");
|
|
802
|
+
if (totalFailed === 0) {
|
|
803
|
+
console.log(`✅ All checks passed! (${totalPassed}/${totalPassed})`);
|
|
804
|
+
process.exit(0);
|
|
805
|
+
} else {
|
|
806
|
+
console.log(`❌ ${totalFailed} checks failed`);
|
|
807
|
+
process.exit(1);
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Running Check Scripts
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
# Single control
|
|
815
|
+
node src/ui/server/gazetteer/checks/GazetteerAppControl.check.js
|
|
816
|
+
|
|
817
|
+
# Multiple controls (PowerShell)
|
|
818
|
+
node src/ui/server/diagramAtlas/checks/DiagramAtlasAppControl.check.js; `
|
|
819
|
+
node src/ui/server/dataExplorer/checks/ExplorerAppControl.check.js
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
## Development Server & Detached Mode
|
|
825
|
+
|
|
826
|
+
### Server Overview
|
|
827
|
+
|
|
828
|
+
jsgui3 UI applications run on Express servers that serve SSR pages and API endpoints. The primary server for the Data Explorer is `dataExplorerServer.js`.
|
|
829
|
+
|
|
830
|
+
### The Problem: Servers Die When Terminal Commands Run
|
|
831
|
+
|
|
832
|
+
**Critical for AI Agents**: When you start a server in a terminal and then run another command, the server process often terminates. This happens because:
|
|
833
|
+
|
|
834
|
+
1. Running a new command in the same terminal may send signals to child processes
|
|
835
|
+
2. PowerShell/shell may propagate interrupt signals
|
|
836
|
+
3. The agent's next action kills the previous process
|
|
837
|
+
|
|
838
|
+
**Symptoms**:
|
|
839
|
+
- Server starts successfully, shows "listening on port 4600"
|
|
840
|
+
- Agent runs another command (e.g., `npm run build`)
|
|
841
|
+
- Subsequent requests to `http://127.0.0.1:4600/` fail with connection refused
|
|
842
|
+
- Agent wastes time debugging "why isn't the server working"
|
|
843
|
+
|
|
844
|
+
### Solution: Detached Mode
|
|
845
|
+
|
|
846
|
+
The `dataExplorerServer.js` supports **detached mode** for running independently of the terminal:
|
|
847
|
+
|
|
848
|
+
```bash
|
|
849
|
+
# Start server in detached mode (runs in background)
|
|
850
|
+
node src/ui/server/dataExplorerServer.js --detached --port 4600
|
|
851
|
+
|
|
852
|
+
# Check if server is running
|
|
853
|
+
node src/ui/server/dataExplorerServer.js --status
|
|
854
|
+
|
|
855
|
+
# Stop detached server
|
|
856
|
+
node src/ui/server/dataExplorerServer.js --stop
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
**Output examples**:
|
|
860
|
+
```
|
|
861
|
+
# --detached
|
|
862
|
+
🔍 Data Explorer started in background (PID: 12345)
|
|
863
|
+
URL: http://127.0.0.1:4600/domains
|
|
864
|
+
Stop with: node src\ui\server\dataExplorerServer.js --stop
|
|
865
|
+
|
|
866
|
+
# --status (running)
|
|
867
|
+
🔍 Data Explorer: running (PID: 12345)
|
|
868
|
+
|
|
869
|
+
# --status (not running)
|
|
870
|
+
🔍 Data Explorer: not running
|
|
871
|
+
|
|
872
|
+
# --stop
|
|
873
|
+
🔍 Data Explorer stopped (was PID: 12345)
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Agent Workflow: Server Management
|
|
877
|
+
|
|
878
|
+
**Before starting a server**:
|
|
879
|
+
```bash
|
|
880
|
+
# Always stop any existing detached server first
|
|
881
|
+
node src/ui/server/dataExplorerServer.js --stop 2>$null
|
|
882
|
+
|
|
883
|
+
# Then start fresh in detached mode
|
|
884
|
+
node src/ui/server/dataExplorerServer.js --detached --port 4600
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
**When debugging server issues**:
|
|
888
|
+
```bash
|
|
889
|
+
# Check if server is actually running
|
|
890
|
+
node src/ui/server/dataExplorerServer.js --status
|
|
891
|
+
|
|
892
|
+
# If not running, start it
|
|
893
|
+
node src/ui/server/dataExplorerServer.js --detached --port 4600
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
**After making server-side changes**:
|
|
897
|
+
```bash
|
|
898
|
+
# Restart to pick up code changes
|
|
899
|
+
node src/ui/server/dataExplorerServer.js --stop
|
|
900
|
+
node src/ui/server/dataExplorerServer.js --detached --port 4600
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
### PID File Location
|
|
904
|
+
|
|
905
|
+
Detached mode uses a PID file to track the running process:
|
|
906
|
+
- **Location**: `tmp/.data-explorer.pid`
|
|
907
|
+
- **Contents**: Process ID of the detached server
|
|
908
|
+
- **Cleanup**: Automatically deleted when `--stop` succeeds
|
|
909
|
+
|
|
910
|
+
### When NOT to Use Detached Mode
|
|
911
|
+
|
|
912
|
+
- **Debugging with console.log**: Use foreground mode to see output
|
|
913
|
+
- **Watching for errors**: Foreground mode shows stack traces immediately
|
|
914
|
+
- **Development iteration**: Sometimes easier to Ctrl+C and restart
|
|
915
|
+
|
|
916
|
+
For debugging, run in foreground in a dedicated terminal:
|
|
917
|
+
```bash
|
|
918
|
+
node src/ui/server/dataExplorerServer.js --port 4600
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
### Quick Reference
|
|
922
|
+
|
|
923
|
+
| Command | Purpose |
|
|
924
|
+
|---------|---------|
|
|
925
|
+
| `--detached` | Start server in background, survives terminal commands |
|
|
926
|
+
| `--status` | Check if detached server is running |
|
|
927
|
+
| `--stop` | Stop detached server |
|
|
928
|
+
| `--port <n>` | Specify port (default: 4600) |
|
|
929
|
+
|
|
930
|
+
### Adding Detached Mode to Other Servers
|
|
931
|
+
|
|
932
|
+
If you need to add detached mode to another server, the pattern is:
|
|
933
|
+
|
|
934
|
+
```javascript
|
|
935
|
+
const { spawn } = require("child_process");
|
|
936
|
+
const fs = require("fs");
|
|
937
|
+
const path = require("path");
|
|
938
|
+
|
|
939
|
+
const PID_FILE = path.join(__dirname, "../../../tmp/.my-server.pid");
|
|
940
|
+
|
|
941
|
+
function spawnDetached(port) {
|
|
942
|
+
const child = spawn(process.execPath, [__filename, "--port", String(port)], {
|
|
943
|
+
detached: true,
|
|
944
|
+
stdio: "ignore",
|
|
945
|
+
cwd: process.cwd()
|
|
946
|
+
});
|
|
947
|
+
child.unref();
|
|
948
|
+
fs.mkdirSync(path.dirname(PID_FILE), { recursive: true });
|
|
949
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
950
|
+
console.log(`Server started in background (PID: ${child.pid})`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function stopDetached() {
|
|
954
|
+
if (!fs.existsSync(PID_FILE)) {
|
|
955
|
+
console.log("No detached server found");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, "utf8"), 10);
|
|
959
|
+
try {
|
|
960
|
+
process.kill(pid, "SIGTERM");
|
|
961
|
+
fs.unlinkSync(PID_FILE);
|
|
962
|
+
console.log(`Server stopped (was PID: ${pid})`);
|
|
963
|
+
} catch (e) {
|
|
964
|
+
fs.unlinkSync(PID_FILE);
|
|
965
|
+
console.log("Server was not running (stale PID file cleaned)");
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function checkStatus() {
|
|
970
|
+
if (!fs.existsSync(PID_FILE)) {
|
|
971
|
+
console.log("Server: not running");
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, "utf8"), 10);
|
|
975
|
+
try {
|
|
976
|
+
process.kill(pid, 0); // Check if process exists
|
|
977
|
+
console.log(`Server: running (PID: ${pid})`);
|
|
978
|
+
} catch {
|
|
979
|
+
console.log("Server: not running (stale PID file)");
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
---
|
|
985
|
+
|
|
986
|
+
## Common Patterns
|
|
987
|
+
|
|
988
|
+
### 1. Form Controls
|
|
989
|
+
|
|
990
|
+
```javascript
|
|
991
|
+
class SearchFormControl extends jsgui.Control {
|
|
992
|
+
constructor(spec = {}) {
|
|
993
|
+
super({ ...spec, tagName: "form", __type_name: "search_form" });
|
|
994
|
+
|
|
995
|
+
this.add_class("search-form");
|
|
996
|
+
this.dom.attributes.action = spec.action || "/search";
|
|
997
|
+
this.dom.attributes.method = "get";
|
|
998
|
+
|
|
999
|
+
this.query = spec.query || "";
|
|
1000
|
+
this.placeholder = spec.placeholder || "Search...";
|
|
1001
|
+
|
|
1002
|
+
if (!spec.el) this.compose();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
compose() {
|
|
1006
|
+
// Text input
|
|
1007
|
+
const input = new jsgui.Control({ context: this.context, tagName: "input" });
|
|
1008
|
+
input.dom.attributes.type = "text";
|
|
1009
|
+
input.dom.attributes.name = "q";
|
|
1010
|
+
input.dom.attributes.placeholder = this.placeholder;
|
|
1011
|
+
if (this.query) {
|
|
1012
|
+
input.dom.attributes.value = this.query;
|
|
1013
|
+
}
|
|
1014
|
+
input.add_class("search-form__input");
|
|
1015
|
+
this.add(input);
|
|
1016
|
+
|
|
1017
|
+
// Submit button
|
|
1018
|
+
const button = new jsgui.Control({ context: this.context, tagName: "button" });
|
|
1019
|
+
button.dom.attributes.type = "submit";
|
|
1020
|
+
button.add_class("search-form__button");
|
|
1021
|
+
button.add(new StringControl({ context: this.context, text: "Search" }));
|
|
1022
|
+
this.add(button);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
### 2. Select/Dropdown Controls
|
|
1028
|
+
|
|
1029
|
+
```javascript
|
|
1030
|
+
const OPTIONS = [
|
|
1031
|
+
{ value: "", label: "All" },
|
|
1032
|
+
{ value: "type1", label: "Type 1" },
|
|
1033
|
+
{ value: "type2", label: "Type 2" }
|
|
1034
|
+
];
|
|
1035
|
+
|
|
1036
|
+
class FilterSelectControl extends jsgui.Control {
|
|
1037
|
+
constructor(spec = {}) {
|
|
1038
|
+
super({ ...spec, tagName: "select", __type_name: "filter_select" });
|
|
1039
|
+
|
|
1040
|
+
this.add_class("filter-select");
|
|
1041
|
+
this.dom.attributes.name = spec.name || "filter";
|
|
1042
|
+
|
|
1043
|
+
this.options = spec.options || OPTIONS;
|
|
1044
|
+
this.selectedValue = spec.selectedValue || "";
|
|
1045
|
+
|
|
1046
|
+
if (!spec.el) this.compose();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
compose() {
|
|
1050
|
+
for (const opt of this.options) {
|
|
1051
|
+
const option = new jsgui.Control({ context: this.context, tagName: "option" });
|
|
1052
|
+
option.dom.attributes.value = opt.value;
|
|
1053
|
+
|
|
1054
|
+
if (opt.value === this.selectedValue) {
|
|
1055
|
+
option.dom.attributes.selected = "selected";
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
option.add(new StringControl({ context: this.context, text: opt.label }));
|
|
1059
|
+
this.add(option);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
### 3. Breadcrumb Navigation
|
|
1066
|
+
|
|
1067
|
+
```javascript
|
|
1068
|
+
class BreadcrumbControl extends jsgui.Control {
|
|
1069
|
+
constructor(spec = {}) {
|
|
1070
|
+
super({ ...spec, tagName: "nav", __type_name: "breadcrumb" });
|
|
1071
|
+
|
|
1072
|
+
this.add_class("breadcrumb");
|
|
1073
|
+
|
|
1074
|
+
this.items = spec.items || []; // [{ label, href }, ...]
|
|
1075
|
+
this.separator = spec.separator || " › ";
|
|
1076
|
+
|
|
1077
|
+
if (!spec.el) this.compose();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
compose() {
|
|
1081
|
+
this.items.forEach((item, index) => {
|
|
1082
|
+
// Add separator (except before first item)
|
|
1083
|
+
if (index > 0) {
|
|
1084
|
+
this.add(new StringControl({ context: this.context, text: this.separator }));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (item.href) {
|
|
1088
|
+
// Linked item
|
|
1089
|
+
const link = new jsgui.Control({ context: this.context, tagName: "a" });
|
|
1090
|
+
link.dom.attributes.href = item.href;
|
|
1091
|
+
link.add(new StringControl({ context: this.context, text: item.label }));
|
|
1092
|
+
this.add(link);
|
|
1093
|
+
} else {
|
|
1094
|
+
// Current page (no link)
|
|
1095
|
+
const span = new jsgui.Control({ context: this.context, tagName: "span" });
|
|
1096
|
+
span.add_class("breadcrumb__current");
|
|
1097
|
+
span.add(new StringControl({ context: this.context, text: item.label }));
|
|
1098
|
+
this.add(span);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
### 4. Card/Tile Components
|
|
1106
|
+
|
|
1107
|
+
```javascript
|
|
1108
|
+
class StatCardControl extends jsgui.Control {
|
|
1109
|
+
constructor(spec = {}) {
|
|
1110
|
+
super({ ...spec, tagName: "div", __type_name: "stat_card" });
|
|
1111
|
+
|
|
1112
|
+
this.add_class("stat-card");
|
|
1113
|
+
if (spec.variant) {
|
|
1114
|
+
this.add_class(`stat-card--${spec.variant}`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
this.label = spec.label || "Stat";
|
|
1118
|
+
this.value = spec.value;
|
|
1119
|
+
this.detail = spec.detail || null;
|
|
1120
|
+
this.icon = spec.icon || null;
|
|
1121
|
+
|
|
1122
|
+
if (!spec.el) this.compose();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
compose() {
|
|
1126
|
+
// Icon (optional)
|
|
1127
|
+
if (this.icon) {
|
|
1128
|
+
const iconEl = new jsgui.Control({ context: this.context, tagName: "span" });
|
|
1129
|
+
iconEl.add_class("stat-card__icon");
|
|
1130
|
+
iconEl.add(new StringControl({ context: this.context, text: this.icon }));
|
|
1131
|
+
this.add(iconEl);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Label
|
|
1135
|
+
const labelEl = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
1136
|
+
labelEl.add_class("stat-card__label");
|
|
1137
|
+
labelEl.add(new StringControl({ context: this.context, text: this.label }));
|
|
1138
|
+
this.add(labelEl);
|
|
1139
|
+
|
|
1140
|
+
// Value
|
|
1141
|
+
const valueEl = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
1142
|
+
valueEl.add_class("stat-card__value");
|
|
1143
|
+
valueEl.add(new StringControl({ context: this.context, text: String(this.value ?? "—") }));
|
|
1144
|
+
this.add(valueEl);
|
|
1145
|
+
|
|
1146
|
+
// Detail (optional)
|
|
1147
|
+
if (this.detail) {
|
|
1148
|
+
const detailEl = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
1149
|
+
detailEl.add_class("stat-card__detail");
|
|
1150
|
+
detailEl.add(new StringControl({ context: this.context, text: this.detail }));
|
|
1151
|
+
this.add(detailEl);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### 5. Table Components
|
|
1158
|
+
|
|
1159
|
+
```javascript
|
|
1160
|
+
class SimpleTableControl extends jsgui.Control {
|
|
1161
|
+
constructor(spec = {}) {
|
|
1162
|
+
super({ ...spec, tagName: "table", __type_name: "simple_table" });
|
|
1163
|
+
|
|
1164
|
+
this.add_class("ui-table");
|
|
1165
|
+
|
|
1166
|
+
this.columns = spec.columns || []; // [{ key, label, width? }, ...]
|
|
1167
|
+
this.rows = spec.rows || []; // [{ key1: val1, key2: val2 }, ...]
|
|
1168
|
+
|
|
1169
|
+
if (!spec.el) this.compose();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
compose() {
|
|
1173
|
+
// Header
|
|
1174
|
+
const thead = new jsgui.Control({ context: this.context, tagName: "thead" });
|
|
1175
|
+
const headerRow = new jsgui.Control({ context: this.context, tagName: "tr" });
|
|
1176
|
+
|
|
1177
|
+
for (const col of this.columns) {
|
|
1178
|
+
const th = new jsgui.Control({ context: this.context, tagName: "th" });
|
|
1179
|
+
if (col.width) {
|
|
1180
|
+
th.dom.attributes.style = `width: ${col.width}`;
|
|
1181
|
+
}
|
|
1182
|
+
th.add(new StringControl({ context: this.context, text: col.label }));
|
|
1183
|
+
headerRow.add(th);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
thead.add(headerRow);
|
|
1187
|
+
this.add(thead);
|
|
1188
|
+
|
|
1189
|
+
// Body
|
|
1190
|
+
const tbody = new jsgui.Control({ context: this.context, tagName: "tbody" });
|
|
1191
|
+
|
|
1192
|
+
for (const row of this.rows) {
|
|
1193
|
+
const tr = new jsgui.Control({ context: this.context, tagName: "tr" });
|
|
1194
|
+
|
|
1195
|
+
for (const col of this.columns) {
|
|
1196
|
+
const td = new jsgui.Control({ context: this.context, tagName: "td" });
|
|
1197
|
+
const value = row[col.key];
|
|
1198
|
+
td.add(new StringControl({
|
|
1199
|
+
context: this.context,
|
|
1200
|
+
text: value != null ? String(value) : ""
|
|
1201
|
+
}));
|
|
1202
|
+
tr.add(td);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
tbody.add(tr);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
this.add(tbody);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
### 6. Pagination
|
|
1214
|
+
|
|
1215
|
+
```javascript
|
|
1216
|
+
class PaginationControl extends jsgui.Control {
|
|
1217
|
+
constructor(spec = {}) {
|
|
1218
|
+
super({ ...spec, tagName: "div", __type_name: "pagination" });
|
|
1219
|
+
|
|
1220
|
+
this.add_class("pagination");
|
|
1221
|
+
|
|
1222
|
+
this.currentPage = spec.currentPage || 1;
|
|
1223
|
+
this.totalPages = spec.totalPages || 1;
|
|
1224
|
+
this.basePath = spec.basePath || "";
|
|
1225
|
+
|
|
1226
|
+
if (!spec.el) this.compose();
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
compose() {
|
|
1230
|
+
// Previous button
|
|
1231
|
+
if (this.currentPage > 1) {
|
|
1232
|
+
const prev = new jsgui.Control({ context: this.context, tagName: "a" });
|
|
1233
|
+
prev.dom.attributes.href = `${this.basePath}?page=${this.currentPage - 1}`;
|
|
1234
|
+
prev.add_class("pagination__btn");
|
|
1235
|
+
prev.add(new StringControl({ context: this.context, text: "← Previous" }));
|
|
1236
|
+
this.add(prev);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Page info
|
|
1240
|
+
const info = new jsgui.Control({ context: this.context, tagName: "span" });
|
|
1241
|
+
info.add_class("pagination__info");
|
|
1242
|
+
info.add(new StringControl({
|
|
1243
|
+
context: this.context,
|
|
1244
|
+
text: `Page ${this.currentPage} of ${this.totalPages}`
|
|
1245
|
+
}));
|
|
1246
|
+
this.add(info);
|
|
1247
|
+
|
|
1248
|
+
// Next button
|
|
1249
|
+
if (this.currentPage < this.totalPages) {
|
|
1250
|
+
const next = new jsgui.Control({ context: this.context, tagName: "a" });
|
|
1251
|
+
next.dom.attributes.href = `${this.basePath}?page=${this.currentPage + 1}`;
|
|
1252
|
+
next.add_class("pagination__btn");
|
|
1253
|
+
next.add(new StringControl({ context: this.context, text: "Next →" }));
|
|
1254
|
+
this.add(next);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
---
|
|
1261
|
+
|
|
1262
|
+
## Extending jsgui3 (Plugins, Mixins, Extensions)
|
|
1263
|
+
|
|
1264
|
+
When jsgui3 doesn't provide functionality you need, you have options. The key principle: **write extensions that could be merged back into jsgui3** without major refactoring.
|
|
1265
|
+
|
|
1266
|
+
### Philosophy: jsgui3 Abstracts the DOM
|
|
1267
|
+
|
|
1268
|
+
jsgui3's job is to abstract over the DOM. If you find yourself writing direct DOM manipulation code repeatedly, that's a signal that:
|
|
1269
|
+
|
|
1270
|
+
1. **jsgui3 should handle it** - Consider writing an extension
|
|
1271
|
+
2. **It's a jsgui3 bug** - Report it and document a temporary workaround
|
|
1272
|
+
3. **It's genuinely client-specific** - Put it in `activate()` with clear comments
|
|
1273
|
+
|
|
1274
|
+
### Extension Location
|
|
1275
|
+
|
|
1276
|
+
Place extensions in a dedicated directory that mirrors jsgui3's structure:
|
|
1277
|
+
|
|
1278
|
+
```
|
|
1279
|
+
src/ui/
|
|
1280
|
+
├── jsgui-extensions/ # Extensions designed for upstream contribution
|
|
1281
|
+
│ ├── controls/ # New control types
|
|
1282
|
+
│ │ └── ContextMenuControl.js
|
|
1283
|
+
│ ├── mixins/ # Behavior mixins (drag, resize, etc.)
|
|
1284
|
+
│ │ └── DraggableMixin.js
|
|
1285
|
+
│ ├── plugins/ # Context-level plugins
|
|
1286
|
+
│ │ └── TooltipPlugin.js
|
|
1287
|
+
│ └── index.js # Exports all extensions
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
### Pattern 1: Control Extensions
|
|
1291
|
+
|
|
1292
|
+
Extend `jsgui.Control` for new component types:
|
|
1293
|
+
|
|
1294
|
+
```javascript
|
|
1295
|
+
// src/ui/jsgui-extensions/controls/ContextMenuControl.js
|
|
1296
|
+
"use strict";
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* ContextMenuControl - Reusable context menu component
|
|
1300
|
+
*
|
|
1301
|
+
* Designed for upstream contribution to jsgui3.
|
|
1302
|
+
*
|
|
1303
|
+
* @example
|
|
1304
|
+
* const menu = new ContextMenuControl({
|
|
1305
|
+
* context,
|
|
1306
|
+
* items: [
|
|
1307
|
+
* { label: "Edit", icon: "✏️", value: "edit" },
|
|
1308
|
+
* { label: "Delete", icon: "🗑️", value: "delete" }
|
|
1309
|
+
* ],
|
|
1310
|
+
* onSelect: (value) => handleAction(value)
|
|
1311
|
+
* });
|
|
1312
|
+
*/
|
|
1313
|
+
class ContextMenuControl extends jsgui.Control {
|
|
1314
|
+
constructor(spec = {}) {
|
|
1315
|
+
super({
|
|
1316
|
+
...spec,
|
|
1317
|
+
tagName: "div",
|
|
1318
|
+
__type_name: "context_menu"
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
this.add_class("jsgui-context-menu");
|
|
1322
|
+
|
|
1323
|
+
this._items = spec.items || [];
|
|
1324
|
+
this._onSelect = spec.onSelect || null;
|
|
1325
|
+
this._visible = false;
|
|
1326
|
+
this._position = { x: 0, y: 0 };
|
|
1327
|
+
|
|
1328
|
+
if (!spec.el) {
|
|
1329
|
+
this.compose();
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
compose() {
|
|
1334
|
+
for (const item of this._items) {
|
|
1335
|
+
const menuItem = new ContextMenuItemControl({
|
|
1336
|
+
context: this.context,
|
|
1337
|
+
...item,
|
|
1338
|
+
onSelect: (value) => this._handleSelect(value)
|
|
1339
|
+
});
|
|
1340
|
+
this.add(menuItem);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Public API - these methods should work pre and post render
|
|
1345
|
+
show(x, y) {
|
|
1346
|
+
this._visible = true;
|
|
1347
|
+
this._position = { x, y };
|
|
1348
|
+
this.remove_class("jsgui-context-menu--hidden");
|
|
1349
|
+
// Position is applied in activate() or via CSS custom properties
|
|
1350
|
+
this._applyPosition();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
hide() {
|
|
1354
|
+
this._visible = false;
|
|
1355
|
+
this.add_class("jsgui-context-menu--hidden");
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
_applyPosition() {
|
|
1359
|
+
// Use CSS custom properties for positioning (works with jsgui3 abstraction)
|
|
1360
|
+
this.dom.attributes.style = `--menu-x: ${this._position.x}px; --menu-y: ${this._position.y}px;`;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
_handleSelect(value) {
|
|
1364
|
+
this.hide();
|
|
1365
|
+
if (this._onSelect) {
|
|
1366
|
+
this._onSelect(value);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
activate() {
|
|
1371
|
+
if (this.__activated) return;
|
|
1372
|
+
this.__activated = true;
|
|
1373
|
+
|
|
1374
|
+
// Client-side: bind keyboard navigation, click-outside-to-close
|
|
1375
|
+
this._bindKeyboardNav();
|
|
1376
|
+
this._bindClickOutside();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
_bindKeyboardNav() {
|
|
1380
|
+
// Keyboard handling is inherently client-side
|
|
1381
|
+
if (!this.dom.el) return;
|
|
1382
|
+
this.dom.el.addEventListener("keydown", (e) => {
|
|
1383
|
+
if (e.key === "Escape") this.hide();
|
|
1384
|
+
// ... arrow key navigation
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
_bindClickOutside() {
|
|
1389
|
+
// Click-outside is inherently client-side
|
|
1390
|
+
document.addEventListener("click", (e) => {
|
|
1391
|
+
if (this._visible && this.dom.el && !this.dom.el.contains(e.target)) {
|
|
1392
|
+
this.hide();
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
module.exports = { ContextMenuControl };
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
### Pattern 2: Behavior Mixins
|
|
1402
|
+
|
|
1403
|
+
For behaviors that can apply to multiple controls (drag, resize, sortable):
|
|
1404
|
+
|
|
1405
|
+
```javascript
|
|
1406
|
+
// src/ui/jsgui-extensions/mixins/DraggableMixin.js
|
|
1407
|
+
"use strict";
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* DraggableMixin - Adds drag behavior to any control
|
|
1411
|
+
*
|
|
1412
|
+
* Designed for upstream contribution to jsgui3.
|
|
1413
|
+
* Apply via: Object.assign(MyControl.prototype, DraggableMixin);
|
|
1414
|
+
*
|
|
1415
|
+
* Requirements:
|
|
1416
|
+
* - Control must have this.dom.el after activation
|
|
1417
|
+
* - Control should have a drag handle element (or uses whole control)
|
|
1418
|
+
*/
|
|
1419
|
+
const DraggableMixin = {
|
|
1420
|
+
/**
|
|
1421
|
+
* Initialize draggable behavior
|
|
1422
|
+
* Call this in your control's activate() method
|
|
1423
|
+
*
|
|
1424
|
+
* @param {Object} options
|
|
1425
|
+
* @param {string} [options.handleSelector] - CSS selector for drag handle
|
|
1426
|
+
* @param {Function} [options.onDragStart] - Called when drag starts
|
|
1427
|
+
* @param {Function} [options.onDragEnd] - Called when drag ends
|
|
1428
|
+
*/
|
|
1429
|
+
initDraggable(options = {}) {
|
|
1430
|
+
if (!this.dom.el) return;
|
|
1431
|
+
|
|
1432
|
+
this._dragOptions = options;
|
|
1433
|
+
this._isDragging = false;
|
|
1434
|
+
this._dragStart = { x: 0, y: 0 };
|
|
1435
|
+
this._posStart = { x: 0, y: 0 };
|
|
1436
|
+
|
|
1437
|
+
const handle = options.handleSelector
|
|
1438
|
+
? this.dom.el.querySelector(options.handleSelector)
|
|
1439
|
+
: this.dom.el;
|
|
1440
|
+
|
|
1441
|
+
if (handle) {
|
|
1442
|
+
handle.addEventListener("mousedown", (e) => this._onDragMouseDown(e));
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// These must be on document to track mouse outside element
|
|
1446
|
+
document.addEventListener("mousemove", (e) => this._onDragMouseMove(e));
|
|
1447
|
+
document.addEventListener("mouseup", (e) => this._onDragMouseUp(e));
|
|
1448
|
+
},
|
|
1449
|
+
|
|
1450
|
+
_onDragMouseDown(e) {
|
|
1451
|
+
if (e.button !== 0) return; // Left click only
|
|
1452
|
+
|
|
1453
|
+
this._isDragging = true;
|
|
1454
|
+
this._dragStart = { x: e.clientX, y: e.clientY };
|
|
1455
|
+
|
|
1456
|
+
// Get current position from control state (not DOM)
|
|
1457
|
+
this._posStart = {
|
|
1458
|
+
x: this._position?.x || 0,
|
|
1459
|
+
y: this._position?.y || 0
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
this.add_class("jsgui-dragging");
|
|
1463
|
+
|
|
1464
|
+
if (this._dragOptions.onDragStart) {
|
|
1465
|
+
this._dragOptions.onDragStart();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
e.preventDefault();
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
_onDragMouseMove(e) {
|
|
1472
|
+
if (!this._isDragging) return;
|
|
1473
|
+
|
|
1474
|
+
const dx = e.clientX - this._dragStart.x;
|
|
1475
|
+
const dy = e.clientY - this._dragStart.y;
|
|
1476
|
+
|
|
1477
|
+
// Update control state
|
|
1478
|
+
this._position = {
|
|
1479
|
+
x: Math.max(0, this._posStart.x + dx),
|
|
1480
|
+
y: Math.max(0, this._posStart.y + dy)
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
// Apply via style attribute (jsgui3 compatible)
|
|
1484
|
+
this._applyDragPosition();
|
|
1485
|
+
},
|
|
1486
|
+
|
|
1487
|
+
_onDragMouseUp(e) {
|
|
1488
|
+
if (!this._isDragging) return;
|
|
1489
|
+
|
|
1490
|
+
this._isDragging = false;
|
|
1491
|
+
this.remove_class("jsgui-dragging");
|
|
1492
|
+
|
|
1493
|
+
if (this._dragOptions.onDragEnd) {
|
|
1494
|
+
this._dragOptions.onDragEnd(this._position);
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
|
|
1498
|
+
_applyDragPosition() {
|
|
1499
|
+
// Use style attribute which jsgui3 handles
|
|
1500
|
+
this.dom.attributes.style = `left: ${this._position.x}px; top: ${this._position.y}px;`;
|
|
1501
|
+
// Sync to DOM if rendered
|
|
1502
|
+
if (this.dom.el) {
|
|
1503
|
+
this.dom.el.style.left = `${this._position.x}px`;
|
|
1504
|
+
this.dom.el.style.top = `${this._position.y}px`;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1509
|
+
module.exports = { DraggableMixin };
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
### Pattern 3: Context Plugins
|
|
1513
|
+
|
|
1514
|
+
For features that need access to all controls (tooltips, focus management):
|
|
1515
|
+
|
|
1516
|
+
```javascript
|
|
1517
|
+
// src/ui/jsgui-extensions/plugins/TooltipPlugin.js
|
|
1518
|
+
"use strict";
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* TooltipPlugin - Automatic tooltip management for jsgui3 contexts
|
|
1522
|
+
*
|
|
1523
|
+
* Designed for upstream contribution to jsgui3.
|
|
1524
|
+
*
|
|
1525
|
+
* @example
|
|
1526
|
+
* const context = new jsgui.Page_Context();
|
|
1527
|
+
* TooltipPlugin.install(context);
|
|
1528
|
+
*
|
|
1529
|
+
* // Then in any control:
|
|
1530
|
+
* myControl.dom.attributes["data-tooltip"] = "Helpful text";
|
|
1531
|
+
*/
|
|
1532
|
+
const TooltipPlugin = {
|
|
1533
|
+
install(context) {
|
|
1534
|
+
// Store plugin state on context
|
|
1535
|
+
context._tooltipPlugin = {
|
|
1536
|
+
activeTooltip: null,
|
|
1537
|
+
tooltipEl: null
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
// Hook into context's activation lifecycle
|
|
1541
|
+
const originalActivate = context.activate?.bind(context) || (() => {});
|
|
1542
|
+
context.activate = function() {
|
|
1543
|
+
originalActivate();
|
|
1544
|
+
TooltipPlugin._initTooltips(context);
|
|
1545
|
+
};
|
|
1546
|
+
},
|
|
1547
|
+
|
|
1548
|
+
_initTooltips(context) {
|
|
1549
|
+
// Create tooltip element once
|
|
1550
|
+
if (!context._tooltipPlugin.tooltipEl) {
|
|
1551
|
+
const tooltip = document.createElement("div");
|
|
1552
|
+
tooltip.className = "jsgui-tooltip jsgui-tooltip--hidden";
|
|
1553
|
+
document.body.appendChild(tooltip);
|
|
1554
|
+
context._tooltipPlugin.tooltipEl = tooltip;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Use event delegation on document
|
|
1558
|
+
document.addEventListener("mouseenter", (e) => {
|
|
1559
|
+
const target = e.target.closest("[data-tooltip]");
|
|
1560
|
+
if (target) {
|
|
1561
|
+
TooltipPlugin._showTooltip(context, target);
|
|
1562
|
+
}
|
|
1563
|
+
}, true);
|
|
1564
|
+
|
|
1565
|
+
document.addEventListener("mouseleave", (e) => {
|
|
1566
|
+
const target = e.target.closest("[data-tooltip]");
|
|
1567
|
+
if (target) {
|
|
1568
|
+
TooltipPlugin._hideTooltip(context);
|
|
1569
|
+
}
|
|
1570
|
+
}, true);
|
|
1571
|
+
},
|
|
1572
|
+
|
|
1573
|
+
_showTooltip(context, target) {
|
|
1574
|
+
const text = target.getAttribute("data-tooltip");
|
|
1575
|
+
const tooltip = context._tooltipPlugin.tooltipEl;
|
|
1576
|
+
|
|
1577
|
+
tooltip.textContent = text;
|
|
1578
|
+
tooltip.classList.remove("jsgui-tooltip--hidden");
|
|
1579
|
+
|
|
1580
|
+
// Position near target
|
|
1581
|
+
const rect = target.getBoundingClientRect();
|
|
1582
|
+
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
|
1583
|
+
tooltip.style.top = `${rect.bottom + 8}px`;
|
|
1584
|
+
},
|
|
1585
|
+
|
|
1586
|
+
_hideTooltip(context) {
|
|
1587
|
+
const tooltip = context._tooltipPlugin.tooltipEl;
|
|
1588
|
+
tooltip.classList.add("jsgui-tooltip--hidden");
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
module.exports = { TooltipPlugin };
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
### Guidelines for Upstream-Ready Extensions
|
|
1596
|
+
|
|
1597
|
+
1. **Use jsgui3 APIs wherever possible**
|
|
1598
|
+
- `add_class()` / `remove_class()` instead of `classList`
|
|
1599
|
+
- `this.dom.attributes` instead of `setAttribute()`
|
|
1600
|
+
- `this.add()` for child controls
|
|
1601
|
+
|
|
1602
|
+
2. **Keep DOM access in `activate()`**
|
|
1603
|
+
- Event listeners: always in `activate()`
|
|
1604
|
+
- Document-level listeners: in `activate()`
|
|
1605
|
+
- Element measurements: only when needed, in methods called post-activation
|
|
1606
|
+
|
|
1607
|
+
3. **Document the contract**
|
|
1608
|
+
- JSDoc with `@example`
|
|
1609
|
+
- Note any requirements (must call `initX()` in `activate()`)
|
|
1610
|
+
- Explain what's client-only vs isomorphic
|
|
1611
|
+
|
|
1612
|
+
4. **Use consistent naming**
|
|
1613
|
+
- Classes: `jsgui-*` prefix for CSS
|
|
1614
|
+
- Types: `__type_name` follows existing patterns
|
|
1615
|
+
- Methods: match jsgui3 conventions (`add_class`, not `addClass`)
|
|
1616
|
+
|
|
1617
|
+
5. **Test isomorphically**
|
|
1618
|
+
- Check script should work (server-side render)
|
|
1619
|
+
- Manual test should work (client-side activation)
|
|
1620
|
+
- Document any client-only features
|
|
1621
|
+
|
|
1622
|
+
6. **Keep dependencies minimal**
|
|
1623
|
+
- No external libraries in extensions
|
|
1624
|
+
- If you need a utility, write it or reference jsgui3's internals
|
|
1625
|
+
|
|
1626
|
+
### Filing Upstream
|
|
1627
|
+
|
|
1628
|
+
When an extension is stable and useful:
|
|
1629
|
+
|
|
1630
|
+
1. Open issue in jsgui3 repo describing the use case
|
|
1631
|
+
2. Reference this repo's implementation
|
|
1632
|
+
3. Propose API changes if the extension reveals jsgui3 gaps
|
|
1633
|
+
4. Be prepared to adapt to jsgui3's code style
|
|
1634
|
+
|
|
1635
|
+
---
|
|
1636
|
+
|
|
1637
|
+
## Dashboard Server Performance Patterns
|
|
1638
|
+
|
|
1639
|
+
Dashboards often need to query databases or aggregate data. Poor patterns here cause "waiting for ages" load times.
|
|
1640
|
+
|
|
1641
|
+
### ✅ 1. Cache Expensive Queries
|
|
1642
|
+
|
|
1643
|
+
**Problem**: Listing databases requires scanning directories and opening each DB to check tables - slow for many DBs.
|
|
1644
|
+
|
|
1645
|
+
**Solution**: Cache the result with a TTL (Time To Live).
|
|
1646
|
+
|
|
1647
|
+
```javascript
|
|
1648
|
+
// Server-side caching pattern
|
|
1649
|
+
let databaseListCache = { data: null, timestamp: 0 };
|
|
1650
|
+
const DATABASE_CACHE_TTL_MS = 30000; // 30 seconds
|
|
1651
|
+
|
|
1652
|
+
async function listDatabases() {
|
|
1653
|
+
const now = Date.now();
|
|
1654
|
+
|
|
1655
|
+
// Return cached data if still valid
|
|
1656
|
+
if (databaseListCache.data && (now - databaseListCache.timestamp) < DATABASE_CACHE_TTL_MS) {
|
|
1657
|
+
return databaseListCache.data;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Expensive operation: scan directory, check each DB
|
|
1661
|
+
const dataDir = path.resolve(__dirname, "../../../data");
|
|
1662
|
+
const files = fs.readdirSync(dataDir).filter(f => f.endsWith(".db"));
|
|
1663
|
+
|
|
1664
|
+
const databases = [];
|
|
1665
|
+
for (const file of files) {
|
|
1666
|
+
const info = await getBasicDbInfo(path.join(dataDir, file));
|
|
1667
|
+
databases.push(info);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Update cache
|
|
1671
|
+
databaseListCache = { data: databases, timestamp: now };
|
|
1672
|
+
return databases;
|
|
1673
|
+
}
|
|
1674
|
+
```
|
|
1675
|
+
|
|
1676
|
+
**API Enhancement**: Add `?refresh=true` to force cache refresh:
|
|
1677
|
+
|
|
1678
|
+
```javascript
|
|
1679
|
+
app.get("/api/databases", async (req, res) => {
|
|
1680
|
+
if (req.query.refresh === "true") {
|
|
1681
|
+
databaseListCache = { data: null, timestamp: 0 }; // Invalidate
|
|
1682
|
+
}
|
|
1683
|
+
const databases = await listDatabases();
|
|
1684
|
+
res.json(databases);
|
|
1685
|
+
});
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
### ✅ 2. Optimize Database Info Queries
|
|
1689
|
+
|
|
1690
|
+
**Problem**: Counting all rows in large tables (millions of rows) is slow.
|
|
1691
|
+
|
|
1692
|
+
**Solution**: Use existence checks (`LIMIT 1`) and approximate counts for large DBs.
|
|
1693
|
+
|
|
1694
|
+
```javascript
|
|
1695
|
+
async function getBasicDbInfo(dbPath) {
|
|
1696
|
+
const stats = fs.statSync(dbPath);
|
|
1697
|
+
const sizeBytes = stats.size;
|
|
1698
|
+
|
|
1699
|
+
// For large DBs (>100MB), use approximate count
|
|
1700
|
+
const isLarge = sizeBytes > 100 * 1024 * 1024;
|
|
1701
|
+
|
|
1702
|
+
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY);
|
|
1703
|
+
|
|
1704
|
+
try {
|
|
1705
|
+
// Quick existence check - faster than COUNT(*)
|
|
1706
|
+
const hasGazetteerTables = await new Promise((resolve) => {
|
|
1707
|
+
db.get(
|
|
1708
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='places' LIMIT 1",
|
|
1709
|
+
(err, row) => resolve(!err && !!row)
|
|
1710
|
+
);
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
let placeCount = 0;
|
|
1714
|
+
if (hasGazetteerTables) {
|
|
1715
|
+
if (isLarge) {
|
|
1716
|
+
// Approximate count for large DBs - instant
|
|
1717
|
+
const approx = await dbGet(db,
|
|
1718
|
+
"SELECT MAX(rowid) as approx FROM places");
|
|
1719
|
+
placeCount = approx?.approx || 0;
|
|
1720
|
+
} else {
|
|
1721
|
+
// Exact count for small DBs
|
|
1722
|
+
const exact = await dbGet(db, "SELECT COUNT(*) as cnt FROM places");
|
|
1723
|
+
placeCount = exact?.cnt || 0;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
return {
|
|
1728
|
+
name: path.basename(dbPath),
|
|
1729
|
+
path: dbPath,
|
|
1730
|
+
sizeBytes,
|
|
1731
|
+
hasGazetteerTables,
|
|
1732
|
+
placeCount,
|
|
1733
|
+
isApproximate: isLarge
|
|
1734
|
+
};
|
|
1735
|
+
} finally {
|
|
1736
|
+
db.close();
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
### ✅ 3. Timeout Guards for DB Operations
|
|
1742
|
+
|
|
1743
|
+
**Problem**: Corrupt or locked DBs can hang queries indefinitely.
|
|
1744
|
+
|
|
1745
|
+
**Solution**: Wrap in Promise.race with timeout.
|
|
1746
|
+
|
|
1747
|
+
```javascript
|
|
1748
|
+
async function safeDbQuery(db, sql, timeout = 5000) {
|
|
1749
|
+
return Promise.race([
|
|
1750
|
+
new Promise((resolve, reject) => {
|
|
1751
|
+
db.get(sql, (err, row) => err ? reject(err) : resolve(row));
|
|
1752
|
+
}),
|
|
1753
|
+
new Promise((_, reject) =>
|
|
1754
|
+
setTimeout(() => reject(new Error("Query timeout")), timeout)
|
|
1755
|
+
)
|
|
1756
|
+
]);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Usage
|
|
1760
|
+
try {
|
|
1761
|
+
const result = await safeDbQuery(db, "SELECT COUNT(*) FROM large_table", 3000);
|
|
1762
|
+
} catch (err) {
|
|
1763
|
+
if (err.message === "Query timeout") {
|
|
1764
|
+
return { count: "unknown", timedOut: true };
|
|
1765
|
+
}
|
|
1766
|
+
throw err;
|
|
1767
|
+
}
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
### ✅ 4. Invalidate Cache on Mutations
|
|
1771
|
+
|
|
1772
|
+
When creating/deleting databases, invalidate the cache:
|
|
1773
|
+
|
|
1774
|
+
```javascript
|
|
1775
|
+
app.post("/api/databases", async (req, res) => {
|
|
1776
|
+
// ... create database logic ...
|
|
1777
|
+
|
|
1778
|
+
// Invalidate cache so next list reflects new DB
|
|
1779
|
+
databaseListCache = { data: null, timestamp: 0 };
|
|
1780
|
+
|
|
1781
|
+
res.json({ success: true, database: newDb });
|
|
1782
|
+
});
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
### ✅ 5. Show All Items, Style Differently
|
|
1786
|
+
|
|
1787
|
+
**Problem**: Users may want to add features to items that don't have them yet (e.g., add gazetteer tables to a non-gazetteer database).
|
|
1788
|
+
|
|
1789
|
+
**Solution**: Show all items, but style non-applicable ones differently.
|
|
1790
|
+
|
|
1791
|
+
```javascript
|
|
1792
|
+
// In DatabaseItem control
|
|
1793
|
+
compose() {
|
|
1794
|
+
const hasGazetteer = this.database.hasGazetteerTables;
|
|
1795
|
+
|
|
1796
|
+
if (!hasGazetteer) {
|
|
1797
|
+
this.add_class("non-gazetteer"); // Grayed out
|
|
1798
|
+
|
|
1799
|
+
// Add badge
|
|
1800
|
+
const badge = new jsgui.Control({ context: this.context, tagName: "span" });
|
|
1801
|
+
badge.add_class("init-gazetteer-badge");
|
|
1802
|
+
badge.add("📦 No Gazetteer");
|
|
1803
|
+
this.add(badge);
|
|
1804
|
+
|
|
1805
|
+
// Add action button
|
|
1806
|
+
const initBtn = new jsgui.Control({ context: this.context, tagName: "button" });
|
|
1807
|
+
initBtn.add_class("init-gazetteer-btn");
|
|
1808
|
+
initBtn.add("Init Gazetteer");
|
|
1809
|
+
this.add(initBtn);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
```
|
|
1813
|
+
|
|
1814
|
+
**CSS**:
|
|
1815
|
+
```css
|
|
1816
|
+
.non-gazetteer {
|
|
1817
|
+
opacity: 0.7;
|
|
1818
|
+
filter: grayscale(30%);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
.init-gazetteer-badge {
|
|
1822
|
+
background: linear-gradient(135deg, #1e40af, #3b82f6);
|
|
1823
|
+
color: white;
|
|
1824
|
+
padding: 2px 8px;
|
|
1825
|
+
border-radius: 4px;
|
|
1826
|
+
font-size: 0.75rem;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
.init-gazetteer-btn {
|
|
1830
|
+
background: var(--theme-surface, #2d2d2d);
|
|
1831
|
+
color: var(--theme-text-secondary, #888);
|
|
1832
|
+
border: 1px solid var(--theme-border, #3d3d3d);
|
|
1833
|
+
padding: 4px 12px;
|
|
1834
|
+
border-radius: 4px;
|
|
1835
|
+
cursor: pointer;
|
|
1836
|
+
}
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
---
|
|
1840
|
+
|
|
1841
|
+
## Anti-Patterns to Avoid
|
|
1842
|
+
|
|
1843
|
+
### ❌ 1. Inline Compositions in App Controls
|
|
1844
|
+
|
|
1845
|
+
**Bad**: Building complex structures inline in `_build*` methods.
|
|
1846
|
+
|
|
1847
|
+
```javascript
|
|
1848
|
+
// ❌ Avoid: 50+ line inline composition
|
|
1849
|
+
_buildToolbar() {
|
|
1850
|
+
const toolbar = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
1851
|
+
toolbar.add_class("toolbar");
|
|
1852
|
+
|
|
1853
|
+
const statusCard = new jsgui.Control({ context: this.context, tagName: "div" });
|
|
1854
|
+
statusCard.add_class("toolbar__status");
|
|
1855
|
+
// ... 40 more lines of inline building
|
|
1856
|
+
|
|
1857
|
+
return toolbar;
|
|
1858
|
+
}
|
|
1859
|
+
```
|
|
1860
|
+
|
|
1861
|
+
**Good**: Extract to a dedicated control.
|
|
1862
|
+
|
|
1863
|
+
```javascript
|
|
1864
|
+
// ✅ Better: Use a dedicated control
|
|
1865
|
+
_buildToolbar() {
|
|
1866
|
+
return new ToolbarControl({
|
|
1867
|
+
context: this.context,
|
|
1868
|
+
status: this.status,
|
|
1869
|
+
timestamp: this.timestamp
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
```
|
|
1873
|
+
|
|
1874
|
+
### ❌ 2. Calling compose() in Base Class Constructor
|
|
1875
|
+
|
|
1876
|
+
**Bad**: Base class calls `compose()`, breaking child property initialization.
|
|
1877
|
+
|
|
1878
|
+
```javascript
|
|
1879
|
+
// ❌ Avoid
|
|
1880
|
+
class BaseControl extends jsgui.Control {
|
|
1881
|
+
constructor(spec) {
|
|
1882
|
+
super(spec);
|
|
1883
|
+
this.compose(); // Child properties not set yet!
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
```
|
|
1887
|
+
|
|
1888
|
+
**Good**: Let child classes call `compose()` after setting their properties.
|
|
1889
|
+
|
|
1890
|
+
```javascript
|
|
1891
|
+
// ✅ Better
|
|
1892
|
+
class BaseControl extends jsgui.Control {
|
|
1893
|
+
constructor(spec) {
|
|
1894
|
+
super(spec);
|
|
1895
|
+
// Don't compose here
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
class ChildControl extends BaseControl {
|
|
1900
|
+
constructor(spec) {
|
|
1901
|
+
super(spec);
|
|
1902
|
+
this.myProp = spec.myProp; // Set first
|
|
1903
|
+
if (!spec.el) this.compose(); // Then compose
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
```
|
|
1907
|
+
|
|
1908
|
+
### ✅ 3. Raw Strings Work Fine
|
|
1909
|
+
|
|
1910
|
+
jsgui3 automatically handles raw strings - you don't need to wrap them:
|
|
1911
|
+
|
|
1912
|
+
```javascript
|
|
1913
|
+
// ✅ This works - jsgui3 auto-wraps strings
|
|
1914
|
+
element.add("Hello World"); // Renders correctly!
|
|
1915
|
+
|
|
1916
|
+
// ✅ Also fine - explicit StringControl
|
|
1917
|
+
element.add(new StringControl({ context: this.context, text: "Hello World" }));
|
|
1918
|
+
```
|
|
1919
|
+
|
|
1920
|
+
**Note**: Some older code in this repo uses explicit `String_Control` everywhere. That's fine but not required.
|
|
1921
|
+
|
|
1922
|
+
### ❌ 4. Forgetting to Pass Context
|
|
1923
|
+
|
|
1924
|
+
**Bad**: Creating child controls without context.
|
|
1925
|
+
|
|
1926
|
+
```javascript
|
|
1927
|
+
// ❌ Avoid
|
|
1928
|
+
const child = new MyChildControl({ data: this.data }); // Missing context!
|
|
1929
|
+
```
|
|
1930
|
+
|
|
1931
|
+
**Good**: Always pass `this.context` to child controls.
|
|
1932
|
+
|
|
1933
|
+
```javascript
|
|
1934
|
+
// ✅ Better
|
|
1935
|
+
const child = new MyChildControl({ context: this.context, data: this.data });
|
|
1936
|
+
```
|
|
1937
|
+
|
|
1938
|
+
### ❌ 5. God Controls
|
|
1939
|
+
|
|
1940
|
+
**Bad**: One massive control that handles everything.
|
|
1941
|
+
|
|
1942
|
+
```javascript
|
|
1943
|
+
// ❌ Avoid: 500+ line control with 15 different view types
|
|
1944
|
+
class EverythingControl extends jsgui.Control {
|
|
1945
|
+
compose() {
|
|
1946
|
+
// Hundreds of lines mixing unrelated concerns
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
**Good**: Small, focused controls composed together.
|
|
1952
|
+
|
|
1953
|
+
```javascript
|
|
1954
|
+
// ✅ Better: Focused controls
|
|
1955
|
+
// - HeaderControl (~50 lines)
|
|
1956
|
+
// - SearchFormControl (~40 lines)
|
|
1957
|
+
// - ResultsListControl (~60 lines)
|
|
1958
|
+
// - PaginationControl (~40 lines)
|
|
1959
|
+
// - AppControl composes them all (~80 lines)
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
### ❌ 6. Missing Check Scripts
|
|
1963
|
+
|
|
1964
|
+
**Bad**: No verification for new controls.
|
|
1965
|
+
|
|
1966
|
+
**Good**: Every control has a check script that verifies rendering.
|
|
1967
|
+
|
|
1968
|
+
### ❌ 7. Interactive Components as Plain JS/CSS (Context Menu Case Study)
|
|
1969
|
+
|
|
1970
|
+
**Bad**: Building interactive components (context menus, tooltips, modals) as plain JavaScript functions with CSS.
|
|
1971
|
+
|
|
1972
|
+
```javascript
|
|
1973
|
+
// ❌ Avoid: Context menu as plain JS
|
|
1974
|
+
function showColumnContextMenu(x, y) {
|
|
1975
|
+
const menu = document.querySelector("[data-context-menu='columns']");
|
|
1976
|
+
menu.style.display = "block";
|
|
1977
|
+
menu.style.left = x + "px";
|
|
1978
|
+
menu.style.top = y + "px";
|
|
1979
|
+
|
|
1980
|
+
// 40 more lines of positioning, event handling, keyboard nav...
|
|
1981
|
+
document.addEventListener("click", closeOnClickOutside);
|
|
1982
|
+
document.addEventListener("keydown", closeOnEscape);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function hideColumnContextMenu() {
|
|
1986
|
+
const menu = document.querySelector("[data-context-menu='columns']");
|
|
1987
|
+
menu.style.display = "none";
|
|
1988
|
+
}
|
|
1989
|
+
```
|
|
1990
|
+
|
|
1991
|
+
**Why this is wrong**:
|
|
1992
|
+
- Not testable with check scripts
|
|
1993
|
+
- Event handlers scattered across global scope
|
|
1994
|
+
- Can't reuse for other context menus (row actions, toolbar options)
|
|
1995
|
+
- No clear ownership of state (open/closed)
|
|
1996
|
+
- `js-scan` can't track dependencies
|
|
1997
|
+
|
|
1998
|
+
**Good**: Extract to a dedicated `ContextMenuControl`.
|
|
1999
|
+
|
|
2000
|
+
```javascript
|
|
2001
|
+
// ✅ Better: Dedicated control class
|
|
2002
|
+
// src/ui/controls/ContextMenuControl.js
|
|
2003
|
+
|
|
2004
|
+
class ContextMenuControl extends jsgui.Control {
|
|
2005
|
+
constructor(spec = {}) {
|
|
2006
|
+
super({ ...spec, tagName: "div", __type_name: "context_menu" });
|
|
2007
|
+
this.add_class("context-menu");
|
|
2008
|
+
this.items = spec.items || [];
|
|
2009
|
+
this.onSelect = spec.onSelect || (() => {});
|
|
2010
|
+
if (!spec.el) this.compose();
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
compose() {
|
|
2014
|
+
for (const item of this.items) {
|
|
2015
|
+
const menuItem = new ContextMenuItemControl({
|
|
2016
|
+
context: this.context,
|
|
2017
|
+
label: item.label,
|
|
2018
|
+
icon: item.icon, // 🔍 for search, ⚙️ for settings, etc.
|
|
2019
|
+
checked: item.checked,
|
|
2020
|
+
value: item.value
|
|
2021
|
+
});
|
|
2022
|
+
this.add(menuItem);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
activate() {
|
|
2027
|
+
if (this.__active) return;
|
|
2028
|
+
this.__active = true;
|
|
2029
|
+
this._bindClickOutside();
|
|
2030
|
+
this._bindKeyboardNav();
|
|
2031
|
+
this._bindItemSelection();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
show(x, y) {
|
|
2035
|
+
const el = this.dom?.el;
|
|
2036
|
+
if (!el) return;
|
|
2037
|
+
el.style.display = "block";
|
|
2038
|
+
this._positionAt(x, y);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
hide() {
|
|
2042
|
+
const el = this.dom?.el;
|
|
2043
|
+
if (el) el.style.display = "none";
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
**Benefits of extraction**:
|
|
2049
|
+
- ✅ Testable: Check script verifies menu renders with correct items
|
|
2050
|
+
- ✅ Reusable: Same control for column options, row actions, any dropdown
|
|
2051
|
+
- ✅ Maintainable: Event handlers live with the component
|
|
2052
|
+
- ✅ Discoverable: `js-scan --what-imports ContextMenuControl.js` shows all usages
|
|
2053
|
+
- ✅ Type-safe: Props documented in JSDoc
|
|
2054
|
+
|
|
2055
|
+
### ❌ 8. Missing Visual Affordances (No Icons)
|
|
2056
|
+
|
|
2057
|
+
**Bad**: Interactive elements with text-only labels that users can't quickly scan.
|
|
2058
|
+
|
|
2059
|
+
```javascript
|
|
2060
|
+
// ❌ Avoid: No visual cue for action type
|
|
2061
|
+
const button = new jsgui.Control({ context: this.context, tagName: "button" });
|
|
2062
|
+
button.add("Options"); // User must read to understand
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
**Good**: Use emoji icons for instant visual recognition.
|
|
2066
|
+
|
|
2067
|
+
```javascript
|
|
2068
|
+
// ✅ Better: Emoji provides instant recognition
|
|
2069
|
+
const button = new jsgui.Control({ context: this.context, tagName: "button" });
|
|
2070
|
+
button.add("⚙️ Options"); // Gear = settings, instantly clear
|
|
2071
|
+
|
|
2072
|
+
// Or with dedicated icon element
|
|
2073
|
+
const icon = new jsgui.Control({ context: this.context, tagName: "span" });
|
|
2074
|
+
icon.add_class("btn__icon");
|
|
2075
|
+
icon.add("🔍");
|
|
2076
|
+
button.add(icon);
|
|
2077
|
+
button.add(" Search");
|
|
2078
|
+
```
|
|
2079
|
+
|
|
2080
|
+
**Standard emoji vocabulary for UI**:
|
|
2081
|
+
|
|
2082
|
+
| Action | Emoji | Notes |
|
|
2083
|
+
|--------|-------|-------|
|
|
2084
|
+
| Search | 🔍 | Magnifying glass - universal |
|
|
2085
|
+
| Settings/Options | ⚙️ | Gear - configuration |
|
|
2086
|
+
| Add/Create | ➕ | Plus sign |
|
|
2087
|
+
| Delete | 🗑️ | Trash can |
|
|
2088
|
+
| Edit | ✏️ | Pencil |
|
|
2089
|
+
| Refresh | 🔄 | Circular arrows |
|
|
2090
|
+
| Sort asc | ▲ | Triangle up |
|
|
2091
|
+
| Sort desc | ▼ | Triangle down |
|
|
2092
|
+
| Menu | ☰ | Hamburger |
|
|
2093
|
+
| More options | ⋮ | Kebab (vertical dots) |
|
|
2094
|
+
| Close | ✕ | X mark |
|
|
2095
|
+
| Success | ✅ | Check mark |
|
|
2096
|
+
| Error | ❌ | X in circle |
|
|
2097
|
+
| Warning | ⚠️ | Triangle alert |
|
|
2098
|
+
| Info | ℹ️ | Information |
|
|
2099
|
+
| Folder | 📁 | Directory |
|
|
2100
|
+
| File | 📄 | Document |
|
|
2101
|
+
|
|
2102
|
+
### ❌ 9. Shadowing Reserved Property Names (CRITICAL: `this.content`)
|
|
2103
|
+
|
|
2104
|
+
**⚠️ CRITICAL BUG**: Never use `this.content` as a property name in jsgui3 controls!
|
|
2105
|
+
|
|
2106
|
+
**Bad**: Using `content` as a property name shadows the base class Collection.
|
|
2107
|
+
|
|
2108
|
+
```javascript
|
|
2109
|
+
// ❌ CRITICAL BUG - DO NOT DO THIS
|
|
2110
|
+
class TwoColumnLayout extends jsgui.Control {
|
|
2111
|
+
constructor(spec) {
|
|
2112
|
+
super(spec);
|
|
2113
|
+
this.sidebar = null;
|
|
2114
|
+
this.content = null; // 💥 SHADOWS jsgui.Control.content (a Collection)!
|
|
2115
|
+
if (!spec.el) this.compose();
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
compose() {
|
|
2119
|
+
// This will CRASH because this.add() tries to use this.content
|
|
2120
|
+
// which is now null instead of the Collection!
|
|
2121
|
+
const container = new jsgui.Control({ ... });
|
|
2122
|
+
this.add(container); // 💥 TypeError: Cannot read property 'push' of null
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
**Why this breaks**: In jsgui3, `Control.content` is a **Collection** that holds child controls. When you call `this.add(child)`, it internally calls `this.content.push(child)`. If you shadow `this.content` with `null` or any non-Collection value, `this.add()` crashes.
|
|
2128
|
+
|
|
2129
|
+
**Good**: Use a different property name.
|
|
2130
|
+
|
|
2131
|
+
```javascript
|
|
2132
|
+
// ✅ CORRECT - use a unique property name
|
|
2133
|
+
class TwoColumnLayout extends jsgui.Control {
|
|
2134
|
+
constructor(spec) {
|
|
2135
|
+
super(spec);
|
|
2136
|
+
this.sidebar = null;
|
|
2137
|
+
this.contentArea = null; // ✅ Unique name, doesn't shadow base class
|
|
2138
|
+
if (!spec.el) this.compose();
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
compose() {
|
|
2142
|
+
const container = new jsgui.Control({ ... });
|
|
2143
|
+
this.add(container); // ✅ Works - this.content is still the Collection
|
|
2144
|
+
|
|
2145
|
+
this.contentArea = new jsgui.Control({ context: this.context, tagName: 'div' });
|
|
2146
|
+
this.contentArea.add_class('content-area');
|
|
2147
|
+
container.add(this.contentArea);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
addContent(control) {
|
|
2151
|
+
if (this.contentArea) {
|
|
2152
|
+
this.contentArea.add(control); // ✅ Uses our custom property
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
```
|
|
2157
|
+
|
|
2158
|
+
**Reserved property names to avoid**:
|
|
2159
|
+
- `content` - The Collection holding child controls
|
|
2160
|
+
- `context` - The jsgui context object
|
|
2161
|
+
- `dom` - DOM element references
|
|
2162
|
+
- `__type_name` - Internal type identifier
|
|
2163
|
+
|
|
2164
|
+
**How to spot this bug**: If you see errors like:
|
|
2165
|
+
- `TypeError: Cannot read property 'push' of null`
|
|
2166
|
+
- `TypeError: this.content.push is not a function`
|
|
2167
|
+
- Controls don't render any children despite calling `this.add()`
|
|
2168
|
+
|
|
2169
|
+
Check if you've accidentally shadowed `this.content` in your constructor.
|
|
2170
|
+
|
|
2171
|
+
### ❌ 10. Direct DOM Manipulation Instead of jsgui3 Methods
|
|
2172
|
+
|
|
2173
|
+
**Bad**: Using `classList.add/remove` or `this.dom.el.style` directly when jsgui3 methods exist.
|
|
2174
|
+
|
|
2175
|
+
```javascript
|
|
2176
|
+
// ❌ Avoid: Direct DOM manipulation
|
|
2177
|
+
setVisible(visible) {
|
|
2178
|
+
if (this.dom.el) {
|
|
2179
|
+
if (visible) {
|
|
2180
|
+
this.dom.el.classList.remove("hidden");
|
|
2181
|
+
} else {
|
|
2182
|
+
this.dom.el.classList.add("hidden");
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
**Good**: Use jsgui3's `add_class` and `remove_class` methods - they work whether the control is in the DOM or not.
|
|
2189
|
+
|
|
2190
|
+
```javascript
|
|
2191
|
+
// ✅ Correct: Use jsgui3 methods
|
|
2192
|
+
setVisible(visible) {
|
|
2193
|
+
if (visible) {
|
|
2194
|
+
this.remove_class("hidden");
|
|
2195
|
+
} else {
|
|
2196
|
+
this.add_class("hidden");
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
```
|
|
2200
|
+
|
|
2201
|
+
**Why jsgui3 methods are better**:
|
|
2202
|
+
- **Work pre-render**: Classes are tracked internally and applied when HTML is generated
|
|
2203
|
+
- **Work post-render**: If `this.dom.el` exists, jsgui3 syncs to the DOM automatically
|
|
2204
|
+
- **Consistent API**: Same code works on server and client
|
|
2205
|
+
- **Maintainable**: State stays in the control, not scattered in DOM
|
|
2206
|
+
|
|
2207
|
+
**When direct DOM manipulation IS appropriate**:
|
|
2208
|
+
- **Client-side activation**: Setting up event listeners in `activate()`
|
|
2209
|
+
- **Dynamic innerHTML updates**: When replacing content after initial render (see [Dynamic Control Updates](#dynamic-control-updates))
|
|
2210
|
+
- **Scroll position**: `this.dom.el.scrollTop = this.dom.el.scrollHeight`
|
|
2211
|
+
- **Focus management**: `this.dom.el.focus()`
|
|
2212
|
+
- **Measurements**: Reading `offsetWidth`, `getBoundingClientRect()`, etc.
|
|
2213
|
+
|
|
2214
|
+
**If jsgui3 methods don't work as expected** (e.g., `add_class` doesn't update the DOM), **report it as a bug** in jsgui3. Don't work around it with direct DOM manipulation unless absolutely necessary, and document the workaround with a `// WORKAROUND: jsgui3 bug - <description>` comment.
|
|
2215
|
+
|
|
2216
|
+
---
|
|
2217
|
+
|
|
2218
|
+
## Quick Reference
|
|
2219
|
+
|
|
2220
|
+
### Creating a New Control
|
|
2221
|
+
|
|
2222
|
+
```javascript
|
|
2223
|
+
"use strict";
|
|
2224
|
+
|
|
2225
|
+
const jsgui = require("jsgui3-html");
|
|
2226
|
+
const StringControl = jsgui.String_Control;
|
|
2227
|
+
|
|
2228
|
+
class MyControl extends jsgui.Control {
|
|
2229
|
+
constructor(spec = {}) {
|
|
2230
|
+
super({
|
|
2231
|
+
...spec,
|
|
2232
|
+
tagName: "div",
|
|
2233
|
+
__type_name: "my_control"
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
this.add_class("my-control");
|
|
2237
|
+
|
|
2238
|
+
// 1. Store state from spec
|
|
2239
|
+
this.title = spec.title || "Default";
|
|
2240
|
+
|
|
2241
|
+
// 2. Compose after state is set
|
|
2242
|
+
if (!spec.el) {
|
|
2243
|
+
this.compose();
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
compose() {
|
|
2248
|
+
// 3. Build child elements
|
|
2249
|
+
const heading = new jsgui.Control({ context: this.context, tagName: "h2" });
|
|
2250
|
+
heading.add(new StringControl({ context: this.context, text: this.title }));
|
|
2251
|
+
this.add(heading);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
module.exports = { MyControl };
|
|
2256
|
+
```
|
|
2257
|
+
|
|
2258
|
+
### Rendering to HTML
|
|
2259
|
+
|
|
2260
|
+
```javascript
|
|
2261
|
+
const jsgui = require("jsgui3-html");
|
|
2262
|
+
const { MyControl } = require("./MyControl");
|
|
2263
|
+
|
|
2264
|
+
const context = new jsgui.Page_Context();
|
|
2265
|
+
const control = new MyControl({ context, title: "Hello" });
|
|
2266
|
+
const html = control.all_html_render();
|
|
2267
|
+
// → '<div class="my-control"><h2>Hello</h2></div>'
|
|
2268
|
+
```
|
|
2269
|
+
|
|
2270
|
+
### Common Element Patterns
|
|
2271
|
+
|
|
2272
|
+
```javascript
|
|
2273
|
+
// Link
|
|
2274
|
+
const link = new jsgui.Control({ context: this.context, tagName: "a" });
|
|
2275
|
+
link.dom.attributes.href = "/path";
|
|
2276
|
+
link.add(new StringControl({ context: this.context, text: "Click me" }));
|
|
2277
|
+
|
|
2278
|
+
// Button
|
|
2279
|
+
const btn = new jsgui.Control({ context: this.context, tagName: "button" });
|
|
2280
|
+
btn.dom.attributes.type = "submit";
|
|
2281
|
+
btn.add_class("btn");
|
|
2282
|
+
btn.add(new StringControl({ context: this.context, text: "Submit" }));
|
|
2283
|
+
|
|
2284
|
+
// Input
|
|
2285
|
+
const input = new jsgui.Control({ context: this.context, tagName: "input" });
|
|
2286
|
+
input.dom.attributes.type = "text";
|
|
2287
|
+
input.dom.attributes.name = "query";
|
|
2288
|
+
input.dom.attributes.placeholder = "Search...";
|
|
2289
|
+
input.dom.attributes.value = this.value;
|
|
2290
|
+
|
|
2291
|
+
// Image
|
|
2292
|
+
const img = new jsgui.Control({ context: this.context, tagName: "img" });
|
|
2293
|
+
img.dom.attributes.src = "/image.png";
|
|
2294
|
+
img.dom.attributes.alt = "Description";
|
|
2295
|
+
```
|
|
2296
|
+
|
|
2297
|
+
### BEM Naming Convention
|
|
2298
|
+
|
|
2299
|
+
```javascript
|
|
2300
|
+
// Block
|
|
2301
|
+
control.add_class("toolbar");
|
|
2302
|
+
|
|
2303
|
+
// Element
|
|
2304
|
+
control.add_class("toolbar__button");
|
|
2305
|
+
control.add_class("toolbar__status");
|
|
2306
|
+
|
|
2307
|
+
// Modifier
|
|
2308
|
+
control.add_class("toolbar--compact");
|
|
2309
|
+
control.add_class("toolbar__button--active");
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
---
|
|
2313
|
+
|
|
2314
|
+
## Summary
|
|
2315
|
+
|
|
2316
|
+
1. **jsgui3-html is isomorphic** - same code works on server (SSR) and client (bundled)
|
|
2317
|
+
2. **jsgui3-client extends jsgui3-html** - use it only for client-specific features like `activate()`
|
|
2318
|
+
3. **Controls are classes** that extend `jsgui.Control` and build DOM programmatically
|
|
2319
|
+
4. **Context flows down** - always pass `context` to child controls
|
|
2320
|
+
5. **Text auto-wraps** - `.add("text")` works directly, `String_Control` is optional
|
|
2321
|
+
6. **Compose timing matters** - set properties before calling `compose()`
|
|
2322
|
+
7. **Activation via `spec.el`** - pass existing DOM element to skip `compose()` and bind events (aka "hydration" in other frameworks)
|
|
2323
|
+
8. **Extract reusable pieces** - prefer small, focused controls over inline compositions
|
|
2324
|
+
9. **Verify with check scripts** - every control should have a verification script
|
|
2325
|
+
10. **Use BEM naming** - consistent CSS class naming improves maintainability
|
|
2326
|
+
|
|
2327
|
+
---
|
|
2328
|
+
|
|
2329
|
+
## Client-Side Activation Flow (CRITICAL)
|
|
2330
|
+
|
|
2331
|
+
> ⚠️ **This section documents hard-won knowledge from debugging jsgui3 activation issues. Future agents: READ THIS BEFORE working on client-side jsgui3 code.**
|
|
2332
|
+
|
|
2333
|
+
### The Problem
|
|
2334
|
+
|
|
2335
|
+
When rendering jsgui3 controls on the client side (e.g., in Electron or browser), calling `app.activate()` after `innerHTML = html` often **fails silently**. Event handlers don't bind, and `this.dom.el` is `null` in controls.
|
|
2336
|
+
|
|
2337
|
+
### Root Cause
|
|
2338
|
+
|
|
2339
|
+
jsgui3 requires a specific activation sequence:
|
|
2340
|
+
|
|
2341
|
+
1. Controls must be **registered** in `context.map_controls`
|
|
2342
|
+
2. Control instances must be **linked** to their DOM elements (`this.dom.el`)
|
|
2343
|
+
3. Only then will `activate()` properly bind events
|
|
2344
|
+
|
|
2345
|
+
The `all_html_render()` method generates HTML with `data-jsgui-id` attributes, but it does NOT automatically link controls to DOM elements.
|
|
2346
|
+
|
|
2347
|
+
### The Solution: Proper Activation Sequence
|
|
2348
|
+
|
|
2349
|
+
```javascript
|
|
2350
|
+
// ❌ WRONG - This will NOT work properly
|
|
2351
|
+
const html = app.all_html_render();
|
|
2352
|
+
rootEl.innerHTML = html;
|
|
2353
|
+
app.activate(); // DOM refs are null!
|
|
2354
|
+
|
|
2355
|
+
// ✅ CORRECT - Proper activation sequence
|
|
2356
|
+
const html = app.all_html_render();
|
|
2357
|
+
rootEl.innerHTML = html;
|
|
2358
|
+
|
|
2359
|
+
// Step 1: Register ALL controls in context.map_controls
|
|
2360
|
+
app.register_this_and_subcontrols();
|
|
2361
|
+
|
|
2362
|
+
// Step 2: Find and link the root control's DOM element
|
|
2363
|
+
const appEl = rootEl.querySelector('[data-jsgui-id="' + app._id() + '"]');
|
|
2364
|
+
app.dom.el = appEl;
|
|
2365
|
+
|
|
2366
|
+
// Step 3: Recursively link ALL child controls to their DOM elements
|
|
2367
|
+
app.rec_desc_ensure_ctrl_el_refs(appEl);
|
|
2368
|
+
|
|
2369
|
+
// Step 4: NOW activate (event binding will work)
|
|
2370
|
+
app.activate();
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
### Key Methods Explained
|
|
2374
|
+
|
|
2375
|
+
| Method | What it does | Why it's needed |
|
|
2376
|
+
|--------|-------------|------------------|
|
|
2377
|
+
| `register_this_and_subcontrols()` | Adds control + children to `context.map_controls` | Required for `activate()` to find controls |
|
|
2378
|
+
| `rec_desc_ensure_ctrl_el_refs(el)` | Recursively links `ctrl.dom.el` to DOM elements by `data-jsgui-id` | Without this, `this.dom.el` is null |
|
|
2379
|
+
| `_id()` | Returns the control's jsgui id (e.g., `"zserver_app_0"`) | Used to find DOM element |
|
|
2380
|
+
|
|
2381
|
+
### Dynamic Control Updates
|
|
2382
|
+
|
|
2383
|
+
When creating new controls after initial render (e.g., populating a list):
|
|
2384
|
+
|
|
2385
|
+
```javascript
|
|
2386
|
+
setItems(items) {
|
|
2387
|
+
this._items = items;
|
|
2388
|
+
|
|
2389
|
+
if (this.dom.el) {
|
|
2390
|
+
// Already rendered - must update DOM properly
|
|
2391
|
+
this.dom.el.innerHTML = '';
|
|
2392
|
+
|
|
2393
|
+
items.forEach(item => {
|
|
2394
|
+
const ctrl = new ItemControl({ context: this.context, item });
|
|
2395
|
+
|
|
2396
|
+
// Step 1: Register new control
|
|
2397
|
+
ctrl.register_this_and_subcontrols();
|
|
2398
|
+
|
|
2399
|
+
// Step 2: Render and insert HTML
|
|
2400
|
+
const itemHtml = ctrl.all_html_render();
|
|
2401
|
+
this.dom.el.insertAdjacentHTML('beforeend', itemHtml);
|
|
2402
|
+
|
|
2403
|
+
// Step 3: Find and link DOM element
|
|
2404
|
+
const itemEl = this.dom.el.querySelector('[data-jsgui-id="' + ctrl._id() + '"]');
|
|
2405
|
+
ctrl.dom.el = itemEl;
|
|
2406
|
+
this.context.map_els[ctrl._id()] = itemEl;
|
|
2407
|
+
|
|
2408
|
+
// Step 4: Link children
|
|
2409
|
+
if (ctrl.rec_desc_ensure_ctrl_el_refs) {
|
|
2410
|
+
ctrl.rec_desc_ensure_ctrl_el_refs(itemEl);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Step 5: Activate
|
|
2414
|
+
ctrl.activate();
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
```
|
|
2419
|
+
|
|
2420
|
+
### How jsgui3 Finds DOM Elements (Internal Detail)
|
|
2421
|
+
|
|
2422
|
+
Inside `activate()` and `pre_activate()`, jsgui3 tries to find DOM elements via:
|
|
2423
|
+
|
|
2424
|
+
```javascript
|
|
2425
|
+
let found_el = this.context.get_ctrl_el(this)
|
|
2426
|
+
|| this.context.map_els[this._id()]
|
|
2427
|
+
|| document.querySelectorAll('[data-jsgui-id="' + this._id() + '"]')[0];
|
|
2428
|
+
```
|
|
2429
|
+
|
|
2430
|
+
The DOM query fallback exists but is unreliable for nested controls. Always use `rec_desc_ensure_ctrl_el_refs()` for proper initialization.
|
|
2431
|
+
|
|
2432
|
+
### Complete Working Example (Electron App)
|
|
2433
|
+
|
|
2434
|
+
```javascript
|
|
2435
|
+
// renderer.src.js - Electron renderer process entry
|
|
2436
|
+
"use strict";
|
|
2437
|
+
|
|
2438
|
+
// Fix for jsgui3-client bug
|
|
2439
|
+
if (typeof window !== 'undefined') {
|
|
2440
|
+
window.page_context = null;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
const jsgui = require("jsgui3-client");
|
|
2444
|
+
const { MyAppControl } = require("./controls");
|
|
2445
|
+
|
|
2446
|
+
document.addEventListener("DOMContentLoaded", async () => {
|
|
2447
|
+
const context = new jsgui.Client_Page_Context();
|
|
2448
|
+
|
|
2449
|
+
const app = new MyAppControl({
|
|
2450
|
+
context,
|
|
2451
|
+
api: window.electronAPI
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
const rootEl = document.getElementById("app-root");
|
|
2455
|
+
const html = app.all_html_render();
|
|
2456
|
+
rootEl.innerHTML = html;
|
|
2457
|
+
|
|
2458
|
+
// CRITICAL: Proper activation sequence
|
|
2459
|
+
app.register_this_and_subcontrols();
|
|
2460
|
+
console.log("Controls registered:", Object.keys(context.map_controls).length);
|
|
2461
|
+
|
|
2462
|
+
const appEl = rootEl.querySelector('[data-jsgui-id="' + app._id() + '"]');
|
|
2463
|
+
if (appEl) {
|
|
2464
|
+
app.dom.el = appEl;
|
|
2465
|
+
app.rec_desc_ensure_ctrl_el_refs(appEl);
|
|
2466
|
+
console.log("DOM elements linked:", Object.keys(context.map_els).length);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
app.activate();
|
|
2470
|
+
await app.init();
|
|
2471
|
+
});
|
|
2472
|
+
```
|
|
2473
|
+
|
|
2474
|
+
### Debugging Checklist
|
|
2475
|
+
|
|
2476
|
+
If controls aren't working after render:
|
|
2477
|
+
|
|
2478
|
+
- [ ] Did you call `register_this_and_subcontrols()` before `activate()`?
|
|
2479
|
+
- [ ] Did you link the root element: `app.dom.el = appEl`?
|
|
2480
|
+
- [ ] Did you call `rec_desc_ensure_ctrl_el_refs(appEl)`?
|
|
2481
|
+
- [ ] Check `console.log(Object.keys(context.map_controls).length)` - should match control count
|
|
2482
|
+
- [ ] Check `console.log(Object.keys(context.map_els).length)` - should be close to control count
|
|
2483
|
+
- [ ] In your control's method, check `console.log("DOM el:", this.dom.el)` - should NOT be null
|
|
2484
|
+
|
|
2485
|
+
---
|
|
2486
|
+
|
|
2487
|
+
## Troubleshooting
|
|
2488
|
+
|
|
2489
|
+
### Known Issues
|
|
2490
|
+
|
|
2491
|
+
#### Controls don't respond to clicks / `this.dom.el` is null
|
|
2492
|
+
|
|
2493
|
+
**Symptom**: Controls render visually but event handlers don't fire. `this.dom.el` is `null` or `undefined` inside control methods.
|
|
2494
|
+
|
|
2495
|
+
**Cause**: Missing activation sequence steps. The `all_html_render()` method generates HTML but does NOT link Control instances to DOM elements.
|
|
2496
|
+
|
|
2497
|
+
**Solution**: Follow the complete activation sequence in the [Client-Side Activation Flow](#client-side-activation-flow-critical) section above.
|
|
2498
|
+
|
|
2499
|
+
```javascript
|
|
2500
|
+
// ALWAYS do all 4 steps:
|
|
2501
|
+
app.register_this_and_subcontrols(); // 1. Register
|
|
2502
|
+
app.dom.el = rootEl.querySelector('[data-jsgui-id="' + app._id() + '"]'); // 2. Link root
|
|
2503
|
+
app.rec_desc_ensure_ctrl_el_refs(app.dom.el); // 3. Link children
|
|
2504
|
+
app.activate(); // 4. Activate
|
|
2505
|
+
```
|
|
2506
|
+
|
|
2507
|
+
#### jsgui3-client: `page_context` ReferenceError
|
|
2508
|
+
|
|
2509
|
+
**Symptom**: `ReferenceError: page_context is not defined` during client-side activation.
|
|
2510
|
+
**Cause**: A bug in `jsgui3-client` (v0.0.121+) where `page_context` is assigned without declaration in the `activate` function.
|
|
2511
|
+
**Workaround**: Define `page_context` globally before loading `jsgui3-client`.
|
|
2512
|
+
|
|
2513
|
+
```javascript
|
|
2514
|
+
// In your client entry point (e.g., renderer.src.js)
|
|
2515
|
+
if (typeof window !== 'undefined') {
|
|
2516
|
+
window.page_context = null; // Fix for jsgui3-client bug
|
|
2517
|
+
}
|
|
2518
|
+
const jsgui = require("jsgui3-client");
|
|
2519
|
+
```
|
|
2520
|
+
|
|
2521
|
+
#### "Missing context.map_Controls for type X"
|
|
2522
|
+
|
|
2523
|
+
**Symptom**: Console logs `"Missing context.map_Controls for type my_control, using generic Control"`
|
|
2524
|
+
|
|
2525
|
+
**Cause**: jsgui3's internal activation tries to reconstruct controls from DOM but can't find the custom control types. This is informational - your controls are still registered via `register_this_and_subcontrols()`.
|
|
2526
|
+
|
|
2527
|
+
**Solution**: This warning is harmless if you're using the proper activation sequence. The custom controls are already instantiated - jsgui3 is just warning that it can't re-instantiate them from DOM alone.
|