@rashidee/co2 1.3.7 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/.co2-dat/app.db +0 -0
  2. package/dist/.co2-dat/app.db-shm +0 -0
  3. package/dist/.co2-dat/app.db-wal +0 -0
  4. package/dist/index.js +167 -68
  5. package/package.json +41 -41
  6. package/plugin/skills/conductor-feature-develop/SKILL.md +1384 -1383
  7. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +225 -224
  8. package/plugin/skills/mockgen-shadcn/SKILL.md +1073 -1067
  9. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +5 -3
  10. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +631 -498
  11. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +2 -2
  12. package/plugin/skills/mockgen-tailwind/SKILL.md +913 -904
  13. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +722 -720
  14. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +631 -498
  15. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -190
  16. package/static/assets/{abnfDiagram-VRR7QNED-CsyqZblo.js → abnfDiagram-VRR7QNED-DP7zbvBZ.js} +1 -1
  17. package/static/assets/{arc-it3yvCvj.js → arc-Ckh0cqx_.js} +1 -1
  18. package/static/assets/{architectureDiagram-ZJ3FMSHR-sjNv2MOQ.js → architectureDiagram-ZJ3FMSHR-EpVuCwzS.js} +1 -1
  19. package/static/assets/{blockDiagram-677ZJIJ3-DW9ZjtwO.js → blockDiagram-677ZJIJ3-DsgBkX_g.js} +1 -1
  20. package/static/assets/{c4Diagram-LMCZKHZV-DP3gJkhN.js → c4Diagram-LMCZKHZV-CR04rrP-.js} +1 -1
  21. package/static/assets/channel-DfxyTZ0M.js +1 -0
  22. package/static/assets/{chunk-2Q5K7J3B-ByxTr7KB.js → chunk-2Q5K7J3B-DeQC-sio.js} +1 -1
  23. package/static/assets/{chunk-32BRIVSS-vNETwpX4.js → chunk-32BRIVSS-Qbam-XOV.js} +1 -1
  24. package/static/assets/{chunk-5VM5RSS4-DbdsRtpE.js → chunk-5VM5RSS4-CH-IX0uh.js} +1 -1
  25. package/static/assets/{chunk-EX3LRPZG-DPkDGikN.js → chunk-EX3LRPZG-6gUCPZ6x.js} +1 -1
  26. package/static/assets/{chunk-JWPE2WC7-Ccpx-Rog.js → chunk-JWPE2WC7-DSNEXnbo.js} +1 -1
  27. package/static/assets/{chunk-MOJQB5TN-CnRPtHb3.js → chunk-MOJQB5TN-BVmlLOXk.js} +1 -1
  28. package/static/assets/{chunk-RYQCIY6F-C--_KRGR.js → chunk-RYQCIY6F-2pIZN6PI.js} +1 -1
  29. package/static/assets/{chunk-V7JOEXUC-BJQ5fwNa.js → chunk-V7JOEXUC-Dj8a39S7.js} +1 -1
  30. package/static/assets/{chunk-VR4S4FIN-BPcVRYqJ.js → chunk-VR4S4FIN-D2kLoVlo.js} +1 -1
  31. package/static/assets/{chunk-XXDRQBXY-BoC3DCKQ.js → chunk-XXDRQBXY-x3SQWIAg.js} +1 -1
  32. package/static/assets/classDiagram-OUVF2IWQ-DPLLyb_P.js +1 -0
  33. package/static/assets/classDiagram-v2-EOCWNBFH-DPLLyb_P.js +1 -0
  34. package/static/assets/{cose-bilkent-JH36ORCC-DlKrOw_E.js → cose-bilkent-JH36ORCC-ltgQwU4R.js} +1 -1
  35. package/static/assets/{cynefin-VYW2F7L2-CZQhaPM1.js → cynefin-VYW2F7L2-BwJnOy5O.js} +1 -1
  36. package/static/assets/{cynefinDiagram-TSTJHNR4-cZP74_0I.js → cynefinDiagram-TSTJHNR4-Bi47Fvnv.js} +1 -1
  37. package/static/assets/{dagre-VKFMJZFB-tB2cBd_e.js → dagre-VKFMJZFB-5_l7QdUh.js} +1 -1
  38. package/static/assets/{diagram-FQU43EPY-QwoADT9c.js → diagram-FQU43EPY-HKDyXOzo.js} +1 -1
  39. package/static/assets/{diagram-G47NLZAW-Bjz2rwUz.js → diagram-G47NLZAW-xHHInyx1.js} +1 -1
  40. package/static/assets/{diagram-NH7WQ7WH-DS9j6K7F.js → diagram-NH7WQ7WH-nftmRdjw.js} +1 -1
  41. package/static/assets/{diagram-OA4YK3LP-CUPwlGEi.js → diagram-OA4YK3LP-CX8gIXu7.js} +1 -1
  42. package/static/assets/{diagram-WEI45ONY-iejOZOdV.js → diagram-WEI45ONY-BaT3ur6a.js} +1 -1
  43. package/static/assets/{ebnfDiagram-CCIWWBDH-Bg3puNXE.js → ebnfDiagram-CCIWWBDH-CM9hwJSh.js} +1 -1
  44. package/static/assets/{erDiagram-Q63AITRT-DTxdGEtK.js → erDiagram-Q63AITRT-C9cK36a5.js} +1 -1
  45. package/static/assets/{flowDiagram-23GEKE2U-CRl-AJlj.js → flowDiagram-23GEKE2U-BQw_JFaW.js} +1 -1
  46. package/static/assets/{ganttDiagram-NO4QXBWP-wCOJ8cfC.js → ganttDiagram-NO4QXBWP-Cgg9ZbM9.js} +1 -1
  47. package/static/assets/{gitGraphDiagram-IHSO6WYX-CnVtKot-.js → gitGraphDiagram-IHSO6WYX-SciNAnN3.js} +1 -1
  48. package/static/assets/{index-xLqMMC0F.css → index-CM4GfOLV.css} +1 -1
  49. package/static/assets/{index-DAL1vVaa.js → index-HUEMwsDE.js} +194 -194
  50. package/static/assets/{infoDiagram-FWYZ7A6U-DmvEl4fi.js → infoDiagram-FWYZ7A6U-BPCKxKmJ.js} +1 -1
  51. package/static/assets/{ishikawaDiagram-FXEZZL3T-CjKerAXn.js → ishikawaDiagram-FXEZZL3T-C0eQKvpo.js} +1 -1
  52. package/static/assets/{journeyDiagram-5HDEW3XC-Bmc4ARKl.js → journeyDiagram-5HDEW3XC-DgrJ90BU.js} +1 -1
  53. package/static/assets/{kanban-definition-HUTT4EX6-BZ9I3imN.js → kanban-definition-HUTT4EX6-ISgFrxBl.js} +1 -1
  54. package/static/assets/{linear-CrHKLpp_.js → linear-CaPqviiq.js} +1 -1
  55. package/static/assets/{mindmap-definition-LN4V7U3C-SUk59XXF.js → mindmap-definition-LN4V7U3C-DTpJ3Okf.js} +1 -1
  56. package/static/assets/{pegDiagram-2B236MQR-CknFYdh_.js → pegDiagram-2B236MQR-D0EbYB8X.js} +1 -1
  57. package/static/assets/{pieDiagram-ENE6RG2P-B3VlLXXT.js → pieDiagram-ENE6RG2P-lAwviOjS.js} +1 -1
  58. package/static/assets/{quadrantDiagram-ABIIQ3AL-BUkZjvW_.js → quadrantDiagram-ABIIQ3AL-Dn8yVCWJ.js} +1 -1
  59. package/static/assets/{railroadDiagram-RFXS5EU6-LklmPimD.js → railroadDiagram-RFXS5EU6-BVLcNBsS.js} +1 -1
  60. package/static/assets/{requirementDiagram-TGXJPOKE-DnRFaZbz.js → requirementDiagram-TGXJPOKE-Bmjsh1uy.js} +1 -1
  61. package/static/assets/{sankeyDiagram-HTMAVEWB-D242DUlQ.js → sankeyDiagram-HTMAVEWB-tdNqcgyd.js} +1 -1
  62. package/static/assets/{sequenceDiagram-DBY2YBRQ-DmiiStqo.js → sequenceDiagram-DBY2YBRQ-ChtQkNW9.js} +1 -1
  63. package/static/assets/{sizeCapture-X5ZJPWSS-cbMvR047.js → sizeCapture-X5ZJPWSS-BAnRJGV8.js} +1 -1
  64. package/static/assets/{stateDiagram-2N3HPSRC-YIIylI9N.js → stateDiagram-2N3HPSRC-CI7wmYAe.js} +1 -1
  65. package/static/assets/stateDiagram-v2-6OUMAXLB-BJrR07NX.js +1 -0
  66. package/static/assets/{swimlanes-5IMT3BWC-Ba309Ysu.js → swimlanes-5IMT3BWC-k6NGsb3p.js} +2 -2
  67. package/static/assets/swimlanesDiagram-G3AALYLV-B0-5v6_-.js +8 -0
  68. package/static/assets/{timeline-definition-FHXFAJF6-BdpW3kGY.js → timeline-definition-FHXFAJF6-CweEa8iU.js} +1 -1
  69. package/static/assets/{vennDiagram-L72KCM5P-BiSgQ9Lf.js → vennDiagram-L72KCM5P-DDPbV_sp.js} +1 -1
  70. package/static/assets/{wardleyDiagram-EHGQE667-Clr8YWGW.js → wardleyDiagram-EHGQE667-IP4J8kqe.js} +1 -1
  71. package/static/assets/{xychartDiagram-FW5EYKEG-LsC066MO.js → xychartDiagram-FW5EYKEG-CMfWLoM8.js} +1 -1
  72. package/static/index.html +2 -2
  73. package/static/assets/channel-C_J_aLZ7.js +0 -1
  74. package/static/assets/classDiagram-OUVF2IWQ-yUfBrCjx.js +0 -1
  75. package/static/assets/classDiagram-v2-EOCWNBFH-yUfBrCjx.js +0 -1
  76. package/static/assets/stateDiagram-v2-6OUMAXLB-m0MSGh0v.js +0 -1
  77. package/static/assets/swimlanesDiagram-G3AALYLV-DY_J_ih_.js +0 -8
