@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.179.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.
- package/dist/agent-hooks/notis-agent-hook.mjs +128 -12
- package/dist/base-skills/notis-apps/SKILL.md +2 -0
- package/dist/base-skills/notis-apps/references/architecture.md +5 -2
- package/dist/base-skills/notis-apps/references/release.md +44 -0
- package/package.json +1 -1
- package/src/command-specs/apps.js +2 -1
- package/src/runtime/agent-browser.js +56 -11
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/store-screenshot.js +10 -7
|
@@ -11748,6 +11748,78 @@ 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
|
+
|
|
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
|
|
11751
11823
|
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; })()";
|
|
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)); })()";
|
|
@@ -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 (
|
|
12111
|
-
screenshotArgs.push(
|
|
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
|
-
|
|
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 (
|
|
12243
|
+
if (captureFocus) {
|
|
12132
12244
|
framing = {
|
|
12133
12245
|
...framing || {},
|
|
12134
|
-
focus_selector:
|
|
12246
|
+
focus_selector: captureFocus,
|
|
12247
|
+
content_bounds: contentBounds,
|
|
12135
12248
|
source_dimensions: dimensions
|
|
12136
12249
|
};
|
|
12137
12250
|
}
|
|
@@ -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 /
|
|
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 *
|
|
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
|
|
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: "
|
|
12373
|
-
|
|
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(
|
|
13909
|
+
focused: Boolean(result.framing?.focus_selector),
|
|
13794
13910
|
theme: theme || "light"
|
|
13795
13911
|
});
|
|
13796
13912
|
} catch (error) {
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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,
|
package/package.json
CHANGED
|
@@ -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(
|
|
953
|
+
focused: Boolean(result.framing?.focus_selector),
|
|
953
954
|
theme: theme || 'light',
|
|
954
955
|
});
|
|
955
956
|
} catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
|
|
4
5
|
|
|
5
6
|
export const HIDE_HARNESS_STATUS_SCRIPT =
|
|
6
7
|
"(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
|
|
@@ -322,18 +323,18 @@ export async function runHarnessRoute({
|
|
|
322
323
|
|
|
323
324
|
/**
|
|
324
325
|
* Open a harness route, wait for it to mount, then capture a PNG screenshot of
|
|
325
|
-
* the rendered app.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* fixed output frame. Used by `notis apps screenshot`.
|
|
326
|
+
* the rendered app. Raw captures retain exact requested pixel dimensions.
|
|
327
|
+
* Framed captures can select the complete bounded app or a width-fitted first
|
|
328
|
+
* screen; the Store compositor normalizes them to the final listing size.
|
|
329
|
+
* Device scaling keeps text sharp at that size without changing app styles.
|
|
330
|
+
* Used by `notis apps screenshot`.
|
|
331
331
|
*/
|
|
332
332
|
export async function captureHarnessScreenshot({
|
|
333
333
|
url,
|
|
334
334
|
sessionName,
|
|
335
335
|
screenshotPath,
|
|
336
336
|
focusSelector = null,
|
|
337
|
+
frameContent = false,
|
|
337
338
|
width = 2000,
|
|
338
339
|
height = 1250,
|
|
339
340
|
timeoutMs = 15_000,
|
|
@@ -433,13 +434,50 @@ export async function captureHarnessScreenshot({
|
|
|
433
434
|
|
|
434
435
|
// Let the route settle (async data, resize transitions) before the capture.
|
|
435
436
|
await delay(500);
|
|
437
|
+
|
|
438
|
+
let contentBounds = null;
|
|
439
|
+
if (frameContent && !focusSelector) {
|
|
440
|
+
const measured = await runAgentBrowser(
|
|
441
|
+
['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
|
|
442
|
+
{ timeoutMs: 5000 },
|
|
443
|
+
);
|
|
444
|
+
if (measured.exitCode === 0) {
|
|
445
|
+
try {
|
|
446
|
+
const bounds = parseHarnessFromEval(measured.stdout);
|
|
447
|
+
if (bounds?.selector === '[data-notis-screenshot-content]'
|
|
448
|
+
&& Number.isFinite(bounds.width) && bounds.width > 0
|
|
449
|
+
&& Number.isFinite(bounds.height) && bounds.height > 0) {
|
|
450
|
+
contentBounds = bounds;
|
|
451
|
+
}
|
|
452
|
+
} catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let captureFocus = focusSelector || contentBounds?.selector || null;
|
|
456
|
+
if (contentBounds && scaledViewport) {
|
|
457
|
+
const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
|
|
458
|
+
if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
|
|
459
|
+
await delay(300);
|
|
460
|
+
if (!plan.focus) {
|
|
461
|
+
const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
|
|
462
|
+
if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
|
|
463
|
+
plan.height = safeHeight;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
captureFocus = plan.focus ? contentBounds.selector : null;
|
|
467
|
+
framing = {
|
|
468
|
+
...(framing || {}), ...plan, content_bounds: contentBounds,
|
|
469
|
+
capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
|
|
470
|
+
};
|
|
471
|
+
await delay(300);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
436
474
|
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
437
475
|
const design = Array.isArray(designResult) ? designResult : [];
|
|
438
476
|
|
|
439
477
|
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
440
478
|
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
441
|
-
if (
|
|
442
|
-
screenshotArgs.push(
|
|
479
|
+
if (captureFocus) {
|
|
480
|
+
screenshotArgs.push(captureFocus);
|
|
443
481
|
}
|
|
444
482
|
screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
|
|
445
483
|
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
|
|
@@ -451,7 +489,13 @@ export async function captureHarnessScreenshot({
|
|
|
451
489
|
// capture came out wrong (e.g. the browser ignored the scale factor),
|
|
452
490
|
// recapture once at a plain fixed viewport.
|
|
453
491
|
const dimensions = pngDimensions(screenshotPath);
|
|
454
|
-
|
|
492
|
+
const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
|
|
493
|
+
? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
|
|
494
|
+
: { width, height };
|
|
495
|
+
// Fractional browser scaling can round one pixel either way. Framed output
|
|
496
|
+
// is normalized by the compositor; raw captures still require exact pixels.
|
|
497
|
+
const tolerance = frameContent ? 1 : 0;
|
|
498
|
+
if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
|
|
455
499
|
await setViewport(sessionName, width, height);
|
|
456
500
|
await delay(300);
|
|
457
501
|
const retry = await runAgentBrowser(
|
|
@@ -464,10 +508,11 @@ export async function captureHarnessScreenshot({
|
|
|
464
508
|
framing = null;
|
|
465
509
|
}
|
|
466
510
|
|
|
467
|
-
if (
|
|
511
|
+
if (captureFocus) {
|
|
468
512
|
framing = {
|
|
469
513
|
...(framing || {}),
|
|
470
|
-
focus_selector:
|
|
514
|
+
focus_selector: captureFocus,
|
|
515
|
+
content_bounds: contentBounds,
|
|
471
516
|
source_dimensions: dimensions,
|
|
472
517
|
};
|
|
473
518
|
}
|
|
@@ -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 /
|
|
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 *
|
|
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
|
-
*
|
|
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
|
|
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: '
|
|
103
|
-
|
|
105
|
+
fit: 'contain',
|
|
106
|
+
background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
|
|
104
107
|
})
|
|
105
108
|
.composite([{
|
|
106
109
|
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|