diff --git a/assets/webstore-explainer-2026-4/01-hero-light.png b/assets/webstore-explainer-2026-4/01-hero-light.png new file mode 100644 index 000000000..507722dcd Binary files /dev/null and b/assets/webstore-explainer-2026-4/01-hero-light.png differ diff --git a/assets/webstore-explainer-2026-4/01-hero.png b/assets/webstore-explainer-2026-4/01-hero.png new file mode 100644 index 000000000..0b41608d2 Binary files /dev/null and b/assets/webstore-explainer-2026-4/01-hero.png differ diff --git a/assets/webstore-explainer-2026-4/02-tell-the-browser.png b/assets/webstore-explainer-2026-4/02-tell-the-browser.png new file mode 100644 index 000000000..1a95e7573 Binary files /dev/null and b/assets/webstore-explainer-2026-4/02-tell-the-browser.png differ diff --git a/assets/webstore-explainer-2026-4/03-ask-any-page.png b/assets/webstore-explainer-2026-4/03-ask-any-page.png new file mode 100644 index 000000000..5c33861c0 Binary files /dev/null and b/assets/webstore-explainer-2026-4/03-ask-any-page.png differ diff --git a/assets/webstore-explainer-2026-4/04-any-llm.png b/assets/webstore-explainer-2026-4/04-any-llm.png new file mode 100644 index 000000000..bea7a6992 Binary files /dev/null and b/assets/webstore-explainer-2026-4/04-any-llm.png differ diff --git a/assets/webstore-explainer-2026-4/05-plan-before-act-light.png b/assets/webstore-explainer-2026-4/05-plan-before-act-light.png new file mode 100644 index 000000000..bbb0ff35f Binary files /dev/null and b/assets/webstore-explainer-2026-4/05-plan-before-act-light.png differ diff --git a/assets/webstore-explainer-2026-4/05-plan-before-act.png b/assets/webstore-explainer-2026-4/05-plan-before-act.png new file mode 100644 index 000000000..badbc60c1 Binary files /dev/null and b/assets/webstore-explainer-2026-4/05-plan-before-act.png differ diff --git a/assets/webstore-explainer-2026-4/06-launch-offer.png b/assets/webstore-explainer-2026-4/06-launch-offer.png new file mode 100644 index 000000000..32b85606a Binary files /dev/null and b/assets/webstore-explainer-2026-4/06-launch-offer.png differ diff --git a/assets/webstore-explainer-2026-4/07-social-proof.png b/assets/webstore-explainer-2026-4/07-social-proof.png new file mode 100644 index 000000000..4807c8860 Binary files /dev/null and b/assets/webstore-explainer-2026-4/07-social-proof.png differ diff --git a/assets/webstore-explainer-2026-4/README.md b/assets/webstore-explainer-2026-4/README.md new file mode 100644 index 000000000..20e891cf0 --- /dev/null +++ b/assets/webstore-explainer-2026-4/README.md @@ -0,0 +1,47 @@ +# Web Store explainer visuals 2026 — v4 (typographic) + +Same gallery and copy as v3 (`../webstore-explainer-2026-3`), retypeset. No product +icon and no "WebBrain" corner wordmark on any slide — the Web Store shows both beside +the listing. The hero keeps a small mono "WEBBRAIN" kicker as the only brand mark. + +Type system: +- Display (headlines, prices, stat numerals) — **Instrument Serif** 400 +- UI (body, subs, panel text) — **Instrument Sans** 400–700 +- Labels (eyebrows, chips, URLs, buttons) — **Geist Mono** 600, uppercase, wide tracking + +Fonts are vendored in `fonts/` and inlined as base64 at render time, so output no longer +depends on what is installed locally. All three are OFL 1.1; license texts sit beside them +in `fonts/OFL-*.txt` and must stay with the binaries. Latin subsets only, 68 KB total. + +The mock agent panel on 02 still reads "WebBrain is acting" — that is depicted product UI, +not slide branding. Say so if you want it neutralised too. + +v2/v3 are untouched and still use the old sans stack; the four versions are independent +copies, so a copy change needs applying in each. + +Files (1280×800): +- 01-hero.png: Mono kicker + serif tagline hero +- 02-tell-the-browser.png: Flight-search command front and center as chat input, browser acting on it +- 03-ask-any-page.png: Ask mode, cropped to the answer panel +- 04-any-llm.png: Model picker, cropped to the provider dropdown +- 05-plan-before-act.png: Plan review with Approve/Adjust before actions run +- 06-launch-offer.png: WebBrain Cloud $5/mo (reg. $8), Save 35% +- 07-social-proof.png: 500+ GitHub stars, 20+ contributors, MIT — repo bar with contributor avatars + +Light-background alternates of the two dark slides (originals kept, use whichever fits the gallery): +- 01-hero-light.png +- 05-plan-before-act-light.png + +Both come from the same `hero(light)` / `planScene(light)` functions, so edits apply to +dark and light together. In the light plan card the panel goes white and the accent green +darkens for contrast against white button text. + +Star/contributor counts are hardcoded in `proofScene()`; bump them there when they go stale (544 stars at last render, 2026-07-29). The avatar initials are decorative, not real contributor handles. + +Slide 07 is appended after the offer to keep the existing filenames stable. If proof-then-price reads better, swap the last two entries in the `scenes` array and rename the two PNGs. + +Regenerate: + +```bash +node assets/webstore-explainer-2026-4/render.mjs +``` diff --git a/assets/webstore-explainer-2026-4/fonts/OFL-geistmono.txt b/assets/webstore-explainer-2026-4/fonts/OFL-geistmono.txt new file mode 100644 index 000000000..61f6f4b78 --- /dev/null +++ b/assets/webstore-explainer-2026-4/fonts/OFL-geistmono.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/webstore-explainer-2026-4/fonts/OFL-instrumentsans.txt b/assets/webstore-explainer-2026-4/fonts/OFL-instrumentsans.txt new file mode 100644 index 000000000..26bd2f954 --- /dev/null +++ b/assets/webstore-explainer-2026-4/fonts/OFL-instrumentsans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/webstore-explainer-2026-4/fonts/OFL-instrumentserif.txt b/assets/webstore-explainer-2026-4/fonts/OFL-instrumentserif.txt new file mode 100644 index 000000000..669ce7917 --- /dev/null +++ b/assets/webstore-explainer-2026-4/fonts/OFL-instrumentserif.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/webstore-explainer-2026-4/fonts/geist-mono-var.woff2 b/assets/webstore-explainer-2026-4/fonts/geist-mono-var.woff2 new file mode 100644 index 000000000..cf24126b0 Binary files /dev/null and b/assets/webstore-explainer-2026-4/fonts/geist-mono-var.woff2 differ diff --git a/assets/webstore-explainer-2026-4/fonts/instrument-sans-var.woff2 b/assets/webstore-explainer-2026-4/fonts/instrument-sans-var.woff2 new file mode 100644 index 000000000..665fa65c8 Binary files /dev/null and b/assets/webstore-explainer-2026-4/fonts/instrument-sans-var.woff2 differ diff --git a/assets/webstore-explainer-2026-4/fonts/instrument-serif-400.woff2 b/assets/webstore-explainer-2026-4/fonts/instrument-serif-400.woff2 new file mode 100644 index 000000000..17653042a Binary files /dev/null and b/assets/webstore-explainer-2026-4/fonts/instrument-serif-400.woff2 differ diff --git a/assets/webstore-explainer-2026-4/render.mjs b/assets/webstore-explainer-2026-4/render.mjs new file mode 100644 index 000000000..aaa587aee --- /dev/null +++ b/assets/webstore-explainer-2026-4/render.mjs @@ -0,0 +1,458 @@ +import { chromium } from 'playwright'; +import { readFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(DIR, '../..'); + +// No product logo or wordmark in this variant — the Web Store already shows both beside the listing. +const assets = { + ask: dataUri('assets/screenshot-1-ask-mode.png'), + models: dataUri('assets/screenshots_v1/s7-model-selector.png'), +}; + +// Vendored so renders never depend on what happens to be installed locally. See fonts/OFL-*.txt. +const fonts = { + serif: fontUri('instrument-serif-400.woff2'), + sans: fontUri('instrument-sans-var.woff2'), + mono: fontUri('geist-mono-var.woff2'), +}; + +function dataUri(relativePath) { + const bytes = readFileSync(path.join(ROOT, relativePath)); + return `data:image/png;base64,${bytes.toString('base64')}`; +} + +function fontUri(name) { + const bytes = readFileSync(path.join(DIR, 'fonts', name)); + return `data:font/woff2;base64,${bytes.toString('base64')}`; +} + +const W = 1280; +const H = 800; + +const baseCss = ` + @font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; + src: url(${fonts.serif}) format('woff2'); } + @font-face { font-family: 'Instrument Sans'; font-weight: 400 700; font-style: normal; + src: url(${fonts.sans}) format('woff2'); } + @font-face { font-family: 'Geist Mono'; font-weight: 400 700; font-style: normal; + src: url(${fonts.mono}) format('woff2'); } + :root { + color-scheme: light; + --display: 'Instrument Serif', ui-serif, Georgia, serif; + --ui: 'Instrument Sans', ui-sans-serif, system-ui, -apple-system, sans-serif; + --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + } + * { box-sizing: border-box; } + html, body { + margin: 0; width: ${W}px; height: ${H}px; overflow: hidden; + font-family: var(--ui); + -webkit-font-smoothing: antialiased; + } + .canvas { + position: relative; width: ${W}px; height: ${H}px; overflow: hidden; + background: linear-gradient(135deg, var(--bg1), var(--bg2)); + color: var(--ink); isolation: isolate; + } + .canvas:after { + content: ""; position: absolute; z-index: 0; pointer-events: none; + right: -120px; bottom: -140px; width: 540px; height: 360px; + background: + linear-gradient(90deg, transparent 0 28px, rgba(255,255,255,0.14) 28px 30px, transparent 30px 72px), + linear-gradient(0deg, transparent 0 28px, rgba(255,255,255,0.12) 28px 30px, transparent 30px 72px); + transform: rotate(-8deg); opacity: 0.8; + } + .dark { --bg1:#121525; --bg2:#243044; --ink:#ffffff; --muted:#d5d9e3; + --accent:#f56ca8; --accent2:#65d69d; --border:rgba(255,255,255,0.16); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 70px rgba(0,0,0,0.32); } + .act { --bg1:#f7fbff; --bg2:#fff4ef; --ink:#182033; --muted:#596473; + --accent:#df573f; --accent2:#4d7df5; --border:rgba(31,40,64,0.14); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(68,50,40,0.20); } + .read { --bg1:#fffaf3; --bg2:#eef5ff; --ink:#171827; --muted:#565e70; + --accent:#6757ff; --accent2:#ff6f9d; --border:rgba(37,42,66,0.12); + --panel:rgba(255,255,255,0.88); --shadow:0 28px 70px rgba(31,35,62,0.20); } + .plan { --bg1:#111827; --bg2:#223040; --ink:#f8fafc; --muted:#c7d2de; + --accent:#45d483; --accent2:#ffc857; --border:rgba(255,255,255,0.18); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 80px rgba(0,0,0,0.34); } + .provider { --bg1:#f6f8fb; --bg2:#eef8f1; --ink:#182033; --muted:#5b6574; + --accent:#3e6ff4; --accent2:#28a96b; --border:rgba(25,38,68,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(24,52,90,0.19); } + .hero-light { --bg1:#f7f9ff; --bg2:#fff1f6; --ink:#141828; --muted:#586074; + --accent:#d6417f; --accent2:#12a06a; --border:rgba(28,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(40,44,80,0.18); } + .plan-light { --bg1:#f5fbf7; --bg2:#eef4ff; --ink:#141b26; --muted:#586374; + --accent:#12a25f; --accent2:#dd9414; --border:rgba(24,38,48,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(28,50,44,0.18); } + .proof { --bg1:#f7f6ff; --bg2:#eef4ff; --ink:#171a2b; --muted:#5a6274; + --accent:#6e56cf; --accent2:#f0a52b; --border:rgba(30,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(38,34,78,0.18); } + .content { position: relative; z-index: 1; width: 100%; height: 100%; padding: 44px 56px; } + .eyebrow { + display: inline-flex; align-items: center; gap: 10px; padding: 10px 15px; + color: var(--accent); background: var(--panel); border: 1px solid var(--border); + border-radius: 999px; font-family: var(--mono); font-size: 13px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.11em; line-height: 1; + } + .head { display: flex; align-items: center; justify-content: flex-end; } + .eyebrow .dot { width: 9px; height: 9px; border-radius: 999px; background: var(--accent2); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent2) 18%, transparent); } + h1 { + margin: 18px 0 0; font-family: var(--display); font-size: 66px; line-height: 1.02; + font-weight: 400; letter-spacing: -0.015em; text-wrap: balance; + } + .sub { margin-top: 16px; color: var(--muted); font-size: 24px; font-weight: 450; letter-spacing: -0.005em; } + .num { font-family: var(--display); font-weight: 400; letter-spacing: -0.02em; } + .crop-frame { + overflow: hidden; border: 1px solid var(--border); border-radius: 24px; + background: #ffffff; box-shadow: var(--shadow); position: relative; + } + .crop-frame img { display: block; } +`; + +/* ---------- 01 HERO (dark + light variant) ---------- */ +function hero(light = false) { + return { + file: light ? '01-hero-light.png' : '01-hero.png', + theme: light ? 'hero-light' : 'dark', + body: ` +
+
WebBrain
+

