diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 6fc9f6f1a1f7..32d907240b3c 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -1,4 +1,4 @@
reviews:
review_status: false
auto_review:
- enabled: false
+ enabled: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 000000000000..69dc53dc4393
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,8 @@
+# Security policy
+
+Report security vulnerabilities affecting T3 Code or T3 Tools-operated infrastructure to
+[security@ping.gg](mailto:security@ping.gg). Please do not disclose them publicly until we have had
+a reasonable opportunity to investigate and remediate them.
+
+See the [full security policy](https://t3.codes/security-policy) for reporting details, scope,
+and safe harbor terms for good-faith research.
diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs
index dc4b55bc6517..82ee40da1aff 100644
--- a/.github/scripts/check-nightly-release.cjs
+++ b/.github/scripts/check-nightly-release.cjs
@@ -1,19 +1,21 @@
const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
-// Runs after the workflow acquires the nightly concurrency lock.
-async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
+const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
+
+// Newest published nightly by publication time, or undefined when none exists.
+async function findLatestNightly({ github, context }) {
const releases = await github.paginate(github.rest.repos.listReleases, {
...context.repo,
per_page: 100,
});
- const lastNightly = releases
- .filter(
- (release) =>
- !release.draft &&
- release.published_at &&
- (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")),
- )
+ return releases
+ .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name))
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
+}
+
+// Runs after the workflow acquires the nightly concurrency lock.
+async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
+ const lastNightly = await findLatestNightly({ github, context });
if (!lastNightly) {
core.info("No published nightly found. Proceeding with release.");
@@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() })
return true;
}
-module.exports = { shouldReleaseNightly };
+// Stable releases build the commit the latest nightly shipped, so the stable
+// build is one nightly users already ran. Returns the nightly tag, its commit,
+// and the stable version that nightly was a preview of.
+async function resolveLatestNightlyCommit({ github, context, core }) {
+ const lastNightly = await findLatestNightly({ github, context });
+ if (!lastNightly) {
+ throw new Error("No published nightly found. Stable releases build the latest nightly commit.");
+ }
+
+ const tag = lastNightly.tag_name;
+ // repos.getCommit dereferences annotated tags, so this is the commit either way.
+ const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag });
+ const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1];
+ if (!version) {
+ throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`);
+ }
+
+ core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`);
+ return { tag, sha: commit.sha, version };
+}
+
+module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit };
diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs
index 476773bc4e5a..49ade68aeef7 100644
--- a/.github/scripts/check-nightly-release.test.cjs
+++ b/.github/scripts/check-nightly-release.test.cjs
@@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) {
assert.equal(await shouldReleaseNightly(options), false);
});
}
+
+const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
+
+function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
+ const refs = [];
+ const { options } = fixture({ releases });
+ options.github.rest.repos.getCommit = async ({ ref }) => {
+ refs.push(ref);
+ return { data: { sha: commitSha } };
+ };
+ return { options, refs };
+}
+
+test("stable releases resolve the commit of the newest published nightly", async () => {
+ const { options, refs } = nightlyCommitFixture({
+ releases: [
+ nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }),
+ nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }),
+ nightly(0, { tag_name: "v1.0.0" }),
+ nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }),
+ ],
+ commitSha: "deadbeef",
+ });
+ assert.deepEqual(await resolveLatestNightlyCommit(options), {
+ tag: "v1.0.1-nightly.20260905.123",
+ sha: "deadbeef",
+ version: "1.0.1",
+ });
+ assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
+});
+
+test("stable releases derive the version from legacy nightly tags", async () => {
+ const { options } = nightlyCommitFixture({
+ releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })],
+ });
+ assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
+});
+
+test("stable releases fail without a published nightly", async () => {
+ const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] });
+ await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/);
+});
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 938efdf51c39..7f167aa70bf0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ on:
- stable
- nightly
version:
- description: "Release version (for example 1.2.3 or v1.2.3)"
+ description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
required: false
type: string
artifacts_only:
@@ -44,33 +44,54 @@ permissions:
id-token: none
jobs:
- check_changes:
- name: Check automatic nightly release
- if: github.event_name == 'schedule'
+ # Picks the commit every later job builds. Nightlies and tag pushes build the
+ # triggering commit. Manual stable releases build the commit of the latest
+ # published nightly, so stable only ever ships a build that nightly users
+ # have already run. Scheduled runs also decide here whether a nightly is due.
+ resolve_commit:
+ name: Resolve release commit
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
- has_changes: ${{ steps.check.outputs.result }}
+ ref: ${{ steps.resolve.outputs.ref }}
+ nightly_version: ${{ steps.resolve.outputs.nightly_version }}
+ has_changes: ${{ steps.resolve.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
- - id: check
- name: Check release gap and new commits
+ - id: resolve
+ name: Resolve release commit
uses: actions/github-script@v8
+ env:
+ DISPATCH_CHANNEL: ${{ inputs.channel }}
with:
script: |
- const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');
- return await shouldReleaseNightly({ github, context, core });
+ const {
+ shouldReleaseNightly,
+ resolveLatestNightlyCommit,
+ } = require('./.github/scripts/check-nightly-release.cjs');
+
+ if (context.eventName === 'schedule') {
+ core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
+ core.setOutput('ref', context.sha);
+ } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') {
+ const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
+ core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
+ core.setOutput('ref', sha);
+ core.setOutput('nightly_version', version);
+ } else {
+ core.setOutput('ref', context.sha);
+ }
preflight:
name: Preflight
- needs: [check_changes]
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -83,11 +104,12 @@ jobs:
cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
make_latest: ${{ steps.release_meta.outputs.make_latest }}
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
+ ref: ${{ needs.resolve_commit.outputs.ref }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -109,8 +131,9 @@ jobs:
env:
DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
DISPATCH_VERSION: ${{ github.event.inputs.version }}
+ NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
NIGHTLY_DATE: ${{ github.run_started_at }}
- NIGHTLY_SHA: ${{ github.sha }}
+ NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
@@ -128,9 +151,9 @@ jobs:
echo "make_latest=false" >> "$GITHUB_OUTPUT"
else
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
- raw="${DISPATCH_VERSION}"
+ raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
if [[ -z "$raw" ]]; then
- echo "workflow_dispatch stable releases require the version input." >&2
+ echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
exit 1
fi
else
@@ -215,14 +238,12 @@ jobs:
relay_public_config:
name: Resolve T3 Connect public config
- # Consumes only the commit SHA, not preflight's resolved version, so it runs
- # alongside preflight instead of after it. The condition mirrors preflight's:
- # check_changes is skipped on manual and tag releases (skipped is neither failure
- # nor success, so success() would be wrong here).
- needs: [check_changes]
+ # Consumes only the release commit, not preflight's resolved version, so it
+ # runs alongside preflight instead of after it. The condition mirrors preflight's.
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: ubuntu-24.04
timeout-minutes: 5
environment:
@@ -244,7 +265,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
sparse-checkout: |
/*
!/.repos/
@@ -317,19 +338,19 @@ jobs:
# machine. node-pty is N-API, so one binary works across all WSL Node versions.
build_wsl_node_pty:
name: Build WSL node-pty (linux-x64)
- # Same gating as relay_public_config: only the commit SHA is needed, so this
- # runs alongside preflight. See the condition comment there.
- needs: [check_changes]
+ # Same gating as relay_public_config: only the release commit is needed, so
+ # this runs alongside preflight. See the condition comment there.
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
sparse-checkout: |
/*
!/.repos/
@@ -1042,6 +1063,60 @@ jobs:
"${vercel_scope_args[@]}"
fi
+ deploy_marketing:
+ name: Deploy marketing site
+ needs: [preflight, release]
+ if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.preflight.outputs.ref }}
+ sparse-checkout: |
+ /*
+ !/.repos/
+ sparse-checkout-cone-mode: false
+
+ - name: Setup Vite+
+ uses: voidzero-dev/setup-vp@v1
+ with:
+ node-version-file: package.json
+ cache: true
+ run-install: |
+ args:
+ - --filter=@t3tools/marketing...
+
+ - name: Deploy marketing site to Vercel
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" ]]; then
+ echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID." >&2
+ exit 1
+ fi
+
+ VERCEL_PROJECT_ID="$(
+ curl --fail --silent --show-error \
+ --header "Authorization: Bearer $VERCEL_TOKEN" \
+ "https://api.vercel.com/v9/projects/t3code-marketing?teamId=$VERCEL_ORG_ID" \
+ | jq --exit-status --raw-output '.id'
+ )"
+ export VERCEL_PROJECT_ID
+
+ vp dlx vercel@53.1.1 deploy \
+ --archive=tgz \
+ --prod \
+ --yes \
+ --token "$VERCEL_TOKEN" \
+ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
+
finalize:
name: Finalize release
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }}
diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md
index 316cf3509bf5..cfb810e37b45 100644
--- a/.macroscope/check-run-agents/effect-service-conventions.md
+++ b/.macroscope/check-run-agents/effect-service-conventions.md
@@ -1,7 +1,7 @@
---
title: Effect Service Conventions
-model: claude-opus-5
-effort: high
+model: gpt-5-6-sol
+effort: medium
input: full_diff
tools:
- browse_code
@@ -16,6 +16,7 @@ labels:
- vouch:trusted
requires:
- Check
+maxBudgetPerRun: 5
maxBudgetPerPR: 25
conclusion: failure
showToolCalls: true
diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md
index 32df265d3313..dd88c891ed9b 100644
--- a/.macroscope/check-run-agents/ui-consistency.md
+++ b/.macroscope/check-run-agents/ui-consistency.md
@@ -1,6 +1,6 @@
---
title: UI Consistency
-model: claude-opus-5
+model: gpt-5-6-terra
effort: medium
input: full_diff
tools:
@@ -15,9 +15,9 @@ labels:
- vouch:trusted
requires:
- Check
-maxBudgetPerPR: 25
+maxBudgetPerRun: 2
+maxBudgetPerPR: 10
conclusion: failure
-maxBudgetPerRun: 10
---
# UI consistency review
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1cb32c858509..9eb5a9625eaa 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -28,7 +28,6 @@ const clientSettings: ClientSettings = {
confirmWorktreeRemoval: true,
confirmThreadUnpin: false,
contextWindowMeterEnabled: false,
- composerCollapseOnBlur: false,
composerCollapseOnScroll: true,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
diff --git a/apps/marketing/public/nightly-sky.svg b/apps/marketing/public/nightly-sky.svg
new file mode 100644
index 000000000000..3165b211b287
--- /dev/null
+++ b/apps/marketing/public/nightly-sky.svg
@@ -0,0 +1,44 @@
+
diff --git a/apps/marketing/src/assets/icon-nightly.webp b/apps/marketing/src/assets/icon-nightly.webp
new file mode 100644
index 000000000000..8a00067e43b8
Binary files /dev/null and b/apps/marketing/src/assets/icon-nightly.webp differ
diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro
index 4c95bc86560e..a06521986ea0 100644
--- a/apps/marketing/src/layouts/Layout.astro
+++ b/apps/marketing/src/layouts/Layout.astro
@@ -80,6 +80,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
T3 Code
+
Download
();
style = { setProperty: (name: string, value: string) => this.properties.set(name, value) };
children: ElementStub[] = [];
- scrollLeft = 0;
- scrollWidth = 1_200;
- clientWidth = 400;
- matches = () => false;
- contains = (target: EventTarget | null) =>
- target === this || (target instanceof ElementStub && this.children.includes(target));
querySelectorAll = () => this.children;
getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 }));
- scrollTo = vi.fn((options: ScrollToOptions) => {
- this.scrollLeft = options.left ?? this.scrollLeft;
- });
}
let observers: ObserverStub[] = [];
@@ -34,29 +25,25 @@ class ObserverStub {
}
}
-let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null });
-let viewport = new EventTarget();
+let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
let reduced = Object.assign(new EventTarget(), { matches: false });
let fine = Object.assign(new EventTarget(), { matches: true });
let frames = new Map();
let dispose: (() => void) | undefined;
beforeEach(() => {
- vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
observers = [];
frames = new Map();
- page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null });
- viewport = new EventTarget();
+ page = Object.assign(new EventTarget(), { visibilityState: "visible" });
reduced = Object.assign(new EventTarget(), { matches: false });
fine = Object.assign(new EventTarget(), { matches: true });
vi.stubGlobal("document", page);
vi.stubGlobal(
"window",
- Object.assign(viewport, {
+ Object.assign(new EventTarget(), {
matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine),
}),
);
- vi.stubGlobal("Node", ElementStub);
vi.stubGlobal("IntersectionObserver", ObserverStub);
let frameId = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
@@ -69,7 +56,6 @@ beforeEach(() => {
afterEach(() => {
dispose?.();
dispose = undefined;
- vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -79,12 +65,12 @@ function fixture() {
const mark = new ElementStub();
const otherMark = new ElementStub();
field.children = [mark, otherMark];
- const endorsements = new ElementStub();
+ const track = new ElementStub();
const caret = new ElementStub();
- dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters<
+ dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters<
typeof startHomeMotion
>[0]);
- return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! };
+ return { hero, field, mark, otherMark, track, caret, observer: observers[0]! };
}
function movePointer(hero: ElementStub, x = 400, y = 600) {
@@ -92,13 +78,16 @@ function movePointer(hero: ElementStub, x = 400, y = 600) {
}
describe("homepage motion", () => {
- it("gates each mark and caret and batches pointer input into one frame", () => {
- const { hero, field, mark, otherMark, caret, observer } = fixture();
+ it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => {
+ const { hero, field, mark, otherMark, track, caret, observer } = fixture();
expect(mark.properties.get("--home-motion-state")).toBe("paused");
+ expect(track.properties.get("--home-motion-state")).toBe("paused");
observer.report(mark, true);
+ observer.report(track, true);
observer.report(caret, true);
expect(mark.properties.get("--home-motion-state")).toBe("running");
expect(otherMark.properties.get("--home-motion-state")).toBe("paused");
+ expect(track.properties.get("--home-motion-state")).toBe("running");
expect(caret.properties.get("--home-motion-state")).toBe("running");
movePointer(hero, 100, 100);
@@ -117,6 +106,7 @@ describe("homepage motion", () => {
expect(frames.size).toBe(0);
expect(field.properties.get("--px")).toBe("0px");
expect(mark.properties.get("--home-motion-state")).toBe("paused");
+ expect(track.properties.get("--home-motion-state")).toBe("paused");
expect(caret.properties.get("--home-motion-state")).toBe("paused");
page.visibilityState = "visible";
page.dispatchEvent(new Event("visibilitychange"));
@@ -133,86 +123,10 @@ describe("homepage motion", () => {
expect(mark.properties.get("--home-motion-state")).toBe("running");
});
- it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => {
- const { endorsements, observer } = fixture();
- expect(vi.getTimerCount()).toBe(0);
- observer.report(endorsements, true);
- vi.advanceTimersByTime(7_999);
- expect(endorsements.scrollTo).not.toHaveBeenCalled();
- vi.advanceTimersByTime(16_001);
- expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([
- 400, 800, 400,
- ]);
- expect(
- endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"),
- ).toBe(true);
-
- endorsements.clientWidth = endorsements.scrollWidth;
- viewport.dispatchEvent(new Event("resize"));
- expect(vi.getTimerCount()).toBe(0);
- expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" });
- endorsements.clientWidth = 400;
- viewport.dispatchEvent(new Event("resize"));
- expect(vi.getTimerCount()).toBe(1);
- });
-
- it("pauses paging for hover, focus, hidden content, and reduced motion", () => {
- const { endorsements, observer } = fixture();
- observer.report(endorsements, true);
- const changeVisibility = (visible: boolean) => {
- page.visibilityState = visible ? "visible" : "hidden";
- page.dispatchEvent(new Event("visibilitychange"));
- };
- const changeMotion = (matches: boolean) => {
- reduced.matches = matches;
- reduced.dispatchEvent(new Event("change"));
- };
- const pauses = [
- [
- () => endorsements.dispatchEvent(new Event("pointerenter")),
- () => endorsements.dispatchEvent(new Event("pointerleave")),
- ],
- [
- () => endorsements.dispatchEvent(new Event("focusin")),
- () =>
- endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })),
- ],
- [() => changeVisibility(false), () => changeVisibility(true)],
- [() => observer.report(endorsements, false), () => observer.report(endorsements, true)],
- [() => changeMotion(true), () => changeMotion(false)],
- ] as const;
- for (const [pause, resume] of pauses) {
- pause();
- expect(vi.getTimerCount()).toBe(0);
- vi.advanceTimersByTime(16_000);
- resume();
- expect(vi.getTimerCount()).toBe(1);
- }
- expect(endorsements.scrollTo).not.toHaveBeenCalled();
- vi.advanceTimersByTime(8_000);
- expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" });
- endorsements.dispatchEvent(new Event("pointerenter"));
- expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" });
- expect(vi.getTimerCount()).toBe(0);
- });
-
- it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => {
- const { endorsements, observer } = fixture();
- observer.report(endorsements, true);
- endorsements.dispatchEvent(new Event(event));
- observer.report(endorsements, false);
- observer.report(endorsements, true);
- endorsements.dispatchEvent(new Event("pointerleave"));
- viewport.dispatchEvent(new Event("resize"));
- vi.advanceTimersByTime(60_000);
- expect(vi.getTimerCount()).toBe(0);
- expect(endorsements.scrollTo).not.toHaveBeenCalled();
- });
-
it("cancels pending work and ignores events after cleanup", () => {
- const { hero, mark, endorsements, observer } = fixture();
+ const { hero, mark, track, observer } = fixture();
observer.report(mark, true);
- observer.report(endorsements, true);
+ observer.report(track, true);
movePointer(hero);
dispose?.();
observer.report(mark, true);
@@ -220,7 +134,7 @@ describe("homepage motion", () => {
reduced.dispatchEvent(new Event("change"));
expect(observer.disconnect).toHaveBeenCalledTimes(1);
expect(frames.size).toBe(0);
- expect(vi.getTimerCount()).toBe(0);
expect(mark.properties.get("--home-motion-state")).toBe("paused");
+ expect(track.properties.get("--home-motion-state")).toBe("paused");
});
});
diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts
index 5322eae4406d..b747bd68566c 100644
--- a/apps/marketing/src/lib/homeMotion.ts
+++ b/apps/marketing/src/lib/homeMotion.ts
@@ -1,30 +1,25 @@
-/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */
+/** Runs homepage motion (marquee, mark drift, caret, parallax) only while its content is visible. */
export function startHomeMotion({
hero,
field,
- endorsements,
+ tracks,
caret,
}: {
hero: HTMLElement;
field: HTMLElement;
- endorsements: HTMLElement;
+ tracks: HTMLElement[];
caret: HTMLElement;
}) {
if (typeof IntersectionObserver === "undefined") return () => {};
const marks = Array.from(field.querySelectorAll(".hero-float-mark"));
+ const gated = [...marks, ...tracks, caret];
const visible = new Set();
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const finePointer = window.matchMedia("(pointer: fine)");
const events = new AbortController();
const eventOptions = { signal: events.signal };
let disposed = false;
- let hovered = endorsements.matches(":hover");
- let focused = endorsements.contains(document.activeElement);
- let userControlled = false;
- let direction = 1;
- let automaticScroll = false;
- let pageTimer: ReturnType | undefined;
let pointerFrame: number | undefined;
let pointer: { x: number; y: number } | null = null;
@@ -34,12 +29,6 @@ export function startHomeMotion({
document.visibilityState === "visible" &&
!reducedMotion.matches;
const canParallax = () => finePointer.matches && marks.some(canMove);
- const canPage = () =>
- canMove(endorsements) &&
- !hovered &&
- !focused &&
- !userControlled &&
- endorsements.scrollWidth > endorsements.clientWidth;
function resetPointer() {
if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame);
@@ -49,43 +38,13 @@ export function startHomeMotion({
field.style.setProperty("--py", "0px");
}
- function updatePaging() {
- if (canPage()) {
- pageTimer ??= setTimeout(advancePage, 8_000);
- return;
- }
- if (pageTimer !== undefined) clearTimeout(pageTimer);
- pageTimer = undefined;
- if (automaticScroll) {
- automaticScroll = false;
- endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" });
- }
- }
-
- function advancePage() {
- pageTimer = undefined;
- if (!canPage()) return;
- const end = endorsements.scrollWidth - endorsements.clientWidth;
- const current = endorsements.scrollLeft;
- if (current >= end - 1) direction = -1;
- else if (current <= 1) direction = 1;
- automaticScroll = true;
- endorsements.scrollTo({
- left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)),
- behavior: "smooth",
- });
- updatePaging();
- }
-
function update() {
- for (const mark of marks) {
- mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused");
+ for (const element of gated) {
+ element.style.setProperty("--home-motion-state", canMove(element) ? "running" : "paused");
}
- caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused");
const parallax = canParallax();
field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s");
if (!parallax) resetPointer();
- updatePaging();
}
const observer = new IntersectionObserver((entries) => {
@@ -96,7 +55,7 @@ export function startHomeMotion({
}
update();
});
- for (const element of [...marks, endorsements, caret]) observer.observe(element);
+ for (const element of gated) observer.observe(element);
hero.addEventListener(
"pointermove",
@@ -121,47 +80,7 @@ export function startHomeMotion({
eventOptions,
);
hero.addEventListener("pointerleave", resetPointer, eventOptions);
- endorsements.addEventListener(
- "pointerenter",
- () => {
- hovered = true;
- updatePaging();
- },
- eventOptions,
- );
- endorsements.addEventListener(
- "pointerleave",
- () => {
- hovered = false;
- updatePaging();
- },
- eventOptions,
- );
- endorsements.addEventListener(
- "focusin",
- () => {
- focused = true;
- updatePaging();
- },
- eventOptions,
- );
- endorsements.addEventListener(
- "focusout",
- (event) => {
- focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget);
- updatePaging();
- },
- eventOptions,
- );
- const takeControl = () => {
- userControlled = true;
- updatePaging();
- };
- endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true });
- endorsements.addEventListener("pointerdown", takeControl, eventOptions);
- endorsements.addEventListener("keydown", takeControl, eventOptions);
document.addEventListener("visibilitychange", update, eventOptions);
- window.addEventListener("resize", update, eventOptions);
reducedMotion.addEventListener("change", update, eventOptions);
finePointer.addEventListener("change", update, eventOptions);
update();
diff --git a/apps/marketing/src/lib/releases.ts b/apps/marketing/src/lib/releases.ts
index 5f3209acf89b..7f1ffb9a17b3 100644
--- a/apps/marketing/src/lib/releases.ts
+++ b/apps/marketing/src/lib/releases.ts
@@ -1,9 +1,15 @@
const REPO = "pingdotgg/t3code";
export const RELEASES_URL = `https://github.com/${REPO}/releases`;
+export const NIGHTLY_RELEASES_URL = `${RELEASES_URL}?q=nightly&expanded=true`;
-const API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
-const CACHE_KEY = "t3code-latest-release";
+const LATEST_API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
+// The `latest` endpoint skips prereleases, so nightly needs the list. GitHub
+// returns it newest first and nightlies land several times a day, so the first
+// nightly tag in a small page is the current build.
+const LIST_API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=10`;
+
+export type ReleaseChannel = "stable" | "nightly";
export interface ReleaseAsset {
name: string;
@@ -13,17 +19,36 @@ export interface ReleaseAsset {
export interface Release {
tag_name: string;
html_url: string;
+ published_at: string;
assets: ReleaseAsset[];
}
-export async function fetchLatestRelease(): Promise {
- const cached = sessionStorage.getItem(CACHE_KEY);
+function cacheKey(channel: ReleaseChannel) {
+ return `t3code-${channel}-release`;
+}
+
+async function fetchStable(): Promise {
+ return fetch(LATEST_API_URL).then((r) => r.json());
+}
+
+async function fetchNightly(): Promise {
+ const list: Release[] = await fetch(LIST_API_URL).then((r) => r.json());
+ const nightly = Array.isArray(list)
+ ? list.find((release) => release.tag_name?.includes("-nightly."))
+ : undefined;
+ if (!nightly) throw new Error("No nightly release in the latest page");
+ return nightly;
+}
+
+export async function fetchLatestRelease(channel: ReleaseChannel = "stable"): Promise {
+ const key = cacheKey(channel);
+ const cached = sessionStorage.getItem(key);
if (cached) return JSON.parse(cached);
- const data = await fetch(API_URL).then((r) => r.json());
+ const data = channel === "nightly" ? await fetchNightly() : await fetchStable();
if (data?.assets) {
- sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
+ sessionStorage.setItem(key, JSON.stringify(data));
}
return data;
diff --git a/apps/marketing/src/pages/95.astro b/apps/marketing/src/pages/95.astro
index 4c3edabbd68a..d6ab25cf867d 100644
--- a/apps/marketing/src/pages/95.astro
+++ b/apps/marketing/src/pages/95.astro
@@ -104,7 +104,7 @@ const userDigits = MARKETING_STATS.users.replaceAll(",", "").padStart(7, "0").sp
Get Stable ↗
-
+
Get Nightly ↗
diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro
index 6b58e4c29137..08a1c008453e 100644
--- a/apps/marketing/src/pages/download.astro
+++ b/apps/marketing/src/pages/download.astro
@@ -1,134 +1,215 @@
---
+import type { ComponentProps } from "astro/types";
+import { Image } from "astro:assets";
+import iconStable from "../assets/icon.webp";
+import iconNightly from "../assets/icon-nightly.webp";
import Layout from "../layouts/Layout.astro";
import { RELEASES_URL } from "../lib/releases";
import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
+
+const imageProps = {
+ alt: "",
+ densities: [1, 2, 3],
+ format: "webp",
+ quality: 90,
+ decoding: "async",
+} satisfies Partial
>;
---
-
-
Download T3 Code
-
- Loading latest release…
- View changelog ↗
-
-
-