@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.157.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5168 -7271
  4. package/dist/base-skills/notis-apps/SKILL.md +108 -194
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +322 -1560
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +45 -44
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +20 -7
  23. package/template/packages/sdk/src/config.ts +0 -2
  24. package/template/packages/sdk/src/interactions.ts +2 -1
  25. package/template/packages/sdk/src/styles.css +28 -1
  26. package/src/runtime/app-dev-build-supervisor.js +0 -47
  27. package/src/runtime/app-dev-build.js +0 -41
  28. package/src/runtime/app-dev-consumers.js +0 -154
  29. package/src/runtime/app-dev-host-lock.js +0 -80
  30. package/src/runtime/app-dev-process-identity.js +0 -111
  31. package/src/runtime/app-dev-roots.js +0 -284
  32. package/src/runtime/app-dev-server.js +0 -1136
  33. package/src/runtime/app-dev-sessions.js +0 -185
  34. package/src/runtime/cli-mode.generated.js +0 -5
  35. package/src/runtime/cli-mode.js +0 -34
@@ -227,6 +227,7 @@ export async function runHarnessRoute({
227
227
  sessionName,
228
228
  timeoutMs = 10_000,
229
229
  snapshotPath = null,
230
+ designViewports = DEFAULT_DESIGN_VIEWPORTS,
230
231
  }) {
231
232
  const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
232
233
  timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
@@ -283,14 +284,33 @@ export async function runHarnessRoute({
283
284
  }
284
285
 
285
286
  const timedOut = !harness.mounted && Date.now() >= deadline;
287
+ const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
288
+
289
+ // Design assertions only mean something on a mounted route. Loading
290
+ // placeholders are excused while a runtime call is still in flight.
291
+ let design = [];
292
+ let designToolError = null;
293
+ if (harness.mounted && Array.isArray(designViewports) && designViewports.length > 0) {
294
+ await delay(500);
295
+ const pending = runtimeCalls.some((call) => call && call.ok == null);
296
+ const collected = await collectDesignFindings(sessionName, {
297
+ viewports: designViewports,
298
+ skipLoadingCheck: pending,
299
+ });
300
+ design = collected.findings;
301
+ designToolError = collected.tool_error;
302
+ }
303
+
286
304
  return {
287
305
  mounted: Boolean(harness.mounted),
288
306
  renderStarted: Boolean(harness.renderStarted),
289
307
  errors: Array.isArray(harness.errors) ? harness.errors : [],
290
- runtimeCalls: Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [],
308
+ runtimeCalls,
291
309
  snapshotPath: savedSnapshotPath,
292
310
  timed_out: timedOut,
293
311
  tool_error: null,
312
+ design,
313
+ design_tool_error: designToolError,
294
314
  raw: harness,
295
315
  };
296
316
  }
@@ -408,6 +428,8 @@ export async function captureHarnessScreenshot({
408
428
 
409
429
  // Let the route settle (async data, resize transitions) before the capture.
410
430
  await delay(500);
431
+ const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
432
+ const design = Array.isArray(designResult) ? designResult : [];
411
433
 
412
434
  mkdirSync(dirname(screenshotPath), { recursive: true });
413
435
  const screenshotArgs = ['--session', sessionName, 'screenshot'];
@@ -446,6 +468,7 @@ export async function captureHarnessScreenshot({
446
468
  }
447
469
 
448
470
  return {
471
+ design,
449
472
  ok: true,
450
473
  mounted: true,
451
474
  screenshotPath,
@@ -462,3 +485,148 @@ export async function closeAgentBrowserSession(sessionName) {
462
485
  });
463
486
  return result.exitCode === 0;
464
487
  }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Runtime design assertions.
491
+ //
492
+ // The static design lint (app-boundary-validator.js) catches banned class
493
+ // names. This script runs in the mounted harness page and catches what a
494
+ // regex cannot: a bordered box nested in a bordered box across files, tinted
495
+ // panels stacked three deep, text that computes below 12px, horizontal
496
+ // overflow at a phone width, and loading placeholders that outlive the data.
497
+ // Palette colors are deliberately not checked here: at runtime a token and a
498
+ // hardcoded hue resolve to the same RGB, so that stays with the static lint.
499
+ // ---------------------------------------------------------------------------
500
+ export const DESIGN_ASSERTIONS_MARKER = '__notisDesignAssertions';
501
+
502
+ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
503
+ /* ${DESIGN_ASSERTIONS_MARKER} */
504
+ const root = document.getElementById('root') || document.body;
505
+ const out = [];
506
+ const seen = new Set();
507
+ const push = (kind, el, extra) => {
508
+ const cls = String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className || '').trim().slice(0, 80);
509
+ const text = (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
510
+ const snippet = el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\\s+/).slice(0, 6).join('.') : '') + (text ? ' "' + text + '"' : '');
511
+ const key = kind + '|' + snippet;
512
+ if (seen.has(key)) return;
513
+ seen.add(key);
514
+ out.push(Object.assign({ kind, snippet }, extra || {}));
515
+ };
516
+ const controls = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'A', 'SUMMARY']);
517
+ const isControl = (el) => controls.has(el.tagName) || /^(button|checkbox|switch|radio|combobox|textbox|tab|menuitem)$/.test(el.getAttribute('role') || '');
518
+ const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
519
+ const isBox = (el) => {
520
+ const cs = getComputedStyle(el);
521
+ if (!['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].every((k) => parseFloat(cs[k]) > 0)) return false;
522
+ if (/rgba\\(\\d+, \\d+, \\d+, 0\\)|transparent/.test(cs.borderTopColor)) return false;
523
+ const r = el.getBoundingClientRect();
524
+ return r.width > 40 && r.height > 20;
525
+ };
526
+ const bg = (el) => getComputedStyle(el).backgroundColor;
527
+ const transparent = (c) => !c || c === 'transparent' || /rgba\\(\\d+, \\d+, \\d+, 0\\)/.test(c);
528
+ const isPanel = (el) => {
529
+ if (transparent(bg(el))) return false;
530
+ if (parseFloat(getComputedStyle(el).borderRadius) <= 0) return false;
531
+ const r = el.getBoundingClientRect();
532
+ return r.width > 160 && r.height > 48;
533
+ };
534
+ const all = Array.from(root.querySelectorAll('*'));
535
+ for (const el of all) {
536
+ if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
537
+ if (isBox(el)) {
538
+ let p = el.parentElement; let depth = 0;
539
+ while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
540
+ }
541
+ if (isPanel(el)) {
542
+ let p = el.parentElement; let panels = 0;
543
+ while (p && p !== root) { if (isPanel(p)) panels += 1; p = p.parentElement; }
544
+ if (panels >= 2) push('nested_tinted_panel', el, { depth: panels + 1 });
545
+ }
546
+ }
547
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
548
+ let node;
549
+ while ((node = walker.nextNode())) {
550
+ const text = (node.textContent || '').trim();
551
+ if (!text) continue;
552
+ const el = node.parentElement;
553
+ if (!el || el.closest('#harness-status') || !visible(el)) continue;
554
+ const size = parseFloat(getComputedStyle(el).fontSize);
555
+ if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
556
+ if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
557
+ }
558
+ if (root.querySelector('[data-notis-content-skeleton]')) push('loading_placeholder_after_mount', root.querySelector('[data-notis-content-skeleton]'));
559
+ const doc = document.documentElement;
560
+ if (doc.scrollWidth > doc.clientWidth + 1) out.push({ kind: 'horizontal_overflow', snippet: 'document', scroll_width: doc.scrollWidth, client_width: doc.clientWidth });
561
+ return JSON.stringify(out.slice(0, 40));
562
+ })()`;
563
+
564
+ export const DEFAULT_DESIGN_VIEWPORTS = [
565
+ { name: 'desktop', width: 1280, height: 900 },
566
+ { name: 'mobile', width: 390, height: 844 },
567
+ ];
568
+
569
+ const DESIGN_KIND_MESSAGES = {
570
+ nested_border_box: 'a bordered box sits inside another bordered box',
571
+ nested_tinted_panel: 'tinted panels are nested three deep',
572
+ text_below_12px: 'visible text renders below 12px',
573
+ loading_placeholder_after_mount: 'a loading placeholder is still visible after the route mounted with its data',
574
+ horizontal_overflow: 'the page overflows horizontally',
575
+ };
576
+
577
+ export function describeDesignFinding(finding) {
578
+ const base = DESIGN_KIND_MESSAGES[finding.kind] || finding.kind;
579
+ const where = finding.viewport ? ` at ${finding.viewport}` : '';
580
+ const detail = finding.snippet && finding.snippet !== 'document' ? `: ${finding.snippet}` : '';
581
+ return `${base}${where}${detail}`;
582
+ }
583
+
584
+ /**
585
+ * Evaluate the design assertions in the current page. Returns an array of
586
+ * findings, or `{ tool_error }` when agent-browser could not run the script.
587
+ */
588
+ export async function evalDesignAssertions(sessionName, { viewport = null, timeoutMs = 8000 } = {}) {
589
+ const result = await runAgentBrowser(
590
+ ['--session', sessionName, '--json', 'eval', DESIGN_ASSERTIONS_SCRIPT],
591
+ { timeoutMs },
592
+ );
593
+ if (result.exitCode !== 0) {
594
+ return { tool_error: commandError('design_eval', result) };
595
+ }
596
+ try {
597
+ const payload = parseAgentBrowserJson(result.stdout);
598
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
599
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
600
+ if (!Array.isArray(value)) throw new Error('Design evaluation did not return a findings array.');
601
+ return value.map((finding) => ({ ...finding, viewport: viewport || finding.viewport || null }));
602
+ } catch (error) {
603
+ return { tool_error: { phase: 'design_eval_parse', message: error.message } };
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Run every requested viewport. Missing browser checks are incomplete proof,
609
+ * not a passing design result, even when static source lint passed.
610
+ */
611
+ export async function collectDesignFindings(sessionName, { viewports = DEFAULT_DESIGN_VIEWPORTS, settleMs = 350, skipLoadingCheck = false } = {}) {
612
+ const findings = [];
613
+ let toolError = null;
614
+ for (const viewport of viewports) {
615
+ const applied = await setViewport(sessionName, viewport.width, viewport.height);
616
+ if (!applied) {
617
+ toolError ||= { phase: 'design_viewport', message: `Could not set ${viewport.name} viewport.` };
618
+ continue;
619
+ }
620
+ await delay(settleMs);
621
+ const result = await evalDesignAssertions(sessionName, { viewport: viewport.name });
622
+ if (Array.isArray(result)) {
623
+ for (const finding of result) {
624
+ if (skipLoadingCheck && finding.kind === 'loading_placeholder_after_mount') continue;
625
+ findings.push(finding);
626
+ }
627
+ } else if (!toolError) {
628
+ toolError = result.tool_error;
629
+ }
630
+ }
631
+ return { findings, tool_error: toolError };
632
+ }
@@ -181,3 +181,224 @@ export function validateArtifactBoundary(files) {
181
181
  );
182
182
  }
183
183
  }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Design rules
187
+ //
188
+ // The portal boundary rules above keep an app inside its surface. The design
189
+ // rules below keep it looking like a native, flat Notis page: no bordered
190
+ // boxes around items, no dividers, no palette colors, no eyebrows, no loading
191
+ // text. They are path-scoped, report line numbers, and only ever run on the
192
+ // app's own source files (never on the bundled artifact, where every
193
+ // dependency legitimately contains the word "border").
194
+ //
195
+ // The only override is an inline directive on the same line or the line
196
+ // before the match:
197
+ // // notis-design-allow: <rule-id> <reason of at least N characters>
198
+ // Allowed matches are reported with `allowed: true` so they stay visible.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export const DESIGN_RULES_PATH_CANDIDATES = [
202
+ resolve(moduleDir, '../../../../server/config/notis_app_design_rules.json'),
203
+ resolve(moduleDir, '../../config/notis_app_design_rules.json'),
204
+ ];
205
+
206
+ export function resolveDesignRulesPath() {
207
+ for (const candidate of DESIGN_RULES_PATH_CANDIDATES) {
208
+ if (existsSync(candidate)) {
209
+ return candidate;
210
+ }
211
+ }
212
+ return DESIGN_RULES_PATH_CANDIDATES[DESIGN_RULES_PATH_CANDIDATES.length - 1];
213
+ }
214
+
215
+ const DEFAULT_DESIGN_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
216
+
217
+ function globToRegExp(glob) {
218
+ let source = '';
219
+ for (let index = 0; index < glob.length; index += 1) {
220
+ const char = glob[index];
221
+ if (char === '*') {
222
+ if (glob[index + 1] === '*') {
223
+ index += 1;
224
+ if (glob[index + 1] === '/') {
225
+ index += 1;
226
+ source += '(?:.*/)?';
227
+ } else {
228
+ source += '.*';
229
+ }
230
+ } else {
231
+ source += '[^/]*';
232
+ }
233
+ } else if (char === '?') {
234
+ source += '[^/]';
235
+ } else {
236
+ source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
237
+ }
238
+ }
239
+ return new RegExp(`^${source}$`);
240
+ }
241
+
242
+ function matchesAny(relPath, globs) {
243
+ return (globs || []).some((glob) => globToRegExp(glob).test(relPath));
244
+ }
245
+
246
+ let compiledDesignRules = null;
247
+
248
+ export function loadDesignRules({ rulesPath = resolveDesignRulesPath() } = {}) {
249
+ let payload = null;
250
+ try {
251
+ const parsed = JSON.parse(readFileSync(rulesPath, 'utf-8'));
252
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
253
+ payload = parsed;
254
+ }
255
+ } catch (error) {
256
+ process.stderr.write(
257
+ `Warning: could not load Notis app design rules (${error.message}); skipping design checks.\n`,
258
+ );
259
+ }
260
+ if (!payload) {
261
+ return { include: [], exclude: [], extensions: DEFAULT_DESIGN_EXTENSIONS, rules: [], allowDirective: 'notis-design-allow', allowReasonMinLength: 12 };
262
+ }
263
+ return {
264
+ include: Array.isArray(payload.include) ? payload.include : ['app/**', 'components/**'],
265
+ exclude: Array.isArray(payload.exclude) ? payload.exclude : [],
266
+ extensions: Array.isArray(payload.extensions) ? payload.extensions : DEFAULT_DESIGN_EXTENSIONS,
267
+ allowDirective: typeof payload.allow_directive === 'string' ? payload.allow_directive : 'notis-design-allow',
268
+ allowReasonMinLength: Number.isInteger(payload.allow_reason_min_length) ? payload.allow_reason_min_length : 12,
269
+ rules: (Array.isArray(payload.rules) ? payload.rules : []).map((rule) => ({
270
+ id: rule.id,
271
+ severity: rule.severity === 'warn' ? 'warn' : 'error',
272
+ scope: rule.scope === 'file' ? 'file' : 'line',
273
+ regex: new RegExp(rule.pattern, rule.scope === 'file' ? '' : 'gm'),
274
+ message: rule.message,
275
+ include: Array.isArray(rule.include) ? rule.include : null,
276
+ exclude: Array.isArray(rule.exclude) ? rule.exclude : [],
277
+ })),
278
+ };
279
+ }
280
+
281
+ function getCompiledDesignRules() {
282
+ if (!compiledDesignRules) {
283
+ compiledDesignRules = loadDesignRules();
284
+ }
285
+ return compiledDesignRules;
286
+ }
287
+
288
+ function lineNumberAt(content, index) {
289
+ let line = 1;
290
+ for (let cursor = 0; cursor < index; cursor += 1) {
291
+ if (content.charCodeAt(cursor) === 10) line += 1;
292
+ }
293
+ return line;
294
+ }
295
+
296
+ function findAllowDirective(lines, lineNumber, ruleId, directive) {
297
+ const pattern = new RegExp(`${directive}:\\s*${ruleId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(.*)$`);
298
+ for (const candidate of [lines[lineNumber - 1], lines[lineNumber - 2]]) {
299
+ if (typeof candidate !== 'string') continue;
300
+ const match = candidate.match(pattern);
301
+ if (match) {
302
+ return match[1].replace(/\*\/\s*}?\s*$/, '').replace(/-->\s*$/, '').trim();
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ export function collectDesignViolationsForFile(relPath, content, config = getCompiledDesignRules()) {
309
+ const normalized = relPath.split('\\').join('/');
310
+ if (!config.extensions.includes(extname(normalized))) return [];
311
+ if (!matchesAny(normalized, config.include)) return [];
312
+ if (matchesAny(normalized, config.exclude)) return [];
313
+
314
+ const lines = content.split('\n');
315
+ const violations = [];
316
+ for (const rule of config.rules) {
317
+ if (rule.include && !matchesAny(normalized, rule.include)) continue;
318
+ if (matchesAny(normalized, rule.exclude)) continue;
319
+
320
+ const matches = [];
321
+ if (rule.scope === 'file') {
322
+ const match = rule.regex.exec(content);
323
+ if (match) matches.push(match.index);
324
+ } else {
325
+ rule.regex.lastIndex = 0;
326
+ let match;
327
+ while ((match = rule.regex.exec(content)) !== null) {
328
+ matches.push(match.index);
329
+ if (match[0].length === 0) rule.regex.lastIndex += 1;
330
+ }
331
+ }
332
+
333
+ const seenLines = new Set();
334
+ for (const index of matches) {
335
+ const line = lineNumberAt(content, index);
336
+ if (seenLines.has(line)) continue;
337
+ seenLines.add(line);
338
+ const reason = findAllowDirective(lines, line, rule.id, config.allowDirective);
339
+ const allowed = reason !== null && reason.length >= config.allowReasonMinLength;
340
+ violations.push({
341
+ file: normalized,
342
+ line,
343
+ ruleId: rule.id,
344
+ severity: allowed ? 'warn' : rule.severity,
345
+ message: reason !== null && !allowed
346
+ ? `${rule.message} (a notis-design-allow directive needs a reason of at least ${config.allowReasonMinLength} characters)`
347
+ : rule.message,
348
+ allowed,
349
+ reason: allowed ? reason : null,
350
+ });
351
+ }
352
+ }
353
+ return violations;
354
+ }
355
+
356
+ export function collectProjectDesignViolations(projectDir, config = getCompiledDesignRules()) {
357
+ const files = [];
358
+ collectProjectFiles(projectDir, projectDir, files);
359
+ return files.flatMap((file) => collectDesignViolationsForFile(file.relPath, file.content, config));
360
+ }
361
+
362
+ export function collectSourceDesignViolations(files, config = getCompiledDesignRules()) {
363
+ return Object.entries(files).flatMap(([relPath, rawContent]) => {
364
+ const content = Buffer.isBuffer(rawContent)
365
+ ? rawContent.toString('utf-8')
366
+ : typeof rawContent === 'string'
367
+ ? rawContent
368
+ : String(rawContent ?? '');
369
+ return collectDesignViolationsForFile(relPath, content, config);
370
+ });
371
+ }
372
+
373
+ export function formatDesignViolation(violation) {
374
+ const prefix = violation.allowed ? 'allowed' : violation.severity;
375
+ const suffix = violation.allowed ? ` (${violation.reason})` : '';
376
+ return `${violation.file}:${violation.line} [${violation.ruleId}] ${prefix}: ${violation.message}${suffix}`;
377
+ }
378
+
379
+ /**
380
+ * Enforce the design rules on an app's source tree.
381
+ *
382
+ * Returns every violation (allowed ones included) so callers can print
383
+ * warnings. When `enforce` is true (build, verify, screenshot, deploy) any
384
+ * unallowed error-severity violation aborts with a usage error that lists the
385
+ * exact file and line to fix. Dev servers pass `enforce: false` so a
386
+ * half-edited page still reloads.
387
+ */
388
+ export function validateProjectDesign(projectDir, { enforce = true, log = null } = {}) {
389
+ const violations = collectProjectDesignViolations(projectDir);
390
+ const blocking = violations.filter((violation) => !violation.allowed && violation.severity === 'error');
391
+ const nonBlocking = violations.filter((violation) => !blocking.includes(violation));
392
+ if (typeof log === 'function') {
393
+ for (const violation of nonBlocking) log(`[design] ${formatDesignViolation(violation)}`);
394
+ if (!enforce) for (const violation of blocking) log(`[design] ${formatDesignViolation(violation)}`);
395
+ }
396
+ if (enforce && blocking.length > 0) {
397
+ throw usageError(
398
+ `Project violates the Notis app design bar (${blocking.length} issue${blocking.length === 1 ? '' : 's'}). `
399
+ + 'Fix each line below; the only override is an inline "// notis-design-allow: <rule-id> <reason>" comment on the line before.\n'
400
+ + blocking.map((violation) => ` - ${formatDesignViolation(violation)}`).join('\n'),
401
+ );
402
+ }
403
+ return violations;
404
+ }