@@ -1,498 +1,631 @@
1
- # Mockup Hub — Single Node Server for ALL Application Mockups
2
-
3
- The Mockup Hub is ONE zero-dependency Node.js application at `<root>/mockup/` that serves
4
- the mockups of EVERY application in the project. Per-application mockup folders
5
- (`<app_folder>/context/mockup/`) contain ONLY assets (HTML/CSS/JS/images + a manifest) —
6
- they contain NO server of their own.
7
-
8
- - **Tailwind mockups** (`mockgen-tailwind`): the hub assembles shell + header + sidebar +
9
- content + footer server-side at `/{app_slug}/{role}/{page}`.
10
- - **shadcn mockups** (`mockgen-shadcn`): the hub serves the app's built `dist/` folder at
11
- `/{app_slug}/...` with SPA fallback to `dist/index.html`.
12
- - **Landing page** at `/`: dynamically lists ALL applications and their roles. Roles are
13
- clickable only when the app's mockup is ready; otherwise they render as disabled
14
- (grayed, no `href`) chips with a status hint ("Mockups not generated yet" /
15
- "Build required").
16
- - **Port** is configurable: `PORT` env var → `mockup.config.json` `port` → default `3000`.
17
-
18
- The hub has **zero npm dependencies** `npm start` (or `node server.js`) works with no
19
- `npm install`.
20
-
21
- ## File Structure
22
-
23
- ```
24
- <root>/
25
- mockup/ # ← the hub (this template)
26
- server.js # zero-dependency Node HTTP server
27
- package.json # no dependencies; "start": "node server.js"
28
- mockup.config.json # { "port": 3000 } — user-editable
29
- .gitignore
30
- 1_first_app/
31
- context/mockup/ # assets only (+ mockup-manifest.json)
32
- 2_second_app/
33
- context/mockup/
34
- ```
35
-
36
- ## Ensure-Hub Rules (run by every mockgen-* execution, including module-filtered runs)
37
-
38
- 1. If `<root>/mockup/server.js` does NOT exist: create ALL four hub files from the
39
- templates below.
40
- 2. If it exists: read the `HUB_VERSION` constant in the existing `server.js`.
41
- - Existing version **< template version** overwrite `server.js` and `package.json`
42
- with the templates (upgrade).
43
- - Existing version **>= template version**, or no marker found (user-customized)
44
- leave `server.js` and `package.json` untouched.
45
- 3. `mockup.config.json`: create ONLY if missing. NEVER overwrite an existing one — it
46
- holds the user's port choice. It is per-machine and gitignored.
47
- 4. `.gitignore`: create ONLY if missing.
48
- 5. Never place per-application assets inside `<root>/mockup/`. Conversely, everything the
49
- hub generates or needs (config, logs, any `node_modules/`) stays inside `<root>/mockup/`
50
- and is covered by its `.gitignore`.
51
-
52
- ## mockup-manifest.json Schema (written to `<app_folder>/context/mockup/`)
53
-
54
- Every mockgen-* run writes/updates this file. The hub uses it for discovery, routing and
55
- the landing page.
56
-
57
- | Field | Type | Description |
58
- |-------|------|-------------|
59
- | `app` | string | App slug used in URLs = app folder name minus leading `<number>_` (e.g. `hub_middleware`) |
60
- | `appName` | string | Display name (e.g. "Hub Middleware") |
61
- | `description` | string | Short app description from PRD.md |
62
- | `stack` | string | `"tailwind"` or `"shadcn"` |
63
- | `version` | string | Target version the mockups were generated for (e.g. `v1.0.3`) |
64
- | `generatedAt` | string | ISO date of the last generation |
65
- | `roles` | array | One entry per role: `{ "name", "slug", "screens" }`. `slug` uses the stack's convention — snake_case (tailwind) or kebab-case (shadcn). `screens` = number of screens generated for the role |
66
- | `reports` | array (optional) | Report layout file names, if any |
67
-
68
- Example:
69
-
70
- ```json
71
- {
72
- "app": "hub_middleware",
73
- "appName": "Hub Middleware",
74
- "description": "Central middleware for labour mobility data exchange.",
75
- "stack": "tailwind",
76
- "version": "v1.0.3",
77
- "generatedAt": "2026-07-12",
78
- "roles": [
79
- { "name": "Hub Administrator", "slug": "hub_administrator", "screens": 12 },
80
- { "name": "Hub Operation Support", "slug": "hub_operation_support", "screens": 9 }
81
- ],
82
- "reports": ["staff_allocation_summary.html"]
83
- }
84
- ```
85
-
86
- ## server.js Template
87
-
88
- ```javascript
89
- #!/usr/bin/env node
90
- /*
91
- * CO2 Mockup Hub — one server for ALL application mockups.
92
- * HUB_VERSION: 1 (marker used by mockgen-* skills to decide upgrades — do not remove)
93
- *
94
- * Discovers every root-level application folder containing context/PRD.md.
95
- * Apps with a context/mockup/mockup-manifest.json are routable:
96
- * - stack "tailwind": pages assembled from partials + content fragments
97
- * - stack "shadcn": served from the built dist/ folder (SPA fallback)
98
- * Port: PORT env var > mockup.config.json "port" > 3000.
99
- */
100
- 'use strict';
101
-
102
- const http = require('http');
103
- const fs = require('fs');
104
- const path = require('path');
105
-
106
- const HUB_VERSION = 1;
107
- const HUB_DIR = __dirname;
108
- const ROOT_DIR = path.resolve(HUB_DIR, '..');
109
- const SAFE_SEG = /^[A-Za-z0-9._-]+$/;
110
-
111
- function resolvePort() {
112
- const envPort = parseInt(process.env.PORT || '', 10);
113
- if (Number.isInteger(envPort) && envPort > 0) return envPort;
114
- try {
115
- const cfg = JSON.parse(fs.readFileSync(path.join(HUB_DIR, 'mockup.config.json'), 'utf8'));
116
- const cfgPort = parseInt(cfg.port, 10);
117
- if (Number.isInteger(cfgPort) && cfgPort > 0) return cfgPort;
118
- } catch (err) { /* missing/invalid config — use default */ }
119
- return 3000;
120
- }
121
-
122
- const MIME = {
123
- '.html': 'text/html; charset=utf-8',
124
- '.css': 'text/css; charset=utf-8',
125
- '.js': 'text/javascript; charset=utf-8',
126
- '.mjs': 'text/javascript; charset=utf-8',
127
- '.json': 'application/json; charset=utf-8',
128
- '.svg': 'image/svg+xml',
129
- '.png': 'image/png',
130
- '.jpg': 'image/jpeg',
131
- '.jpeg': 'image/jpeg',
132
- '.gif': 'image/gif',
133
- '.webp': 'image/webp',
134
- '.ico': 'image/x-icon',
135
- '.woff': 'font/woff',
136
- '.woff2': 'font/woff2',
137
- '.ttf': 'font/ttf',
138
- '.map': 'application/json',
139
- '.txt': 'text/plain; charset=utf-8',
140
- '.pdf': 'application/pdf',
141
- };
142
-
143
- // ── Discovery ────────────────────────────────────────────────────────────────
144
-
145
- function titleCase(slug) {
146
- return slug.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
147
- }
148
-
149
- function rolesFromPrd(appDir) {
150
- try {
151
- const prd = fs.readFileSync(path.join(appDir, 'context', 'PRD.md'), 'utf8');
152
- const names = new Set();
153
- for (const line of prd.split('\n')) {
154
- if (line.includes('~~')) continue; // skip strikethrough (deprecated) items
155
- const m = line.match(/As an? (.+?) user/);
156
- if (m) names.add(m[1].trim());
157
- }
158
- return Array.from(names).map((name) => ({ name, slug: null, screens: 0 }));
159
- } catch (err) {
160
- return [];
161
- }
162
- }
163
-
164
- function discoverApps() {
165
- let entries;
166
- try { entries = fs.readdirSync(ROOT_DIR, { withFileTypes: true }); } catch (err) { return []; }
167
- const apps = [];
168
- for (const entry of entries) {
169
- if (!entry.isDirectory()) continue;
170
- const appDir = path.join(ROOT_DIR, entry.name);
171
- if (!fs.existsSync(path.join(appDir, 'context', 'PRD.md'))) continue;
172
- const slug = entry.name.replace(/^\d+_/, '');
173
- const mockupDir = path.join(appDir, 'context', 'mockup');
174
- let manifest = null;
175
- try {
176
- manifest = JSON.parse(fs.readFileSync(path.join(mockupDir, 'mockup-manifest.json'), 'utf8'));
177
- } catch (err) { /* mockups not generated yet */ }
178
- let status = 'not-generated';
179
- if (manifest && manifest.stack === 'shadcn') {
180
- status = fs.existsSync(path.join(mockupDir, 'dist', 'index.html')) ? 'ready' : 'build-required';
181
- } else if (manifest) {
182
- status = fs.existsSync(path.join(mockupDir, 'partials', 'shell.html')) ? 'ready' : 'not-generated';
183
- }
184
- apps.push({
185
- slug,
186
- folder: entry.name,
187
- appName: (manifest && manifest.appName) || titleCase(slug),
188
- description: (manifest && manifest.description) || '',
189
- version: (manifest && manifest.version) || '',
190
- stack: manifest ? manifest.stack : null,
191
- status,
192
- mockupDir,
193
- roles: manifest ? (manifest.roles || []) : rolesFromPrd(appDir),
194
- });
195
- }
196
- return apps.sort((a, b) => a.folder.localeCompare(b.folder));
197
- }
198
-
199
- function findApp(slug) {
200
- return discoverApps().find((a) => a.slug === slug) || null;
201
- }
202
-
203
- // ── Response helpers ─────────────────────────────────────────────────────────
204
-
205
- function send(res, code, body, type) {
206
- res.writeHead(code, { 'Content-Type': type || 'text/html; charset=utf-8' });
207
- res.end(body);
208
- }
209
-
210
- function redirect(res, location) {
211
- res.writeHead(302, { Location: location });
212
- res.end();
213
- }
214
-
215
- function escapeHtml(s) {
216
- return String(s)
217
- .replace(/&/g, '&amp;').replace(/</g, '&lt;')
218
- .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
219
- }
220
-
221
- function safeJoin(baseDir, segments) {
222
- if (!segments.every((s) => SAFE_SEG.test(s))) return null;
223
- const target = path.normalize(path.join(baseDir, segments.join(path.sep)));
224
- if (target !== baseDir && !target.startsWith(baseDir + path.sep)) return null;
225
- return target;
226
- }
227
-
228
- function serveFile(res, filePath) {
229
- if (!filePath) return false;
230
- let stat;
231
- try { stat = fs.statSync(filePath); } catch (err) { return false; }
232
- if (!stat.isFile()) return false;
233
- const type = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
234
- send(res, 200, fs.readFileSync(filePath), type);
235
- return true;
236
- }
237
-
238
- function readText(filePath) {
239
- try { return fs.readFileSync(filePath, 'utf8'); } catch (err) { return null; }
240
- }
241
-
242
- // ── Landing page ─────────────────────────────────────────────────────────────
243
-
244
- const LANDING_CSS = `
245
- * { box-sizing: border-box; margin: 0; }
246
- body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
247
- background: #f1f5f9; color: #0f172a; padding: 40px 20px; }
248
- .wrap { max-width: 960px; margin: 0 auto; }
249
- h1 { font-size: 26px; margin-bottom: 4px; }
250
- .sub { color: #64748b; font-size: 14px; margin-bottom: 28px; }
251
- .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
252
- padding: 20px; margin-bottom: 16px; }
253
- .card h2 { font-size: 18px; display: inline; }
254
- .meta { color: #94a3b8; font-size: 12px; margin-left: 8px; }
255
- .badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px;
256
- margin-left: 8px; vertical-align: 2px; }
257
- .badge.ready { background: #dcfce7; color: #166534; }
258
- .badge.pending { background: #fef3c7; color: #92400e; }
259
- .hint { color: #94a3b8; font-size: 12px; margin-top: 10px; }
260
- .hint code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; }
261
- .roles { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
262
- .role { display: inline-block; font-size: 13px; padding: 6px 14px; border-radius: 8px;
263
- border: 1px solid #cbd5e1; text-decoration: none; color: #0f172a; }
264
- a.role:hover { border-color: #2563eb; color: #2563eb; }
265
- .role.disabled { color: #cbd5e1; border-color: #e2e8f0; cursor: not-allowed; }
266
- .index-link { font-size: 12px; margin-left: 8px; }
267
- .foot { color: #94a3b8; font-size: 12px; text-align: center; margin-top: 28px; }
268
- `;
269
-
270
- function renderRoleChips(app) {
271
- if (!app.roles.length) {
272
- return '<span class="role disabled">No roles found</span>';
273
- }
274
- return app.roles.map((role) => {
275
- if (app.status === 'ready' && role.slug) {
276
- return '<a class="role" href="/' + app.slug + '/' + role.slug + '/home">'
277
- + escapeHtml(role.name) + ' &rarr;</a>';
278
- }
279
- return '<span class="role disabled" aria-disabled="true">' + escapeHtml(role.name) + '</span>';
280
- }).join('\n ');
281
- }
282
-
283
- function renderAppCard(app) {
284
- const badge = app.status === 'ready'
285
- ? '<span class="badge ready">Ready' + (app.stack ? ' · ' + escapeHtml(app.stack) : '') + '</span>'
286
- : (app.status === 'build-required'
287
- ? '<span class="badge pending">Build required</span>'
288
- : '<span class="badge pending">Not generated yet</span>');
289
- const indexLink = app.status === 'ready'
290
- ? '<a class="index-link" href="/' + app.slug + '">screen index</a>' : '';
291
- let hint = '';
292
- if (app.status === 'build-required') {
293
- hint = '<p class="hint">Run <code>cd ' + escapeHtml(app.folder)
294
- + '/context/mockup &amp;&amp; npm install &amp;&amp; npm run build</code> to make this app viewable.</p>';
295
- } else if (app.status === 'not-generated') {
296
- hint = '<p class="hint">Run a <code>mockgen-*</code> skill for this application to generate its mockups.</p>';
297
- }
298
- return '<div class="card">\n'
299
- + ' <h2>' + escapeHtml(app.appName) + '</h2>'
300
- + (app.version ? '<span class="meta">' + escapeHtml(app.version) + '</span>' : '')
301
- + badge + indexLink + '\n'
302
- + (app.description ? ' <p class="sub" style="margin:6px 0 0">' + escapeHtml(app.description) + '</p>\n' : '')
303
- + ' <div class="roles">\n ' + renderRoleChips(app) + '\n </div>\n'
304
- + hint + '\n</div>';
305
- }
306
-
307
- function renderLanding(apps) {
308
- return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n'
309
- + '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
310
- + '<title>Mockup Hub</title>\n<style>' + LANDING_CSS + '</style>\n</head>\n<body>\n'
311
- + '<div class="wrap">\n<h1>Mockup Hub</h1>\n'
312
- + '<p class="sub">All application mockups, served from one place. '
313
- + apps.length + ' application(s) discovered. Click a role to open its dashboard.</p>\n'
314
- + apps.map(renderAppCard).join('\n')
315
- + '\n<p class="foot">CO2 Mockup Hub v' + HUB_VERSION
316
- + ' &middot; port via PORT env or mockup.config.json</p>\n</div>\n</body>\n</html>';
317
- }
318
-
319
- function notReadyPage(app) {
320
- return renderLanding([app]);
321
- }
322
-
323
- function notFoundPage(what) {
324
- return '<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px">'
325
- + '<h1>404 Not found' + (what ? ': ' + escapeHtml(what) : '') + '</h1>'
326
- + '<p><a href="/">Back to Mockup Hub</a></p></body></html>';
327
- }
328
-
329
- // ── Tailwind assembly ────────────────────────────────────────────────────────
330
-
331
- function assemblePage(app, role, page) {
332
- const dir = app.mockupDir;
333
- const shell = readText(path.join(dir, 'partials', 'shell.html'));
334
- let header = readText(path.join(dir, 'partials', 'header.html'));
335
- const sidebar = readText(path.join(dir, 'partials', 'sidebar-' + role + '.html'));
336
- const footer = readText(path.join(dir, 'partials', 'footer.html'));
337
- const content = readText(path.join(dir, role, 'content', page + '.html'));
338
- if (!shell || !header || !sidebar || !footer) return null;
339
- header = header.replace(/\{\{ROLE\}\}/g, role).replace(/\{\{APP_SLUG\}\}/g, app.slug);
340
- const body = content
341
- || '<div class="p-6 text-red-500">Page not found: ' + escapeHtml(page) + '</div>';
342
- return shell
343
- .replace('{{HEADER}}', header)
344
- .replace('{{SIDEBAR}}', sidebar.replace(/\{\{APP_SLUG\}\}/g, app.slug))
345
- .replace('{{CONTENT}}', body)
346
- .replace('{{FOOTER}}', footer);
347
- }
348
-
349
- // ── Routing ──────────────────────────────────────────────────────────────────
350
-
351
- function routeTailwind(app, segs, res) {
352
- if (segs.length === 1) {
353
- if (serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
354
- const first = app.roles.find((r) => r.slug);
355
- if (first) return redirect(res, '/' + app.slug + '/' + first.slug + '/home');
356
- return send(res, 404, notFoundPage(app.slug));
357
- }
358
- if (segs[1] === 'static' || segs[1] === 'reports') {
359
- const file = safeJoin(path.join(app.mockupDir, segs[1]), segs.slice(2));
360
- if (serveFile(res, file)) return;
361
- return send(res, 404, notFoundPage());
362
- }
363
- if (!SAFE_SEG.test(segs[1])) return send(res, 400, notFoundPage());
364
- if (segs.length === 2) return redirect(res, '/' + app.slug + '/' + segs[1] + '/home');
365
- if (segs.length === 3 && SAFE_SEG.test(segs[2])) {
366
- const html = assemblePage(app, segs[1], segs[2]);
367
- if (html === null) {
368
- return send(res, 500, '<h1>Missing partials. Re-run the mockup generator.</h1>');
369
- }
370
- return send(res, 200, html);
371
- }
372
- return send(res, 404, notFoundPage());
373
- }
374
-
375
- function routeShadcn(app, segs, res) {
376
- if (segs.length === 1 && serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
377
- if (segs[1] === 'reports') {
378
- const file = safeJoin(path.join(app.mockupDir, 'public', 'reports'), segs.slice(2));
379
- if (serveFile(res, file)) return;
380
- return send(res, 404, notFoundPage());
381
- }
382
- const distDir = path.join(app.mockupDir, 'dist');
383
- if (segs.length > 1) {
384
- const file = safeJoin(distDir, segs.slice(1));
385
- if (serveFile(res, file)) return;
386
- }
387
- // SPA fallback React Router (basename /{app_slug}) handles the path client-side
388
- if (serveFile(res, path.join(distDir, 'index.html'))) return;
389
- return send(res, 404, notFoundPage(app.slug));
390
- }
391
-
392
- function route(req, res) {
393
- const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
394
- const segs = urlPath.split('/').filter(Boolean);
395
-
396
- if (segs.length === 0) return send(res, 200, renderLanding(discoverApps()));
397
- if (urlPath === '/favicon.ico') { res.writeHead(204); return res.end(); }
398
-
399
- // HTMX fragment endpoint (tailwind): /api/content/:app/:role/:page
400
- if (segs[0] === 'api' && segs[1] === 'content') {
401
- if (segs.length !== 5) return send(res, 404, notFoundPage());
402
- const app = findApp(segs[2]);
403
- if (!app || app.status !== 'ready' || app.stack !== 'tailwind') {
404
- return send(res, 404, notFoundPage(segs[2]));
405
- }
406
- if (!SAFE_SEG.test(segs[3]) || !SAFE_SEG.test(segs[4])) return send(res, 400, notFoundPage());
407
- const content = readText(path.join(app.mockupDir, segs[3], 'content', segs[4] + '.html'));
408
- if (content === null) {
409
- return send(res, 404, '<div class="p-6 text-red-500">Content not found.</div>');
410
- }
411
- return send(res, 200, content);
412
- }
413
-
414
- if (!SAFE_SEG.test(segs[0])) return send(res, 400, notFoundPage());
415
- const app = findApp(segs[0]);
416
- if (!app) return send(res, 404, notFoundPage(segs[0]));
417
- if (app.status !== 'ready') return send(res, 200, notReadyPage(app));
418
-
419
- if (app.stack === 'shadcn') return routeShadcn(app, segs, res);
420
- return routeTailwind(app, segs, res);
421
- }
422
-
423
- // ── Start ────────────────────────────────────────────────────────────────────
424
-
425
- const PORT = resolvePort();
426
- const server = http.createServer((req, res) => {
427
- try {
428
- route(req, res);
429
- } catch (err) {
430
- send(res, 500, '<h1>500 — Hub error</h1><pre>' + escapeHtml(err && err.stack || err) + '</pre>');
431
- }
432
- });
433
-
434
- server.listen(PORT, () => {
435
- console.log('');
436
- console.log(' CO2 Mockup Hub v' + HUB_VERSION + ' running at:');
437
- console.log(' -> http://localhost:' + PORT);
438
- console.log('');
439
- console.log(' Serving mockups for all applications under: ' + ROOT_DIR);
440
- console.log(' Change port: PORT env var, or "port" in mockup.config.json');
441
- console.log('');
442
- });
443
- ```
444
-
445
- ## package.json Template
446
-
447
- ```json
448
- {
449
- "name": "co2-mockup-hub",
450
- "private": true,
451
- "version": "1.0.0",
452
- "description": "CO2 Mockup Hub — single server for all application mockups",
453
- "main": "server.js",
454
- "scripts": {
455
- "start": "node server.js"
456
- }
457
- }
458
- ```
459
-
460
- ## mockup.config.json Template
461
-
462
- Create only if missing never overwrite (it stores the user's port choice).
463
-
464
- ```json
465
- {
466
- "port": 3000
467
- }
468
- ```
469
-
470
- Port precedence at startup: `PORT` env var (e.g. `PORT=4000 npm start`)
471
- `mockup.config.json` `port` → `3000`.
472
-
473
- ## .gitignore Template
474
-
475
- Everything npm- or machine-generated inside `<root>/mockup/` stays inside `<root>/mockup/`
476
- and is gitignored there. The hub has zero dependencies, so `node_modules/` normally never
477
- exists the rule is a safety net in case a user installs packages. `mockup.config.json`
478
- is per-machine (each machine keeps its own port choice; the skills recreate it if missing),
479
- so it is gitignored too.
480
-
481
- ```
482
- node_modules/
483
- package-lock.json
484
- mockup.config.json
485
- .DS_Store
486
- *.log
487
- ```
488
-
489
- ## Quick Start (for output summaries)
490
-
491
- ```
492
- Mockup Hub
493
- ==========
494
- 1. cd <root>/mockup
495
- 2. npm start (zero dependencies — no npm install required)
496
- 3. Open http://localhost:3000 in your browser
497
- (change port: PORT=4000 npm start, or edit mockup.config.json)
498
- ```
1
+ # Mockup Hub — Single Node Server for ALL Application Mockups
2
+
3
+ The Mockup Hub is ONE zero-dependency Node.js application at `<root>/mockup/` that serves
4
+ the mockups of EVERY application in the project. Per-application mockup folders
5
+ (`<app_folder>/context/mockup/`) contain ONLY assets (HTML/CSS/JS/images + a manifest) —
6
+ they contain NO server of their own.
7
+
8
+ - **Tailwind mockups** (`mockgen-tailwind`): the hub assembles shell + header + sidebar +
9
+ content + footer server-side at `/{app_slug}/{role}/{page}`.
10
+ - **shadcn mockups** (`mockgen-shadcn`): the hub serves the app's built `dist/` folder at
11
+ `/{app_slug}/...` with SPA fallback to `dist/index.html`.
12
+ - **Landing page** at `/`: dynamically lists ALL applications and their roles. Roles are
13
+ clickable only when the app's mockup is ready; otherwise they render as disabled
14
+ (grayed, no `href`) chips with a status hint ("Mockups not generated yet" /
15
+ "Build required").
16
+ - **Port** is configurable: `PORT` env var → `mockup.config.json` `port` → auto-pick the
17
+ first **unused port starting from 4000**. The landing page carries a **port input text**
18
+ (defaulting to the current port) whose Apply action persists the choice to
19
+ `mockup.config.json` and re-listens on the new port. Compound Context Studio's Mockup
20
+ page reads its own topbar port input and passes it via the `PORT` env when its
21
+ **Start Hub** button spawns this server.
22
+
23
+ The hub has **zero npm dependencies** — `npm start` (or `node server.js`) works with no
24
+ `npm install`.
25
+
26
+ ## File Structure
27
+
28
+ ```
29
+ <root>/
30
+ mockup/ # ← the hub (this template)
31
+ server.js # zero-dependency Node HTTP server
32
+ package.json # no dependencies; "start": "node server.js"
33
+ mockup.config.json # { "port": 4000 } — user-editable (first unused port ≥ 4000)
34
+ .gitignore
35
+ 1_first_app/
36
+ context/mockup/ # assets only (+ mockup-manifest.json)
37
+ 2_second_app/
38
+ context/mockup/
39
+ ```
40
+
41
+ ## Ensure-Hub Rules (run by every mockgen-* execution, including module-filtered runs)
42
+
43
+ 1. If `<root>/mockup/server.js` does NOT exist: create ALL four hub files from the
44
+ templates below.
45
+ 2. If it exists: read the `HUB_VERSION` constant in the existing `server.js`.
46
+ - Existing version **< template version** overwrite `server.js` and `package.json`
47
+ with the templates (upgrade).
48
+ - Existing version **>= template version**, or no marker found (user-customized) →
49
+ leave `server.js` and `package.json` untouched.
50
+ 3. `mockup.config.json`: create ONLY if missing. NEVER overwrite an existing one — it
51
+ holds the user's port choice. It is per-machine and gitignored. When creating it, set
52
+ `port` to the **first unused port starting from 4000** (default `4000`; if you cannot
53
+ check, just write `4000` — the server auto-scans upward from a busy config port at
54
+ startup and writes the effective port back).
55
+ 4. `.gitignore`: create ONLY if missing.
56
+ 5. Never place per-application assets inside `<root>/mockup/`. Conversely, everything the
57
+ hub generates or needs (config, logs, any `node_modules/`) stays inside `<root>/mockup/`
58
+ and is covered by its `.gitignore`.
59
+
60
+ ## mockup-manifest.json Schema (written to `<app_folder>/context/mockup/`)
61
+
62
+ Every mockgen-* run writes/updates this file. The hub uses it for discovery, routing and
63
+ the landing page.
64
+
65
+ | Field | Type | Description |
66
+ |-------|------|-------------|
67
+ | `app` | string | App slug used in URLs = app folder name minus leading `<number>_` (e.g. `hub_middleware`) |
68
+ | `appName` | string | Display name (e.g. "Hub Middleware") |
69
+ | `description` | string | Short app description from PRD.md |
70
+ | `stack` | string | `"tailwind"` or `"shadcn"` |
71
+ | `version` | string | Target version the mockups were generated for (e.g. `v1.0.3`) |
72
+ | `generatedAt` | string | ISO date of the last generation |
73
+ | `roles` | array | One entry per role: `{ "name", "slug", "screens" }`. `slug` uses the stack's convention — snake_case (tailwind) or kebab-case (shadcn). `screens` = number of screens generated for the role |
74
+ | `reports` | array (optional) | Report layout file names, if any |
75
+
76
+ Example:
77
+
78
+ ```json
79
+ {
80
+ "app": "hub_middleware",
81
+ "appName": "Hub Middleware",
82
+ "description": "Central middleware for labour mobility data exchange.",
83
+ "stack": "tailwind",
84
+ "version": "v1.0.3",
85
+ "generatedAt": "2026-07-12",
86
+ "roles": [
87
+ { "name": "Hub Administrator", "slug": "hub_administrator", "screens": 12 },
88
+ { "name": "Hub Operation Support", "slug": "hub_operation_support", "screens": 9 }
89
+ ],
90
+ "reports": ["staff_allocation_summary.html"]
91
+ }
92
+ ```
93
+
94
+ ## server.js Template
95
+
96
+ ```javascript
97
+ #!/usr/bin/env node
98
+ /*
99
+ * CO2 Mockup Hub — one server for ALL application mockups.
100
+ * HUB_VERSION: 2 (marker used by mockgen-* skills to decide upgrades — do not remove)
101
+ *
102
+ * Discovers every root-level application folder containing context/PRD.md.
103
+ * Apps with a context/mockup/mockup-manifest.json are routable:
104
+ * - stack "tailwind": pages assembled from partials + content fragments
105
+ * - stack "shadcn": served from the built dist/ folder (SPA fallback)
106
+ * Port: PORT env var > mockup.config.json "port" > first unused port from 4000.
107
+ * - A PORT env value is exact (Compound Context Studio's Start Hub passes it) — a busy
108
+ * port fails fast so the caller sees the error.
109
+ * - A config/auto port scans upward from a busy port; the effective port is written
110
+ * back to mockup.config.json so probes and the next start agree.
111
+ * - The landing page has a port input (POST /api/port) that persists a new port and
112
+ * re-listens on it.
113
+ */
114
+ 'use strict';
115
+
116
+ const http = require('http');
117
+ const net = require('net');
118
+ const fs = require('fs');
119
+ const path = require('path');
120
+
121
+ const HUB_VERSION = 2;
122
+ const HUB_DIR = __dirname;
123
+ const ROOT_DIR = path.resolve(HUB_DIR, '..');
124
+ const SAFE_SEG = /^[A-Za-z0-9._-]+$/;
125
+ const PORT_SCAN_START = 4000; // default: first unused port starting here
126
+ const PORT_SCAN_LIMIT = 100;
127
+
128
+ /** { port, source: 'env' | 'config' | 'auto' } — env is exact, config/auto may scan. */
129
+ function resolvePort() {
130
+ const envPort = parseInt(process.env.PORT || '', 10);
131
+ if (Number.isInteger(envPort) && envPort > 0) return { port: envPort, source: 'env' };
132
+ try {
133
+ const cfg = JSON.parse(fs.readFileSync(path.join(HUB_DIR, 'mockup.config.json'), 'utf8'));
134
+ const cfgPort = parseInt(cfg.port, 10);
135
+ if (Number.isInteger(cfgPort) && cfgPort > 0) return { port: cfgPort, source: 'config' };
136
+ } catch (err) { /* missing/invalid config — auto-pick */ }
137
+ return { port: PORT_SCAN_START, source: 'auto' };
138
+ }
139
+
140
+ function persistPort(port) {
141
+ try {
142
+ fs.writeFileSync(
143
+ path.join(HUB_DIR, 'mockup.config.json'),
144
+ JSON.stringify({ port: port }, null, 2) + '\n'
145
+ );
146
+ } catch (err) { /* read-only folder the port still applies for this run */ }
147
+ }
148
+
149
+ const MIME = {
150
+ '.html': 'text/html; charset=utf-8',
151
+ '.css': 'text/css; charset=utf-8',
152
+ '.js': 'text/javascript; charset=utf-8',
153
+ '.mjs': 'text/javascript; charset=utf-8',
154
+ '.json': 'application/json; charset=utf-8',
155
+ '.svg': 'image/svg+xml',
156
+ '.png': 'image/png',
157
+ '.jpg': 'image/jpeg',
158
+ '.jpeg': 'image/jpeg',
159
+ '.gif': 'image/gif',
160
+ '.webp': 'image/webp',
161
+ '.ico': 'image/x-icon',
162
+ '.woff': 'font/woff',
163
+ '.woff2': 'font/woff2',
164
+ '.ttf': 'font/ttf',
165
+ '.map': 'application/json',
166
+ '.txt': 'text/plain; charset=utf-8',
167
+ '.pdf': 'application/pdf',
168
+ };
169
+
170
+ // ── Discovery ────────────────────────────────────────────────────────────────
171
+
172
+ function titleCase(slug) {
173
+ return slug.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
174
+ }
175
+
176
+ function rolesFromPrd(appDir) {
177
+ try {
178
+ const prd = fs.readFileSync(path.join(appDir, 'context', 'PRD.md'), 'utf8');
179
+ const names = new Set();
180
+ for (const line of prd.split('\n')) {
181
+ if (line.includes('~~')) continue; // skip strikethrough (deprecated) items
182
+ const m = line.match(/As an? (.+?) user/);
183
+ if (m) names.add(m[1].trim());
184
+ }
185
+ return Array.from(names).map((name) => ({ name, slug: null, screens: 0 }));
186
+ } catch (err) {
187
+ return [];
188
+ }
189
+ }
190
+
191
+ function discoverApps() {
192
+ let entries;
193
+ try { entries = fs.readdirSync(ROOT_DIR, { withFileTypes: true }); } catch (err) { return []; }
194
+ const apps = [];
195
+ for (const entry of entries) {
196
+ if (!entry.isDirectory()) continue;
197
+ const appDir = path.join(ROOT_DIR, entry.name);
198
+ if (!fs.existsSync(path.join(appDir, 'context', 'PRD.md'))) continue;
199
+ const slug = entry.name.replace(/^\d+_/, '');
200
+ const mockupDir = path.join(appDir, 'context', 'mockup');
201
+ let manifest = null;
202
+ try {
203
+ manifest = JSON.parse(fs.readFileSync(path.join(mockupDir, 'mockup-manifest.json'), 'utf8'));
204
+ } catch (err) { /* mockups not generated yet */ }
205
+ let status = 'not-generated';
206
+ if (manifest && manifest.stack === 'shadcn') {
207
+ status = fs.existsSync(path.join(mockupDir, 'dist', 'index.html')) ? 'ready' : 'build-required';
208
+ } else if (manifest) {
209
+ status = fs.existsSync(path.join(mockupDir, 'partials', 'shell.html')) ? 'ready' : 'not-generated';
210
+ }
211
+ apps.push({
212
+ slug,
213
+ folder: entry.name,
214
+ appName: (manifest && manifest.appName) || titleCase(slug),
215
+ description: (manifest && manifest.description) || '',
216
+ version: (manifest && manifest.version) || '',
217
+ stack: manifest ? manifest.stack : null,
218
+ status,
219
+ mockupDir,
220
+ roles: manifest ? (manifest.roles || []) : rolesFromPrd(appDir),
221
+ });
222
+ }
223
+ return apps.sort((a, b) => a.folder.localeCompare(b.folder));
224
+ }
225
+
226
+ function findApp(slug) {
227
+ return discoverApps().find((a) => a.slug === slug) || null;
228
+ }
229
+
230
+ // ── Response helpers ─────────────────────────────────────────────────────────
231
+
232
+ function send(res, code, body, type) {
233
+ res.writeHead(code, { 'Content-Type': type || 'text/html; charset=utf-8' });
234
+ res.end(body);
235
+ }
236
+
237
+ function redirect(res, location) {
238
+ res.writeHead(302, { Location: location });
239
+ res.end();
240
+ }
241
+
242
+ function escapeHtml(s) {
243
+ return String(s)
244
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
245
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
246
+ }
247
+
248
+ function safeJoin(baseDir, segments) {
249
+ if (!segments.every((s) => SAFE_SEG.test(s))) return null;
250
+ const target = path.normalize(path.join(baseDir, segments.join(path.sep)));
251
+ if (target !== baseDir && !target.startsWith(baseDir + path.sep)) return null;
252
+ return target;
253
+ }
254
+
255
+ function serveFile(res, filePath) {
256
+ if (!filePath) return false;
257
+ let stat;
258
+ try { stat = fs.statSync(filePath); } catch (err) { return false; }
259
+ if (!stat.isFile()) return false;
260
+ const type = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
261
+ send(res, 200, fs.readFileSync(filePath), type);
262
+ return true;
263
+ }
264
+
265
+ function readText(filePath) {
266
+ try { return fs.readFileSync(filePath, 'utf8'); } catch (err) { return null; }
267
+ }
268
+
269
+ // ── Landing page ─────────────────────────────────────────────────────────────
270
+
271
+ const LANDING_CSS = `
272
+ * { box-sizing: border-box; margin: 0; }
273
+ body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
274
+ background: #f1f5f9; color: #0f172a; padding: 40px 20px; }
275
+ .wrap { max-width: 960px; margin: 0 auto; }
276
+ h1 { font-size: 26px; margin-bottom: 4px; }
277
+ .sub { color: #64748b; font-size: 14px; margin-bottom: 28px; }
278
+ .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
279
+ padding: 20px; margin-bottom: 16px; }
280
+ .card h2 { font-size: 18px; display: inline; }
281
+ .meta { color: #94a3b8; font-size: 12px; margin-left: 8px; }
282
+ .badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px;
283
+ margin-left: 8px; vertical-align: 2px; }
284
+ .badge.ready { background: #dcfce7; color: #166534; }
285
+ .badge.pending { background: #fef3c7; color: #92400e; }
286
+ .hint { color: #94a3b8; font-size: 12px; margin-top: 10px; }
287
+ .hint code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; }
288
+ .roles { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
289
+ .role { display: inline-block; font-size: 13px; padding: 6px 14px; border-radius: 8px;
290
+ border: 1px solid #cbd5e1; text-decoration: none; color: #0f172a; }
291
+ a.role:hover { border-color: #2563eb; color: #2563eb; }
292
+ .role.disabled { color: #cbd5e1; border-color: #e2e8f0; cursor: not-allowed; }
293
+ .index-link { font-size: 12px; margin-left: 8px; }
294
+ .foot { color: #94a3b8; font-size: 12px; text-align: center; margin-top: 28px; }
295
+ .portbox { display: inline-flex; align-items: center; gap: 6px; margin-left: 10px; }
296
+ .portbox input { width: 64px; font-size: 12px; padding: 3px 6px; border: 1px solid #cbd5e1;
297
+ border-radius: 6px; color: #0f172a; }
298
+ .portbox button { font-size: 12px; padding: 3px 10px; border: 1px solid #cbd5e1;
299
+ border-radius: 6px; background: #fff; cursor: pointer; }
300
+ .portbox button:hover { border-color: #2563eb; color: #2563eb; }
301
+ #port-msg { font-size: 12px; margin-left: 6px; }
302
+ `;
303
+
304
+ function renderRoleChips(app) {
305
+ if (!app.roles.length) {
306
+ return '<span class="role disabled">No roles found</span>';
307
+ }
308
+ return app.roles.map((role) => {
309
+ if (app.status === 'ready' && role.slug) {
310
+ return '<a class="role" href="/' + app.slug + '/' + role.slug + '/home">'
311
+ + escapeHtml(role.name) + ' &rarr;</a>';
312
+ }
313
+ return '<span class="role disabled" aria-disabled="true">' + escapeHtml(role.name) + '</span>';
314
+ }).join('\n ');
315
+ }
316
+
317
+ function renderAppCard(app) {
318
+ const badge = app.status === 'ready'
319
+ ? '<span class="badge ready">Ready' + (app.stack ? ' · ' + escapeHtml(app.stack) : '') + '</span>'
320
+ : (app.status === 'build-required'
321
+ ? '<span class="badge pending">Build required</span>'
322
+ : '<span class="badge pending">Not generated yet</span>');
323
+ const indexLink = app.status === 'ready'
324
+ ? '<a class="index-link" href="/' + app.slug + '">screen index</a>' : '';
325
+ let hint = '';
326
+ if (app.status === 'build-required') {
327
+ hint = '<p class="hint">Run <code>cd ' + escapeHtml(app.folder)
328
+ + '/context/mockup &amp;&amp; npm install &amp;&amp; npm run build</code> to make this app viewable.</p>';
329
+ } else if (app.status === 'not-generated') {
330
+ hint = '<p class="hint">Run a <code>mockgen-*</code> skill for this application to generate its mockups.</p>';
331
+ }
332
+ return '<div class="card">\n'
333
+ + ' <h2>' + escapeHtml(app.appName) + '</h2>'
334
+ + (app.version ? '<span class="meta">' + escapeHtml(app.version) + '</span>' : '')
335
+ + badge + indexLink + '\n'
336
+ + (app.description ? ' <p class="sub" style="margin:6px 0 0">' + escapeHtml(app.description) + '</p>\n' : '')
337
+ + ' <div class="roles">\n ' + renderRoleChips(app) + '\n </div>\n'
338
+ + hint + '\n</div>';
339
+ }
340
+
341
+ // Port input (default: the current port — auto-picked as the first unused port from 4000
342
+ // when nothing was configured). Apply persists to mockup.config.json via POST /api/port
343
+ // and the hub re-listens on the new port; the page then follows it.
344
+ const PORT_SCRIPT = `
345
+ function applyPort() {
346
+ var msg = document.getElementById('port-msg');
347
+ var port = parseInt(document.getElementById('hub-port').value, 10);
348
+ if (!port || port < 1024 || port > 65535) {
349
+ msg.style.color = '#dc2626'; msg.textContent = 'Port must be 1024-65535'; return;
350
+ }
351
+ msg.style.color = '#64748b'; msg.textContent = 'Applying\\u2026';
352
+ fetch('/api/port', { method: 'POST', headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify({ port: port }) })
354
+ .then(function (r) { return r.json(); })
355
+ .then(function (res) {
356
+ if (!res.ok) { msg.style.color = '#dc2626'; msg.textContent = res.error || 'Failed'; return; }
357
+ msg.textContent = 'Restarting on port ' + res.port + '\\u2026';
358
+ setTimeout(function () {
359
+ location.href = location.protocol + '//' + location.hostname + ':' + res.port + '/';
360
+ }, 700);
361
+ })
362
+ .catch(function () { msg.style.color = '#dc2626'; msg.textContent = 'Request failed'; });
363
+ }
364
+ `;
365
+
366
+ function renderLanding(apps, currentPort) {
367
+ return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n'
368
+ + '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
369
+ + '<title>Mockup Hub</title>\n<style>' + LANDING_CSS + '</style>\n'
370
+ + '<script>' + PORT_SCRIPT + '</script>\n</head>\n<body>\n'
371
+ + '<div class="wrap">\n<h1>Mockup Hub</h1>\n'
372
+ + '<p class="sub">All application mockups, served from one place. '
373
+ + apps.length + ' application(s) discovered. Click a role to open its dashboard.</p>\n'
374
+ + apps.map(renderAppCard).join('\n')
375
+ + '\n<p class="foot">CO2 Mockup Hub v' + HUB_VERSION
376
+ + '<span class="portbox">Port <input id="hub-port" type="text" inputmode="numeric" value="'
377
+ + (currentPort || '') + '"> <button type="button" onclick="applyPort()">Apply</button>'
378
+ + '<span id="port-msg"></span></span></p>\n</div>\n</body>\n</html>';
379
+ }
380
+
381
+ function notReadyPage(app) {
382
+ return renderLanding([app], CURRENT_PORT);
383
+ }
384
+
385
+ function notFoundPage(what) {
386
+ return '<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px">'
387
+ + '<h1>404Not found' + (what ? ': ' + escapeHtml(what) : '') + '</h1>'
388
+ + '<p><a href="/">Back to Mockup Hub</a></p></body></html>';
389
+ }
390
+
391
+ // ── Tailwind assembly ────────────────────────────────────────────────────────
392
+
393
+ function assemblePage(app, role, page) {
394
+ const dir = app.mockupDir;
395
+ const shell = readText(path.join(dir, 'partials', 'shell.html'));
396
+ let header = readText(path.join(dir, 'partials', 'header.html'));
397
+ const sidebar = readText(path.join(dir, 'partials', 'sidebar-' + role + '.html'));
398
+ const footer = readText(path.join(dir, 'partials', 'footer.html'));
399
+ const content = readText(path.join(dir, role, 'content', page + '.html'));
400
+ if (!shell || !header || !sidebar || !footer) return null;
401
+ header = header.replace(/\{\{ROLE\}\}/g, role).replace(/\{\{APP_SLUG\}\}/g, app.slug);
402
+ const body = content
403
+ || '<div class="p-6 text-red-500">Page not found: ' + escapeHtml(page) + '</div>';
404
+ return shell
405
+ .replace('{{HEADER}}', header)
406
+ .replace('{{SIDEBAR}}', sidebar.replace(/\{\{APP_SLUG\}\}/g, app.slug))
407
+ .replace('{{CONTENT}}', body)
408
+ .replace('{{FOOTER}}', footer);
409
+ }
410
+
411
+ // ── Routing ──────────────────────────────────────────────────────────────────
412
+
413
+ function routeTailwind(app, segs, res) {
414
+ if (segs.length === 1) {
415
+ if (serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
416
+ const first = app.roles.find((r) => r.slug);
417
+ if (first) return redirect(res, '/' + app.slug + '/' + first.slug + '/home');
418
+ return send(res, 404, notFoundPage(app.slug));
419
+ }
420
+ if (segs[1] === 'static' || segs[1] === 'reports') {
421
+ const file = safeJoin(path.join(app.mockupDir, segs[1]), segs.slice(2));
422
+ if (serveFile(res, file)) return;
423
+ return send(res, 404, notFoundPage());
424
+ }
425
+ if (!SAFE_SEG.test(segs[1])) return send(res, 400, notFoundPage());
426
+ if (segs.length === 2) return redirect(res, '/' + app.slug + '/' + segs[1] + '/home');
427
+ if (segs.length === 3 && SAFE_SEG.test(segs[2])) {
428
+ const html = assemblePage(app, segs[1], segs[2]);
429
+ if (html === null) {
430
+ return send(res, 500, '<h1>Missing partials. Re-run the mockup generator.</h1>');
431
+ }
432
+ return send(res, 200, html);
433
+ }
434
+ return send(res, 404, notFoundPage());
435
+ }
436
+
437
+ function routeShadcn(app, segs, res) {
438
+ if (segs.length === 1 && serveFile(res, path.join(app.mockupDir, 'MOCKUP.html'))) return;
439
+ if (segs[1] === 'reports') {
440
+ const file = safeJoin(path.join(app.mockupDir, 'public', 'reports'), segs.slice(2));
441
+ if (serveFile(res, file)) return;
442
+ return send(res, 404, notFoundPage());
443
+ }
444
+ const distDir = path.join(app.mockupDir, 'dist');
445
+ if (segs.length > 1) {
446
+ const file = safeJoin(distDir, segs.slice(1));
447
+ if (serveFile(res, file)) return;
448
+ }
449
+ // SPA fallback — React Router (basename /{app_slug}) handles the path client-side
450
+ if (serveFile(res, path.join(distDir, 'index.html'))) return;
451
+ return send(res, 404, notFoundPage(app.slug));
452
+ }
453
+
454
+ // POST /api/port — persist a new port (from the landing page's port input) and re-listen.
455
+ function handlePortUpdate(req, res) {
456
+ let body = '';
457
+ req.on('data', (chunk) => { body += chunk; });
458
+ req.on('end', () => {
459
+ let port = NaN;
460
+ try { port = parseInt(JSON.parse(body).port, 10); } catch (err) { /* invalid JSON */ }
461
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
462
+ return send(res, 400, JSON.stringify({ ok: false, error: 'Port must be 1024-65535' }),
463
+ 'application/json; charset=utf-8');
464
+ }
465
+ persistPort(port);
466
+ send(res, 200, JSON.stringify({ ok: true, port: port }), 'application/json; charset=utf-8');
467
+ if (port !== CURRENT_PORT) {
468
+ setTimeout(() => { server.close(() => listenOn(port, false)); }, 200);
469
+ }
470
+ });
471
+ }
472
+
473
+ function route(req, res) {
474
+ const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
475
+ const segs = urlPath.split('/').filter(Boolean);
476
+
477
+ if (segs.length === 0) return send(res, 200, renderLanding(discoverApps(), CURRENT_PORT));
478
+ if (urlPath === '/favicon.ico') { res.writeHead(204); return res.end(); }
479
+
480
+ if (segs[0] === 'api' && segs[1] === 'port' && segs.length === 2 && req.method === 'POST') {
481
+ return handlePortUpdate(req, res);
482
+ }
483
+
484
+ // HTMX fragment endpoint (tailwind): /api/content/:app/:role/:page
485
+ if (segs[0] === 'api' && segs[1] === 'content') {
486
+ if (segs.length !== 5) return send(res, 404, notFoundPage());
487
+ const app = findApp(segs[2]);
488
+ if (!app || app.status !== 'ready' || app.stack !== 'tailwind') {
489
+ return send(res, 404, notFoundPage(segs[2]));
490
+ }
491
+ if (!SAFE_SEG.test(segs[3]) || !SAFE_SEG.test(segs[4])) return send(res, 400, notFoundPage());
492
+ const content = readText(path.join(app.mockupDir, segs[3], 'content', segs[4] + '.html'));
493
+ if (content === null) {
494
+ return send(res, 404, '<div class="p-6 text-red-500">Content not found.</div>');
495
+ }
496
+ return send(res, 200, content);
497
+ }
498
+
499
+ if (!SAFE_SEG.test(segs[0])) return send(res, 400, notFoundPage());
500
+ const app = findApp(segs[0]);
501
+ if (!app) return send(res, 404, notFoundPage(segs[0]));
502
+ if (app.status !== 'ready') return send(res, 200, notReadyPage(app));
503
+
504
+ if (app.stack === 'shadcn') return routeShadcn(app, segs, res);
505
+ return routeTailwind(app, segs, res);
506
+ }
507
+
508
+ // ── Start ────────────────────────────────────────────────────────────────────
509
+
510
+ let CURRENT_PORT = 0;
511
+ const server = http.createServer((req, res) => {
512
+ try {
513
+ route(req, res);
514
+ } catch (err) {
515
+ send(res, 500, '<h1>500 — Hub error</h1><pre>' + escapeHtml(err && err.stack || err) + '</pre>');
516
+ }
517
+ });
518
+
519
+ /** True when a TCP connect to 127.0.0.1:<port> is answered (something listens there).
520
+ * Needed besides the bind error: Windows lets a wildcard bind coexist with another
521
+ * process's 127.0.0.1-specific bind, so a plain listen() would "succeed" while
522
+ * http://localhost:<port> still hits the other app. */
523
+ function tcpBusy(port, cb) {
524
+ const sock = net.connect({ host: '127.0.0.1', port: port, timeout: 400 });
525
+ sock.once('connect', function () { sock.destroy(); cb(true); });
526
+ sock.once('timeout', function () { sock.destroy(); cb(false); });
527
+ sock.once('error', function () { cb(false); });
528
+ }
529
+
530
+ /**
531
+ * Listen on `port`; when `scan` is true a busy port tries the next one (bounded). The
532
+ * effective port is written back to mockup.config.json so probes and restarts agree.
533
+ * An exact (PORT env) port never scans — the caller asked for THAT port.
534
+ */
535
+ function listenOn(port, scan) {
536
+ tcpBusy(port, (busy) => {
537
+ if (busy) {
538
+ if (scan && port < PORT_SCAN_START + PORT_SCAN_LIMIT) return listenOn(port + 1, true);
539
+ console.error(' CO2 Mockup Hub failed to listen on port ' + port + ': already in use');
540
+ process.exit(1);
541
+ }
542
+ server.removeAllListeners('error'); // drop stale handlers from prior attempts/restarts
543
+ server.once('error', (err) => {
544
+ if (scan && err && err.code === 'EADDRINUSE' && port < PORT_SCAN_START + PORT_SCAN_LIMIT) {
545
+ return listenOn(port + 1, true);
546
+ }
547
+ console.error(' CO2 Mockup Hub failed to listen on port ' + port + ': ' + (err && err.code || err));
548
+ process.exit(1);
549
+ });
550
+ // Bind the IPv4 wildcard explicitly — a bare listen(port) grabs the IPv6 wildcard on
551
+ // some hosts and would miss IPv4 conflicts entirely.
552
+ server.listen(port, '0.0.0.0', () => {
553
+ CURRENT_PORT = port;
554
+ persistPort(port);
555
+ console.log('');
556
+ console.log(' CO2 Mockup Hub v' + HUB_VERSION + ' running at:');
557
+ console.log(' -> http://localhost:' + port);
558
+ console.log('');
559
+ console.log(' Serving mockups for all applications under: ' + ROOT_DIR);
560
+ console.log(' Change port: the landing page port input, PORT env var, or "port" in mockup.config.json');
561
+ console.log('');
562
+ });
563
+ });
564
+ }
565
+
566
+ const resolved = resolvePort();
567
+ // env = exact (fail fast when busy); config/auto = scan upward from the resolved port.
568
+ listenOn(resolved.port, resolved.source !== 'env');
569
+ ```
570
+
571
+ ## package.json Template
572
+
573
+ ```json
574
+ {
575
+ "name": "co2-mockup-hub",
576
+ "private": true,
577
+ "version": "1.0.0",
578
+ "description": "CO2 Mockup Hub — single server for all application mockups",
579
+ "main": "server.js",
580
+ "scripts": {
581
+ "start": "node server.js"
582
+ }
583
+ }
584
+ ```
585
+
586
+ ## mockup.config.json Template
587
+
588
+ Create only if missing — never overwrite (it stores the user's port choice). When
589
+ creating, set `port` to the first unused port starting from 4000 (write `4000` if you
590
+ cannot check — the server scans upward from a busy config port and writes the effective
591
+ port back).
592
+
593
+ ```json
594
+ {
595
+ "port": 4000
596
+ }
597
+ ```
598
+
599
+ Port precedence at startup: `PORT` env var (e.g. `PORT=4100 npm start` — exact, fails
600
+ fast when busy; Compound Context Studio's Start Hub passes the port this way) →
601
+ `mockup.config.json` `port` → first unused port starting from `4000`. The landing page's
602
+ port input (POST `/api/port`) persists a new port and re-listens on it.
603
+
604
+ ## .gitignore Template
605
+
606
+ Everything npm- or machine-generated inside `<root>/mockup/` stays inside `<root>/mockup/`
607
+ and is gitignored there. The hub has zero dependencies, so `node_modules/` normally never
608
+ exists — the rule is a safety net in case a user installs packages. `mockup.config.json`
609
+ is per-machine (each machine keeps its own port choice; the skills recreate it if missing),
610
+ so it is gitignored too.
611
+
612
+ ```
613
+ node_modules/
614
+ package-lock.json
615
+ mockup.config.json
616
+ .DS_Store
617
+ *.log
618
+ ```
619
+
620
+ ## Quick Start (for output summaries)
621
+
622
+ ```
623
+ Mockup Hub
624
+ ==========
625
+ 1. cd <root>/mockup
626
+ 2. npm start (zero dependencies — no npm install required)
627
+ 3. Open http://localhost:4000 in your browser
628
+ (the hub picks the first unused port from 4000; the exact URL is printed on start.
629
+ Change port: the landing page's Port input, PORT=4100 npm start, or edit
630
+ mockup.config.json — or use Compound Context Studio's Mockup page Start/Stop Hub.)
631
+ ```