Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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": 5135,
"codexAdditionalContextBytes": 6865,
"mcpToolListJsonBytes": 46007
"mcpToolListJsonBytes": 46397
}
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
12 changes: 10 additions & 2 deletions packages/shared/src/htmlRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,15 @@ 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.",
"The base sheet sets background, text, and font; your own unlayered CSS overrides it.",
].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.",
"Standalone pages add a 16px gutter. Use container-relative widths (%, auto, max-width); viewport widths (vw and variants, innerWidth, clientWidth) and fixed widths can overflow.",
"Reset body or * rather than html to preserve the gutter. Only intentional edge-to-edge pages should remove it with unlayered html{padding:0}; @layer rules cannot override 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.",
].join(" ");
Expand Down Expand Up @@ -341,6 +343,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 +365,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.

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