Your open-source
AI browser agent

+
+ Ask. + Act. + Automate. Any LLM. +
+
+ ${['Chromium browsers and Firefox', 'Local or cloud models', 'MIT licensed'].map((c) => ` + ${c}`).join('')} +
+
`, + }; +} + +/* ---------- 02 TELL THE BROWSER (command front and center) ---------- */ +function actScene() { + const steps = [ + ['done', 'Found the flight search form'], + ['done', 'Typed “Istanbul (IST)”'], + ['done', 'Typed “San Francisco (SFO)”'], + ['live', 'Clicking Search…'], + ]; + return { + file: '02-tell-the-browser.png', + theme: 'act', + body: ` +
+ Act mode +
+
+

Tell the browser what to do.

+
+ + +
+
+ Search for the cheapest flights from Istanbul to San Francisco +
+
+
+ + +
+
+
+ + + + google.com/travel/flights +
+
+
Google Flights
+
+
Istanbul (IST)
+
+
San Francisco (SFO)
+
+
+
Search
+ + + +
+
+
+ + $612 +
+
+ + $688 +
+
+
+
+
+
WebBrain is acting
+
+ ${steps.map(([state, label]) => ` +
+ ${state === 'live' ? '●' : '✓'} + ${label} +
`).join('')} +
+
+ Pauses before anything irreversible +
+
+
`, + }; +} + +/* ---------- 03 ASK ANY PAGE (cropped to the answer panel) ---------- */ +function askScene() { + // Source 1280x800. Relevant UI: sidebar below header x 905-1280, y 122-474. Scale 1.62. + const s = 1.62; + const x = 906, y = 122, w = 374, h = 352; + return { + file: '03-ask-any-page.png', + theme: 'read', + body: ` +
+ Ask mode +
+
+
+

Ask any page.
Get the useful part.

