tux-review 0.1.0

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 (60) hide show
  1. package/README.md +67 -0
  2. package/bin/tux.js +17 -0
  3. package/client/tux-review.css +155 -0
  4. package/client/tux-review.js +586 -0
  5. package/package.json +59 -0
  6. package/skills/tux-design-create.md +50 -0
  7. package/skills/tux-design-incorporate.md +61 -0
  8. package/skills/tux-design-install.md +56 -0
  9. package/skills/tux-design-start-review.md +51 -0
  10. package/skills/tux-design-stop-review.md +44 -0
  11. package/skills/tux-feedback-delete.md +45 -0
  12. package/skills/tux-feedback-export.md +47 -0
  13. package/skills/tux-feedback-show.md +55 -0
  14. package/skills/tux-live-create.md +50 -0
  15. package/skills/tux-live-incorporate.md +62 -0
  16. package/skills/tux-live-install.md +63 -0
  17. package/skills/tux-live-start-review.md +47 -0
  18. package/skills/tux-live-stop-review.md +46 -0
  19. package/src/canonical.js +28 -0
  20. package/src/cli.js +229 -0
  21. package/src/config.js +72 -0
  22. package/src/design.js +140 -0
  23. package/src/errors.js +28 -0
  24. package/src/feedback.js +289 -0
  25. package/src/ids.js +57 -0
  26. package/src/index.js +17 -0
  27. package/src/live.js +261 -0
  28. package/src/schema.js +77 -0
  29. package/src/serve-main.js +42 -0
  30. package/src/server.js +347 -0
  31. package/src/store.js +72 -0
  32. package/src/templates.js +44 -0
  33. package/templates/angular/README.md +19 -0
  34. package/templates/angular/angular.json +49 -0
  35. package/templates/angular/package.json +25 -0
  36. package/templates/angular/src/app/app.component.html +40 -0
  37. package/templates/angular/src/app/app.component.ts +30 -0
  38. package/templates/angular/src/index.html +11 -0
  39. package/templates/angular/src/main.ts +4 -0
  40. package/templates/angular/src/styles.css +11 -0
  41. package/templates/angular/tsconfig.app.json +8 -0
  42. package/templates/angular/tsconfig.json +11 -0
  43. package/templates/react/README.md +18 -0
  44. package/templates/react/index.html +12 -0
  45. package/templates/react/package.json +18 -0
  46. package/templates/react/src/App.jsx +80 -0
  47. package/templates/react/src/main.jsx +6 -0
  48. package/templates/react/src/styles.css +11 -0
  49. package/templates/vanilla/README.md +22 -0
  50. package/templates/vanilla/index.html +18 -0
  51. package/templates/vanilla/package.json +6 -0
  52. package/templates/vanilla/src/app.js +79 -0
  53. package/templates/vanilla/src/styles.css +11 -0
  54. package/templates/vue/README.md +18 -0
  55. package/templates/vue/index.html +12 -0
  56. package/templates/vue/package.json +17 -0
  57. package/templates/vue/src/App.vue +61 -0
  58. package/templates/vue/src/main.js +5 -0
  59. package/templates/vue/src/styles.css +11 -0
  60. package/templates/vue/vite.config.js +6 -0
