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; --- - -