Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
236 changes: 215 additions & 21 deletions .fork/customizations.yaml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ jobs:
with:
tag_name: ${{ steps.meta.outputs.tag }}
target_commitish: ${{ github.sha }}
name: N3 Code ${{ steps.meta.outputs.tag }}
name: no3y Code ${{ steps.meta.outputs.tag }}
generate_release_notes: true
prerelease: ${{ inputs.prerelease }}
# `inputs.prerelease` is already a boolean for a typed dispatch
Expand All @@ -171,12 +171,12 @@ jobs:
signed by an identified developer. To open it:

```
xattr -dr com.apple.quarantine "/Applications/N3 Code.app"
xattr -dr com.apple.quarantine "/Applications/no3y Code.app"
```

Or right-click the app and choose Open, then confirm.

N3 Code keeps all state in its own `~/.t3-fork` and never touches
no3y Code keeps all state in its own `~/.t3-fork` and never touches
the real T3 Code app's `~/.t3`. First launch therefore starts from
an empty environment — no projects, no sign-in.

Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ describe("DesktopAppIdentity", () => {
yield* identity.configure;

// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
assert.deepEqual(calls.setName, ["N3 Code (Alpha)"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "N3 Code (Alpha)");
assert.deepEqual(calls.setName, ["no3y Code (Alpha)"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "no3y Code (Alpha)");
// fork:end fork-app-identity
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class DesktopEnvironment extends Context.Service<
>()("@t3tools/desktop/app/DesktopEnvironment") {}

// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
const APP_BASE_NAME = "N3 Code";
const APP_BASE_NAME = "no3y Code";
// fork:end fork-app-identity

function resolveDesktopAppStageLabel(input: {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ DesktopApp.program.pipe(
Effect.sync(() => {
const message = String(Cause.squash(cause));
if (message.includes("Refusing to start")) {
Electron.dialog.showErrorBox("N3 Code cannot start", message);
Electron.dialog.showErrorBox("no3y Code cannot start", message);
}
}),
),
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/__fork_guards__/cssRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* A minimal CSS rule reader for the fork guards.
*
* Guards that ask "is this declaration scoped?" have to read a rule's own
* selector. Two earlier attempts got this wrong in ways worth recording:
*
* - Asking whether the fork marker appears *earlier in the file* than a given
* line passes for anything below the first marker block, scoped or not.
* - A regex over `selector { body }` breaks on `theme.custom.css` specifically,
* because it holds `@keyframes` — nested blocks whose inner braces make the
* naive match swallow the wrong span — and comments that contain braces.
*
* So: strip comments, then walk the braces, and return only leaf rules (blocks
* whose body contains no further block). Every selector a fork guard cares
* about is a leaf; the at-rules that are not are exactly the ones to skip.
*
* This is not a CSS parser and does not want to be. It is enough to answer
* "which selector does this declaration sit under", which is the only question
* the guards ask.
*/

export interface CssRule {
/** Everything between the previous block and this one's `{`, trimmed. */
readonly selector: string;
/** The declarations, verbatim. */
readonly body: string;
}

function stripComments(css: string): string {
// Replaced with spaces rather than removed so every offset still lines up
// with the original text, which keeps selector slicing honest.
return css.replace(/\/\*[\s\S]*?\*\//gu, (match) => " ".repeat(match.length));
}

export function cssRules(css: string): ReadonlyArray<CssRule> {
const source = stripComments(css);
const rules: CssRule[] = [];

for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "{") continue;

const close = source.indexOf("}", index);
if (close < 0) break;

const body = source.slice(index + 1, close);
// A nested block: this `{` opens an at-rule, so its "body" is another
// selector. Step inside rather than recording it.
if (body.includes("{")) continue;

const previousBoundary = Math.max(
source.lastIndexOf("}", index - 1),
source.lastIndexOf("{", index - 1),
);
rules.push({
selector: source.slice(previousBoundary + 1, index).trim(),
body,
});
index = close;
}

return rules;
}
27 changes: 24 additions & 3 deletions apps/web/src/__fork_guards__/forkAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import { describe, expect, it } from "vite-plus/test";

import { FORK_APP_BASE_NAME } from "../custom/forkBranding";

const repoRoot = NodePath.resolve(
NodeURL.fileURLToPath(new URL(".", import.meta.url)),
"../../../..",
Expand All @@ -55,21 +57,40 @@ describe("fork guard: fork-app-identity", () => {

it("installs under a fork-owned app name", () => {
const script = read(BUILD_SCRIPT);
expect(script).toContain('"N3 Code"');
expect(script).toContain('"no3y Code"');
// Upstream reads productName from the desktop package.json ("T3 Code
// (Alpha)") — the exact name of the installed release's bundle.
expect(script).not.toContain("desktopPackageJson.productName");
});

it("brands the rendered app as the fork, not upstream", () => {
// The packaged build gets its name over the desktop bridge, so this
// fallback is the only thing a dev or hosted session has. Upstream's is
// "T3 Code" — leaving it meant every dev session ran under upstream's name
// while the installed app ran under the fork's, which is precisely the
// confusion the rename was asked for.
expect(FORK_APP_BASE_NAME).toBe("no3y Code");
const branding = read("apps/web/src/branding.ts");
expect(branding).toContain("?? FORK_APP_BASE_NAME");
expect(branding).not.toContain('?? "T3 Code"');
});

it("shows that name in the sidebar rather than upstream's wordmark", () => {
const chrome = read("apps/web/src/components/sidebar/SidebarChrome.tsx");
expect(chrome).toContain("{APP_BASE_NAME}");
// The borrowed T3 glyph, which read as a mismatch beside a different name.
expect(chrome).not.toContain("T3Wordmark");
});

it("keeps the release workflow on the fork's install name", () => {
// fork-release.yml is fork-owned (see fork-desktop-release), so upstream
// drift-watching can never catch it going stale against this
// customization's naming — and it did go stale: v0.1.1's release notes
// told users to de-quarantine "T3 Code (Alpha).app", a bundle the fork
// never installs as. Pin the strings that must track the product name.
const workflow = read(FORK_RELEASE_WORKFLOW);
expect(workflow).toContain('"/Applications/N3 Code.app"');
expect(workflow).toContain("name: N3 Code");
expect(workflow).toContain('"/Applications/no3y Code.app"');
expect(workflow).toContain("name: no3y Code");
// The install-path shape specifically: a "T3 Code Fork.app" mention
// survives legitimately in the v0.1.1 cleanup instructions.
expect(workflow).not.toContain("/Applications/T3 Code");
Expand Down
163 changes: 163 additions & 0 deletions apps/web/src/__fork_guards__/forkSidebarChrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Fork guard — see `.fork/README.md` §4b and
* `.fork/customizations.yaml#fork-sidebar-chrome`.
*
* Moving the collapse toggle into the sidebar header creates one way to brick
* the app: the toggle now unmounts with the panel it collapses. Two things stop
* that — the floating control still renders while the sidebar is shut, and the
* keybinding is registered above both. Most of this file is about those two.
*/

import * as NodeFS from "node:fs";
import * as NodeURL from "node:url";
import { describe, expect, it } from "vite-plus/test";

import { resolveForkSidebarHeaderArt } from "../custom/SidebarHeaderBackdrop";

function readSibling(relativePath: string): string {
return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}

const layout = readSibling("../components/AppSidebarLayout.tsx");
const chrome = readSibling("../components/sidebar/SidebarChrome.tsx");

describe("fork guard: fork-sidebar-chrome", () => {
it("still offers a toggle while the sidebar is collapsed", () => {
// The inline toggle leaves with the panel. Without this branch a collapsed
// sidebar can only be reopened by keyboard or by finding the rail.
expect(layout).toContain("if (isSidebarVisible) return null;");
expect(layout).toContain("data-sidebar-control");
});

it("registers the toggle keybinding above the button that can unmount", () => {
// Order matters, not just presence: an early return placed before the
// effect would make the shortcut itself conditional on the sidebar being
// open — the one state in which you need it most.
const effect = layout.indexOf('!== "sidebar.toggle"');
const earlyReturn = layout.indexOf("if (isSidebarVisible) return null;");
expect(effect).toBeGreaterThanOrEqual(0);
expect(earlyReturn).toBeGreaterThan(effect);
});

it("draws the toggle inside the sidebar header on every viewport", () => {
// Hiding it above `md` was correct while the desktop toggle floated
// elsewhere. Left in place it would hide the only toggle an open sidebar
// has on desktop. Scoped to the element rather than the file so prose about
// the class cannot satisfy or break the check.
const start = chrome.indexOf("<SidebarTrigger");
expect(start).toBeGreaterThanOrEqual(0);
const trigger = chrome.slice(start, chrome.indexOf("/>", start));
expect(trigger).not.toContain("md:hidden");
});

it("clears the macOS traffic lights", () => {
// The floating control used this inset; the inline toggle inherits the same
// problem, and without it the button lands under the window buttons.
expect(chrome).toContain("pl-[var(--workspace-controls-left)]");
});

it("draws the fork's dither on the Dev channel and leaves Nightly alone", () => {
// Nightly is the control: it is the one label still routed to upstream's
// own art, so a change that collapsed every channel onto one artwork fails
// here.
const backdrop = readSibling("../components/SidebarStageBackdrop.tsx");
expect(backdrop).toContain("<SidebarStageDitherArt");
expect(backdrop).toContain("NightlySkyArt");
expect(backdrop).not.toContain("stage-blueprint");
});

it("gives the sidebar header artwork on every build, release included", () => {
// The point of the split: upstream renders header art only on a non-prod
// build. A regression here is invisible in dev — where art shows either way
// — and only surfaces as a bare header in the packaged app.
expect(chrome).toContain("<ForkSidebarHeaderBackdrop");
// Behaviour, not source text: "Alpha" is the label a packaged fork build
// carries, and upstream classifies it as no-art.
expect(resolveForkSidebarHeaderArt("Alpha")).toBe("release");
expect(resolveForkSidebarHeaderArt("Dev")).toBe("dev");
expect(resolveForkSidebarHeaderArt("Nightly")).toBe("nightly");
});

it("leaves the send button and auth screen gated on the build channel", () => {
// Those two are what still say "this is a Dev build" now that the sidebar
// no longer does. Both must keep testing the variant rather than rendering
// unconditionally.
for (const path of [
"../components/chat/ComposerPrimaryActions.tsx",
"../components/auth/AuthSurfaceShell.tsx",
]) {
const file = readSibling(path);
expect(file, `${path} stopped gating its stage art`).toMatch(/stage(?:Backdrop)?Variant \?/u);
expect(file).not.toContain("ForkSidebarHeaderBackdrop");
}
});

it("keeps the release and dev builds on different artwork", () => {
// The one thing this split exists for: telling a dev build from a release
// build at a glance. Both tones resolving to the same file still renders
// and still looks right in isolation, which is why it needs asserting.
const art = readSibling("../custom/SidebarStageDitherArt.tsx");
expect(art).toContain("release: releaseDitherUrl");
expect(art).toContain("dev: devDitherUrl");
for (const asset of ["sidebar-stage-dither.png", "sidebar-stage-dither-dev.png"]) {
expect(
NodeFS.existsSync(
NodeURL.fileURLToPath(new URL(`../custom/assets/${asset}`, import.meta.url)),
),
`${asset} is missing`,
).toBe(true);
}
});

it("paints the supplied artwork rather than regenerating it", () => {
// The art is the designer's own PNG. An earlier revision reproduced it as a
// Bayer dither in SVG, which scaled better but flattened the reference's
// diagonal ramp — so "it still renders and still looks green" is exactly
// the failure this asserts against.
const art = readSibling("../custom/SidebarStageDitherArt.tsx");
expect(art).toContain('from "./assets/sidebar-stage-dither.png"');
expect(
NodeFS.existsSync(
NodeURL.fileURLToPath(
new URL("../custom/assets/sidebar-stage-dither.png", import.meta.url),
),
),
).toBe(true);
});

it("covers rather than tiles the band", () => {
// The source ramps diagonally, so any repeat butts a light edge against a
// dark one and draws a seam at every tile boundary.
const art = readSibling("../custom/SidebarStageDitherArt.tsx");
expect(art).toContain("bg-cover");
expect(art).toContain("bg-no-repeat");
});

it("ends the band on a hard edge instead of upstream's dissolve", () => {
// Upstream masks its art out and ramps a gradient ::after over it. Both
// have to be switched off for this variant, and only for this variant.
const theme = readSibling("../theme.custom.css");
expect(theme).toMatch(
/\.sidebar-stage-backdrop:has\(\.stage-dither\)[\s\S]{0,200}mask-image:\s*none/u,
);
expect(theme).toMatch(
/\.sidebar-stage-backdrop:has\(\.stage-dither\)::after\s*\{[^}]*background:\s*none/u,
);
});

it("keeps the search and project rows fork-owned", () => {
// ~150 lines of pure presentation. Fenced in place it left SidebarV2
// carrying the whole rewrite; here the fence is two call sites.
const sidebarV2 = readSibling("../components/SidebarV2.tsx");
expect(sidebarV2).toContain("<SidebarV2SearchRow");
expect(sidebarV2).toContain("<SidebarV2ProjectScopeRow");
expect(sidebarV2).not.toContain('aria-label="Filter threads by project"');
expect(sidebarV2).not.toContain('data-testid="command-palette-trigger"');
});

it("puts the brand on the header's trailing edge", () => {
expect(chrome).toMatch(/sidebar-brand[^"]*ml-auto/u);
expect(chrome).not.toContain("ml-[var(--workspace-titlebar-content-left)]");
});
});
Loading
Loading