package/src/schema.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Canonical feedback schema v1.0 (SPC sections 14–20).
3
+ *
4
+ * @module schema
5
+ */
6
+ import { sortedObject } from './canonical.js';
7
+
8
+ export const SCHEMA_VERSION = '1.0';
9
+
10
+ export const FEEDBACK_TYPES = ['change', 'issue', 'question', 'approval'];
11
+ export const FEEDBACK_STATUSES = ['open', 'incorporated', 'resolved', 'rejected'];
12
+ export const ORIGIN_MODES = ['design', 'live'];
13
+
14
+ /**
15
+ * Validate a feedback item. Returns a list of problems; empty means valid.
16
+ * @param {object} item
17
+ * @returns {string[]}
18
+ */
19
+ export function validateFeedback(item) {
20
+ const problems = [];
21
+ if (item.schema_version !== SCHEMA_VERSION) problems.push('schema_version must be "1.0"');
22
+ if (typeof item.id !== 'string' || !/^fb_[0-9A-HJKMNP-TV-Z]{26}$/.test(item.id)) {
23
+ problems.push('id must match fb_<26 Crockford chars>');
24
+ }
25
+ if (typeof item.project_id !== 'string' || item.project_id === '') problems.push('project_id must be a non-empty string');
26
+ if (typeof item.session_id !== 'string' || item.session_id === '') problems.push('session_id must be a non-empty string');
27
+ if (!item.author || typeof item.author.user_id !== 'string' || item.author.user_id === '') problems.push('author.user_id must be a non-empty string');
28
+ if (!item.origin || !ORIGIN_MODES.includes(item.origin.mode)) problems.push(`origin.mode must be one of ${ORIGIN_MODES.join(', ')}`);
29
+ if (!item.feedback || !FEEDBACK_TYPES.includes(item.feedback.type)) problems.push(`feedback.type must be one of ${FEEDBACK_TYPES.join(', ')}`);
30
+ if (!item.feedback || typeof item.feedback.text !== 'string' || item.feedback.text === '') problems.push('feedback.text must be a non-empty string');
31
+ if (!FEEDBACK_STATUSES.includes(item.status)) problems.push(`status must be one of ${FEEDBACK_STATUSES.join(', ')}`);
32
+ return problems;
33
+ }
34
+
35
+ /** Construct a canonical feedback item (fields in schema order). */
36
+ export function makeFeedback(fields) {
37
+ const uiState = sortedObject(fields.ui_state ?? {});
38
+ const item = {
39
+ schema_version: SCHEMA_VERSION,
40
+ id: fields.id,
41
+ project_id: fields.project_id,
42
+ session_id: fields.session_id,
43
+ author: { user_id: fields.author.user_id, display_name: fields.author.display_name },
44
+ origin: { mode: fields.origin },
45
+ };
46
+ if (fields.location) item.location = orderedLocation(fields.location);
47
+ item.target = orderedTarget(fields.target ?? {});
48
+ item.ui_state = uiState;
49
+ item.feedback = { type: fields.type, text: fields.text };
50
+ item.status = fields.status ?? 'open';
51
+ item.created_at = fields.created_at;
52
+ item.updated_at = fields.updated_at ?? fields.created_at;
53
+ if (fields.incorporation) item.incorporation = { ...fields.incorporation };
54
+ if (fields.validation) item.validation = { ...fields.validation };
55
+ return item;
56
+ }
57
+
58
+ function orderedLocation(loc) {
59
+ const out = {};
60
+ for (const k of ['route', 'page', 'component', 'component_instance']) {
61
+ if (loc[k] !== undefined) out[k] = loc[k];
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function orderedTarget(target) {
67
+ const out = {};
68
+ for (const k of ['tux_id', 'test_id', 'component', 'role', 'accessible_name', 'text', 'css_selector', 'dom_path', 'bounding_box']) {
69
+ if (target[k] !== undefined) out[k] = target[k];
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Normalized text for duplicate detection: trimmed, whitespace-collapsed, case-folded. */
75
+ export function normalizedText(text) {
76
+ return text.trim().replace(/\s+/g, ' ').toLowerCase();
77
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Internal entry point for the detached server child process.
3
+ * Not part of the public CLI surface.
4
+ */
5
+ import { resolve } from 'node:path';
6
+ import { startServer } from './server.js';
7
+
8
+ function argValue(name) {
9
+ const i = process.argv.indexOf(name);
10
+ return i === -1 ? undefined : process.argv[i + 1];
11
+ }
12
+
13
+ const cwd = resolve(argValue('--cwd') ?? process.cwd());
14
+ const mode = argValue('--mode') ?? 'design';
15
+ const host = argValue('--host') ?? '127.0.0.1';
16
+ const port = Number(argValue('--port') ?? 4173);
17
+ const target = argValue('--target') ?? null;
18
+ const session = argValue('--session') ?? 'default';
19
+ const environment = argValue('--environment') ?? (mode === 'design' ? 'design' : 'development');
20
+ const store = argValue('--store') ?? '.tux/feedback.json';
21
+ const projectId = argValue('--project-id');
22
+ const root = argValue('--root') ?? null;
23
+
24
+ const server = startServer({
25
+ mode,
26
+ host,
27
+ port,
28
+ root,
29
+ target,
30
+ cwd,
31
+ session,
32
+ environment,
33
+ config: {
34
+ project_id: projectId ?? 'default',
35
+ review: { enabled: true, store, host, port },
36
+ identity: { provider: 'local', user_id: process.env.TUX_USER_ID ?? 'anonymous', display_name: process.env.TUX_DISPLAY_NAME ?? 'Anonymous', admins: [] },
37
+ },
38
+ });
39
+
40
+ server.listen(port, host, () => {
41
+ process.stdout.write(`tux server listening on http://${host}:${port}\n`);
42
+ });
package/src/server.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * TUX review/design server (framework-agnostic, zero dependencies).
3
+ *
4
+ * Two modes:
5
+ * - `design`: serves a static design directory from disk, injecting the
6
+ * Review Client into every HTML response.
7
+ * - `live`: reverse-proxies an existing application, injecting the
8
+ * Review Client into HTML responses on the way through.
9
+ *
10
+ * Endpoints (canonical API):
11
+ * - GET /api/tux/health
12
+ * - GET /api/tux/session
13
+ * - GET /api/tux/feedback[?route=&status=&type=&mine=&session=]
14
+ * - POST /api/tux/feedback
15
+ * - GET /api/tux/feedback/{id}
16
+ * - PATCH /api/tux/feedback/{id} (owner only)
17
+ * - DELETE /api/tux/feedback/{id} (owner or admin)
18
+ * - POST /api/tux/feedback/clear {scope:"mine"|"all",route?,session?}
19
+ * - GET /__tux__/bootstrap.js activation + identity bootstrap
20
+ * - GET /__tux__/client.js the Review Client module
21
+ * - GET /__tux__/client.css Review Client styles
22
+ *
23
+ * @module server
24
+ */
25
+ import { createServer, request as httpRequest } from 'node:http';
26
+ import { existsSync, readFileSync, statSync } from 'node:fs';
27
+ import { extname, join, normalize, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { canonicalJson } from './canonical.js';
30
+ import { canonicalTimestamp, nowMs, newId } from './ids.js';
31
+ import { FEEDBACK_TYPES, makeFeedback, SCHEMA_VERSION } from './schema.js';
32
+ import { loadStore, saveStore, ensureSession } from './store.js';
33
+ import { resolveIdentity } from './config.js';
34
+
35
+ const CLIENT_DIR = fileURLToPath(new URL('../client/', import.meta.url));
36
+
37
+ const MIME = {
38
+ '.html': 'text/html; charset=utf-8',
39
+ '.js': 'text/javascript; charset=utf-8',
40
+ '.mjs': 'text/javascript; charset=utf-8',
41
+ '.css': 'text/css; charset=utf-8',
42
+ '.json': 'application/json; charset=utf-8',
43
+ '.svg': 'image/svg+xml',
44
+ '.png': 'image/png',
45
+ '.jpg': 'image/jpeg',
46
+ '.jpeg': 'image/jpeg',
47
+ '.gif': 'image/gif',
48
+ '.ico': 'image/x-icon',
49
+ '.woff2': 'font/woff2',
50
+ '.map': 'application/json',
51
+ };
52
+
53
+ /**
54
+ * @param {object} opts
55
+ * @param {'design'|'live'} opts.mode
56
+ * @param {string} opts.host bind host
57
+ * @param {number} opts.port bind port
58
+ * @param {string|null} opts.root static root (design mode)
59
+ * @param {string|null} opts.target proxy target (live mode)
60
+ * @param {string} opts.cwd project cwd for store access
61
+ * @param {object} opts.config resolved project config
62
+ * @param {string} opts.session session id exposed to clients
63
+ * @param {string} opts.environment environment label
64
+ */
65
+ export function startServer(opts) {
66
+ const state = {
67
+ ...opts,
68
+ root: opts.root ? resolve(opts.cwd, opts.root) : null,
69
+ identity: resolveIdentity(opts.config),
70
+ startedAt: canonicalTimestamp(nowMs()),
71
+ };
72
+ ensureSession(opts.cwd, opts.config, opts.session, opts.environment, state.startedAt);
73
+ const server = createServer((req, res) => {
74
+ handle(state, req, res).catch((err) => {
75
+ sendJson(res, 500, { error: { code: 'internal', message: String(err && err.message ? err.message : err) } });
76
+ });
77
+ });
78
+ return server;
79
+ }
80
+
81
+ async function handle(state, req, res) {
82
+ const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
83
+ const path = url.pathname;
84
+
85
+ if (path === '/api/tux/health') {
86
+ return sendJson(res, 200, { status: 'ok', mode: state.mode, session_id: state.session });
87
+ }
88
+ if (path.startsWith('/api/tux/')) return api(state, req, res, url);
89
+ if (path.startsWith('/__tux__/')) return assets(state, res, path);
90
+
91
+ if (state.mode === 'design') return static_(state, res, path);
92
+ return proxy(state, req, res);
93
+ }
94
+
95
+ function identityOf(state, req) {
96
+ const userId = req.headers['x-tux-user-id'] ?? state.identity.user_id;
97
+ const displayName = req.headers['x-tux-display-name'] ?? state.identity.display_name;
98
+ return { user_id: String(userId), display_name: String(displayName) };
99
+ }
100
+
101
+ function isAdmin(state, userId) {
102
+ return (state.identity.admins ?? []).includes(userId);
103
+ }
104
+
105
+ async function api(state, req, res, url) {
106
+ const path = url.pathname;
107
+ const identity = identityOf(state, req);
108
+ const method = req.method ?? 'GET';
109
+
110
+ if (path === '/api/tux/session' && method === 'GET') {
111
+ return sendJson(res, 200, {
112
+ session_id: state.session,
113
+ project_id: state.config.project_id,
114
+ environment: state.environment,
115
+ status: 'active',
116
+ });
117
+ }
118
+
119
+ if (path === '/api/tux/feedback' && method === 'GET') {
120
+ const store = loadStore(state.cwd, state.config);
121
+ let items = store.feedback;
122
+ const q = url.searchParams;
123
+ if (q.get('route')) items = items.filter((f) => f.location?.route === q.get('route'));
124
+ if (q.get('status')) items = items.filter((f) => f.status === q.get('status'));
125
+ if (q.get('type')) items = items.filter((f) => f.feedback.type === q.get('type'));
126
+ if (q.get('session')) items = items.filter((f) => f.session_id === q.get('session'));
127
+ if (q.get('mine') === '1' || q.get('mine') === 'true') items = items.filter((f) => f.author.user_id === identity.user_id);
128
+ return sendJson(res, 200, { schema_version: SCHEMA_VERSION, project_id: store.project_id, feedback: items });
129
+ }
130
+
131
+ if (path === '/api/tux/feedback' && method === 'POST') {
132
+ const body = await readJson(req);
133
+ if (body.error) return sendJson(res, 400, { error: { code: 'invalid', message: body.error } });
134
+ const data = body.data ?? {};
135
+ const type = data.type ?? 'issue';
136
+ if (!FEEDBACK_TYPES.includes(type)) {
137
+ return sendJson(res, 400, { error: { code: 'invalid', message: `invalid feedback type: ${type}` } });
138
+ }
139
+ const text = typeof data.text === 'string' ? data.text : '';
140
+ if (text.trim() === '') return sendJson(res, 400, { error: { code: 'invalid', message: 'text is required' } });
141
+ const store = loadStore(state.cwd, state.config);
142
+ const session = data.session_id ?? state.session;
143
+ const seq = store.feedback.length + 1;
144
+ const id = newId('fb', store.project_id, session, seq, 'feedback', nowMs());
145
+ const item = makeFeedback({
146
+ id,
147
+ project_id: store.project_id,
148
+ session_id: session,
149
+ author: identity,
150
+ origin: state.mode === 'design' ? 'design' : 'live',
151
+ location: data.location ?? {},
152
+ target: data.target ?? {},
153
+ ui_state: data.ui_state ?? {},
154
+ type,
155
+ text,
156
+ created_at: canonicalTimestamp(nowMs()),
157
+ });
158
+ store.feedback.push(item);
159
+ saveStore(state.cwd, state.config, store);
160
+ return sendJson(res, 201, item);
161
+ }
162
+ if (path === '/api/tux/feedback/clear' && method === 'POST') {
163
+ const body = await readJson(req);
164
+ if (body.error) return sendJson(res, 400, { error: { code: 'invalid', message: body.error } });
165
+ const scope = body.data?.scope;
166
+ if (scope !== 'mine' && scope !== 'all') {
167
+ return sendJson(res, 400, { error: { code: 'invalid', message: 'scope must be "mine" or "all"' } });
168
+ }
169
+ if (scope === 'all' && !isAdmin(state, identity.user_id)) {
170
+ return sendJson(res, 403, { error: { code: 'forbidden', message: 'clearing all feedback requires admin' } });
171
+ }
172
+ const route = body.data?.route;
173
+ const session = body.data?.session;
174
+ const store = loadStore(state.cwd, state.config);
175
+ const before = store.feedback.length;
176
+ store.feedback = store.feedback.filter((f) => {
177
+ if (scope === 'mine' && f.author.user_id !== identity.user_id) return true;
178
+ if (route && f.location?.route !== route) return true;
179
+ if (session && f.session_id !== session) return true;
180
+ return false;
181
+ });
182
+ const cleared = before - store.feedback.length;
183
+ saveStore(state.cwd, state.config, store);
184
+ return sendJson(res, 200, { cleared });
185
+ }
186
+
187
+ const feedbackMatch = path.match(/^\/api\/tux\/feedback\/([^/]+)$/);
188
+ if (feedbackMatch) {
189
+ const id = decodeURIComponent(feedbackMatch[1]);
190
+ const store = loadStore(state.cwd, state.config);
191
+ const idx = store.feedback.findIndex((f) => f.id === id);
192
+ if (idx === -1) return sendJson(res, 404, { error: { code: 'not_found', message: `feedback not found: ${id}` } });
193
+ const item = store.feedback[idx];
194
+ if (method === 'GET') return sendJson(res, 200, item);
195
+ if (method === 'PATCH') {
196
+ if (item.author.user_id !== identity.user_id) {
197
+ return sendJson(res, 403, { error: { code: 'forbidden', message: 'only the author may update this feedback' } });
198
+ }
199
+ const body = await readJson(req);
200
+ if (body.error) return sendJson(res, 400, { error: { code: 'invalid', message: body.error } });
201
+ const patch = body.data ?? {};
202
+ if (patch.text !== undefined) item.feedback.text = String(patch.text);
203
+ if (patch.type !== undefined) {
204
+ if (!FEEDBACK_TYPES.includes(patch.type)) return sendJson(res, 400, { error: { code: 'invalid', message: `invalid feedback type: ${patch.type}` } });
205
+ item.feedback.type = patch.type;
206
+ }
207
+ item.updated_at = canonicalTimestamp(nowMs());
208
+ saveStore(state.cwd, state.config, store);
209
+ return sendJson(res, 200, item);
210
+ }
211
+ if (method === 'DELETE') {
212
+ const owner = item.author.user_id === identity.user_id;
213
+ if (!owner && !isAdmin(state, identity.user_id)) {
214
+ return sendJson(res, 403, { error: { code: 'forbidden', message: 'only the author or an admin may delete this feedback' } });
215
+ }
216
+ store.feedback.splice(idx, 1);
217
+ saveStore(state.cwd, state.config, store);
218
+ return sendJson(res, 200, { deleted: [id] });
219
+ }
220
+ }
221
+
222
+ return sendJson(res, 404, { error: { code: 'not_found', message: `unknown API path: ${path}` } });
223
+ }
224
+
225
+ function assets(state, res, path) {
226
+ if (path === '/__tux__/bootstrap.js') {
227
+ const bootstrap = `window.__TUX__ = ${JSON.stringify({
228
+ enabled: state.config.review?.enabled !== false, // startup configuration (SPC 64–65)
229
+ build: 'included',
230
+ mode: state.mode,
231
+ apiBase: '/api/tux',
232
+ session: state.session,
233
+ environment: state.environment,
234
+ user: state.identity.user_id,
235
+ displayName: state.identity.display_name,
236
+ project: state.config.project_id,
237
+ })};\n`;
238
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
239
+ res.end(bootstrap);
240
+ return;
241
+ }
242
+ if (path === '/__tux__/client.js' || path === '/__tux__/client.css') {
243
+ const file = join(CLIENT_DIR, path === '/__tux__/client.js' ? 'tux-review.js' : 'tux-review.css');
244
+ if (!existsSync(file)) {
245
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
246
+ res.end('not found');
247
+ return;
248
+ }
249
+ res.writeHead(200, { 'Content-Type': MIME[extname(file)], 'Cache-Control': 'no-store' });
250
+ res.end(readFileSync(file));
251
+ return;
252
+ }
253
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
254
+ res.end('not found');
255
+ }
256
+
257
+ function static_(state, res, path) {
258
+ let rel = normalize(decodeURIComponent(path)).replace(/^([/\\])+/, '');
259
+ if (rel === '') rel = 'index.html';
260
+ let file = join(state.root, rel);
261
+ if (!file.startsWith(state.root)) {
262
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
263
+ res.end('forbidden');
264
+ return;
265
+ }
266
+ if (!existsSync(file) || statSync(file).isDirectory()) {
267
+ const candidates = [join(file, 'index.html'), file + '.html'];
268
+ file = candidates.find((c) => existsSync(c) && statSync(c).isFile());
269
+ if (!file) {
270
+ // SPA fallback: unknown extension-less routes render the app shell
271
+ if (!extname(path)) file = join(state.root, 'index.html');
272
+ if (!existsSync(file)) {
273
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
274
+ res.end('not found');
275
+ return;
276
+ }
277
+ }
278
+ }
279
+ const type = MIME[extname(file)] ?? 'application/octet-stream';
280
+ const body = readFileSync(file);
281
+ if (type.startsWith('text/html')) {
282
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
283
+ res.end(inject(body.toString('utf8')));
284
+ return;
285
+ }
286
+ res.writeHead(200, { 'Content-Type': type });
287
+ res.end(body);
288
+ }
289
+
290
+ function proxy(state, req, res) {
291
+ const target = new URL(state.target);
292
+ const options = {
293
+ protocol: target.protocol,
294
+ hostname: target.hostname,
295
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
296
+ path: req.url,
297
+ method: req.method,
298
+ headers: { ...req.headers, host: target.host },
299
+ };
300
+ const upstream = httpRequest(options, (up) => {
301
+ const type = up.headers['content-type'] ?? '';
302
+ if (type.startsWith('text/html')) {
303
+ const chunks = [];
304
+ up.on('data', (c) => chunks.push(c));
305
+ up.on('end', () => {
306
+ res.writeHead(up.statusCode, up.headers);
307
+ res.end(inject(Buffer.concat(chunks).toString('utf8')));
308
+ });
309
+ return;
310
+ }
311
+ res.writeHead(up.statusCode, up.headers);
312
+ up.pipe(res);
313
+ });
314
+ upstream.on('error', (err) => {
315
+ sendJson(res, 502, { error: { code: 'bad_gateway', message: `target unreachable: ${err.message}` } });
316
+ });
317
+ req.pipe(upstream);
318
+ }
319
+
320
+ /** Inject the TUX bootstrap + client into an HTML document. */
321
+ export function inject(html) {
322
+ const tags = '<script src="/__tux__/bootstrap.js"></script><script type="module" src="/__tux__/client.js"></script>';
323
+ if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${tags}</head>`);
324
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${tags}</body>`);
325
+ return html + tags;
326
+ }
327
+
328
+ function readJson(req) {
329
+ return new Promise((resolvePromise) => {
330
+ const chunks = [];
331
+ req.on('data', (c) => chunks.push(c));
332
+ req.on('end', () => {
333
+ const raw = Buffer.concat(chunks).toString('utf8');
334
+ if (raw === '') return resolvePromise({ data: {} });
335
+ try {
336
+ resolvePromise({ data: JSON.parse(raw) });
337
+ } catch (e) {
338
+ resolvePromise({ error: `invalid JSON: ${e.message}` });
339
+ }
340
+ });
341
+ });
342
+ }
343
+
344
+ function sendJson(res, status, data) {
345
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
346
+ res.end(canonicalJson(data));
347
+ }
package/src/store.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * JSON persistence backend (SPC section 22): a single canonical JSON file
3
+ * at `review.store` (default `.tux/feedback.json`), plus review sessions
4
+ * under `.tux/sessions/` and incorporation batches under
5
+ * `.tux/incorporations/`. The logical schema is independent of the
6
+ * storage backend; agents go through the CLI/API, never the file layout.
7
+ *
8
+ * @module store
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { canonicalJson } from './canonical.js';
13
+ import { SCHEMA_VERSION } from './schema.js';
14
+
15
+ export function storeFilePath(cwd, config) {
16
+ return join(cwd, config.review.store);
17
+ }
18
+
19
+ export function loadStore(cwd, config) {
20
+ const p = storeFilePath(cwd, config);
21
+ if (!existsSync(p)) return { schema_version: SCHEMA_VERSION, project_id: config.project_id, feedback: [] };
22
+ const store = JSON.parse(readFileSync(p, 'utf8'));
23
+ if (store.feedback === undefined) store.feedback = [];
24
+ return store;
25
+ }
26
+
27
+ export function saveStore(cwd, config, store) {
28
+ const p = storeFilePath(cwd, config);
29
+ mkdirSync(join(cwd, '.tux'), { recursive: true });
30
+ writeFileSync(p, canonicalJson(store) + '\n', 'utf8');
31
+ }
32
+
33
+ export function storeExists(cwd, config) {
34
+ return existsSync(storeFilePath(cwd, config));
35
+ }
36
+
37
+ /** Sessions directory handling. */
38
+ export function sessionsDir(cwd) {
39
+ return join(cwd, '.tux', 'sessions');
40
+ }
41
+
42
+ export function ensureSession(cwd, config, sessionId, environment, timestamp) {
43
+ mkdirSync(sessionsDir(cwd), { recursive: true });
44
+ const p = join(sessionsDir(cwd), `${sessionId}.json`);
45
+ if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8'));
46
+ const session = {
47
+ session_id: sessionId,
48
+ project_id: config.project_id,
49
+ environment,
50
+ status: 'active',
51
+ created_at: timestamp,
52
+ };
53
+ writeFileSync(p, canonicalJson(session) + '\n', 'utf8');
54
+ return session;
55
+ }
56
+
57
+ /** Incorporation batches directory handling. */
58
+ export function incorporationsDir(cwd) {
59
+ return join(cwd, '.tux', 'incorporations');
60
+ }
61
+
62
+ export function countIncorporationBatches(cwd) {
63
+ const dir = incorporationsDir(cwd);
64
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')).length : 0;
65
+ }
66
+
67
+ export function saveIncorporationBatch(cwd, batch) {
68
+ mkdirSync(incorporationsDir(cwd), { recursive: true });
69
+ const p = join(incorporationsDir(cwd), `${batch.batch_id}.json`);
70
+ writeFileSync(p, canonicalJson(batch) + '\n', 'utf8');
71
+ return p;
72
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Design templates for `tux design create --framework <framework>`.
3
+ *
4
+ * Template content lives as real files under `js/templates/<framework>/`
5
+ * (single source of truth). The Python mirror ships byte-identical copies
6
+ * under `py/tux/templates/` (synced by `tools/sync-artifacts.mjs`,
7
+ * enforced by the artifact identity tests). Templates are plain files on
8
+ * purpose: no string duplication, no escaping traps, byte-exact fixtures.
9
+ *
10
+ * Every template is a runnable multi-route design (History-API routing,
11
+ * tabs, a modal) annotated with TUX targeting attributes
12
+ * (data-tux-id, data-tux-component, data-tux-instance, data-tux-state).
13
+ *
14
+ * @module templates
15
+ */
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates');
21
+
22
+ export const SUPPORTED_FRAMEWORKS = ['vanilla', 'react', 'vue', 'angular'];
23
+
24
+ /** Files (relative path → content) for `tux design create --framework <framework>`. */
25
+ export function designTemplate(framework) {
26
+ if (!SUPPORTED_FRAMEWORKS.includes(framework)) {
27
+ throw new Error(`unsupported framework: ${framework}`);
28
+ }
29
+ return readTree(join(TEMPLATE_ROOT, framework));
30
+ }
31
+
32
+ function readTree(dir, prefix = '') {
33
+ const out = {};
34
+ for (const entry of readdirSync(dir)) {
35
+ const p = join(dir, entry);
36
+ const rel = prefix ? `${prefix}/${entry}` : entry;
37
+ if (statSync(p).isDirectory()) {
38
+ Object.assign(out, readTree(p, rel));
39
+ } else {
40
+ out[rel] = readFileSync(p, 'utf8');
41
+ }
42
+ }
43
+ return out;
44
+ }
@@ -0,0 +1,19 @@
1
+ # checkout (angular design)
2
+
3
+ This design itself uses npm. Choose one TUX CLI: Node users run
4
+ `npm install --save-dev tux-review` then `npx tux …`; Python users install
5
+ `tux-review` with `pipx` or `pip` then run `tux …`. The TUX commands below are
6
+ otherwise identical.
7
+
8
+ ```bash
9
+ npm install
10
+ npm run build
11
+
12
+ # Node.js/npm TUX variant — serves the built design with TUX review
13
+ npx tux design start-review --foreground --dir .
14
+
15
+ # Python/PyPI TUX variant — same command after Python installation
16
+ tux design start-review --foreground --dir .
17
+
18
+ # Alternative for either variant: npm start + <tux command> live start-review --url http://localhost:4200
19
+ ```
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
+ "version": 1,
4
+ "newProjectRoot": "projects",
5
+ "projects": {
6
+ "tux-design": {
7
+ "projectType": "application",
8
+ "root": "",
9
+ "sourceRoot": "src",
10
+ "prefix": "app",
11
+ "architect": {
12
+ "build": {
13
+ "builder": "@angular/build:application",
14
+ "options": {
15
+ "outputPath": "dist/tux-design",
16
+ "index": "src/index.html",
17
+ "browser": "src/main.ts",
18
+ "tsConfig": "tsconfig.app.json",
19
+ "assets": [],
20
+ "styles": ["src/styles.css"]
21
+ },
22
+ "configurations": {
23
+ "production": {
24
+ "outputHashing": "all"
25
+ },
26
+ "development": {
27
+ "optimization": false,
28
+ "extractLicenses": false,
29
+ "sourceMap": true
30
+ }
31
+ },
32
+ "defaultConfiguration": "production"
33
+ },
34
+ "serve": {
35
+ "builder": "@angular/build:dev-server",
36
+ "configurations": {
37
+ "production": {
38
+ "buildTarget": "tux-design:build:production"
39
+ },
40
+ "development": {
41
+ "buildTarget": "tux-design:build:development"
42
+ }
43
+ },
44
+ "defaultConfiguration": "development"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "tux-design",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "scripts": {
6
+ "ng": "ng",
7
+ "start": "ng serve --port 4200",
8
+ "build": "ng build"
9
+ },
10
+ "dependencies": {
11
+ "@angular/common": "^19.0.0",
12
+ "@angular/compiler": "^19.0.0",
13
+ "@angular/core": "^19.0.0",
14
+ "@angular/platform-browser": "^19.0.0",
15
+ "rxjs": "~7.8.0",
16
+ "tslib": "^2.3.0",
17
+ "zone.js": "~0.15.0"
18
+ },
19
+ "devDependencies": {
20
+ "@angular/build": "^19.0.0",
21
+ "@angular/cli": "^19.0.0",
22
+ "@angular/compiler-cli": "^19.0.0",
23
+ "typescript": "~5.6.2"
24
+ }
25
+ }