Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dc068ef
fix(shared): give standalone HTML replies a page gutter
fearandesire Oct 22, 2026
7f94d6f
fix(shared): keep hosted pages unpadded after they reload themselves
fearandesire Oct 23, 2026
36c9be0
docs(shared): steer HTML replies off vw widths and routine html paddi…
fearandesire Oct 23, 2026
5f927bb
docs(shared): cover every viewport width unit and reset form in the l…
fearandesire Oct 23, 2026
1d6ed16
fix(shared): keep hosted reloads unpadded without exposing a marker
fearandesire Oct 23, 2026
380f3ef
fix(shared): mark the mobile WebView as hosted through its user agent
fearandesire Oct 23, 2026
8e5bfe4
Merge branch 'main' into agent/artifact-browser-padding
fearandesire Oct 23, 2026
e6cae15
Merge upstream main into standalone HTML gutter fix
fearandesire Oct 25, 2026
86f4db4
Merge latest upstream main into standalone HTML gutter fix
fearandesire Oct 25, 2026
26e994a
Merge branch 'main' into agent/artifact-browser-padding
fearandesire Oct 26, 2026
93789f7
chore: merge main into standalone html gutter fix
maria-rcks Oct 26, 2026
044d53e
refactor(shared): simplify standalone html gutter guidance and coverage
maria-rcks Oct 26, 2026
5137291
Merge remote-tracking branch 'github/main' into fixup-23845
maria-rcks Oct 27, 2026
4034c5f
chore: merge main into standalone html gutter fix
maria-rcks Oct 30, 2026
7005e33
fix(server): account for standalone html layout guidance
maria-rcks Oct 30, 2026
dfc1c9b
chore: merge main into standalone html gutter fix
maria-rcks Oct 31, 2026
077851b
fix(shared): keep html layout guidance within context budget
maria-rcks Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/HtmlRenderWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { useNavigation } from "@react-navigation/native";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
HTML_RENDER_WEBVIEW_APP_NAME,
htmlRenderFileName,
htmlRenderFrameHeight,
htmlRenderThemeFragment,
Expand Down Expand Up @@ -122,6 +123,7 @@ export function HtmlRenderWebView(props: {
key={generation}
ref={webView}
source={source}
applicationNameForUserAgent={HTML_RENDER_WEBVIEW_APP_NAME}
accessibilityLabel={props.title}
style={{ flex: 1, backgroundColor: "transparent" }}
allowsInlineMediaPlayback
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5129,
"codexAdditionalContextBytes": 6859,
"mcpToolListJsonBytes": 46007
"mcpToolListJsonBytes": 46002
}
52 changes: 52 additions & 0 deletions packages/shared/src/htmlRender.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as NodeVM from "node:vm";

import { describe, expect, it } from "vite-plus/test";

import {
HTML_RENDER_WEBVIEW_APP_NAME,
htmlRenderThemeFragment,
injectHtmlRenderBootstrap,
readHtmlRenderImageRequest,
readHtmlRenderLinkRequest,
Expand All @@ -9,6 +13,54 @@ import { htmlRenderFromToolItem } from "./toolOutput.ts";

const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 };

const bootstrap = injectHtmlRenderBootstrap("<p>x</p>").match(/<script>([\s\S]*)<\/script>/)![1]!;
const themed = htmlRenderThemeFragment({
appearance: "dark",
variables: { "--background": "#000" },
});

function openPage(options: {
readonly framed?: boolean;
readonly hash?: string;
readonly userAgent?: string;
}) {
const style = { textContent: "" };
const history: { state: unknown; replaceState: (state: unknown) => void } = {
state: "tab2",
replaceState(state: unknown) {
history.state = state;
},
};
const window = { name: '{"key":"t3-hosted:"}', parent: {}, addEventListener() {} };
if (!options.framed) window.parent = window;
NodeVM.runInNewContext(bootstrap, {
window,
history,
navigator: { userAgent: options.userAgent ?? "Mozilla/5.0" },
location: { hash: options.hash ?? "", pathname: "/page.html", search: "" },
document: { getElementById: () => style, addEventListener() {} },
});
return { window, history, padded: () => style.textContent.includes("padding:16px") };
}

describe("html render bootstrap", () => {
it.each([
{ page: "standalone", options: {}, padded: true },
{ page: "framed", options: { framed: true }, padded: false },
{ page: "top-level themed", options: { hash: themed }, padded: false },
{
page: "reloaded in the mobile WebView",
options: { userAgent: `Mozilla/5.0 Mobile/15E148 ${HTML_RENDER_WEBVIEW_APP_NAME}` },
padded: false,
},
])("gives only a page opened on its own the gutter: $page", ({ options, padded }) => {
const page = openPage(options);
expect(page.padded()).toBe(padded);
expect(page.window.name).toBe('{"key":"t3-hosted:"}');
expect(page.history.state).toBe("tab2");
});
});

describe("injectHtmlRenderBootstrap", () => {
it("puts the theme ahead of the page's own head content", () => {
const html =
Expand Down
21 changes: 14 additions & 7 deletions packages/shared/src/htmlRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,16 @@ export const HTML_RENDER_THEME_GUIDE = [
"--background, --foreground, --muted, --card, --popover, --secondary, --border, --ring, --primary, --accent, --destructive, --warning, --success, --info, --code-background",
"(most have a matching -foreground; accent, destructive, and warning also have -surface),",
"--chart-1 to --chart-6, --radius, --font-sans, --font-mono.",
"The base sheet sets background, text, and font.",
"Base background/text/font yield to unlayered CSS.",
].join(" ");

export const HTML_RENDER_LAYOUT_GUIDE = [
`Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`,
"Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.",
"T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.",
"A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.",
"Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.",
`Width: ${HTML_RENDER_COLUMN_WIDTH}px desktop, ~360px phones; thread background.`,
"Ignore page-background requests: keep html/body/outer element transparent; no outer card, border, banner title, or side padding.",
"Html gutters: 8px hosted for rings/shadows, 16px standalone. Use %, auto, max-width; viewport units, innerWidth/clientWidth, and fixed widths can overflow.",
"Reset body or *. Full bleed only: unlayered html{padding:0}; @layer cannot override it.",
"Background boxes: 16px padding, var(--radius) corners. Charts: pixel heights.",
"Content sets height; no vh or html/body height:100% (rejected/loops).",
].join(" ");

const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
Expand Down Expand Up @@ -341,6 +342,9 @@ export function htmlRenderResult(id: string | number) {

const THEME_FRAGMENT_KEY = "t3-theme";

// A WebView user-agent token survives reloads without changing page-owned name or state.
export const HTML_RENDER_WEBVIEW_APP_NAME = "T3CodeHtmlRender";

export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string {
return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`;
}
Expand All @@ -360,6 +364,9 @@ const BASE_CSS =
":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" +
":where(img:not(a[href] img,button img)){cursor:zoom-in}";

// Unlayered html padding survives * resets; border-box keeps a 100%-wide root inside the viewport.
const STANDALONE_CSS = "html{box-sizing:border-box;padding:16px}";
Comment thread
fearandesire marked this conversation as resolved.
Resolved

function rootRule(theme: HtmlRenderTheme): string {
const declarations = Object.entries(theme.variables)
.map(([name, value]) => `${name}:${value};`)
Expand All @@ -369,7 +376,7 @@ function rootRule(theme: HtmlRenderTheme): string {

// Apply overridable theme CSS before paint and strip the fragment before routing; framed navigation uses the client.
// Forward shortcuts after page handlers; text fields retain their keys.
const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){if(!e.defaultPrevented)window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||""}},"*");});});var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`;
const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)},m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(window.parent===window&&!m&&navigator.userAgent.indexOf(${JSON.stringify(HTML_RENDER_WEBVIEW_APP_NAME)})<0){b+=${JSON.stringify(STANDALONE_CSS)};s.textContent+=${JSON.stringify(STANDALONE_CSS)};}function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){if(!e.defaultPrevented)window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||""}},"*");});});var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`;

function bootstrapMarkup(markup: string): string {
const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
Expand Down
Loading