+
Clean answers from messy pages. Read-only by default.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 04 ANY LLM (cropped to model dropdown) ---------- */ +function modelsScene() { + // Source 1280x800. Relevant UI: dropdown+header x 418-1162, y 6-748. Scale 0.82. + const s = 0.82; + const x = 418, y = 6, w = 744, h = 742; + return { + file: '04-any-llm.png', + theme: 'provider', + body: ` +
+ Any LLM +
+
+
+

Use the model you trust.

+
Local, cloud, or your own keys — switch anytime.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 05 PLAN BEFORE ACT (dark + light variant) ---------- */ +function planScene(light = false) { + const steps = [ + 'Read the visible form and required fields', + 'Fill only what you asked for', + 'Pause before any purchase or submit', + ]; + const card = light + ? { bg: '#ffffff', border: 'var(--border)', shadow: '0 30px 70px rgba(28,50,44,0.18)', + label: 'var(--muted)', step: 'var(--ink)', onAccent: '#ffffff' } + : { bg: '#151c2a', border: 'rgba(255,255,255,0.16)', shadow: '0 30px 80px rgba(0,0,0,0.42)', + label: '#8d99ad', step: '#d7e2ea', onAccent: '#102319' }; + return { + file: light ? '05-plan-before-act-light.png' : '05-plan-before-act.png', + theme: light ? 'plan-light' : 'plan', + body: ` +
+ Plan before Act +
+
+
+

See the plan before it touches the page.

+
Approve first. You stay in the loop.
+
+
+
+
Proposed browser plan
+
+ ${steps.map((label, i) => ` +
+ ${i + 1} + ${label} +
`).join('')} +
+
+ Approve + Adjust +
+
+
+
`, + }; +} + +/* ---------- 06 LAUNCH OFFER ---------- */ +function offerScene() { + return { + file: '06-launch-offer.png', + theme: 'dark', + body: ` +
+ Launch offer +
+
+

WebBrain Cloud launch pricing

+
+ $8 + $5/mo + Save 35% +
+
No setup, no API keys — just install and go.
+
Or free forever with your own keys or local models.
+
`, + }; +} + +/* ---------- 07 SOCIAL PROOF ---------- */ +function proofScene() { + const icon = (paths, color) => + ``; + const stats = [ + [icon('', 'var(--accent2)'), + '500+', 'GitHub stars'], + [icon('', 'var(--accent)'), + '20+', 'contributors'], + [icon('', 'var(--accent)'), + 'MIT', 'licensed, free forever'], + ]; + // Decorative contributor avatars: initials over a fixed palette. + const avatars = [ + ['ES', '#6e56cf'], ['MK', '#e0644f'], ['AY', '#2b9f6b'], ['JR', '#2f6fe0'], + ['LP', '#c8478f'], ['DT', '#e08b2b'], ['SN', '#4a5568'], ['KV', '#0f9aa8'], + ['BW', '#8b5cf6'], ['RO', '#d14f6d'], + ]; + return { + file: '07-social-proof.png', + theme: 'proof', + body: ` +
+ Open source +
+ +
+

Built in the open.

+
+ +
+ ${stats.map(([glyph, value, label]) => ` +
+
${glyph}
+
${value}
+
${label}
+
`).join('')} +
+ +
+
+ +
+
github.com/webbrain-one/webbrain
+
Star it, fork it, ship a PR.
+
+
+
+
+ ${avatars.map(([initials, bg], i) => ` + ${initials}`).join('')} + +10 +
+ + Star +
+
`, + }; +} + +const scenes = [ + hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(), + hero(true), planScene(true), +]; + +function html(scene) { + return ` + +
${scene.body}
`; +} + +async function renderAll() { + await mkdir(DIR, { recursive: true }); + const browser = await chromium.launch(); + for (const scene of scenes) { + const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); + await page.setContent(html(scene), { waitUntil: 'load' }); + await page.evaluate(async () => { + await Promise.all(Array.from(document.images).map((img) => img.complete ? undefined : new Promise((res, rej) => { + img.addEventListener('load', res, { once: true }); + img.addEventListener('error', rej, { once: true }); + }))); + await document.fonts.ready; + }); + await page.screenshot({ path: path.join(DIR, scene.file) }); + await page.close(); + console.log('rendered', scene.file); + } + await browser.close(); +} + +renderAll().catch((error) => { console.error(error); process.exit(1); }); diff --git a/assets/webstore-explainer-2026-5/01-hero-light.png b/assets/webstore-explainer-2026-5/01-hero-light.png new file mode 100644 index 000000000..acc61ca07 Binary files /dev/null and b/assets/webstore-explainer-2026-5/01-hero-light.png differ diff --git a/assets/webstore-explainer-2026-5/01-hero.png b/assets/webstore-explainer-2026-5/01-hero.png new file mode 100644 index 000000000..8de6310bb Binary files /dev/null and b/assets/webstore-explainer-2026-5/01-hero.png differ diff --git a/assets/webstore-explainer-2026-5/02-tell-the-browser.png b/assets/webstore-explainer-2026-5/02-tell-the-browser.png new file mode 100644 index 000000000..ff06d61f8 Binary files /dev/null and b/assets/webstore-explainer-2026-5/02-tell-the-browser.png differ diff --git a/assets/webstore-explainer-2026-5/03-ask-any-page.png b/assets/webstore-explainer-2026-5/03-ask-any-page.png new file mode 100644 index 000000000..cc4fb2043 Binary files /dev/null and b/assets/webstore-explainer-2026-5/03-ask-any-page.png differ diff --git a/assets/webstore-explainer-2026-5/04-any-llm.png b/assets/webstore-explainer-2026-5/04-any-llm.png new file mode 100644 index 000000000..5b4dbd567 Binary files /dev/null and b/assets/webstore-explainer-2026-5/04-any-llm.png differ diff --git a/assets/webstore-explainer-2026-5/05-plan-before-act-light.png b/assets/webstore-explainer-2026-5/05-plan-before-act-light.png new file mode 100644 index 000000000..1c3a99049 Binary files /dev/null and b/assets/webstore-explainer-2026-5/05-plan-before-act-light.png differ diff --git a/assets/webstore-explainer-2026-5/05-plan-before-act.png b/assets/webstore-explainer-2026-5/05-plan-before-act.png new file mode 100644 index 000000000..36ce9227d Binary files /dev/null and b/assets/webstore-explainer-2026-5/05-plan-before-act.png differ diff --git a/assets/webstore-explainer-2026-5/06-launch-offer.png b/assets/webstore-explainer-2026-5/06-launch-offer.png new file mode 100644 index 000000000..b9bda5cfd Binary files /dev/null and b/assets/webstore-explainer-2026-5/06-launch-offer.png differ diff --git a/assets/webstore-explainer-2026-5/07-social-proof.png b/assets/webstore-explainer-2026-5/07-social-proof.png new file mode 100644 index 000000000..0f0077e5a Binary files /dev/null and b/assets/webstore-explainer-2026-5/07-social-proof.png differ diff --git a/assets/webstore-explainer-2026-5/README.md b/assets/webstore-explainer-2026-5/README.md new file mode 100644 index 000000000..05dd63749 --- /dev/null +++ b/assets/webstore-explainer-2026-5/README.md @@ -0,0 +1,61 @@ +# Web Store explainer visuals 2026 — v5 (typographic, thumbnail-safe) + +Same gallery and copy as v3/v4. v4 set headlines in Instrument Serif 400, which looked +good at full size but fell apart small: its hairlines are about a pixel at 88px, so on a +Product Hunt gallery thumbnail (~300px wide, headline ~20px effective) the strokes broke +up and went grey. v5 keeps the same structure with a display face that survives +downscaling. + +No product icon and no "WebBrain" corner wordmark on any slide. The hero keeps a small +mono "WEBBRAIN" kicker as the only brand mark. + +Type system: +- Display (headlines, prices, stat numerals) — **Bricolage Grotesque** 800, `opsz` 72–96 +- UI (body, subs, panel text) — **Instrument Sans** 400–700 +- Labels (eyebrows, chips, URLs, buttons) — **Geist Mono** 650, uppercase + +Versus v4, tracking on the mono labels dropped from 0.11em to ~0.07em and their size went +up ~1px — wide-tracked small caps smear when downscaled. Sub weight went 450 → 520. + +Check any type change at thumbnail size before shipping it: + +```bash +sips -Z 320 --out /tmp/thumb.png assets/webstore-explainer-2026-5/01-hero.png && open /tmp/thumb.png +``` + +Fonts are vendored in `fonts/` and inlined as base64 at render time, so output does not +depend on what is installed locally. Both families are OFL 1.1; the license texts in +`fonts/OFL-*.txt` must stay with the binaries. + +The mock agent panel on 02 still reads "WebBrain is acting" — depicted product UI, not +slide branding. + +v2–v4 are untouched; the versions are independent copies, so a copy change needs applying +in each. + +Files (1280×800): +- 01-hero.png: Mono kicker + heavy grotesque tagline hero +- 02-tell-the-browser.png: Flight-search command front and center as chat input, browser acting on it +- 03-ask-any-page.png: Ask mode, cropped to the answer panel +- 04-any-llm.png: Model picker, cropped to the provider dropdown +- 05-plan-before-act.png: Plan review with Approve/Adjust before actions run +- 06-launch-offer.png: WebBrain Cloud $5/mo (reg. $8), Save 35% +- 07-social-proof.png: 500+ GitHub stars, 20+ contributors, MIT — repo bar with contributor avatars + +Light-background alternates of the two dark slides (originals kept, use whichever fits the gallery): +- 01-hero-light.png +- 05-plan-before-act-light.png + +Both come from the same `hero(light)` / `planScene(light)` functions, so edits apply to +dark and light together. In the light plan card the panel goes white and the accent green +darkens for contrast against white button text. + +Star/contributor counts are hardcoded in `proofScene()`; bump them there when they go stale (544 stars at last render, 2026-07-29). The avatar initials are decorative, not real contributor handles. + +Slide 07 is appended after the offer to keep the existing filenames stable. If proof-then-price reads better, swap the last two entries in the `scenes` array and rename the two PNGs. + +Regenerate: + +```bash +node assets/webstore-explainer-2026-5/render.mjs +``` diff --git a/assets/webstore-explainer-2026-5/fonts/OFL-bricolagegrotesque.txt b/assets/webstore-explainer-2026-5/fonts/OFL-bricolagegrotesque.txt new file mode 100644 index 000000000..a397658a5 --- /dev/null +++ b/assets/webstore-explainer-2026-5/fonts/OFL-bricolagegrotesque.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/webstore-explainer-2026-5/fonts/OFL-geistmono.txt b/assets/webstore-explainer-2026-5/fonts/OFL-geistmono.txt new file mode 100644 index 000000000..61f6f4b78 --- /dev/null +++ b/assets/webstore-explainer-2026-5/fonts/OFL-geistmono.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/webstore-explainer-2026-5/fonts/OFL-instrumentsans.txt b/assets/webstore-explainer-2026-5/fonts/OFL-instrumentsans.txt new file mode 100644 index 000000000..26bd2f954 --- /dev/null +++ b/assets/webstore-explainer-2026-5/fonts/OFL-instrumentsans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/webstore-explainer-2026-5/fonts/bricolage-grotesque-var.woff2 b/assets/webstore-explainer-2026-5/fonts/bricolage-grotesque-var.woff2 new file mode 100644 index 000000000..b908aff81 Binary files /dev/null and b/assets/webstore-explainer-2026-5/fonts/bricolage-grotesque-var.woff2 differ diff --git a/assets/webstore-explainer-2026-5/fonts/geist-mono-var.woff2 b/assets/webstore-explainer-2026-5/fonts/geist-mono-var.woff2 new file mode 100644 index 000000000..cf24126b0 Binary files /dev/null and b/assets/webstore-explainer-2026-5/fonts/geist-mono-var.woff2 differ diff --git a/assets/webstore-explainer-2026-5/fonts/instrument-sans-var.woff2 b/assets/webstore-explainer-2026-5/fonts/instrument-sans-var.woff2 new file mode 100644 index 000000000..665fa65c8 Binary files /dev/null and b/assets/webstore-explainer-2026-5/fonts/instrument-sans-var.woff2 differ diff --git a/assets/webstore-explainer-2026-5/render.mjs b/assets/webstore-explainer-2026-5/render.mjs new file mode 100644 index 000000000..2c6e3e493 --- /dev/null +++ b/assets/webstore-explainer-2026-5/render.mjs @@ -0,0 +1,463 @@ +import { chromium } from 'playwright'; +import { readFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(DIR, '../..'); + +// No product logo or wordmark in this variant — the Web Store already shows both beside the listing. +const assets = { + ask: dataUri('assets/screenshot-1-ask-mode.png'), + models: dataUri('assets/screenshots_v1/s7-model-selector.png'), +}; + +// Vendored so renders never depend on what happens to be installed locally. See fonts/OFL-*.txt. +const fonts = { + display: fontUri('bricolage-grotesque-var.woff2'), + sans: fontUri('instrument-sans-var.woff2'), + mono: fontUri('geist-mono-var.woff2'), +}; + +function dataUri(relativePath) { + const bytes = readFileSync(path.join(ROOT, relativePath)); + return `data:image/png;base64,${bytes.toString('base64')}`; +} + +function fontUri(name) { + const bytes = readFileSync(path.join(DIR, 'fonts', name)); + return `data:font/woff2;base64,${bytes.toString('base64')}`; +} + +const W = 1280; +const H = 800; + +const baseCss = ` + @font-face { font-family: 'Bricolage Grotesque'; font-weight: 200 800; font-style: normal; + src: url(${fonts.display}) format('woff2'); } + @font-face { font-family: 'Instrument Sans'; font-weight: 400 700; font-style: normal; + src: url(${fonts.sans}) format('woff2'); } + @font-face { font-family: 'Geist Mono'; font-weight: 400 700; font-style: normal; + src: url(${fonts.mono}) format('woff2'); } + :root { + color-scheme: light; + --display: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif; + --ui: 'Instrument Sans', ui-sans-serif, system-ui, -apple-system, sans-serif; + --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + } + * { box-sizing: border-box; } + html, body { + margin: 0; width: ${W}px; height: ${H}px; overflow: hidden; + font-family: var(--ui); + -webkit-font-smoothing: antialiased; + } + .canvas { + position: relative; width: ${W}px; height: ${H}px; overflow: hidden; + background: linear-gradient(135deg, var(--bg1), var(--bg2)); + color: var(--ink); isolation: isolate; + } + .canvas:after { + content: ""; position: absolute; z-index: 0; pointer-events: none; + right: -120px; bottom: -140px; width: 540px; height: 360px; + background: + linear-gradient(90deg, transparent 0 28px, rgba(255,255,255,0.14) 28px 30px, transparent 30px 72px), + linear-gradient(0deg, transparent 0 28px, rgba(255,255,255,0.12) 28px 30px, transparent 30px 72px); + transform: rotate(-8deg); opacity: 0.8; + } + .dark { --bg1:#121525; --bg2:#243044; --ink:#ffffff; --muted:#d5d9e3; + --accent:#f56ca8; --accent2:#65d69d; --border:rgba(255,255,255,0.16); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 70px rgba(0,0,0,0.32); } + .act { --bg1:#f7fbff; --bg2:#fff4ef; --ink:#182033; --muted:#596473; + --accent:#df573f; --accent2:#4d7df5; --border:rgba(31,40,64,0.14); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(68,50,40,0.20); } + .read { --bg1:#fffaf3; --bg2:#eef5ff; --ink:#171827; --muted:#565e70; + --accent:#6757ff; --accent2:#ff6f9d; --border:rgba(37,42,66,0.12); + --panel:rgba(255,255,255,0.88); --shadow:0 28px 70px rgba(31,35,62,0.20); } + .plan { --bg1:#111827; --bg2:#223040; --ink:#f8fafc; --muted:#c7d2de; + --accent:#45d483; --accent2:#ffc857; --border:rgba(255,255,255,0.18); + --panel:rgba(255,255,255,0.08); --shadow:0 28px 80px rgba(0,0,0,0.34); } + .provider { --bg1:#f6f8fb; --bg2:#eef8f1; --ink:#182033; --muted:#5b6574; + --accent:#3e6ff4; --accent2:#28a96b; --border:rgba(25,38,68,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(24,52,90,0.19); } + .hero-light { --bg1:#f7f9ff; --bg2:#fff1f6; --ink:#141828; --muted:#586074; + --accent:#d6417f; --accent2:#12a06a; --border:rgba(28,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(40,44,80,0.18); } + .plan-light { --bg1:#f5fbf7; --bg2:#eef4ff; --ink:#141b26; --muted:#586374; + --accent:#12a25f; --accent2:#dd9414; --border:rgba(24,38,48,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(28,50,44,0.18); } + .proof { --bg1:#f7f6ff; --bg2:#eef4ff; --ink:#171a2b; --muted:#5a6274; + --accent:#6e56cf; --accent2:#f0a52b; --border:rgba(30,34,64,0.13); + --panel:rgba(255,255,255,0.9); --shadow:0 28px 70px rgba(38,34,78,0.18); } + .content { position: relative; z-index: 1; width: 100%; height: 100%; padding: 44px 56px; } + .eyebrow { + display: inline-flex; align-items: center; gap: 10px; padding: 10px 15px; + color: var(--accent); background: var(--panel); border: 1px solid var(--border); + border-radius: 999px; font-family: var(--mono); font-size: 13.5px; font-weight: 650; + text-transform: uppercase; letter-spacing: 0.07em; line-height: 1; + } + .head { display: flex; align-items: center; justify-content: flex-end; } + .eyebrow .dot { width: 9px; height: 9px; border-radius: 999px; background: var(--accent2); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent2) 18%, transparent); } + h1 { + margin: 18px 0 0; font-family: var(--display); font-size: 62px; line-height: 1.05; + font-weight: 800; letter-spacing: -0.02em; font-variation-settings: 'opsz' 72; + text-wrap: balance; + } + .sub { margin-top: 16px; color: var(--muted); font-size: 24px; font-weight: 520; letter-spacing: -0.005em; } + .num { font-family: var(--display); font-weight: 800; letter-spacing: -0.03em; + font-variation-settings: 'opsz' 96; } + .crop-frame { + overflow: hidden; border: 1px solid var(--border); border-radius: 24px; + background: #ffffff; box-shadow: var(--shadow); position: relative; + } + .crop-frame img { display: block; } +`; + +/* ---------- 01 HERO (dark + light variant) ---------- */ +function hero(light = false) { + return { + file: light ? '01-hero-light.png' : '01-hero.png', + theme: light ? 'hero-light' : 'dark', + body: ` +
+
WebBrain
+

Your open-source
AI browser agent

+
+ Ask. + Act. + Automate. Any LLM. +
+
+ ${['Chromium browsers and Firefox', 'Local or cloud models', 'MIT licensed'].map((c) => ` + ${c}`).join('')} +
+
`, + }; +} + +/* ---------- 02 TELL THE BROWSER (command front and center) ---------- */ +function actScene() { + const steps = [ + ['done', 'Found the flight search form'], + ['done', 'Typed “Istanbul (IST)”'], + ['done', 'Typed “San Francisco (SFO)”'], + ['live', 'Clicking Search…'], + ]; + return { + file: '02-tell-the-browser.png', + theme: 'act', + body: ` +
+ Act mode +
+
+

Tell the browser what to do.

+
+ + +
+
+ Search for the cheapest flights from Istanbul to San Francisco +
+
+
+ + +
+
+
+ + + + google.com/travel/flights +
+
+
Google Flights
+
+
Istanbul (IST)
+
+
San Francisco (SFO)
+
+
+
Search
+ + + +
+
+
+ + $612 +
+
+ + $688 +
+
+
+
+
+
WebBrain is acting
+
+ ${steps.map(([state, label]) => ` +
+ ${state === 'live' ? '●' : '✓'} + ${label} +
`).join('')} +
+
+ Pauses before anything irreversible +
+
+
`, + }; +} + +/* ---------- 03 ASK ANY PAGE (cropped to the answer panel) ---------- */ +function askScene() { + // Source 1280x800. Relevant UI: sidebar below header x 905-1280, y 122-474. Scale 1.62. + const s = 1.62; + const x = 906, y = 122, w = 374, h = 352; + return { + file: '03-ask-any-page.png', + theme: 'read', + body: ` +
+ Ask mode +
+
+
+

Ask any page.
Get the useful part.

+
Clean answers from messy pages. Read-only by default.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 04 ANY LLM (cropped to model dropdown) ---------- */ +function modelsScene() { + // Source 1280x800. Relevant UI: dropdown+header x 418-1162, y 6-748. Scale 0.82. + const s = 0.82; + const x = 418, y = 6, w = 744, h = 742; + return { + file: '04-any-llm.png', + theme: 'provider', + body: ` +
+ Any LLM +
+
+
+

Use the model you trust.

+
Local, cloud, or your own keys — switch anytime.
+
+
+
+ +
+
+
`, + }; +} + +/* ---------- 05 PLAN BEFORE ACT (dark + light variant) ---------- */ +function planScene(light = false) { + const steps = [ + 'Read the visible form and required fields', + 'Fill only what you asked for', + 'Pause before any purchase or submit', + ]; + const card = light + ? { bg: '#ffffff', border: 'var(--border)', shadow: '0 30px 70px rgba(28,50,44,0.18)', + label: 'var(--muted)', step: 'var(--ink)', onAccent: '#ffffff' } + : { bg: '#151c2a', border: 'rgba(255,255,255,0.16)', shadow: '0 30px 80px rgba(0,0,0,0.42)', + label: '#8d99ad', step: '#d7e2ea', onAccent: '#102319' }; + return { + file: light ? '05-plan-before-act-light.png' : '05-plan-before-act.png', + theme: light ? 'plan-light' : 'plan', + body: ` +
+ Plan before Act +
+
+
+

See the plan before it touches the page.

+
Approve first. You stay in the loop.
+
+
+
+
Proposed browser plan
+
+ ${steps.map((label, i) => ` +
+ ${i + 1} + ${label} +
`).join('')} +
+
+ Approve + Adjust +
+
+
+
`, + }; +} + +/* ---------- 06 LAUNCH OFFER ---------- */ +function offerScene() { + return { + file: '06-launch-offer.png', + theme: 'dark', + body: ` +
+ Launch offer +
+
+

WebBrain Cloud launch pricing

+
+ + $8 + + + $5/mo + Save 35% +
+
No setup, no API keys — just install and go.
+
Or free forever with your own keys or local models.
+
`, + }; +} + +/* ---------- 07 SOCIAL PROOF ---------- */ +function proofScene() { + const icon = (paths, color) => + ``; + const stats = [ + [icon('', 'var(--accent2)'), + '500+', 'GitHub stars'], + [icon('', 'var(--accent)'), + '20+', 'contributors'], + [icon('', 'var(--accent)'), + 'MIT', 'licensed, free forever'], + ]; + // Decorative contributor avatars: initials over a fixed palette. + const avatars = [ + ['ES', '#6e56cf'], ['MK', '#e0644f'], ['AY', '#2b9f6b'], ['JR', '#2f6fe0'], + ['LP', '#c8478f'], ['DT', '#e08b2b'], ['SN', '#4a5568'], ['KV', '#0f9aa8'], + ['BW', '#8b5cf6'], ['RO', '#d14f6d'], + ]; + return { + file: '07-social-proof.png', + theme: 'proof', + body: ` +
+ Open source +
+ +
+

Built in the open.

+
+ +
+ ${stats.map(([glyph, value, label]) => ` +
+
${glyph}
+
${value}
+
${label}
+
`).join('')} +
+ +
+
+ +
+
github.com/webbrain-one/webbrain
+
Star it, fork it, ship a PR.
+
+
+
+
+ ${avatars.map(([initials, bg], i) => ` + ${initials}`).join('')} + +10 +
+ + Star +
+
`, + }; +} + +const scenes = [ + hero(), actScene(), askScene(), modelsScene(), planScene(), offerScene(), proofScene(), + hero(true), planScene(true), +]; + +function html(scene) { + return ` + +
${scene.body}
`; +} + +async function renderAll() { + await mkdir(DIR, { recursive: true }); + const browser = await chromium.launch(); + for (const scene of scenes) { + const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); + await page.setContent(html(scene), { waitUntil: 'load' }); + await page.evaluate(async () => { + await Promise.all(Array.from(document.images).map((img) => img.complete ? undefined : new Promise((res, rej) => { + img.addEventListener('load', res, { once: true }); + img.addEventListener('error', rej, { once: true }); + }))); + await document.fonts.ready; + }); + await page.screenshot({ path: path.join(DIR, scene.file) }); + await page.close(); + console.log('rendered', scene.file); + } + await browser.close(); +} + +renderAll().catch((error) => { console.error(error); process.exit(1); }); diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 490bb1b14..cbd00a597 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1,5 +1,6 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX, SYSTEM_PROMPT_WEBMCP_ASK, SYSTEM_PROMPT_WEBMCP_ACT } from './tools.js'; import { handleDoneJson } from './cloud-output.js'; +import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { LoopDetector } from './loop-detector.js'; import { parseToolCallsFromText } from './tool-call-parser.js'; import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js'; @@ -12645,13 +12646,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d */ _limitToolResult(result) { const maxResultChars = 8000; // ~2k tokens - const safeResult = result == null ? { + const windowSafeResult = isReadPageWindowResult(result) + ? fitReadPageWindowResult(result, maxResultChars) + : result; + const safeResult = windowSafeResult == null ? { success: false, errorCode: 'missing_tool_response', missingToolResponse: true, outcomeUnknown: false, error: 'Tool returned no result.', - } : result; + } : windowSafeResult; let json; try { json = JSON.stringify(safeResult); @@ -12668,14 +12672,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (json.length <= maxResultChars) return json; // Try to trim the 'text' field specifically (page content) - if (result && typeof result.text === 'string' && result.text.length > 4000) { - const trimmed = { ...result, text: result.text.slice(0, 4000) + '\n[...page text truncated]' }; + if (safeResult && typeof safeResult.text === 'string' && safeResult.text.length > 4000) { + const trimmed = { ...safeResult, text: safeResult.text.slice(0, 4000) + '\n[...page text truncated]' }; json = JSON.stringify(trimmed); if (json.length <= maxResultChars) return json; } - if (result && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) { - const originalData = result.data; + if (safeResult && safeResult.data && typeof safeResult.data === 'object' && !Array.isArray(safeResult.data)) { + const originalData = safeResult.data; const originalText = typeof originalData.text === 'string' ? originalData.text : null; const originalSegments = Array.isArray(originalData.segments) ? originalData.segments : null; if (originalText || originalSegments) { @@ -12710,7 +12714,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d data.segmentsTruncated = true; data.originalSegmentCount = data.originalSegmentCount ?? originalSegments.length; } - const trimmed = { ...result, data }; + const trimmed = { ...safeResult, data }; json = JSON.stringify(trimmed); if (json.length <= maxResultChars) return json; } @@ -18067,6 +18071,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); this._recordInteractionRect(tabId, name, response, interactionUrl); this._annotateCredentialField(name, response); + if (name === 'read_page') { + response = applyReadPageWindow(response, args); + } return response; } finally { clickAxSideEffectWatch?.stop(); diff --git a/src/chrome/src/agent/read-page-window.js b/src/chrome/src/agent/read-page-window.js new file mode 100644 index 000000000..af9d70136 --- /dev/null +++ b/src/chrome/src/agent/read-page-window.js @@ -0,0 +1,198 @@ +export const READ_PAGE_DEFAULT_LIMIT = 4000; +export const READ_PAGE_MIN_LIMIT = 500; +export const READ_PAGE_MAX_LIMIT = 6000; + +function boundedInteger(value, fallback, min, max) { + if (value == null || value === '') return fallback; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.floor(numeric))); +} + +function withDeliveredText(result, text) { + const originalLength = Number(result.originalLength) || 0; + const textOffset = Number(result.textOffset) || 0; + const returnedLength = text.length; + const deliveredEnd = Math.min(originalLength, textOffset + returnedLength); + const hasMore = deliveredEnd < originalLength; + const textTruncated = textOffset > 0 || hasMore; + const continuationArgs = hasMore + ? { + offset: deliveredEnd, + limit: Number(result.textLimit) || READ_PAGE_DEFAULT_LIMIT, + includeChrome: result.includeChrome === true, + } + : null; + return { + ...result, + text, + returnedLength, + textTruncated, + hasMore, + nextOffset: hasMore ? deliveredEnd : null, + continuationArgs, + truncationReason: textTruncated ? 'tool_output_window' : null, + }; +} + +function compactForm(form, inputLimit) { + if (!form || typeof form !== 'object' || !Array.isArray(form.inputs)) return form; + if (form.inputs.length <= inputLimit) return form; + return { + ...form, + inputs: form.inputs.slice(0, inputLimit), + inputsTruncated: true, + originalInputCount: form.inputs.length, + }; +} + +function compactAuxiliaryContent(result, { + linkLimit, + formLimit, + inputLimit, + shadowLimit, + mediaLimit, +}) { + const links = Array.isArray(result.links) ? result.links : null; + const forms = Array.isArray(result.forms) ? result.forms : null; + const shadowDOM = Array.isArray(result.shadowDOM) ? result.shadowDOM : null; + const media = result.media && typeof result.media === 'object' ? result.media : null; + const videos = Array.isArray(media?.videos) ? media.videos : null; + const images = Array.isArray(media?.images) ? media.images : null; + return { + ...result, + ...(links ? { + links: links.slice(0, linkLimit), + originalLinkCount: links.length, + } : {}), + ...(forms ? { + forms: forms.slice(0, formLimit).map(form => compactForm(form, inputLimit)), + originalFormCount: forms.length, + } : {}), + ...(shadowDOM ? { + shadowDOM: shadowDOM.slice(0, shadowLimit), + originalShadowRootCount: shadowDOM.length, + } : {}), + ...(media ? { + media: { + ...media, + ...(videos ? { videos: videos.slice(0, mediaLimit) } : {}), + ...(images ? { images: images.slice(0, mediaLimit) } : {}), + }, + } : {}), + auxiliaryContentTruncated: true, + }; +} + +function compactCoreResult(result) { + const shortString = (value, limit) => ( + typeof value === 'string' ? value.slice(0, limit) : value + ); + const pageGate = result.pageGate && typeof result.pageGate === 'object' + ? { + type: shortString(result.pageGate.type, 100), + blocking: result.pageGate.blocking === true, + surface: shortString(result.pageGate.surface, 100), + label: shortString(result.pageGate.label, 500), + } + : undefined; + return withDeliveredText({ + url: shortString(result.url, 1000), + title: shortString(result.title, 500), + text: result.text, + textSource: shortString(result.textSource, 200), + isArticlePage: result.isArticlePage === true, + includeChrome: result.includeChrome === true, + originalLength: result.originalLength, + textOffset: result.textOffset, + textLimit: result.textLimit, + accessState: result.accessState, + accessGateEvidence: result.accessGateEvidence, + ...(pageGate ? { pageGate } : {}), + auxiliaryContentTruncated: true, + }, result.text); +} + +export function isReadPageWindowResult(result) { + return !!result + && typeof result === 'object' + && typeof result.text === 'string' + && Number.isFinite(Number(result.originalLength)) + && Number.isFinite(Number(result.textOffset)) + && typeof result.accessState === 'string'; +} + +export function applyReadPageWindow(result, args = {}) { + if (!result || typeof result !== 'object' || typeof result.text !== 'string') return result; + + const originalText = result.text; + const originalLength = originalText.length; + const requestedOffset = boundedInteger(args.offset, 0, 0, Number.MAX_SAFE_INTEGER); + const textOffset = Math.min(requestedOffset, originalLength); + const textLimit = boundedInteger( + args.limit, + READ_PAGE_DEFAULT_LIMIT, + READ_PAGE_MIN_LIMIT, + READ_PAGE_MAX_LIMIT, + ); + const text = originalText.slice(textOffset, textOffset + textLimit); + const blockingPageGate = result.pageGate?.blocking === true; + const { + media, + activeElement, + links, + forms, + shadowDOM, + ...core + } = result; + + return withDeliveredText({ + ...core, + text, + originalLength, + textOffset, + textLimit, + accessState: blockingPageGate ? 'blocked_by_page_gate' : 'no_blocking_page_gate', + accessGateEvidence: blockingPageGate ? 'pageGate' : 'none', + ...(media !== undefined ? { media } : {}), + ...(activeElement !== undefined ? { activeElement } : {}), + ...(links !== undefined ? { links } : {}), + ...(forms !== undefined ? { forms } : {}), + ...(shadowDOM !== undefined ? { shadowDOM } : {}), + }, text); +} + +export function fitReadPageWindowResult(result, maxChars = 8000) { + if (!isReadPageWindowResult(result)) return result; + const fits = candidate => JSON.stringify(candidate).length <= maxChars; + if (fits(result)) return result; + + const profiles = [ + { linkLimit: 20, formLimit: 5, inputLimit: 10, shadowLimit: 5, mediaLimit: 5 }, + { linkLimit: 10, formLimit: 3, inputLimit: 5, shadowLimit: 2, mediaLimit: 2 }, + { linkLimit: 0, formLimit: 0, inputLimit: 0, shadowLimit: 0, mediaLimit: 0 }, + ]; + let compact = result; + for (const profile of profiles) { + compact = compactAuxiliaryContent(result, profile); + if (fits(compact)) return compact; + } + if (!fits(withDeliveredText(compact, ''))) { + compact = compactCoreResult(result); + } + + let low = 0; + let high = compact.text.length; + let best = withDeliveredText(compact, ''); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = withDeliveredText(compact, compact.text.slice(0, mid)); + if (fits(candidate)) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} diff --git a/src/chrome/src/agent/tools.js b/src/chrome/src/agent/tools.js index f719fed04..723c0fdc2 100644 --- a/src/chrome/src/agent/tools.js +++ b/src/chrome/src/agent/tools.js @@ -129,7 +129,7 @@ export const AGENT_TOOLS = [ type: 'function', function: { name: 'read_page', - description: 'Read the current page as PROSE — title, URL, visible text, links, forms. LEGACY read path; prefer get_accessibility_tree for UI tasks. Use read_page only when the user is asking about long-form text content (articles, READMEs, documentation). RESULT SHAPE: `pageGate`, when present, describes a rendered blocking login/registration/subscription surface and means text behind that gate was deliberately excluded; `text` is the readable article body or bounded pre-gate/gate text; `textSource` is the CSS selector that produced the body, "page-gate", or a "(pre-gate)" selector; `isArticlePage` reports article markup. Only treat the article body as complete when no blocking `pageGate` is present and `isArticlePage:true` with a real article selector. NOTE: PDF tabs auto-redirect to read_pdf because Chrome\'s PDF viewer is a chrome-extension:// page that content scripts cannot scrape.', + description: 'Read the current page as a bounded PROSE window — title, URL, visible text, links, and forms. LEGACY read path; prefer get_accessibility_tree for UI tasks. Use read_page only for long-form text content (articles, READMEs, documentation). While `hasMore:true`, continue with the exact returned `continuationArgs`; it carries `offset:nextOffset`, `limit`, and extraction options such as `includeChrome`. Do not scroll and reread the same document prefix. RESULT SHAPE: `text`, `originalLength`, `textOffset`, `textLimit`, `returnedLength`, `textTruncated`, `hasMore`, `nextOffset`, and `continuationArgs` describe the tool-output window. `truncationReason:"tool_output_window"` is a context-window boundary, never evidence of a paywall. `accessState:"blocked_by_page_gate"` plus `accessGateEvidence:"pageGate"` is the structured access-block signal; `accessState:"no_blocking_page_gate"` means tool truncation must not be described as an access restriction. `pageGate`, when present, describes the rendered blocking surface; `textSource` identifies the article selector or bounded pre-gate/gate text; `isArticlePage` reports article markup. NOTE: PDF tabs auto-redirect to read_pdf because Chrome\'s PDF viewer is a chrome-extension:// page that content scripts cannot scrape.', parameters: { type: 'object', properties: { @@ -137,6 +137,17 @@ export const AGENT_TOOLS = [ type: 'boolean', description: 'Include nav / header / footer / aside / ad-slot text in the body. Default false — when the user asks about article/README content you almost never want this. Set true only when the user is asking ABOUT the navigation menu, footer links, cookie banner, advertisement, etc.', }, + offset: { + type: 'integer', + minimum: 0, + description: 'Character offset into the extracted prose. Default 0. When hasMore is true, pass the exact returned continuationArgs so extraction options stay stable.', + }, + limit: { + type: 'integer', + minimum: 500, + maximum: 6000, + description: 'Maximum prose characters to return. Default 4000; bounded to 500..6000.', + }, }, required: [], }, @@ -1403,7 +1414,7 @@ READING THE CURRENT TAB vs. FETCHING URLS — read this: - DO NOT call \`fetch_url\` or \`research_url\` against the URL of the active tab, the API equivalent of the active tab, or a "renderable" / "raw" / "amp" / "mobile" variant of the active tab's URL. Re-fetching content the user is already looking at is the most common wasted step. Symptom of this antipattern: you fetch a Wikipedia/MediaWiki API URL for the same page the user is on, get a truncated result, then fetch a slightly different variant hoping for more content. Stop and call \`read_page\` instead. - \`fetch_url\` and \`research_url\` are for content on OTHER URLs — a referenced article, an API the page links to, a sibling page, a different site entirely. - If \`get_accessibility_tree({filter:"visible"})\` returns \`truncated:true\` / \`hasMore:true\`, call \`get_accessibility_tree({filter:"visible", page: nextPage})\` before scrolling to find a control that may already be visible but omitted from the first chunk. -- If \`read_page\` truncates or doesn't surface what you need, scroll the tab and re-read; or use \`get_accessibility_tree({ref_id: ...})\` to read a specific subtree. Don't escape to fetch_url to retrieve what's already in the DOM. +- If \`read_page\` returns \`hasMore:true\`, continue deterministically with the exact returned \`continuationArgs\` (equivalent to \`{offset: nextOffset, limit: textLimit, includeChrome}\`) until enough article text is covered. Preserve every extraction option across windows; do not scroll and reread the same prefix. \`truncationReason:"tool_output_window"\` with \`accessState:"no_blocking_page_gate"\` is NOT a paywall or access restriction; only a structured blocking \`pageGate\` supports that claim. Guidelines: 1. Read the page first (a11y tree by default) to understand the context, then answer the user's question. diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 0a67c0081..7fdadc9fd 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1,5 +1,6 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX } from './tools.js'; import { handleDoneJson } from './cloud-output.js'; +import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { LoopDetector } from './loop-detector.js'; import { parseToolCallsFromText } from './tool-call-parser.js'; import { IMAGE_BUDGET, estimateImageTokens, fitImageDimensions } from './image-budget.js'; @@ -11217,13 +11218,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d */ _limitToolResult(result) { const maxResultChars = 8000; // ~2k tokens - const safeResult = result == null ? { + const windowSafeResult = isReadPageWindowResult(result) + ? fitReadPageWindowResult(result, maxResultChars) + : result; + const safeResult = windowSafeResult == null ? { success: false, errorCode: 'missing_tool_response', missingToolResponse: true, outcomeUnknown: false, error: 'Tool returned no result.', - } : result; + } : windowSafeResult; let json; try { json = JSON.stringify(safeResult); @@ -11240,14 +11244,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (json.length <= maxResultChars) return json; // Try to trim the 'text' field specifically (page content) - if (result && typeof result.text === 'string' && result.text.length > 4000) { - const trimmed = { ...result, text: result.text.slice(0, 4000) + '\n[...page text truncated]' }; + if (safeResult && typeof safeResult.text === 'string' && safeResult.text.length > 4000) { + const trimmed = { ...safeResult, text: safeResult.text.slice(0, 4000) + '\n[...page text truncated]' }; json = JSON.stringify(trimmed); if (json.length <= maxResultChars) return json; } - if (result && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) { - const originalData = result.data; + if (safeResult && safeResult.data && typeof safeResult.data === 'object' && !Array.isArray(safeResult.data)) { + const originalData = safeResult.data; const originalText = typeof originalData.text === 'string' ? originalData.text : null; const originalSegments = Array.isArray(originalData.segments) ? originalData.segments : null; if (originalText || originalSegments) { @@ -11282,7 +11286,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d data.segmentsTruncated = true; data.originalSegmentCount = data.originalSegmentCount ?? originalSegments.length; } - const trimmed = { ...result, data }; + const trimmed = { ...safeResult, data }; json = JSON.stringify(trimmed); if (json.length <= maxResultChars) return json; } @@ -14224,6 +14228,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } this._annotateCredentialField(name, response); + if (name === 'read_page') { + response = applyReadPageWindow(response, args); + } return response; } catch (e) { // Content script might not be injected — try injecting it @@ -14250,6 +14257,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } this._annotateCredentialField(name, response); + if (name === 'read_page') { + response = applyReadPageWindow(response, args); + } return response; } catch (e2) { let pageUrl = ''; diff --git a/src/firefox/src/agent/read-page-window.js b/src/firefox/src/agent/read-page-window.js new file mode 100644 index 000000000..af9d70136 --- /dev/null +++ b/src/firefox/src/agent/read-page-window.js @@ -0,0 +1,198 @@ +export const READ_PAGE_DEFAULT_LIMIT = 4000; +export const READ_PAGE_MIN_LIMIT = 500; +export const READ_PAGE_MAX_LIMIT = 6000; + +function boundedInteger(value, fallback, min, max) { + if (value == null || value === '') return fallback; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.floor(numeric))); +} + +function withDeliveredText(result, text) { + const originalLength = Number(result.originalLength) || 0; + const textOffset = Number(result.textOffset) || 0; + const returnedLength = text.length; + const deliveredEnd = Math.min(originalLength, textOffset + returnedLength); + const hasMore = deliveredEnd < originalLength; + const textTruncated = textOffset > 0 || hasMore; + const continuationArgs = hasMore + ? { + offset: deliveredEnd, + limit: Number(result.textLimit) || READ_PAGE_DEFAULT_LIMIT, + includeChrome: result.includeChrome === true, + } + : null; + return { + ...result, + text, + returnedLength, + textTruncated, + hasMore, + nextOffset: hasMore ? deliveredEnd : null, + continuationArgs, + truncationReason: textTruncated ? 'tool_output_window' : null, + }; +} + +function compactForm(form, inputLimit) { + if (!form || typeof form !== 'object' || !Array.isArray(form.inputs)) return form; + if (form.inputs.length <= inputLimit) return form; + return { + ...form, + inputs: form.inputs.slice(0, inputLimit), + inputsTruncated: true, + originalInputCount: form.inputs.length, + }; +} + +function compactAuxiliaryContent(result, { + linkLimit, + formLimit, + inputLimit, + shadowLimit, + mediaLimit, +}) { + const links = Array.isArray(result.links) ? result.links : null; + const forms = Array.isArray(result.forms) ? result.forms : null; + const shadowDOM = Array.isArray(result.shadowDOM) ? result.shadowDOM : null; + const media = result.media && typeof result.media === 'object' ? result.media : null; + const videos = Array.isArray(media?.videos) ? media.videos : null; + const images = Array.isArray(media?.images) ? media.images : null; + return { + ...result, + ...(links ? { + links: links.slice(0, linkLimit), + originalLinkCount: links.length, + } : {}), + ...(forms ? { + forms: forms.slice(0, formLimit).map(form => compactForm(form, inputLimit)), + originalFormCount: forms.length, + } : {}), + ...(shadowDOM ? { + shadowDOM: shadowDOM.slice(0, shadowLimit), + originalShadowRootCount: shadowDOM.length, + } : {}), + ...(media ? { + media: { + ...media, + ...(videos ? { videos: videos.slice(0, mediaLimit) } : {}), + ...(images ? { images: images.slice(0, mediaLimit) } : {}), + }, + } : {}), + auxiliaryContentTruncated: true, + }; +} + +function compactCoreResult(result) { + const shortString = (value, limit) => ( + typeof value === 'string' ? value.slice(0, limit) : value + ); + const pageGate = result.pageGate && typeof result.pageGate === 'object' + ? { + type: shortString(result.pageGate.type, 100), + blocking: result.pageGate.blocking === true, + surface: shortString(result.pageGate.surface, 100), + label: shortString(result.pageGate.label, 500), + } + : undefined; + return withDeliveredText({ + url: shortString(result.url, 1000), + title: shortString(result.title, 500), + text: result.text, + textSource: shortString(result.textSource, 200), + isArticlePage: result.isArticlePage === true, + includeChrome: result.includeChrome === true, + originalLength: result.originalLength, + textOffset: result.textOffset, + textLimit: result.textLimit, + accessState: result.accessState, + accessGateEvidence: result.accessGateEvidence, + ...(pageGate ? { pageGate } : {}), + auxiliaryContentTruncated: true, + }, result.text); +} + +export function isReadPageWindowResult(result) { + return !!result + && typeof result === 'object' + && typeof result.text === 'string' + && Number.isFinite(Number(result.originalLength)) + && Number.isFinite(Number(result.textOffset)) + && typeof result.accessState === 'string'; +} + +export function applyReadPageWindow(result, args = {}) { + if (!result || typeof result !== 'object' || typeof result.text !== 'string') return result; + + const originalText = result.text; + const originalLength = originalText.length; + const requestedOffset = boundedInteger(args.offset, 0, 0, Number.MAX_SAFE_INTEGER); + const textOffset = Math.min(requestedOffset, originalLength); + const textLimit = boundedInteger( + args.limit, + READ_PAGE_DEFAULT_LIMIT, + READ_PAGE_MIN_LIMIT, + READ_PAGE_MAX_LIMIT, + ); + const text = originalText.slice(textOffset, textOffset + textLimit); + const blockingPageGate = result.pageGate?.blocking === true; + const { + media, + activeElement, + links, + forms, + shadowDOM, + ...core + } = result; + + return withDeliveredText({ + ...core, + text, + originalLength, + textOffset, + textLimit, + accessState: blockingPageGate ? 'blocked_by_page_gate' : 'no_blocking_page_gate', + accessGateEvidence: blockingPageGate ? 'pageGate' : 'none', + ...(media !== undefined ? { media } : {}), + ...(activeElement !== undefined ? { activeElement } : {}), + ...(links !== undefined ? { links } : {}), + ...(forms !== undefined ? { forms } : {}), + ...(shadowDOM !== undefined ? { shadowDOM } : {}), + }, text); +} + +export function fitReadPageWindowResult(result, maxChars = 8000) { + if (!isReadPageWindowResult(result)) return result; + const fits = candidate => JSON.stringify(candidate).length <= maxChars; + if (fits(result)) return result; + + const profiles = [ + { linkLimit: 20, formLimit: 5, inputLimit: 10, shadowLimit: 5, mediaLimit: 5 }, + { linkLimit: 10, formLimit: 3, inputLimit: 5, shadowLimit: 2, mediaLimit: 2 }, + { linkLimit: 0, formLimit: 0, inputLimit: 0, shadowLimit: 0, mediaLimit: 0 }, + ]; + let compact = result; + for (const profile of profiles) { + compact = compactAuxiliaryContent(result, profile); + if (fits(compact)) return compact; + } + if (!fits(withDeliveredText(compact, ''))) { + compact = compactCoreResult(result); + } + + let low = 0; + let high = compact.text.length; + let best = withDeliveredText(compact, ''); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = withDeliveredText(compact, compact.text.slice(0, mid)); + if (fits(candidate)) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} diff --git a/src/firefox/src/agent/tools.js b/src/firefox/src/agent/tools.js index be2910b82..12e4d50e0 100644 --- a/src/firefox/src/agent/tools.js +++ b/src/firefox/src/agent/tools.js @@ -129,7 +129,7 @@ export const AGENT_TOOLS = [ type: 'function', function: { name: 'read_page', - description: 'Read the current page content including title, URL, visible text, links, and forms. RESULT SHAPE: `pageGate`, when present, describes a rendered blocking login/registration/subscription surface and means text behind that gate was deliberately excluded; `text` is the readable article body or bounded pre-gate/gate text; `textSource` is the CSS selector that produced the body, "page-gate", or a "(pre-gate)" selector; `isArticlePage` reports article markup. Only treat the article body as complete when no blocking `pageGate` is present and `isArticlePage:true` with a real article selector. NOTE: PDF tabs auto-redirect to read_pdf because Firefox\'s built-in viewer is a privileged page that content scripts cannot scrape.', + description: 'Read the current page as a bounded PROSE window — title, URL, visible text, links, and forms. LEGACY read path; prefer get_accessibility_tree for UI tasks. Use read_page only for long-form text content (articles, READMEs, documentation). While `hasMore:true`, continue with the exact returned `continuationArgs`; it carries `offset:nextOffset`, `limit`, and extraction options such as `includeChrome`. Do not scroll and reread the same document prefix. RESULT SHAPE: `text`, `originalLength`, `textOffset`, `textLimit`, `returnedLength`, `textTruncated`, `hasMore`, `nextOffset`, and `continuationArgs` describe the tool-output window. `truncationReason:"tool_output_window"` is a context-window boundary, never evidence of a paywall. `accessState:"blocked_by_page_gate"` plus `accessGateEvidence:"pageGate"` is the structured access-block signal; `accessState:"no_blocking_page_gate"` means tool truncation must not be described as an access restriction. `pageGate`, when present, describes the rendered blocking surface; `textSource` identifies the article selector or bounded pre-gate/gate text; `isArticlePage` reports article markup. NOTE: PDF tabs auto-redirect to read_pdf because Firefox\'s built-in viewer is a privileged page that content scripts cannot scrape.', parameters: { type: 'object', properties: { @@ -137,6 +137,17 @@ export const AGENT_TOOLS = [ type: 'boolean', description: 'Include nav / header / footer / aside / ad-slot text in the body. Default false — when the user asks about article/README content you almost never want this. Set true only when the user is asking ABOUT the navigation menu, footer links, cookie banner, advertisement, etc.', }, + offset: { + type: 'integer', + minimum: 0, + description: 'Character offset into the extracted prose. Default 0. When hasMore is true, pass the exact returned continuationArgs so extraction options stay stable.', + }, + limit: { + type: 'integer', + minimum: 500, + maximum: 6000, + description: 'Maximum prose characters to return. Default 4000; bounded to 500..6000.', + }, }, required: [], }, @@ -1278,7 +1289,7 @@ READING THE CURRENT TAB vs. FETCHING URLS — read this: - DO NOT call \`fetch_url\` or \`research_url\` against the URL of the active tab, the API equivalent of the active tab, or a "renderable" / "raw" / "amp" / "mobile" variant of the active tab's URL. Re-fetching content the user is already looking at is the most common wasted step. Symptom of this antipattern: you fetch a Wikipedia/MediaWiki API URL for the same page the user is on, get a truncated result, then fetch a slightly different variant hoping for more content. Stop and call \`read_page\` instead. - \`fetch_url\` and \`research_url\` are for content on OTHER URLs — a referenced article, an API the page links to, a sibling page, a different site entirely. - If \`get_accessibility_tree({filter:"visible"})\` returns \`truncated:true\` / \`hasMore:true\`, call \`get_accessibility_tree({filter:"visible", page: nextPage})\` before scrolling to find a control that may already be visible but omitted from the first chunk. -- If \`read_page\` truncates or doesn't surface what you need, scroll the tab and re-read; or use \`get_accessibility_tree({ref_id: ...})\` to read a specific subtree. Don't escape to fetch_url to retrieve what's already in the DOM. +- If \`read_page\` returns \`hasMore:true\`, continue deterministically with the exact returned \`continuationArgs\` (equivalent to \`{offset: nextOffset, limit: textLimit, includeChrome}\`) until enough article text is covered. Preserve every extraction option across windows; do not scroll and reread the same prefix. \`truncationReason:"tool_output_window"\` with \`accessState:"no_blocking_page_gate"\` is NOT a paywall or access restriction; only a structured blocking \`pageGate\` supports that claim. Guidelines: 1. Read the page first to understand the context, then answer the user's question. diff --git a/test/run.js b/test/run.js index a86911a84..f64cf51aa 100644 --- a/test/run.js +++ b/test/run.js @@ -536,6 +536,12 @@ const CompletionInvariantCh = await import( const CompletionInvariantFx = await import( 'file://' + path.join(ROOT, 'src/firefox/src/agent/completion-invariant.js').replace(/\\/g, '/') ); +const ReadPageWindowCh = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/agent/read-page-window.js').replace(/\\/g, '/') +); +const ReadPageWindowFx = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/agent/read-page-window.js').replace(/\\/g, '/') +); const { buildGithubStargazerProgressItems, parseGithubStargazerFollowButtons, @@ -3295,6 +3301,107 @@ test('trace export: preserves structured pageGate before truncated article text assert.ok(markdown.indexOf('read_page') < markdown.indexOf('fetch_nytimes_article'), 'fallback tool should follow the blocked browser read'); }); +test('read_page paginates a long friend-link article without inventing a paywall', () => { + const chromeSource = fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/read-page-window.js'), 'utf8'); + const firefoxSource = fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/read-page-window.js'), 'utf8'); + assert.equal(chromeSource, firefoxSource, 'Chrome/Firefox read_page window helpers drifted'); + + const longText = Array.from( + { length: 12 }, + (_, index) => `SECTION-${String(index + 1).padStart(2, '0')}:`.padEnd(1000, String(index % 10)), + ).join(''); + const friendLinkArticle = { + url: 'https://medium.example/long-article?source=friend_link', + title: 'A long article available through the author friend link', + text: longText, + textSource: 'article', + isArticlePage: true, + includeChrome: false, + links: Array.from({ length: 100 }, (_, index) => ({ + text: `Reference ${index}`, + href: `https://medium.example/reference/${index}`, + })), + forms: [], + media: { videoCount: 0, imageCount: 0, videos: [], images: [] }, + }; + + for (const [label, runtime] of [ + ['chrome', ReadPageWindowCh], + ['firefox', ReadPageWindowFx], + ]) { + const pages = []; + let offset = 0; + do { + const page = runtime.applyReadPageWindow(friendLinkArticle, { offset, limit: 3000 }); + pages.push(page); + assert.equal(page.originalLength, longText.length, `${label}: originalLength drifted`); + assert.equal(page.textOffset, offset, `${label}: textOffset drifted`); + assert.equal(page.accessState, 'no_blocking_page_gate', `${label}: readable friend link was treated as blocked`); + assert.equal(page.accessGateEvidence, 'none', `${label}: tool truncation invented access-gate evidence`); + assert.equal(page.truncationReason, 'tool_output_window', `${label}: long page lacks a structured tool-window reason`); + if (page.hasMore) { + assert.deepEqual(page.continuationArgs, { + offset: page.nextOffset, + limit: 3000, + includeChrome: false, + }, `${label}: continuation does not preserve extraction options`); + } + offset = page.nextOffset; + } while (offset !== null); + + assert.equal(pages.map(page => page.text).join(''), longText, `${label}: continuation skipped or duplicated article text`); + assert.equal(pages.at(-1).hasMore, false, `${label}: final window still claims more text`); + assert.equal(pages.at(-1).nextOffset, null, `${label}: final window exposed a stale continuation`); + assert.equal(pages.at(-1).continuationArgs, null, `${label}: final window exposed stale continuation arguments`); + + const chromeIncluded = runtime.applyReadPageWindow({ + ...friendLinkArticle, + includeChrome: true, + }, { includeChrome: true, limit: 3000 }); + assert.deepEqual(chromeIncluded.continuationArgs, { + offset: 3000, + limit: 3000, + includeChrome: true, + }, `${label}: includeChrome was lost across continuation`); + + const blocked = runtime.applyReadPageWindow({ + ...friendLinkArticle, + pageGate: { type: 'subscription', blocking: true, surface: 'dialog', label: 'Subscribe to continue' }, + }, { limit: 3000 }); + assert.equal(blocked.accessState, 'blocked_by_page_gate', `${label}: blocking pageGate was not preserved`); + assert.equal(blocked.accessGateEvidence, 'pageGate', `${label}: blocking pageGate lacks structured evidence`); + + const fitted = runtime.fitReadPageWindowResult(pages[0], 8000); + assert.ok(JSON.stringify(fitted).length <= 8000, `${label}: model-facing read_page window exceeds the tool-result cap`); + assert.equal(fitted.nextOffset, fitted.textOffset + fitted.returnedLength, `${label}: compaction would skip unseen prose`); + const serialized = new (label === 'chrome' ? AgentCh : AgentFx)({})._limitToolResult(pages[0]); + const parsed = JSON.parse(serialized); + assert.equal(parsed.truncationReason, 'tool_output_window', `${label}: limiter discarded structured truncation metadata`); + assert.equal(parsed.accessState, 'no_blocking_page_gate', `${label}: limiter discarded structured access state`); + assert.doesNotMatch(serialized, /page text truncated/, `${label}: legacy prose-only truncation marker survived`); + } +}); + +test('read_page schema and prompts require nextOffset continuation in both browsers', () => { + for (const [label, getTools, prompt] of [ + ['chrome', getToolsForModeCh, SYSTEM_PROMPT_ASK_CH], + ['firefox', getToolsForModeFx, SYSTEM_PROMPT_ASK_FX], + ]) { + const tool = getTools('ask').find(item => item.function.name === 'read_page'); + assert.ok(tool, `${label}: read_page missing from Ask mode`); + const properties = tool.function.parameters.properties; + assert.equal(properties.offset.type, 'integer', `${label}: offset is not an integer`); + assert.equal(properties.offset.minimum, 0, `${label}: offset accepts negative values`); + assert.equal(properties.limit.minimum, 500, `${label}: limit minimum drifted`); + assert.equal(properties.limit.maximum, 6000, `${label}: limit maximum drifted`); + assert.match(tool.function.description, /hasMore:true[\s\S]*continuationArgs[\s\S]*offset:nextOffset/i, `${label}: tool does not teach stable continuation`); + assert.match(tool.function.description, /tool_output_window[\s\S]*never evidence of a paywall/i, `${label}: tool truncation can be mistaken for a paywall`); + assert.match(prompt, /continuationArgs[\s\S]*offset: nextOffset[\s\S]*includeChrome/, `${label}: Ask prompt does not preserve continuation options`); + assert.match(prompt, /no_blocking_page_gate[\s\S]*NOT a paywall/i, `${label}: Ask prompt lacks the structural access distinction`); + assert.doesNotMatch(prompt, /scroll the tab and re-read/i, `${label}: stale scroll-and-reread guidance survived`); + } +}); + test('trace export: empty input → empty transcript, zero counts', () => { const r = tracesToMarkdown([]); assert.equal(r.turnCount, 0);