@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.181.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.
@@ -11748,7 +11748,79 @@ async function startAppTestServer({ apps, port, harness = {} }) {
11748
11748
  import { spawn as spawn2, spawnSync } from "node:child_process";
11749
11749
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
11750
11750
  import { dirname as dirname5 } from "node:path";
11751
- var PREPARE_CAPTURE_SCRIPT = "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none'; const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; } document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
11751
+
11752
+ // src/runtime/screenshot-framing.js
11753
+ function measureScreenshotContent() {
11754
+ const root = document.getElementById("root");
11755
+ if (!root) return null;
11756
+ const viewportWidth = document.documentElement.clientWidth;
11757
+ const visible = (element) => {
11758
+ const rect2 = element.getBoundingClientRect();
11759
+ const style = getComputedStyle(element);
11760
+ return rect2.width > 0 && rect2.height > 0 && style.display !== "none" && style.visibility !== "hidden";
11761
+ };
11762
+ let node = root;
11763
+ let content = null;
11764
+ while (node) {
11765
+ const hasOwnText = Array.from(node.childNodes).some(
11766
+ (child) => child.nodeType === 3 && child.textContent.trim()
11767
+ );
11768
+ const children = Array.from(node.children).filter(visible);
11769
+ if (hasOwnText || children.length !== 1) break;
11770
+ node = children[0];
11771
+ const rect2 = node.getBoundingClientRect();
11772
+ if (["DIV", "MAIN", "SECTION", "ARTICLE", "FORM"].includes(node.tagName) && rect2.width >= 320 && rect2.height >= 160 && rect2.width < viewportWidth * 0.9) {
11773
+ content = node;
11774
+ }
11775
+ }
11776
+ if (!content) return null;
11777
+ content.setAttribute("data-notis-screenshot-content", "");
11778
+ const rect = content.getBoundingClientRect();
11779
+ return {
11780
+ selector: "[data-notis-screenshot-content]",
11781
+ width: rect.width,
11782
+ height: rect.height,
11783
+ viewport_width: viewportWidth
11784
+ };
11785
+ }
11786
+ var MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
11787
+ function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
11788
+ const aspect = outputWidth / outputHeight;
11789
+ if (bounds.width / bounds.height < aspect * 0.75) {
11790
+ const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
11791
+ return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
11792
+ }
11793
+ return {
11794
+ focus: true,
11795
+ width: viewport.width,
11796
+ height: viewport.height,
11797
+ scale: Math.min(4, outputWidth / bounds.width)
11798
+ };
11799
+ }
11800
+ function safeViewportHeight(rectangles, height) {
11801
+ const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height && rect.top >= height * 0.85);
11802
+ return crossing.length ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8)) : height;
11803
+ }
11804
+ var SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
11805
+ const root = document.getElementById('root');
11806
+ if (!root) return innerHeight;
11807
+ const rects = [];
11808
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
11809
+ let node;
11810
+ while ((node = walker.nextNode())) {
11811
+ if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
11812
+ const range = document.createRange(); range.selectNodeContents(node);
11813
+ for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
11814
+ }
11815
+ for (const control of root.querySelectorAll('button,input,select,textarea')) {
11816
+ const rect = control.getBoundingClientRect();
11817
+ rects.push({ top: rect.top, bottom: rect.bottom });
11818
+ }
11819
+ return (${safeViewportHeight.toString()})(rects, innerHeight);
11820
+ })()`;
11821
+
11822
+ // src/runtime/agent-browser.js
11823
+ var PREPARE_CAPTURE_SCRIPT = "(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0'; document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
11752
11824
  var FONTS_STATUS_SCRIPT = "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
11753
11825
  var CONTENT_HEIGHT_SCRIPT = "(() => { const r = document.getElementById('root'); if (!r) return Math.ceil(document.documentElement.scrollHeight); return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()";
11754
11826
  var MIN_CSS_VIEWPORT_HEIGHT = 720;
@@ -12019,6 +12091,7 @@ async function captureHarnessScreenshot({
12019
12091
  sessionName,
12020
12092
  screenshotPath,
12021
12093
  focusSelector = null,
12094
+ frameContent = false,
12022
12095
  width = 2e3,
12023
12096
  height = 1250,
12024
12097
  timeoutMs = 15e3
@@ -12103,12 +12176,49 @@ async function captureHarnessScreenshot({
12103
12176
  }
12104
12177
  }
12105
12178
  await delay(500);
12179
+ let contentBounds = null;
12180
+ if (frameContent && !focusSelector) {
12181
+ const measured = await runAgentBrowser(
12182
+ ["--session", sessionName, "--json", "eval", MEASURE_SCREENSHOT_CONTENT_SCRIPT],
12183
+ { timeoutMs: 5e3 }
12184
+ );
12185
+ if (measured.exitCode === 0) {
12186
+ try {
12187
+ const bounds = parseHarnessFromEval(measured.stdout);
12188
+ if (bounds?.selector === "[data-notis-screenshot-content]" && Number.isFinite(bounds.width) && bounds.width > 0 && Number.isFinite(bounds.height) && bounds.height > 0) {
12189
+ contentBounds = bounds;
12190
+ }
12191
+ } catch {
12192
+ }
12193
+ }
12194
+ }
12195
+ let captureFocus = focusSelector || contentBounds?.selector || null;
12196
+ if (contentBounds && scaledViewport) {
12197
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
12198
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
12199
+ await delay(300);
12200
+ if (!plan.focus) {
12201
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
12202
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
12203
+ plan.height = safeHeight;
12204
+ }
12205
+ }
12206
+ captureFocus = plan.focus ? contentBounds.selector : null;
12207
+ framing = {
12208
+ ...framing || {},
12209
+ ...plan,
12210
+ content_bounds: contentBounds,
12211
+ capture_mode: plan.focus ? "content" : "width-fit-viewport"
12212
+ };
12213
+ await delay(300);
12214
+ }
12215
+ }
12106
12216
  const designResult = await evalDesignAssertions(sessionName, { viewport: "capture" });
12107
12217
  const design = Array.isArray(designResult) ? designResult : [];
12108
12218
  mkdirSync3(dirname5(screenshotPath), { recursive: true });
12109
12219
  const screenshotArgs = ["--session", sessionName, "screenshot"];
12110
- if (focusSelector) {
12111
- screenshotArgs.push(focusSelector);
12220
+ if (captureFocus) {
12221
+ screenshotArgs.push(captureFocus);
12112
12222
  }
12113
12223
  screenshotArgs.push(screenshotPath, "--screenshot-format", "png");
12114
12224
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15e3 });
@@ -12116,7 +12226,9 @@ async function captureHarnessScreenshot({
12116
12226
  return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError("screenshot", shot) };
12117
12227
  }
12118
12228
  const dimensions = pngDimensions2(screenshotPath);
12119
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
12229
+ const expectedDimensions = framing?.capture_mode === "width-fit-viewport" ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) } : { width, height };
12230
+ const tolerance = frameContent ? 1 : 0;
12231
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
12120
12232
  await setViewport(sessionName, width, height);
12121
12233
  await delay(300);
12122
12234
  const retry = await runAgentBrowser(
@@ -12128,10 +12240,11 @@ async function captureHarnessScreenshot({
12128
12240
  }
12129
12241
  framing = null;
12130
12242
  }
12131
- if (focusSelector) {
12243
+ if (captureFocus) {
12132
12244
  framing = {
12133
12245
  ...framing || {},
12134
- focus_selector: focusSelector,
12246
+ focus_selector: captureFocus,
12247
+ content_bounds: contentBounds,
12135
12248
  source_dimensions: dimensions
12136
12249
  };
12137
12250
  }
@@ -12187,7 +12300,7 @@ var DESIGN_ASSERTIONS_SCRIPT = `(() => {
12187
12300
  };
12188
12301
  const all = Array.from(root.querySelectorAll('*'));
12189
12302
  for (const el of all) {
12190
- if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
12303
+ if (!visible(el) || isControl(el)) continue;
12191
12304
  if (isBox(el)) {
12192
12305
  let p = el.parentElement; let depth = 0;
12193
12306
  while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
@@ -12204,7 +12317,7 @@ var DESIGN_ASSERTIONS_SCRIPT = `(() => {
12204
12317
  const text = (node.textContent || '').trim();
12205
12318
  if (!text) continue;
12206
12319
  const el = node.parentElement;
12207
- if (!el || el.closest('#harness-status') || !visible(el)) continue;
12320
+ if (!el || !visible(el)) continue;
12208
12321
  const size = parseFloat(getComputedStyle(el).fontSize);
12209
12322
  if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
12210
12323
  if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
@@ -12322,15 +12435,16 @@ function resolveStoreScreenshotAccent(accent, seed = "notis-app") {
12322
12435
  }
12323
12436
  return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
12324
12437
  }
12325
- function storeScreenshotLayout(width = 2e3, height = 1250) {
12438
+ function storeScreenshotLayout(width = 2e3, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
12439
+ const aspect = Number.isFinite(contentAspect) && contentAspect > 0 ? contentAspect : STORE_SCREENSHOT_ASPECT;
12326
12440
  const horizontalInset = Math.max(48, Math.round(width * 0.06));
12327
12441
  const verticalInset = Math.max(40, Math.round(height * 0.06));
12328
12442
  let cardWidth = width - horizontalInset * 2;
12329
- let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
12443
+ let cardHeight = Math.round(cardWidth / aspect);
12330
12444
  const maximumCardHeight = height - verticalInset * 2;
12331
12445
  if (cardHeight > maximumCardHeight) {
12332
12446
  cardHeight = maximumCardHeight;
12333
- cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
12447
+ cardWidth = Math.round(cardHeight * aspect);
12334
12448
  }
12335
12449
  const cardLeft = Math.round((width - cardWidth) / 2);
12336
12450
  const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 8e-3);
@@ -12366,11 +12480,12 @@ async function composeStoreScreenshot({
12366
12480
  }) {
12367
12481
  const sharp = await loadSharp();
12368
12482
  const resolvedTheme = theme === "dark" ? "dark" : "light";
12369
- const layout = storeScreenshotLayout(width, height);
12483
+ const source = await sharp(inputPath).metadata();
12484
+ const layout = storeScreenshotLayout(width, height, source.width / source.height);
12370
12485
  const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
12371
12486
  const roundedCapture = await sharp(inputPath).resize(layout.cardWidth, layout.cardHeight, {
12372
- fit: "cover",
12373
- position: focused ? "top" : "centre"
12487
+ fit: "contain",
12488
+ background: resolvedTheme === "dark" ? "#09090b" : "#ffffff"
12374
12489
  }).composite([{
12375
12490
  input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
12376
12491
  blend: "dest-in"
@@ -13776,6 +13891,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
13776
13891
  sessionName: captureSessionName,
13777
13892
  screenshotPath: browserScreenshotPath,
13778
13893
  focusSelector: ctx.options.raw ? null : focus,
13894
+ frameContent: !ctx.options.raw,
13779
13895
  width,
13780
13896
  height,
13781
13897
  timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || "", 10) || 15e3
@@ -13790,7 +13906,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
13790
13906
  height,
13791
13907
  accent: appConfig.accent,
13792
13908
  seed: appConfig.name || manifest.app?.name || appSlug,
13793
- focused: Boolean(focus),
13909
+ focused: Boolean(result.framing?.focus_selector),
13794
13910
  theme: theme || "light"
13795
13911
  });
13796
13912
  } catch (error) {
@@ -14085,15 +14201,20 @@ async function appsDeployHandler(ctx) {
14085
14201
  } catch {
14086
14202
  warnings.push("The app was updated, but the temporary release directory needs local cleanup.");
14087
14203
  }
14204
+ const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : []).filter((review) => review && typeof review.review_url === "string");
14088
14205
  return ctx.output.emitSuccess({
14089
14206
  command: ctx.spec.command_path.join(" "),
14090
14207
  data: {
14091
14208
  app_id: appId,
14092
14209
  version: deployedVersion,
14093
- idempotency_key: idempotencyKey
14210
+ idempotency_key: idempotencyKey,
14211
+ ...skillReviews.length ? { skill_reviews: skillReviews } : {}
14094
14212
  },
14095
14213
  warnings,
14096
- humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
14214
+ humanSummary: [
14215
+ `Deployed to app ${appId} (version ${deployedVersion})`,
14216
+ ...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`)
14217
+ ].join("\n"),
14097
14218
  meta: { mutating: true, idempotency_key: idempotencyKey }
14098
14219
  });
