diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml
index 36dfb61f73f..cdec123503e 100644
--- a/.github/workflows/mobile-showcase-screenshots.yml
+++ b/.github/workflows/mobile-showcase-screenshots.yml
@@ -34,6 +34,8 @@ jobs:
if: inputs.platform == 'all' || inputs.platform == 'ios'
runs-on: blacksmith-12vcpu-macos-26
timeout-minutes: 60
+ outputs:
+ artifact_id: ${{ steps.upload_ios.outputs.artifact-id }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -59,6 +61,7 @@ jobs:
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only
- name: Upload iOS screenshots
+ id: upload_ios
if: always()
uses: actions/upload-artifact@v7
with:
@@ -67,6 +70,119 @@ jobs:
if-no-files-found: warn
retention-days: 14
+ publish_marketing_preview:
+ name: Publish marketing mobile preview
+ if: |
+ always() &&
+ needs.ios.result == 'success' &&
+ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
+ inputs.appearance != 'light'
+ needs: ios
+ runs-on: blacksmith-8vcpu-ubuntu-2404
+ timeout-minutes: 10
+ steps:
+ - id: app_token
+ name: Mint release app token
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ secrets.RELEASE_APP_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Checkout default branch
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+ token: ${{ steps.app_token.outputs.token }}
+ persist-credentials: true
+
+ - name: Download iOS screenshots
+ uses: actions/download-artifact@v8
+ with:
+ name: app-store-connect-screenshots
+ path: ${{ runner.temp }}/mobile-showcase
+
+ - id: app_bot
+ name: Resolve GitHub App bot identity
+ env:
+ GH_TOKEN: ${{ steps.app_token.outputs.token }}
+ APP_SLUG: ${{ steps.app_token.outputs.app-slug }}
+ run: |
+ user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
+ echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
+ echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
+
+ - id: preview
+ name: Update website preview asset
+ env:
+ ARTIFACT_ID: ${{ needs.ios.outputs.artifact_id }}
+ HEAD_SHA: ${{ github.sha }}
+ RUN_ID: ${{ github.run_id }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ WORKFLOW_NAME: ${{ github.workflow }}
+ run: |
+ phone_source_path="$RUNNER_TEMP/mobile-showcase/iphone-6.9/dark/threads.png"
+ tablet_source_path="$RUNNER_TEMP/mobile-showcase/ipad-13/dark/threads.png"
+ output_directory="apps/marketing/public/mobile-states"
+ phone_output_path="$output_directory/threads.png"
+ tablet_output_path="$output_directory/ipad-threads.png"
+
+ if [[ ! -f "$phone_source_path" || ! -f "$tablet_source_path" ]]; then
+ echo "Missing expected website preview source." >&2
+ echo "Phone: $phone_source_path" >&2
+ echo "Tablet: $tablet_source_path" >&2
+ exit 1
+ fi
+
+ if [[ -f "$phone_output_path" && -f "$tablet_output_path" ]] &&
+ cmp --silent "$phone_source_path" "$phone_output_path" &&
+ cmp --silent "$tablet_source_path" "$tablet_output_path"; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "Website previews already match the generated screenshots."
+ exit 0
+ fi
+
+ mkdir -p "$output_directory"
+ cp "$phone_source_path" "$phone_output_path"
+ cp "$tablet_source_path" "$tablet_output_path"
+ node --input-type=module <<'NODE'
+ import { writeFile } from "node:fs/promises";
+
+ const metadata = {
+ artifactId: Number(process.env.ARTIFACT_ID),
+ artifactName: "app-store-connect-screenshots",
+ artifactCreatedAt: new Date().toISOString(),
+ runId: Number(process.env.RUN_ID),
+ runUrl: process.env.RUN_URL,
+ headSha: process.env.HEAD_SHA,
+ workflow: process.env.WORKFLOW_NAME,
+ devices: ["iphone-6.9", "ipad-13"],
+ appearance: "dark",
+ };
+
+ await writeFile(
+ "apps/marketing/public/mobile-states/source.json",
+ `${JSON.stringify(metadata, null, 2)}\n`,
+ );
+ NODE
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+
+ - name: Commit website preview
+ if: steps.preview.outputs.changed == 'true'
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ git config user.name "${{ steps.app_bot.outputs.name }}"
+ git config user.email "${{ steps.app_bot.outputs.email }}"
+ git add \
+ apps/marketing/public/mobile-states/source.json \
+ apps/marketing/public/mobile-states/threads.png \
+ apps/marketing/public/mobile-states/ipad-threads.png
+ git commit -m "chore(marketing): refresh mobile showcase screenshot"
+ git pull --rebase origin "$DEFAULT_BRANCH"
+ git push origin "HEAD:$DEFAULT_BRANCH"
+
android:
name: Android phone, 7-inch tablet, and 10-inch tablet
if: inputs.platform == 'all' || inputs.platform == 'android'
diff --git a/.gitignore b/.gitignore
index 07793efe9b5..223d4542205 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ node_modules
*.log
*.tsbuildinfo
apps/*/dist
+apps/marketing/public/sidebar-demo
infra/*/dist
.astro
packages/*/dist
diff --git a/README.md b/README.md
index bc34b3f76b4..52a247e075c 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,13 @@ This will launch T3 Code's backend on your machine as well as the local web app
Tip: Use `npx t3@latest --help` for the full CLI reference.
+### Mobile apps
+
+Control your T3 Code environments from your phone or tablet:
+
+- [Download for iPhone and iPad on the App Store](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824)
+- [Get the Android app on Google Play](https://play.google.com/store/apps/details?id=com.t3tools.t3code)
+
### Desktop app
Install the latest version of the desktop app from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or from your favorite package registry:
@@ -66,6 +73,7 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d
## Documentation
- [Getting started](./docs/getting-started/quick-start.md)
+- [Mobile app](./docs/user/mobile-app.md)
- [Remote access](./docs/user/remote-access.md)
- [Keeping T3 Code in sync](./docs/user/server-updates.md)
- [Architecture overview](./docs/architecture/overview.md)
diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs
index 6f37ae922da..1d9dd09e8c2 100644
--- a/apps/marketing/astro.config.mjs
+++ b/apps/marketing/astro.config.mjs
@@ -1,7 +1,28 @@
import { defineConfig } from "astro/config";
+const cacheSidebarDemoAssets = {
+ name: "cache-sidebar-demo-assets",
+ configureServer(server) {
+ server.middlewares.use((request, response, next) => {
+ if (request.url?.startsWith("/sidebar-demo/assets/")) {
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
+ } else if (
+ request.url?.startsWith("/demo-states/") ||
+ request.url?.startsWith("/mobile-states/")
+ ) {
+ response.setHeader("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400");
+ }
+ next();
+ });
+ },
+};
+
export default defineConfig({
+ site: "https://t3.codes",
server: {
port: Number(process.env.PORT ?? 4173),
},
+ vite: {
+ plugins: [cacheSidebarDemoAssets],
+ },
});
diff --git a/apps/marketing/package.json b/apps/marketing/package.json
index 912faf88164..758307ccd1d 100644
--- a/apps/marketing/package.json
+++ b/apps/marketing/package.json
@@ -4,8 +4,10 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "astro dev",
- "build": "astro build",
+ "dev": "npm run prepare:assets && astro dev",
+ "build": "npm run prepare:assets && astro build",
+ "prepare:assets": "npm run build:sidebar-demo",
+ "build:sidebar-demo": "npm run --prefix ../web build:sidebar-demo && node scripts/capture-demo-screenshots.mjs",
"preview": "astro preview",
"typecheck": "astro check"
},
@@ -16,6 +18,7 @@
"devDependencies": {
"@astrojs/check": "^0.9.7",
"@vercel/config": "^0.3.0",
+ "playwright-core": "1.60.0",
"typescript": "catalog:"
}
}
diff --git a/apps/marketing/public/demo-states/browser-test-crop.png b/apps/marketing/public/demo-states/browser-test-crop.png
new file mode 100644
index 00000000000..608b5af2632
Binary files /dev/null and b/apps/marketing/public/demo-states/browser-test-crop.png differ
diff --git a/apps/marketing/public/demo-states/inbox-overview-crop.png b/apps/marketing/public/demo-states/inbox-overview-crop.png
new file mode 100644
index 00000000000..5715394d162
Binary files /dev/null and b/apps/marketing/public/demo-states/inbox-overview-crop.png differ
diff --git a/apps/marketing/public/demo-states/review-diff-crop.png b/apps/marketing/public/demo-states/review-diff-crop.png
new file mode 100644
index 00000000000..d124f4834d6
Binary files /dev/null and b/apps/marketing/public/demo-states/review-diff-crop.png differ
diff --git a/apps/marketing/public/demo-states/ship-commit-editor-crop.png b/apps/marketing/public/demo-states/ship-commit-editor-crop.png
new file mode 100644
index 00000000000..c267832b483
Binary files /dev/null and b/apps/marketing/public/demo-states/ship-commit-editor-crop.png differ
diff --git a/apps/marketing/public/harnesses/opencode-dark.svg b/apps/marketing/public/harnesses/opencode-dark.svg
index fc467bf8440..bb61598f1d7 100644
--- a/apps/marketing/public/harnesses/opencode-dark.svg
+++ b/apps/marketing/public/harnesses/opencode-dark.svg
@@ -1 +1,16 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/marketing/public/mobile-states/ipad-threads.png b/apps/marketing/public/mobile-states/ipad-threads.png
new file mode 100644
index 00000000000..3250b5e425e
Binary files /dev/null and b/apps/marketing/public/mobile-states/ipad-threads.png differ
diff --git a/apps/marketing/public/mobile-states/source.json b/apps/marketing/public/mobile-states/source.json
new file mode 100644
index 00000000000..121b4a034f6
--- /dev/null
+++ b/apps/marketing/public/mobile-states/source.json
@@ -0,0 +1,11 @@
+{
+ "artifactId": 8683082605,
+ "artifactName": "app-store-connect-screenshots",
+ "artifactCreatedAt": "2026-07-28T09:24:29Z",
+ "runId": 30345538492,
+ "runUrl": "https://github.com/pingdotgg/t3code/actions/runs/30345538492",
+ "headSha": "d25b15737a058614ab6632bbad23d2497e49b62c",
+ "workflow": "mobile-showcase-screenshots.yml",
+ "devices": ["iphone-6.9", "ipad-13"],
+ "appearance": "dark"
+}
diff --git a/apps/marketing/public/mobile-states/threads.png b/apps/marketing/public/mobile-states/threads.png
new file mode 100644
index 00000000000..056e30968e5
Binary files /dev/null and b/apps/marketing/public/mobile-states/threads.png differ
diff --git a/apps/marketing/public/nightly-clouds-foreground.webp b/apps/marketing/public/nightly-clouds-foreground.webp
new file mode 100644
index 00000000000..a1ef48fdab2
Binary files /dev/null and b/apps/marketing/public/nightly-clouds-foreground.webp differ
diff --git a/apps/marketing/public/nightly-clouds.webp b/apps/marketing/public/nightly-clouds.webp
new file mode 100644
index 00000000000..a3c249aa539
Binary files /dev/null and b/apps/marketing/public/nightly-clouds.webp differ
diff --git a/apps/marketing/public/nightly-stars.svg b/apps/marketing/public/nightly-stars.svg
new file mode 100644
index 00000000000..5f6d87228a7
--- /dev/null
+++ b/apps/marketing/public/nightly-stars.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/marketing/public/og-image.png b/apps/marketing/public/og-image.png
new file mode 100644
index 00000000000..baeab0f55fb
Binary files /dev/null and b/apps/marketing/public/og-image.png differ
diff --git a/apps/marketing/public/og-source.png b/apps/marketing/public/og-source.png
new file mode 100644
index 00000000000..1b93f682e78
Binary files /dev/null and b/apps/marketing/public/og-source.png differ
diff --git a/apps/marketing/public/store-logos/appstore.svg b/apps/marketing/public/store-logos/appstore.svg
new file mode 100644
index 00000000000..6e395d1607e
--- /dev/null
+++ b/apps/marketing/public/store-logos/appstore.svg
@@ -0,0 +1 @@
+
diff --git a/apps/marketing/public/store-logos/googleplay.svg b/apps/marketing/public/store-logos/googleplay.svg
new file mode 100644
index 00000000000..b2b4122a653
--- /dev/null
+++ b/apps/marketing/public/store-logos/googleplay.svg
@@ -0,0 +1 @@
+
diff --git a/apps/marketing/scripts/capture-demo-screenshots.mjs b/apps/marketing/scripts/capture-demo-screenshots.mjs
new file mode 100644
index 00000000000..c374c35483f
--- /dev/null
+++ b/apps/marketing/scripts/capture-demo-screenshots.mjs
@@ -0,0 +1,676 @@
+import * as NodeFSP from "node:fs/promises";
+import * as NodeHttp from "node:http";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+import { chromium } from "playwright-core";
+
+const marketingRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL("..", import.meta.url)));
+const publicRoot = NodePath.join(marketingRoot, "public");
+const outputRoot = NodePath.join(publicRoot, "demo-states");
+const ogOutputPath = NodePath.join(publicRoot, "og-image.png");
+const ogSourceOutputPath = NodePath.join(publicRoot, "og-source.png");
+const showcaseSpec = JSON.parse(
+ await NodeFSP.readFile(NodePath.join(marketingRoot, "src", "lib", "showcase-spec.json"), "utf8"),
+);
+const ogStudioSetup = JSON.parse(
+ await NodeFSP.readFile(
+ NodePath.join(marketingRoot, "src", "lib", "og-studio-setup.json"),
+ "utf8",
+ ),
+);
+const captureSize = showcaseSpec.render;
+const viewport = { width: captureSize.width, height: captureSize.height };
+const ogCaptureSize = { width: 1200, height: 630 };
+const ogSourceViewport = { width: 1440, height: 810 };
+const featureClip = (x, y, width) => ({
+ x,
+ y,
+ width,
+ height: Math.round((width * captureSize.height) / captureSize.width),
+});
+
+const frozenMotionCss = `
+ *, *::before, *::after {
+ animation-delay: 0s !important;
+ animation-duration: 0s !important;
+ caret-color: transparent !important;
+ transition-duration: 0s !important;
+ }
+`;
+
+const toDataUrl = (mimeType, bytes) => `data:${mimeType};base64,${bytes.toString("base64")}`;
+
+const ogVertexShaderSource = `
+ attribute vec3 aPosition;
+ attribute vec2 aUv;
+ uniform float uPitch;
+ uniform float uYaw;
+ uniform float uRoll;
+ uniform float uFov;
+ uniform float uScale;
+ uniform float uDistance;
+ uniform vec2 uOffset;
+ uniform float uViewportAspect;
+ varying vec2 vUv;
+ varying float vDepth;
+
+ vec3 rotateX(vec3 point, float angle) {
+ float sine = sin(angle);
+ float cosine = cos(angle);
+ return vec3(point.x, cosine * point.y - sine * point.z, sine * point.y + cosine * point.z);
+ }
+
+ vec3 rotateY(vec3 point, float angle) {
+ float sine = sin(angle);
+ float cosine = cos(angle);
+ return vec3(cosine * point.x + sine * point.z, point.y, -sine * point.x + cosine * point.z);
+ }
+
+ vec3 rotateZ(vec3 point, float angle) {
+ float sine = sin(angle);
+ float cosine = cos(angle);
+ return vec3(cosine * point.x - sine * point.y, sine * point.x + cosine * point.y, point.z);
+ }
+
+ void main() {
+ vec3 point = aPosition * uScale;
+ point = rotateX(point, uPitch);
+ point = rotateY(point, uYaw);
+ point = rotateZ(point, uRoll);
+ point += vec3(uOffset, -uDistance);
+ float focal = 1.0 / tan(uFov * 0.5);
+ gl_Position = vec4(
+ point.x * focal / uViewportAspect,
+ point.y * focal,
+ 0.0,
+ -point.z
+ );
+ vUv = aUv;
+ vDepth = -point.z;
+ }
+`;
+
+const ogFragmentShaderSource = `
+ precision highp float;
+ uniform sampler2D uTexture0;
+ uniform sampler2D uTexture1;
+ uniform sampler2D uTexture2;
+ uniform sampler2D uTexture3;
+ uniform sampler2D uTexture4;
+ uniform float uFocusDistance;
+ uniform float uFarBlur;
+ uniform float uNearBlur;
+ uniform float uMaxBlur;
+ uniform float uFarFade;
+ uniform float uNearFade;
+ uniform float uBrightness;
+ uniform float uSaturation;
+ uniform float uVignette;
+ uniform float uEdgeFade;
+ uniform float uCornerRadius;
+ varying vec2 vUv;
+ varying float vDepth;
+
+ vec4 sampleLens(vec2 uv, float radius) {
+ if (radius < 6.0) {
+ return mix(
+ texture2D(uTexture0, uv),
+ texture2D(uTexture1, uv),
+ smoothstep(0.0, 6.0, radius)
+ );
+ }
+ if (radius < 14.0) {
+ return mix(
+ texture2D(uTexture1, uv),
+ texture2D(uTexture2, uv),
+ smoothstep(6.0, 14.0, radius)
+ );
+ }
+ if (radius < 28.0) {
+ return mix(
+ texture2D(uTexture2, uv),
+ texture2D(uTexture3, uv),
+ smoothstep(14.0, 28.0, radius)
+ );
+ }
+ return mix(
+ texture2D(uTexture3, uv),
+ texture2D(uTexture4, uv),
+ smoothstep(28.0, 56.0, radius)
+ );
+ }
+
+ float roundedRectangleMask(vec2 uv, float radius) {
+ vec2 point = abs(uv - 0.5) - vec2(0.5 - radius);
+ float distance = length(max(point, 0.0)) + min(max(point.x, point.y), 0.0) - radius;
+ return 1.0 - smoothstep(0.0, 0.004, distance);
+ }
+
+ void main() {
+ float depthDelta = vDepth - uFocusDistance;
+ float rawBlur = depthDelta >= 0.0
+ ? depthDelta * uFarBlur
+ : -depthDelta * uNearBlur;
+ float focusFalloff = smoothstep(0.02, 0.22, abs(depthDelta));
+ float blurRadius = uMaxBlur > 0.0
+ ? uMaxBlur * (1.0 - exp(-max(rawBlur, 0.0) / max(uMaxBlur, 0.001)))
+ : 0.0;
+ blurRadius *= focusFalloff;
+
+ vec4 color = sampleLens(vUv, blurRadius);
+ float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
+ color.rgb = mix(vec3(luminance), color.rgb, uSaturation);
+
+ float farAmount = smoothstep(0.0, 1.35, max(depthDelta, 0.0));
+ float nearAmount = smoothstep(0.0, 1.35, max(-depthDelta, 0.0));
+ float depthLight = (1.0 - farAmount * uFarFade) * (1.0 - nearAmount * uNearFade);
+
+ vec2 vignettePoint = (vUv - 0.5) * vec2(0.82, 1.0);
+ float vignetteMask = 1.0 - smoothstep(0.24, 0.72, length(vignettePoint));
+ float vignetteLight = mix(1.0, vignetteMask, uVignette);
+
+ float edge = max(uEdgeFade, 0.0001);
+ float edgeMask =
+ smoothstep(0.0, edge, vUv.x) *
+ smoothstep(0.0, edge, vUv.y) *
+ smoothstep(0.0, edge, 1.0 - vUv.x) *
+ smoothstep(0.0, edge, 1.0 - vUv.y);
+ float shapeMask = roundedRectangleMask(vUv, uCornerRadius);
+
+ color.rgb *= uBrightness * depthLight * vignetteLight;
+ color.a *= edgeMask * shapeMask;
+ gl_FragColor = color;
+ }
+`;
+
+async function freezeMotion(page) {
+ await page.addStyleTag({ content: frozenMotionCss });
+}
+
+async function waitForDemoReady(page) {
+ await page.getByText("All projects", { exact: true }).first().waitFor({
+ state: "visible",
+ timeout: 30_000,
+ });
+}
+
+function ogSceneHtml({ demoFrameDataUrl }) {
+ const state = ogStudioSetup.state;
+ return `
+
+
+
+
+
+
+
+
+ T3 Code
+
+
+`;
+}
+
+function browserDemoHtml(mode) {
+ const annotating = mode === "annotate";
+ return `
+
+
+ThreadDiffBrowser
‹›↻localhost:4321
Build the next thing, faster.
A focused workspace for teams shipping ambitious products without the usual noise.
Start building → ${annotating ? 'section.hero
⌁
' : 'Browser test7 passed
Navigation linksCTA remains visibleMobile menu opensNo console errors
'}
+`;
+}
+
+const states = [
+ {
+ name: "inbox-overview-crop",
+ hash: "#/demo-mac-studio/thread-flaky",
+ clip: featureClip(0, 0, 720),
+ deviceScaleFactor: 3,
+ },
+ {
+ name: "ship-commit-editor-crop",
+ hash: "#/demo-mac-studio/thread-flaky",
+ clip: featureClip(0, 0, captureSize.width),
+ deviceScaleFactor: 3,
+ prepare: async (page) => {
+ await page.locator('button[aria-label="Git action options"]').click();
+ await page.getByText("Commit", { exact: true }).last().click();
+ await page.getByText("Commit changes", { exact: true }).waitFor({ state: "visible" });
+ await page
+ .getByPlaceholder("Leave empty to auto-generate")
+ .fill("Deflake GitManager cross-repo metadata test");
+ },
+ },
+ {
+ name: "review-diff-crop",
+ hash: "#/demo-mac-studio/thread-composer",
+ clip: featureClip(510, 50, 570),
+ deviceScaleFactor: 4,
+ },
+ {
+ name: "browser-test-crop",
+ standalone: "test",
+ clip: featureClip(270, 42, 810),
+ deviceScaleFactor: 3,
+ },
+];
+
+const mimeTypes = new Map([
+ [".css", "text/css; charset=utf-8"],
+ [".html", "text/html; charset=utf-8"],
+ [".ico", "image/x-icon"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".json", "application/json; charset=utf-8"],
+ [".png", "image/png"],
+ [".svg", "image/svg+xml"],
+ [".webp", "image/webp"],
+ [".woff", "font/woff"],
+ [".woff2", "font/woff2"],
+]);
+
+async function exists(path) {
+ try {
+ await NodeFSP.access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function findChromium() {
+ const candidates = [
+ process.env.CHROME_BIN,
+ chromium.executablePath(),
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ "/usr/bin/google-chrome",
+ "/usr/bin/chromium",
+ ].filter(Boolean);
+
+ const playwrightCache = NodePath.join(NodeOS.homedir(), "Library", "Caches", "ms-playwright");
+ if (await exists(playwrightCache)) {
+ const macChromeDirectories = ["chrome-mac-arm64", "chrome-mac-x64"];
+ const revisions = (await NodeFSP.readdir(playwrightCache, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("chromium-"))
+ .map((entry) => entry.name)
+ .sort()
+ .toReversed();
+ for (const revision of revisions) {
+ for (const macChromeDirectory of macChromeDirectories) {
+ candidates.push(
+ NodePath.join(
+ playwrightCache,
+ revision,
+ macChromeDirectory,
+ "Google Chrome for Testing.app",
+ "Contents",
+ "MacOS",
+ "Google Chrome for Testing",
+ ),
+ );
+ }
+ }
+ }
+
+ for (const candidate of candidates) {
+ if (await exists(candidate)) return candidate;
+ }
+ return null;
+}
+
+function startStaticServer() {
+ const server = NodeHttp.createServer(async (request, response) => {
+ try {
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
+ const relativePath = NodePath.normalize(decodeURIComponent(requestUrl.pathname)).replace(
+ /^[/\\]+/,
+ "",
+ );
+ let filePath = NodePath.resolve(publicRoot, relativePath);
+ const pathFromPublicRoot = NodePath.relative(publicRoot, filePath);
+ if (
+ pathFromPublicRoot === ".." ||
+ pathFromPublicRoot.startsWith(`..${NodePath.sep}`) ||
+ NodePath.isAbsolute(pathFromPublicRoot)
+ ) {
+ response.writeHead(403).end();
+ return;
+ }
+ if ((await NodeFSP.stat(filePath)).isDirectory()) {
+ filePath = NodePath.join(filePath, "index.html");
+ }
+ const body = await NodeFSP.readFile(filePath);
+ response.setHeader(
+ "Content-Type",
+ mimeTypes.get(NodePath.extname(filePath)) ?? "application/octet-stream",
+ );
+ response.setHeader("Cache-Control", "no-store");
+ response.end(body);
+ } catch {
+ response.writeHead(404).end();
+ }
+ });
+
+ return new Promise((resolveServer, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ reject(new Error("Could not resolve the demo capture server port."));
+ return;
+ }
+ resolveServer({
+ origin: `http://localhost:${address.port}`,
+ close: () => new Promise((resolveClose) => server.close(resolveClose)),
+ });
+ });
+ });
+}
+
+async function outputsExist() {
+ const demoOutputsExist = (
+ await Promise.all(states.map(({ name }) => exists(NodePath.join(outputRoot, `${name}.png`))))
+ ).every(Boolean);
+ return demoOutputsExist && (await exists(ogOutputPath)) && (await exists(ogSourceOutputPath));
+}
+
+const executablePath = await findChromium();
+if (!executablePath) {
+ if (await outputsExist()) {
+ console.warn("[marketing] Chromium unavailable; keeping committed demo screenshots.");
+ process.exit(0);
+ }
+ throw new Error("Chromium is required to create the initial marketing demo screenshots.");
+}
+
+const hadCommittedOutputs = await outputsExist();
+const temporaryOutputRoot = await NodeFSP.mkdtemp(
+ NodePath.join(NodeOS.tmpdir(), "t3-marketing-demo-"),
+);
+let staticServer;
+let browser;
+
+try {
+ staticServer = await startStaticServer();
+ browser = await chromium.launch({ executablePath, headless: true });
+
+ for (const state of states) {
+ const page = await browser.newPage({
+ colorScheme: "dark",
+ deviceScaleFactor: state.deviceScaleFactor ?? captureSize.deviceScaleFactor,
+ viewport,
+ });
+ try {
+ if (state.standalone) {
+ await page.setContent(browserDemoHtml(state.standalone), {
+ waitUntil: "domcontentloaded",
+ });
+ } else {
+ await page.goto(`${staticServer.origin}/sidebar-demo/demo.html${state.hash}`, {
+ waitUntil: "domcontentloaded",
+ });
+ await waitForDemoReady(page);
+ }
+ await freezeMotion(page);
+ await state.prepare?.(page);
+ await page.waitForTimeout(400);
+ await page.screenshot({
+ path: NodePath.join(temporaryOutputRoot, `${state.name}.png`),
+ type: "png",
+ clip: state.clip,
+ });
+ } finally {
+ await page.close();
+ }
+ }
+
+ let demoFrameBytes;
+ const sourcePage = await browser.newPage({
+ colorScheme: "dark",
+ deviceScaleFactor: 2,
+ viewport: ogSourceViewport,
+ });
+ try {
+ await sourcePage.goto(
+ `${staticServer.origin}/sidebar-demo/demo.html#/demo-mac-studio/thread-flaky`,
+ { waitUntil: "domcontentloaded" },
+ );
+ await waitForDemoReady(sourcePage);
+ await freezeMotion(sourcePage);
+ const modelPickerTrigger = sourcePage.locator('[data-chat-provider-model-picker="true"]');
+ const modelPickerTriggerCount = await modelPickerTrigger.count();
+ if (modelPickerTriggerCount !== 1) {
+ throw new Error(`Expected one demo model picker trigger, found ${modelPickerTriggerCount}.`);
+ }
+ await modelPickerTrigger.click();
+ await sourcePage.locator('[data-model-picker-content="true"]').waitFor({
+ state: "visible",
+ timeout: 10_000,
+ });
+ await sourcePage.waitForTimeout(300);
+ demoFrameBytes = await sourcePage.screenshot({ type: "png" });
+ await NodeFSP.writeFile(NodePath.join(temporaryOutputRoot, "og-source.png"), demoFrameBytes);
+ } finally {
+ await sourcePage.close();
+ }
+
+ const ogPage = await browser.newPage({
+ colorScheme: "dark",
+ deviceScaleFactor: 1,
+ viewport: ogCaptureSize,
+ });
+ try {
+ await ogPage.setContent(
+ ogSceneHtml({
+ demoFrameDataUrl: toDataUrl("image/png", demoFrameBytes),
+ }),
+ { waitUntil: "domcontentloaded" },
+ );
+ await ogPage.waitForFunction(
+ () =>
+ document.documentElement.dataset.rendered === "true" &&
+ Array.from(document.images).every((image) => image.complete && image.naturalWidth > 0),
+ );
+ await ogPage.screenshot({
+ path: NodePath.join(temporaryOutputRoot, "og-image.png"),
+ type: "png",
+ });
+ } finally {
+ await ogPage.close();
+ }
+
+ await NodeFSP.mkdir(outputRoot, { recursive: true });
+ for (const state of states) {
+ await NodeFSP.copyFile(
+ NodePath.join(temporaryOutputRoot, `${state.name}.png`),
+ NodePath.join(outputRoot, `${state.name}.png`),
+ );
+ console.log(`[marketing] captured demo-states/${state.name}.png`);
+ }
+ await NodeFSP.copyFile(NodePath.join(temporaryOutputRoot, "og-image.png"), ogOutputPath);
+ console.log("[marketing] captured og-image.png");
+ await NodeFSP.copyFile(NodePath.join(temporaryOutputRoot, "og-source.png"), ogSourceOutputPath);
+ console.log("[marketing] captured og-source.png");
+} catch (error) {
+ if (!hadCommittedOutputs) throw error;
+ console.warn(
+ `[marketing] demo capture failed; keeping committed screenshots (${String(error)}).`,
+ );
+} finally {
+ if (browser) await browser.close();
+ if (staticServer) await staticServer.close();
+ await NodeFSP.rm(temporaryOutputRoot, { recursive: true, force: true });
+}
diff --git a/apps/marketing/src/assets/t3-dev-384.webp b/apps/marketing/src/assets/t3-dev-384.webp
new file mode 100644
index 00000000000..6c30dc4f847
Binary files /dev/null and b/apps/marketing/src/assets/t3-dev-384.webp differ
diff --git a/apps/marketing/src/assets/t3-latest-384.webp b/apps/marketing/src/assets/t3-latest-384.webp
new file mode 100644
index 00000000000..a26a61259a4
Binary files /dev/null and b/apps/marketing/src/assets/t3-latest-384.webp differ
diff --git a/apps/marketing/src/assets/t3-nightly-384.webp b/apps/marketing/src/assets/t3-nightly-384.webp
new file mode 100644
index 00000000000..f4a802cd510
Binary files /dev/null and b/apps/marketing/src/assets/t3-nightly-384.webp differ
diff --git a/apps/marketing/src/components/LegalPage.astro b/apps/marketing/src/components/LegalPage.astro
index eb824039e98..65e3d9988ab 100644
--- a/apps/marketing/src/components/LegalPage.astro
+++ b/apps/marketing/src/components/LegalPage.astro
@@ -9,13 +9,27 @@ interface Props {
readonly effectiveDate: string;
readonly lastUpdated: string;
readonly sections: ReadonlyArray;
+ readonly simple?: boolean;
}
-const { title, description, heading, lede, effectiveDate, lastUpdated, sections } = Astro.props;
+const {
+ title,
+ description,
+ heading,
+ lede,
+ effectiveDate,
+ lastUpdated,
+ sections,
+ simple = false,
+} = Astro.props;
---
-
-
+
+
← All legal documents
T3 Tools, Inc. / Legal
@@ -36,19 +50,23 @@ const { title, description, heading, lede, effectiveDate, lastUpdated, sections
-
+ {
+ !simple && (
+
+ )
+ }
@@ -103,9 +121,11 @@ const { title, description, heading, lede, effectiveDate, lastUpdated, sections
frame = window.requestAnimationFrame(updateActiveSection);
};
- updateActiveSection();
- window.addEventListener("scroll", scheduleUpdate, { passive: true });
- window.addEventListener("resize", scheduleUpdate);
+ if (sections.length > 0) {
+ updateActiveSection();
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
+ window.addEventListener("resize", scheduleUpdate);
+ }
diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro
index f5e34d0e048..05b75dbeaf3 100644
--- a/apps/marketing/src/layouts/Layout.astro
+++ b/apps/marketing/src/layouts/Layout.astro
@@ -1,60 +1,121 @@
---
-import {
- ANDROID_PLAY_STORE_URL,
- GITHUB_REPOSITORY_URL,
- IOS_APP_STORE_URL,
- MARKETING_STATS,
-} from "../lib/site";
+import devAppIcon from "../assets/t3-dev-384.webp?url";
+import nightlyAppIcon from "../assets/t3-nightly-384.webp?url";
+import stableAppIcon from "../assets/t3-latest-384.webp?url";
+import { GITHUB_REPOSITORY_URL } from "../lib/site";
interface Props {
title?: string;
description?: string;
pageClass?: string;
+ favicon?: string;
+ socialImage?: string;
+ socialImageAlt?: string;
}
const {
title = "T3 Code",
description = "T3 Code — The open-source control plane for coding agents.",
pageClass,
+ favicon = stableAppIcon,
+ socialImage = "/og-image.png",
+ socialImageAlt = "T3 Code model picker shown in cinematic perspective",
} = Astro.props;
+
+const siteUrl = Astro.site ?? Astro.url;
+const canonicalUrl = new URL(Astro.url.pathname, siteUrl);
+const socialImageUrl = new URL(socialImage, siteUrl);
---
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
{title}
+