@techninja/clearstack 0.4.17 → 0.4.21

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.
@@ -14,7 +14,7 @@ import { setOnCopyNote } from './watch-widgets.js';
14
14
  * @param {{ serverRow: object|null, specRows: object[], projectDir: string, watchDirs: string[], schedule: (ext: string) => void, renderNote: () => void }} ctx
15
15
  */
16
16
  export function setupLifecycle(ctx) {
17
- const { serverRow, specRows, projectDir, watchDirs, schedule, renderNote } = ctx;
17
+ const { serverRow, specRows, projectDir, watchDirs, schedule, renderNote, ignore } = ctx;
18
18
 
19
19
  const quit = () => {
20
20
  serverRow?.kill();
@@ -37,7 +37,9 @@ export function setupLifecycle(ctx) {
37
37
 
38
38
  for (const dir of watchDirs) {
39
39
  watch(resolve(projectDir, dir), { recursive: true }, (_event, filename) => {
40
- if (filename) schedule(extname(filename), filename);
40
+ if (!filename) return;
41
+ if (ignore?.some((seg) => filename.includes(seg))) return;
42
+ schedule(extname(filename), filename);
41
43
  });
42
44
  }
43
45
 
@@ -42,6 +42,7 @@ export function runKeys(specRows, keys, ctx) {
42
42
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1) + 's';
43
43
  row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
44
44
  row.pass = row.result.pass;
45
+ if (row.pass) row.ranAt = Date.now();
45
46
  row.detail = row.pass
46
47
  ? `${row.result.detail ?? (row.result.files ? `${row.result.files} files` : '')} (${elapsed})`
47
48
  : (row.result.violations?.length
package/lib/watch-ui.js CHANGED
@@ -1,79 +1,16 @@
1
1
  /**
2
- * Spec watch UI — rendering, split candidates, violation extraction.
2
+ * Spec watch UI — rendering and violation display.
3
+ * Split candidate detection and violation extraction live in watch-violations.js.
3
4
  * Widget construction lives in watch-widgets.js.
4
5
  * @module lib/watch-ui
5
6
  */
6
7
 
7
- import { readFileSync, existsSync } from 'node:fs';
8
- import { resolve } from 'node:path';
9
8
  import { screen, statusBox, divider, logBox, getSpin, getCopyNote } from './watch-widgets.js';
10
9
 
11
10
  export { setQuit } from './watch-widgets.js';
11
+ export { splitCandidates, extractViolations } from './watch-violations.js';
12
12
 
13
- // ── Split candidate detection ─────────────────────────────────────────────────
14
-
15
- /**
16
- * Suggest split seams for a file that exceeds the line limit.
17
- * Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
18
- * @param {string} filePath absolute path
19
- * @returns {string[]}
20
- */
21
- export function splitCandidates(filePath) {
22
- if (!existsSync(filePath)) return [];
23
- const lines = readFileSync(filePath, 'utf-8').split('\n');
24
- const explicit = [];
25
- lines.forEach((l, i) => {
26
- const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
27
- if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
28
- });
29
- if (explicit.length) return explicit;
30
-
31
- const seams = [];
32
- lines.forEach((l, i) => {
33
- // Only exported functions/classes are meaningful split boundaries
34
- if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
35
- });
36
- if (seams.length < 2) return [];
37
- const suggestions = [];
38
- for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
39
- const start = seams[i], end = seams[i + 1] - 1;
40
- if (end - start < 10) continue;
41
- // Peek at the function name for a more useful suggestion
42
- const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
43
- const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
44
- suggestions.push(` L${start}-${end}: ${hint}`);
45
- }
46
- return suggestions;
47
- }
48
-
49
- // ── Violation extraction ──────────────────────────────────────────────────────
50
-
51
- /**
52
- * Process a failed check row into violation tuples for the log panel.
53
- * @param {object} row
54
- * @param {string} projectDir
55
- * @returns {string[][]}
56
- */
57
- export function extractViolations(row, projectDir) {
58
- if (row.pass) return [];
59
- if (row.result?.violations?.length) {
60
- return row.result.violations.map((v) => {
61
- if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
62
- const candidates = splitCandidates(resolve(projectDir, v.file));
63
- return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
64
- });
65
- }
66
- if (row.result?.errors?.length) {
67
- const errors = row.result.errors;
68
- // Prettier: [warn] src/file.js — expand each into its own entry
69
- const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
70
- if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
71
- return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
72
- }
73
- return [];
74
- }
75
-
76
- // ── Render ────────────────────────────────────────────────────────────────────
13
+ // ── Row rendering ─────────────────────────────────────────────────────────────
77
14
 
78
15
  /** @param {object} r */
79
16
  function rowIcon(r) {
@@ -85,11 +22,40 @@ function rowIcon(r) {
85
22
  }
86
23
  if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
87
24
  if (r.pass) return '{green-fg}ok{/}';
25
+ if (r.keyBinding) return '{grey-fg}–{/} ';
88
26
  return '{red-fg}!{/} ';
89
27
  }
90
28
 
29
+ /** @param {number} ms @returns {string} */
30
+ function timeAgo(ms) {
31
+ const s = Math.floor((Date.now() - ms) / 1000);
32
+ if (s < 60) return `${s}s ago`;
33
+ const m = Math.floor(s / 60);
34
+ if (m < 60) return `${m}m ago`;
35
+ return `${Math.floor(m / 60)}h ago`;
36
+ }
37
+
91
38
  /** @param {object} r @param {number} w */
92
- const rowLine = (r, w) => { const n = (r.label ?? r.name ?? r.key).padEnd(w); return ` ${rowIcon(r)} ${r.pass === false ? `{red-fg}${n}{/}` : n} ${r.detail ? `{grey-fg}${r.detail}{/}` : ''}`; };
39
+ const rowLine = (r, w) => {
40
+ const n = (r.label ?? r.name ?? r.key).padEnd(w);
41
+ const isIdle = r.pass === false && r.keyBinding;
42
+ const name = (!isIdle && r.pass === false) ? `{red-fg}${n}{/}` : n;
43
+ const detail = isIdle
44
+ ? `{grey-fg}${r.ranAt ? timeAgo(r.ranAt) : `press ${r.keyBinding} to run`}{/}`
45
+ : (r.detail ? `{grey-fg}${r.detail}{/}` : '');
46
+ return ` ${rowIcon(r)} ${name} ${detail}`;
47
+ };
48
+
49
+ // ── Status box helpers ────────────────────────────────────────────────────────
50
+
51
+ /** @param {object[]} rows @param {string} lastCheck @param {string[]} watchDirs */
52
+ function statusContent(rows, lastCheck, watchDirs) {
53
+ const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
54
+ return [...rows.map((r) => rowLine(r, w)), '',
55
+ `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`];
56
+ }
57
+
58
+ // ── Public API ────────────────────────────────────────────────────────────────
93
59
 
94
60
  /**
95
61
  * Render the dashboard.
@@ -99,14 +65,11 @@ const rowLine = (r, w) => { const n = (r.label ?? r.name ?? r.key).padEnd(w); re
99
65
  * @param {string[][]} violations
100
66
  */
101
67
  export function render(rows, lastCheck, watchDirs, violations) {
102
- const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
103
- const statusLines = rows.map((r) => rowLine(r, w));
104
- statusLines.push('');
105
- statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
106
- statusBox.setContent(statusLines.join('\n'));
107
- statusBox.height = statusLines.length;
68
+ const lines = statusContent(rows, lastCheck, watchDirs);
69
+ statusBox.setContent(lines.join('\n'));
70
+ statusBox.height = lines.length;
108
71
 
109
- const statusHeight = statusLines.length + 1;
72
+ const statusHeight = lines.length + 1;
110
73
  divider.top = statusHeight;
111
74
  logBox.top = statusHeight + 1;
112
75
 
@@ -139,7 +102,6 @@ export function destroyScreen() {
139
102
 
140
103
  /** Refresh just the status box — used for transient notes like copy confirmation. */
141
104
  export function renderNote(rows, lastCheck, watchDirs) {
142
- const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
143
- statusBox.setContent([...rows.map((r) => rowLine(r, w)), '', `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`].join('\n'));
105
+ statusBox.setContent(statusContent(rows, lastCheck, watchDirs).join('\n'));
144
106
  screen.render();
145
107
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Violation extraction and split candidate detection for the spec watch dashboard.
3
+ * @module lib/watch-violations
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+
9
+ /**
10
+ * Suggest split seams for a file that exceeds the line limit.
11
+ * Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
12
+ * @param {string} filePath absolute path
13
+ * @returns {string[]}
14
+ */
15
+ export function splitCandidates(filePath) {
16
+ if (!existsSync(filePath)) return [];
17
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
18
+ const explicit = [];
19
+ lines.forEach((l, i) => {
20
+ const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
21
+ if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
22
+ });
23
+ if (explicit.length) return explicit;
24
+
25
+ const seams = [];
26
+ lines.forEach((l, i) => {
27
+ // Only exported functions/classes are meaningful split boundaries
28
+ if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
29
+ });
30
+ if (seams.length < 2) return [];
31
+ const suggestions = [];
32
+ for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
33
+ const start = seams[i], end = seams[i + 1] - 1;
34
+ if (end - start < 10) continue;
35
+ // Peek at the function name for a more useful suggestion
36
+ const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
37
+ const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
38
+ suggestions.push(` L${start}-${end}: ${hint}`);
39
+ }
40
+ return suggestions;
41
+ }
42
+
43
+ /**
44
+ * Process a failed check row into violation tuples for the log panel.
45
+ * @param {object} row
46
+ * @param {string} projectDir
47
+ * @returns {string[][]}
48
+ */
49
+ export function extractViolations(row, projectDir) {
50
+ if (row.pass) return [];
51
+ if (row.result?.violations?.length) {
52
+ return row.result.violations.map((v) => {
53
+ if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
54
+ const candidates = splitCandidates(resolve(projectDir, v.file));
55
+ return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
56
+ });
57
+ }
58
+ if (row.result?.errors?.length) {
59
+ const errors = row.result.errors;
60
+ // Prettier: [warn] src/file.js — expand each into its own entry
61
+ const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
62
+ if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
63
+ return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
64
+ }
65
+ return [];
66
+ }
package/lib/watch.js CHANGED
@@ -17,7 +17,7 @@ import { screen, outer } from './watch-widgets.js';
17
17
  /**
18
18
  *
19
19
  */
20
- function toRow(check) { return { ...check, pass: null, detail: '', result: null }; }
20
+ function toRow(check) { return { ...check, pass: check.keyBinding ? false : null, detail: '', result: null }; }
21
21
 
22
22
  /**
23
23
  *
@@ -78,10 +78,18 @@ export async function startWatch(projectDir) {
78
78
  render(rows, lastCheck.value, watchDirs, currentViolations.value);
79
79
  run(new Set(specRows.filter((r) => !r.keyBinding).map((r) => r.key)));
80
80
 
81
+ // Refresh idle key-bound rows so "X ago" detail stays current.
82
+ const idleTimer = setInterval(() => {
83
+ if (specRows.some((r) => r.keyBinding && r.pass === false && r.ranAt))
84
+ render(rows, lastCheck.value, watchDirs, currentViolations.value);
85
+ }, 30_000);
86
+ idleTimer.unref();
87
+
81
88
  setupFix(specRows, cmds, ctx, pending, timers, run);
82
89
 
83
90
  setupLifecycle({
84
91
  serverRow, specRows, projectDir, watchDirs,
92
+ ignore: cfg.ignore ?? [],
85
93
  schedule: (ext, filepath) => schedule(specRows, ctx, pending, timers, run, checksForExt, ext, filepath),
86
94
  renderNote: () => renderNote(rows, lastCheck.value, watchDirs),
87
95
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.4.17",
3
+ "version": "0.4.21",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "scripts": {
24
24
  "start": "node src/server.js",
25
25
  "dev": "node --watch --env-file=.env --env-file=.env.local src/server.js",
26
+ "docs": "node scripts/sync-docs.js",
26
27
  "setup": "node scripts/vendor-deps.js && node scripts/build-icons.js",
27
28
  "test": "node scripts/test.js",
28
29
  "spec": "node --env-file=.env scripts/spec.js",
@@ -69,7 +70,7 @@
69
70
  "express": "^5.2.1",
70
71
  "hybrids": "^9.1.22",
71
72
  "lucide-static": "^1.7.0",
72
- "markdownlint-cli2": "^0.12.1",
73
+ "markdownlint-cli2": "^0.23.2",
73
74
  "playwright": "^1.61.0",
74
75
  "prettier": "^3.8.1",
75
76
  "stylelint": "^17.6.0",