14099
14220
  } finally {
@@ -46,6 +46,8 @@ For every app UI create/edit task, read both [Design](references/design.md) and
46
46
  no-deploy, and explicit-consent requirements. Store publication is separate.
47
47
  - Preserve the exact app identity, account/team scope, permissions, and user data.
48
48
  Reconcile an uncertain release instead of blindly retrying it.
49
+ - Before Store submission, complete the [publication privacy and portability gate](references/release.md#publication-privacy-and-portability-gate).
50
+ Public examples must be fictional; never publish the owner's workspace data.
49
51
  - Use SDK hooks and declared tools. Let Notis own its sidebar, search, runtime,
50
52
  and rendering boundary; do not query host DOM or recreate that chrome in the app.
51
53
 
@@ -7,8 +7,11 @@ All Notis apps are built using the Notis CLI, either locally in a repo workspace
7
7
  - the portal renders it as a React component inside the portal's React tree
8
8
 
9
9
  Use standard React pages in `app/`, not Next.js or a custom server. The host
10
- chooses a trusted shadow root or an isolated Store frame; do not create your own
11
- iframe, query Portal-owned DOM, or install a window-global runtime. The host owns
10
+ uses one React/Shadow DOM runtime for authored apps, Store installations,
11
+ duplicates and SDK reports. Do not create your own iframe, query Portal-owned
12
+ DOM, or install a window-global runtime. Shadow DOM isolates styles, not
13
+ JavaScript or credentials: loaded bundles share the Portal browser context.
14
+ The host owns
12
15
  theme injection and authentication. App code uses SDK hooks and the final tool
13
16
  names discovered through the CLI, declared in `notis.config.ts` and enforced by
14
17
  the backend. Runtime permissions stay least-authority; releasing an app does not
@@ -65,6 +65,50 @@ not required for an ordinary Workspace update.
65
65
 
66
66
  ## Special cases — read only when relevant
67
67
 
68
+ ### Publication privacy and portability gate
69
+
70
+ Before submitting or updating a Store listing:
71
+
72
+ 1. Inventory the exact public source archive, listing text/media, database schemas,
73
+ starter rows, bundled skills (including scripts/references), and automation
74
+ prompts/configuration. Review their actual contents, not just filenames or
75
+ a passing secret scan. Exclude personal records, transcripts, health/journal
76
+ history, customer details, private repository/account identifiers, credentials,
77
+ local paths, run logs, and private links. Do not merely replace a person's name
78
+ in otherwise real data. Rebuild examples from wholly fictional scenarios.
79
+ 2. Keep live owner databases structure-only (`seedDocuments` absent or false).
80
+ Opting in seeds the database's live rows, including folders: it is not a
81
+ fixture selector. Use fictional screenshot fixtures and an explicit, idempotent
82
+ onboarding demo-data option. If starter rows are needed in the install snapshot,
83
+ publish only from an isolated, verified fictional dataset; never replace or
84
+ delete the owner's real data to prepare a submission. When the user asks for
85
+ examples to come with the app, include them in that verified install snapshot:
86
+ screenshot fixtures or an optional onboarding seed step do not satisfy this.
87
+ 3. Bundle the full dependency closure of every app/automation skill, including
88
+ referenced helpers and resources. Remove private account-specific defaults;
89
+ resolve the installer's databases, connections, repository, timezone and
90
+ delivery choices at runtime. Preserve existing owner's schedules and data.
91
+ 4. Declare a source-owned onboarding skill. It must work through available MCP
92
+ tools or the Notis CLI in any agent harness, without requiring Notis Manager,
93
+ vendor-specific delegation tools, hidden local files, or publisher access.
94
+ Discover tools and inspect schemas before calls; reconcile existing resources
95
+ before creating them. Obtain installer choices before enabling automation or
96
+ external actions. Installing examples must not activate external deliveries.
97
+ 5. Test onboarding as an independent harness-native proof agent using an account
98
+ isolated from the publisher, then rerun to prove no duplicates. Exercise each
99
+ route and its interactions with fictional data, including empty/error states.
100
+ Record exact identities, versions, results and run-created resource cleanup;
101
+ never use owner records as writable test fixtures.
102
+ For bundled starter-data claims, install the actual published listing into an
103
+ empty test account and read back its rows before onboarding or any manual data
104
+ writes. Confirm the installed listing version and remapped relations; do not
105
+ substitute an editable-source deployment for this Store-install test.
106
+ 6. Inspect the final submitted snapshot and media after packaging. Record the
107
+ privacy audit and verification against that exact source version. Any unknown
108
+ provenance, missing dependency, untested onboarding or suspected personal data
109
+ blocks submission until resolved. Never equate submission with review approval
110
+ or Store publication.
111
+
68
112
  ### Unreleased container or stale checkout
69
113
 
70
114
  An unreleased container has no source to pull. Recover its original local source,
@@ -368,6 +368,8 @@ export interface RuntimeResource {
368
368
  kind: 'app' | 'report';
369
369
  /** Host-provided adoption eligibility, never report-authored authority. */
370
370
  analytics_eligible?: boolean;
371
+ /** Host-derived report family for adoption analytics, e.g. skill review reports. */
372
+ report_kind?: 'skill_review' | 'custom';
371
373
  id: string;
372
374
  revision: number;
373
375
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.178.1",
3
+ "version": "0.2.0-beta.181.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -935,6 +935,7 @@ async function appsScreenshotHandler(ctx) {
935
935
  sessionName: captureSessionName,
936
936
  screenshotPath: browserScreenshotPath,
937
937
  focusSelector: ctx.options.raw ? null : focus,
938
+ frameContent: !ctx.options.raw,
938
939
  width,
939
940
  height,
940
941
  timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
@@ -949,7 +950,7 @@ async function appsScreenshotHandler(ctx) {
949
950
  height,
950
951
  accent: appConfig.accent,
951
952
  seed: appConfig.name || manifest.app?.name || appSlug,
952
- focused: Boolean(focus),
953
+ focused: Boolean(result.framing?.focus_selector),
953
954
  theme: theme || 'light',
954
955
  });
955
956
  } catch (error) {
@@ -1256,15 +1257,22 @@ async function appsDeployHandler(ctx) {
1256
1257
  try { release.close(); }
1257
1258
  catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
1258
1259
 
1260
+ // One review report per skill this release changed; share the link when the user should review it.
1261
+ const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : [])
1262
+ .filter((review) => review && typeof review.review_url === 'string');
1259
1263
  return ctx.output.emitSuccess({
1260
1264
  command: ctx.spec.command_path.join(' '),
1261
1265
  data: {
1262
1266
  app_id: appId,
1263
1267
  version: deployedVersion,
1264
1268
  idempotency_key: idempotencyKey,
1269
+ ...(skillReviews.length ? { skill_reviews: skillReviews } : {}),
1265
1270
  },
1266
1271
  warnings,
1267
- humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
1272
+ humanSummary: [
1273
+ `Deployed to app ${appId} (version ${deployedVersion})`,
1274
+ ...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`),
1275
+ ].join('\n'),
1268
1276
  meta: { mutating: true, idempotency_key: idempotencyKey },
1269
1277
  });
1270
1278
  } finally {
@@ -1,19 +1,15 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
-
5
- export const HIDE_HARNESS_STATUS_SCRIPT =
6
- "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
4
+ import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
7
5
 
8
6
  /**
9
- * Prepare the mounted harness page for a listing capture: remove the debug
10
- * banner and the layout the harness adds around the app (top padding for the
11
- * banner, 100vh min-height) so content height can be measured and framed
12
- * without artificial whitespace.
7
+ * Prepare the mounted harness page for a listing capture: remove the 100vh
8
+ * min-height the harness adds around the app so content height can be
9
+ * measured and framed without artificial whitespace.
13
10
  */
14
11
  const PREPARE_CAPTURE_SCRIPT =
15
- "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none';" +
16
- " const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
12
+ "(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0';" +
17
13
  " document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
18
14
 
19
15
  const FONTS_STATUS_SCRIPT =
@@ -322,18 +318,18 @@ export async function runHarnessRoute({
322
318
 
323
319
  /**
324
320
  * Open a harness route, wait for it to mount, then capture a PNG screenshot of
325
- * the rendered app. Output is always exactly `width` x `height` physical
326
- * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
327
- * CSS viewport is fitted to the rendered content height: short pages render
328
- * larger (no dead whitespace below the app), tall pages get more room before
329
- * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
330
- * fixed output frame. Used by `notis apps screenshot`.
321
+ * the rendered app. Raw captures retain exact requested pixel dimensions.
322
+ * Framed captures can select the complete bounded app or a width-fitted first
323
+ * screen; the Store compositor normalizes them to the final listing size.
324
+ * Device scaling keeps text sharp at that size without changing app styles.
325
+ * Used by `notis apps screenshot`.
331
326
  */
332
327
  export async function captureHarnessScreenshot({
333
328
  url,
334
329
  sessionName,
335
330
  screenshotPath,
336
331
  focusSelector = null,
332
+ frameContent = false,
337
333
  width = 2000,
338
334
  height = 1250,
339
335
  timeoutMs = 15_000,
@@ -433,13 +429,50 @@ export async function captureHarnessScreenshot({
433
429
 
434
430
  // Let the route settle (async data, resize transitions) before the capture.
435
431
  await delay(500);
432
+
433
+ let contentBounds = null;
434
+ if (frameContent && !focusSelector) {
435
+ const measured = await runAgentBrowser(
436
+ ['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
437
+ { timeoutMs: 5000 },
438
+ );
439
+ if (measured.exitCode === 0) {
440
+ try {
441
+ const bounds = parseHarnessFromEval(measured.stdout);
442
+ if (bounds?.selector === '[data-notis-screenshot-content]'
443
+ && Number.isFinite(bounds.width) && bounds.width > 0
444
+ && Number.isFinite(bounds.height) && bounds.height > 0) {
445
+ contentBounds = bounds;
446
+ }
447
+ } catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
448
+ }
449
+ }
450
+ let captureFocus = focusSelector || contentBounds?.selector || null;
451
+ if (contentBounds && scaledViewport) {
452
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
453
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
454
+ await delay(300);
455
+ if (!plan.focus) {
456
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
457
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
458
+ plan.height = safeHeight;
459
+ }
460
+ }
461
+ captureFocus = plan.focus ? contentBounds.selector : null;
462
+ framing = {
463
+ ...(framing || {}), ...plan, content_bounds: contentBounds,
464
+ capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
465
+ };
466
+ await delay(300);
467
+ }
468
+ }
436
469
  const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
437
470
  const design = Array.isArray(designResult) ? designResult : [];
438
471
 
439
472
  mkdirSync(dirname(screenshotPath), { recursive: true });
440
473
  const screenshotArgs = ['--session', sessionName, 'screenshot'];
441
- if (focusSelector) {
442
- screenshotArgs.push(focusSelector);
474
+ if (captureFocus) {
475
+ screenshotArgs.push(captureFocus);
443
476
  }
444
477
  screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
445
478
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
@@ -451,7 +484,13 @@ export async function captureHarnessScreenshot({
451
484
  // capture came out wrong (e.g. the browser ignored the scale factor),
452
485
  // recapture once at a plain fixed viewport.
453
486
  const dimensions = pngDimensions(screenshotPath);
454
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
487
+ const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
488
+ ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
489
+ : { width, height };
490
+ // Fractional browser scaling can round one pixel either way. Framed output
491
+ // is normalized by the compositor; raw captures still require exact pixels.
492
+ const tolerance = frameContent ? 1 : 0;
493
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
455
494
  await setViewport(sessionName, width, height);
456
495
  await delay(300);
457
496
  const retry = await runAgentBrowser(
@@ -464,10 +503,11 @@ export async function captureHarnessScreenshot({
464
503
  framing = null;
465
504
  }
466
505
 
467
- if (focusSelector) {
506
+ if (captureFocus) {
468
507
  framing = {
469
508
  ...(framing || {}),
470
- focus_selector: focusSelector,
509
+ focus_selector: captureFocus,
510
+ content_bounds: contentBounds,
471
511
  source_dimensions: dimensions,
472
512
  };
473
513
  }
@@ -538,7 +578,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
538
578
  };
539
579
  const all = Array.from(root.querySelectorAll('*'));
540
580
  for (const el of all) {
541
- if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
581
+ if (!visible(el) || isControl(el)) continue;
542
582
  if (isBox(el)) {
543
583
  let p = el.parentElement; let depth = 0;
544
584
  while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
@@ -555,7 +595,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
555
595
  const text = (node.textContent || '').trim();
556
596
  if (!text) continue;
557
597
  const el = node.parentElement;
558
- if (!el || el.closest('#harness-status') || !visible(el)) continue;
598
+ if (!el || !visible(el)) continue;
559
599
  const size = parseFloat(getComputedStyle(el).fontSize);
560
600
  if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
561
601
  if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
@@ -0,0 +1,85 @@
1
+ /** Runs only in the capture harness, never in an installed app. */
2
+ export function measureScreenshotContent() {
3
+ const root = document.getElementById('root');
4
+ if (!root) return null;
5
+ const viewportWidth = document.documentElement.clientWidth;
6
+ const visible = (element) => {
7
+ const rect = element.getBoundingClientRect();
8
+ const style = getComputedStyle(element);
9
+ return rect.width > 0 && rect.height > 0
10
+ && style.display !== 'none' && style.visibility !== 'hidden';
11
+ };
12
+
13
+ // Follow layout wrappers only while they contain the entire visible app.
14
+ // Never select one column, card, or control out of a multi-pane layout.
15
+ let node = root;
16
+ let content = null;
17
+ while (node) {
18
+ const hasOwnText = Array.from(node.childNodes).some(
19
+ (child) => child.nodeType === 3 && child.textContent.trim(),
20
+ );
21
+ const children = Array.from(node.children).filter(visible);
22
+ if (hasOwnText || children.length !== 1) break;
23
+ node = children[0];
24
+ const rect = node.getBoundingClientRect();
25
+ if (['DIV', 'MAIN', 'SECTION', 'ARTICLE', 'FORM'].includes(node.tagName)
26
+ && rect.width >= 320 && rect.height >= 160
27
+ && rect.width < viewportWidth * 0.9) {
28
+ content = node;
29
+ }
30
+ }
31
+ if (!content) return null;
32
+ content.setAttribute('data-notis-screenshot-content', '');
33
+ const rect = content.getBoundingClientRect();
34
+ return {
35
+ selector: '[data-notis-screenshot-content]',
36
+ width: rect.width,
37
+ height: rect.height,
38
+ viewport_width: viewportWidth,
39
+ };
40
+ }
41
+
42
+ export const MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
43
+
44
+ /** Keep long pages readable, and rasterize bounded views at their final width. */
45
+ export function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
46
+ const aspect = outputWidth / outputHeight;
47
+ if (bounds.width / bounds.height < aspect * 0.75) {
48
+ const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
49
+ return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
50
+ }
51
+ return {
52
+ focus: true,
53
+ width: viewport.width,
54
+ height: viewport.height,
55
+ scale: Math.min(4, outputWidth / bounds.width),
56
+ };
57
+ }
58
+
59
+ export function safeViewportHeight(rectangles, height) {
60
+ const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height
61
+ && rect.top >= height * 0.85);
62
+ return crossing.length
63
+ ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8))
64
+ : height;
65
+ }
66
+
67
+ // End a long-page viewport in whitespace rather than through a text line or
68
+ // control. Only geometry leaves the browser; no page text is returned.
69
+ export const SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
70
+ const root = document.getElementById('root');
71
+ if (!root) return innerHeight;
72
+ const rects = [];
73
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
74
+ let node;
75
+ while ((node = walker.nextNode())) {
76
+ if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
77
+ const range = document.createRange(); range.selectNodeContents(node);
78
+ for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
79
+ }
80
+ for (const control of root.querySelectorAll('button,input,select,textarea')) {
81
+ const rect = control.getBoundingClientRect();
82
+ rects.push({ top: rect.top, bottom: rect.bottom });
83
+ }
84
+ return (${safeViewportHeight.toString()})(rects, innerHeight);
85
+ })()`;
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
38
38
  return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
39
39
  }
40
40
 
41
- export function storeScreenshotLayout(width = 2000, height = 1250) {
41
+ export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
42
+ const aspect = Number.isFinite(contentAspect) && contentAspect > 0
43
+ ? contentAspect : STORE_SCREENSHOT_ASPECT;
42
44
  const horizontalInset = Math.max(48, Math.round(width * 0.06));
43
45
  const verticalInset = Math.max(40, Math.round(height * 0.06));
44
46
  let cardWidth = width - horizontalInset * 2;
45
- let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
47
+ let cardHeight = Math.round(cardWidth / aspect);
46
48
  const maximumCardHeight = height - verticalInset * 2;
47
49
  if (cardHeight > maximumCardHeight) {
48
50
  cardHeight = maximumCardHeight;
49
- cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
51
+ cardWidth = Math.round(cardHeight * aspect);
50
52
  }
51
53
  const cardLeft = Math.round((width - cardWidth) / 2);
52
54
  const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
80
82
 
81
83
  /**
82
84
  * Composite a truthful browser capture into the deterministic Store frame.
83
- * The source pixels are only resized/cropped; the app UI is never redrawn.
85
+ * Fit the complete source pixels; never crop controls or redraw the app UI.
84
86
  */
85
87
  export async function composeStoreScreenshot({
86
88
  inputPath,
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
94
96
  }) {
95
97
  const sharp = await loadSharp();
96
98
  const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
97
- const layout = storeScreenshotLayout(width, height);
99
+ const source = await sharp(inputPath).metadata();
100
+ const layout = storeScreenshotLayout(width, height, source.width / source.height);
98
101
  const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
99
102
 
100
103
  const roundedCapture = await sharp(inputPath)
101
104
  .resize(layout.cardWidth, layout.cardHeight, {
102
- fit: 'cover',
103
- position: focused ? 'top' : 'centre',
105
+ fit: 'contain',
106
+ background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
104
107
  })
105
108
  .composite([{
106
109
  input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
@@ -93,12 +93,10 @@
93
93
  color: hsl(var(--foreground));
94
94
  -webkit-font-smoothing: antialiased;
95
95
  }
96
- #harness-status { position: fixed; top: 0; left: 0; right: 0; padding: 4px 8px; font-size: 11px; background: #111; color: #0f0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; z-index: 9999; }
97
- #root { padding-top: 24px; min-height: 100vh; }
96
+ #root { min-height: 100vh; }
98
97
  </style>
99
98
  </head>
100
99
  <body>
101
- <div id="harness-status">harness: booting</div>
102
100
  <div id="root" data-notis-app-root></div>
103
101
  <script type="module">
104
102
  const routeExport = {{ROUTE_EXPORT}};
@@ -134,10 +132,9 @@
134
132
  return Object.prototype.hasOwnProperty.call(tools, name) ? tools[name] : undefined;
135
133
  }
136
134
 
137
- const status = document.getElementById('harness-status');
138
- const setStatus = (msg, color = '#0f0') => {
139
- status.textContent = `harness: ${msg}`;
140
- status.style.color = color;
135
+ // Boot progress goes to the console so the rendered page stays identical to the app.
136
+ const setStatus = (msg, failed = false) => {
137
+ (failed ? console.error : console.info)(`[notis harness] ${msg}`);
141
138
  };
142
139
 
143
140
  window.__harness = {
@@ -416,13 +413,13 @@
416
413
  window.__harness.mounted = true;
417
414
  const errors = window.__harness.errors.length;
418
415
  const calls = window.__harness.runtimeCalls.length;
419
- setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors ? '#f55' : '#0f0');
416
+ setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors > 0);
420
417
  } catch (error) {
421
418
  window.__harness.errors.push({
422
419
  type: 'scenario',
423
420
  message: error && error.message ? error.message : String(error),
424
421
  });
425
- setStatus(`scenario failed: ${error && error.message ? error.message : String(error)}`, '#f55');
422
+ setStatus(`scenario failed: ${error && error.message ? error.message : String(error)}`, true);
426
423
  }
427
424
  }));
428
425
  } catch (error) {
@@ -431,7 +428,7 @@
431
428
  message: error && error.message ? error.message : String(error),
432
429
  stack: error && error.stack,
433
430
  });
434
- setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`, '#f55');
431
+ setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`, true);
435
432
  }
436
433
  </script>
437
434
  </body>