diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index aed9065f3ab6..29c6b065426c 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -181,12 +181,12 @@ database. legacyUserDataDirName is redirected too, so a fork build never adopts the legacy directory upstream migrates from. The fork presents as "no3y Code": bundle id com.t3tools.t3code.fork, - installs as "no3y Code.app", ships no3y-Code-* artifacts, packages - placeholder "N" lettermark art from assets/fork on all three platforms, - and keeps all filesystem state under ~/.t3-fork. The bundle id and the - base directory are the stable identities and must never change across - display renames; display strings are safe to rename precisely because - those two are held stable. + installs as "no3y Code.app", ships no3y-Code-* artifacts, packages the + green-grid mark from assets/fork on all three platforms (plus web splash + and favicon), and keeps all filesystem state under ~/.t3-fork. The bundle + id and the base directory are the stable identities and must never change + across display renames; display strings are safe to rename precisely + because those two are held stable. Isolation is forked at the base directory, not the "userdata" leaf, because the packaged app is two processes: the desktop resolves its own state directory and hands only baseDir (as bootstrap t3Home) to the @@ -210,22 +210,41 @@ The rendered app carries the name too: the sidebar brand reads APP_BASE_NAME and drops the T3 wordmark, and branding.ts's bridge-less fallback points at the fork's own constant instead of upstream's - "T3 Code". Deliberately shared or deferred: the t3code:// URL scheme stays - shared (it is the app's own internal origin, not just a deep-link - handler — two installed apps contend for it, untidy but not destructive); - splash, favicon and resolveDesktopWebAssetBrand still carry upstream's T3 - art (images rather than strings; separate work). Known carve-out: the WSL - backend bootstrap deliberately omits t3Home, so WSL-side state is not - isolated by this customization. + "T3 Code". Splash and favicon also ship from assets/fork (copied into + apps/web/public for local/dev and into the packaged desktop client at + build time via FORK_WEB_ICON_ASSETS + the shared applyWebIconOverrides + path). assets/fork/app-icon.svg is the Figma source of truth for the + green-grid mark (file t3-fork, node 157:4036); raster exports are + derived from that family. Filenames still say n3-* for path stability. + Deliberately shared: the t3code:// URL scheme stays shared (it is the + app's own internal origin, not just a deep-link handler — two installed + apps contend for it, untidy but not destructive). + Known carve-out: the WSL backend bootstrap deliberately omits t3Home, so + WSL-side state is not isolated by this customization. + Known carve-out: hosted web (`apps/web/vercel.ts`) still runs + apply-web-brand-assets.ts with the Vercel channel and stamps upstream + production/nightly icons over the fork public/ set at deploy time. + Known carve-out: `npx t3` publish (`apps/server/scripts/cli.ts` + preparePublishIcons) still resolves upstream icons via + resolveWebAssetBrandForPackageVersion into dist/client. + Known conflict: `pnpm icons:export` writes blueprint development art + into apps/web/public via DEVELOPMENT_PUBLIC_ICON_OVERRIDES; after a + brand export the fork public icons must be re-copied from assets/fork + (or icons:check will report them stale / the byte-equality guard fails). History — the v0.1.1 leaf-rename incident, the N3→no3y rename, and the pre-a17cbc3b4 dev-state claim — is in .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md. tier: 4 files: - apps/web/src/custom/forkBranding.ts + - assets/fork/app-icon.svg - assets/fork/n3-macos-1024.png - assets/fork/n3-universal-1024.png - assets/fork/n3-windows.ico + - assets/fork/n3-web-apple-touch-180.png + - assets/fork/n3-web-favicon-16x16.png + - assets/fork/n3-web-favicon-32x32.png + - assets/fork/n3-web-favicon.ico shadows: [] watch: - apps/desktop/src/main.ts @@ -234,14 +253,33 @@ - apps/desktop/src/app/DesktopEnvironment.ts - apps/desktop/src/app/DesktopEnvironment.test.ts - apps/desktop/src/backend/DesktopBackendConfiguration.ts + - apps/desktop/scripts/electron-launcher.mjs - apps/server/src/config.ts - apps/web/src/branding.ts - apps/web/src/branding.test.ts - apps/web/src/components/sidebar/SidebarChrome.tsx + - apps/web/public/apple-touch-icon.png + - apps/web/public/favicon-16x16.png + - apps/web/public/favicon-32x32.png + - apps/web/public/favicon.ico + - apps/desktop/resources/icon.png + - apps/desktop/resources/icon.ico + - apps/desktop/resources/icon.icns - packages/shared/src/devHome.ts - packages/shared/src/hostProcess.ts - scripts/build-desktop-artifact.ts - scripts/build-desktop-artifact.test.ts + # Shared web-icon override builder + apply path packaging reuses for + # FORK_WEB_ICON_ASSETS (no twin copy pipeline in the packaging script). + - scripts/lib/brand-assets.ts + - scripts/apply-web-brand-assets.ts + # icons:export can overwrite apps/web/public with blueprint art — see + # Known conflict in intent. + - scripts/export-brand-icons.ts + # Hosted-web / npx-t3 surfaces still stamp upstream channel icons — see + # Known carve-out lines in intent. + - apps/web/vercel.ts + - apps/server/scripts/cli.ts - scripts/dev-runner.ts - vite.config.ts - .vscode/settings.json @@ -1262,7 +1300,12 @@ worktree, PR, and branch chips sit above the surface as filled chips sharing one selected-surface fill with no hairline, 8px apart (matching the context row's pb-2 under the pills — including the nested PR+branch - pair that upstream keeps at gap-1), on the Auto chip's container at + pair that upstream keeps at gap-1). Upstream's strip children use flex-1 + and the branch's justify-end / md:ml-auto, which opens a large empty gap + between checkout and branch at narrower widths while the checkout label + truncates; the fork forces those direct flex children to flex 0 1 auto, + justify-content flex-start, and margin-inline-start 0 so the chips stay a + packed leading cluster. Chips use the Auto chip's container at 4px/6px inline padding (24px tall, 6px radius — extra 2px on the end so the trailing chevron clears the curve; branch combobox takes 6px start, workspace select 0 for its roomier folder glyph) at 12px type matching @@ -1304,16 +1347,33 @@ mode chip are 12px medium on 16px with 12px glyphs. The prompt wrapper owns the 12.5rem scroll cap so a one-line Lexical editor never paints a phantom scrollbar, and the placeholder/spacer overflow containment remains - in place. Attachment and pending-state content may add height above the + in place. Attachment and pending-state content may add height above the base row, but ordinary composer height changes only when prompt content wraps. Pill styling stays narrowly scoped so pending-state primary actions retain their upstream geometry. + + Compact ⋯ collapse for the below-surface mode row uses fork-lowered + form-width thresholds (400px normal / 520px with wide primary actions), + not upstream's 620/780. Upstream's numbers pack denser in-box footers; + here mode chips sit opposite the model picker on a dedicated row, so + those thresholds hide Full access / Build / Plan behind ⋯ while a large + empty gap remains. The wide-actions threshold must stay under the form's + max-w-3xl (768px) or the expanded row can never appear in that state. + Mode-row collapse and model-picker compaction are deliberately split: + the override drives only ⋯ vs ComposerFooterModeControls; the model + trigger keeps upstream's 620/780 via composerModelSlotCompact.ts so the + right slot does not grow in the newly expanded 400–620 band. Traits stay + gated with the mode row (inline when expanded, CompactComposerControlsMenu + when collapsed) so they are never stranded between the two thresholds. tier: 4 files: - apps/web/src/custom/ComposerShell.tsx - apps/web/src/custom/ComposerShell.css + - apps/web/src/custom/composerModelSlotCompact.ts - apps/web/src/theme.custom.css - shadows: [] + - apps/web/src/overrides/components/composerFooterLayout.ts + shadows: + - apps/web/src/components/composerFooterLayout.ts watch: # Carries the base row, the context/control rows, and contextStrip prop. - apps/web/src/components/chat/ChatComposer.tsx diff --git a/.fork/notes/pr-46/README.md b/.fork/notes/pr-46/README.md new file mode 100644 index 000000000000..80a922783335 --- /dev/null +++ b/.fork/notes/pr-46/README.md @@ -0,0 +1,20 @@ +# PR #46 composer + favicon screenshots + +Before/after captures for the visual checklist on +[`NoahHendrickson/t3code#46`](https://github.com/NoahHendrickson/t3code/pull/46). +Captured from the t3code draft thread (`Current checkout` + `#46` + +`fork/composer-chrome-and-favicon` context strip) with the composer form's +measured `clientWidth` pinned to each target, dark mode, 2x scale. + +- `composer-before-*.png` — `custom@a3c19aa70`: mode row collapses to `⋯` below + 620, checkout chip stretches on upstream `flex-1`, branch chip truncates while + empty space remains. +- `composer-after-*.png` — this branch: Full access / Build stay expanded down + to 400, chips pack left, the 32-char branch label fits at 460+. +- `composer-after-380.png` — below the new 400 threshold the `⋯` compact menu + and model-slot compaction still engage. +- The long-label context-strip case is the same captures: the branch chip + carries the 32-char `fork/composer-chrome-and-favicon` label, truncated in + before-460/620 and fully visible in after-460+. +- `apple-touch-icon-before/after.png` — dev-served web icon: upstream T3 + blueprint vs the Figma green-grid mark (`t3-fork` node `157:4036`). diff --git a/.fork/notes/pr-46/apple-touch-icon-after.png b/.fork/notes/pr-46/apple-touch-icon-after.png new file mode 100644 index 000000000000..7ff1fbcbcb1c Binary files /dev/null and b/.fork/notes/pr-46/apple-touch-icon-after.png differ diff --git a/.fork/notes/pr-46/apple-touch-icon-before.png b/.fork/notes/pr-46/apple-touch-icon-before.png new file mode 100644 index 000000000000..3eed25ea6b78 Binary files /dev/null and b/.fork/notes/pr-46/apple-touch-icon-before.png differ diff --git a/.fork/notes/pr-46/composer-after-380.png b/.fork/notes/pr-46/composer-after-380.png new file mode 100644 index 000000000000..f30a47a94b57 Binary files /dev/null and b/.fork/notes/pr-46/composer-after-380.png differ diff --git a/.fork/notes/pr-46/composer-after-400.png b/.fork/notes/pr-46/composer-after-400.png new file mode 100644 index 000000000000..f05fbdae2c36 Binary files /dev/null and b/.fork/notes/pr-46/composer-after-400.png differ diff --git a/.fork/notes/pr-46/composer-after-460.png b/.fork/notes/pr-46/composer-after-460.png new file mode 100644 index 000000000000..9f4aabb3ff39 Binary files /dev/null and b/.fork/notes/pr-46/composer-after-460.png differ diff --git a/.fork/notes/pr-46/composer-after-520.png b/.fork/notes/pr-46/composer-after-520.png new file mode 100644 index 000000000000..da831c1793f4 Binary files /dev/null and b/.fork/notes/pr-46/composer-after-520.png differ diff --git a/.fork/notes/pr-46/composer-after-620.png b/.fork/notes/pr-46/composer-after-620.png new file mode 100644 index 000000000000..e72fd1413085 Binary files /dev/null and b/.fork/notes/pr-46/composer-after-620.png differ diff --git a/.fork/notes/pr-46/composer-after-768.png b/.fork/notes/pr-46/composer-after-768.png new file mode 100644 index 000000000000..275328cf545d Binary files /dev/null and b/.fork/notes/pr-46/composer-after-768.png differ diff --git a/.fork/notes/pr-46/composer-before-400.png b/.fork/notes/pr-46/composer-before-400.png new file mode 100644 index 000000000000..25cc4df30616 Binary files /dev/null and b/.fork/notes/pr-46/composer-before-400.png differ diff --git a/.fork/notes/pr-46/composer-before-460.png b/.fork/notes/pr-46/composer-before-460.png new file mode 100644 index 000000000000..697bfb1fae57 Binary files /dev/null and b/.fork/notes/pr-46/composer-before-460.png differ diff --git a/.fork/notes/pr-46/composer-before-520.png b/.fork/notes/pr-46/composer-before-520.png new file mode 100644 index 000000000000..f2f8ad11592e Binary files /dev/null and b/.fork/notes/pr-46/composer-before-520.png differ diff --git a/.fork/notes/pr-46/composer-before-620.png b/.fork/notes/pr-46/composer-before-620.png new file mode 100644 index 000000000000..486a2b48a08c Binary files /dev/null and b/.fork/notes/pr-46/composer-before-620.png differ diff --git a/.fork/notes/pr-46/composer-before-768.png b/.fork/notes/pr-46/composer-before-768.png new file mode 100644 index 000000000000..275328cf545d Binary files /dev/null and b/.fork/notes/pr-46/composer-before-768.png differ diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns index da16d12a0c7c..785e9ba9f552 100644 Binary files a/apps/desktop/resources/icon.icns and b/apps/desktop/resources/icon.icns differ diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico index 8298f70d8b36..f968d6bb13df 100644 Binary files a/apps/desktop/resources/icon.ico and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png index 37f3f756a553..589a73155f60 100644 Binary files a/apps/desktop/resources/icon.png and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 929afeeabe9c..293b94a62e94 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -22,12 +22,9 @@ export const APP_BUNDLE_ID = isDevelopment const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = NodePath.join( - repoRoot, - "assets", - "dev", - "blueprint-macos-1024.png", -); +// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity +const developmentMacIconPngPath = NodePath.join(repoRoot, "assets", "fork", "n3-macos-1024.png"); +// fork:end fork-app-identity // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. const hostPlatform = NodeOS.platform(); diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index 3eed25ea6b78..7ff1fbcbcb1c 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index a3431b8c6dfe..db3f5ed2e99f 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 862f7629971f..943a285ba056 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 750da22602ee..3a8f7938a8f7 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/__fork_guards__/forkAppIdentity.test.ts b/apps/web/src/__fork_guards__/forkAppIdentity.test.ts index 5a9c69970349..45dba5d7e86a 100644 --- a/apps/web/src/__fork_guards__/forkAppIdentity.test.ts +++ b/apps/web/src/__fork_guards__/forkAppIdentity.test.ts @@ -99,13 +99,38 @@ describe("fork guard: fork-app-identity", () => { it("packages with the fork's own artwork", () => { // Upstream art would make a fork build indistinguishable from the real // app in the Dock and /Applications, exactly where the two must be - // tell-apart-able. All three platform icons come from assets/fork. + // tell-apart-able. Desktop icons and bundled splash/favicon come from + // assets/fork; packaging must not fall back to upstream channel art. const script = read(BUILD_SCRIPT); expect(script).toContain('"assets/fork/n3-macos-1024.png"'); expect(script).toContain('"assets/fork/n3-universal-1024.png"'); expect(script).toContain('"assets/fork/n3-windows.ico"'); + expect(script).toContain('"assets/fork/n3-web-favicon.ico"'); + expect(script).toContain("FORK_WEB_ICON_ASSETS"); + expect(script).toContain("applyWebIconOverrides"); + expect(script).toContain("resolveWebIconOverridesFromSources"); expect(script).not.toContain("BRAND_ASSET_PATHS.productionMacIconPng"); expect(script).not.toContain("BRAND_ASSET_PATHS.nightlyMacIconPng"); + expect(script).not.toContain("applyWebBrandAssets("); + expect(script).not.toContain("applyForkWebBrandAssets"); + expect(script).not.toContain("resolveForkWebIconOverrides"); + + const launcher = read("apps/desktop/scripts/electron-launcher.mjs"); + expect(launcher).toContain('"assets", "fork", "n3-macos-1024.png"'); + expect(launcher).not.toContain("blueprint-macos-1024.png"); + + // Dev (`vp run dev`) serves apps/web/public — keep it byte-identical to + // the fork sources so local tabs don't silently fall back to T3 blueprint. + for (const [source, target] of [ + ["assets/fork/n3-web-favicon.ico", "apps/web/public/favicon.ico"], + ["assets/fork/n3-web-favicon-16x16.png", "apps/web/public/favicon-16x16.png"], + ["assets/fork/n3-web-favicon-32x32.png", "apps/web/public/favicon-32x32.png"], + ["assets/fork/n3-web-apple-touch-180.png", "apps/web/public/apple-touch-icon.png"], + ] as const) { + expect(NodeFS.readFileSync(NodePath.join(repoRoot, target))).toEqual( + NodeFS.readFileSync(NodePath.join(repoRoot, source)), + ); + } }); it("keeps packaged state out of the shared ~/.t3 base directory", () => { diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index bfb8bb473f80..d3180518a807 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -7,6 +7,16 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; +import { + COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, + COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, + shouldUseCompactComposerFooter, +} from "../components/composerFooterLayout"; +import { + COMPOSER_MODEL_SLOT_COMPACT_BREAKPOINT_PX, + COMPOSER_MODEL_SLOT_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, + shouldUseCompactComposerModelSlot, +} from "../custom/composerModelSlotCompact"; import { ComposerPromptRow, ComposerShell, @@ -16,6 +26,9 @@ import { import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; import { cssRules } from "./cssRules"; +/** Form ceiling from ChatComposer's `max-w-3xl` — keep compact thresholds below it. */ +const COMPOSER_FORM_MAX_WIDTH_PX = 768; + function readSibling(relativePath: string): string { return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); } @@ -307,6 +320,43 @@ describe("fork guard: fork-composer-shell", () => { } }); + it("keeps mode-row ⋯ collapse below the denser fork control-row widths", () => { + // Upstream is 620/780; those fire with a large empty gap on the fork row. + // Mode-row only — model-picker compaction stays at upstream 620/780. + expect(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX).toBe(400); + expect(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX).toBe(520); + expect(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX).toBeLessThan( + COMPOSER_FORM_MAX_WIDTH_PX, + ); + expect(shouldUseCompactComposerFooter(560)).toBe(false); + expect( + shouldUseCompactComposerFooter(560, { + hasWideActions: true, + }), + ).toBe(false); + // Wide-actions branch: expanded at/above 520, compact below. + expect( + shouldUseCompactComposerFooter(519, { + hasWideActions: true, + }), + ).toBe(true); + expect( + shouldUseCompactComposerFooter(520, { + hasWideActions: true, + }), + ).toBe(false); + expect(shouldUseCompactComposerFooter(399)).toBe(true); + expect(shouldUseCompactComposerFooter(400)).toBe(false); + + // Model trigger stays on upstream widths; ChatComposer must wire both. + expect(COMPOSER_MODEL_SLOT_COMPACT_BREAKPOINT_PX).toBe(620); + expect(COMPOSER_MODEL_SLOT_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX).toBe(780); + expect(shouldUseCompactComposerModelSlot(560)).toBe(true); + expect(shouldUseCompactComposerModelSlot(620)).toBe(false); + expect(chatComposer).toContain("shouldUseCompactComposerModelSlot"); + expect(chatComposer).toContain("compact={isComposerModelSlotCompact}"); + }); + it("keeps primary actions out of ghost sizing", () => { expect(primaryActions).toContain('data-fork-composer-action="send"'); expect(primaryActions).toContain('data-fork-composer-action="stop"'); @@ -342,7 +392,8 @@ describe("fork guard: fork-composer-shell", () => { expect(strip?.body).toMatch(/margin:\s*0/u); expect(strip?.body).toMatch(/gap:\s*8px/u); // Nested PR+branch (and env+checkout) wrappers keep upstream gap-1; the - // fork re-gaps them to 8px so checkout→PR→branch reads evenly. + // fork re-gaps them to 8px so checkout→PR→branch reads evenly, and drops + // flex-1 / justify-end / ml-auto so the cluster stays packed when narrow. const nested = rules.find( (rule) => rule.selector.includes("[data-fork-composer-context-row]") && @@ -350,5 +401,9 @@ describe("fork guard: fork-composer-shell", () => { (rule.selector.includes(">.flex") || rule.selector.includes("> .flex")), ); expect(nested?.body).toMatch(/gap:\s*8px/u); + expect(nested?.body).toMatch(/flex:\s*0 1 auto/u); + expect(nested?.body).toMatch(/justify-content:\s*flex-start/u); + expect(nested?.body).toMatch(/margin-inline-start:\s*0/u); + expect(nested?.body).toMatch(/max-width:\s*100%/u); }); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2d577231e607..2b4d617abdf1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -86,6 +86,7 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ +import { shouldUseCompactComposerModelSlot } from "../../custom/composerModelSlotCompact"; import { ComposerPromptRow, ComposerRuntimeModeTrigger, @@ -983,6 +984,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + // Mode-row ⋯ uses the fork footer override; model trigger keeps upstream widths. + const [isComposerModelSlotCompact, setIsComposerModelSlotCompact] = useState(false); + /* fork:end fork-composer-shell */ const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); @@ -1442,6 +1447,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { hasWideActions: composerFooterHasWideActions, }); + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + const modelSlotCompact = shouldUseCompactComposerModelSlot(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + /* fork:end fork-composer-shell */ const primaryActionsCompact = footerCompact && shouldUseCompactComposerPrimaryActions(composerFormWidth, { @@ -1450,12 +1460,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return { primaryActionsCompact, footerCompact, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + modelSlotCompact, + /* fork:end fork-composer-shell */ }; }; const initialCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + setIsComposerModelSlotCompact(initialCompactness.modelSlotCompact); + /* fork:end fork-composer-shell */ if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { @@ -1468,6 +1484,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFooterCompact((previous) => previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, ); + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + setIsComposerModelSlotCompact((previous) => + previous === nextCompactness.modelSlotCompact ? previous : nextCompactness.modelSlotCompact, + ); + /* fork:end fork-composer-shell */ }); observer.observe(composerForm); @@ -2674,10 +2695,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ // The footer's three clusters, split by where the designs put them. + // Model compact uses upstream widths; traits stay with the mode-row ⋯ gate + // so they are never stranded when modes expand before the model slot does. const composerModelControls = noProviderAvailable ? null : ( <> - {/* At compact widths the traits fold into CompactComposerControlsMenu. */} + {/* At compact mode-row widths the traits fold into CompactComposerControlsMenu. */} {!isComposerFooterCompact && providerTraitsPicker ? providerTraitsPicker : null} ); diff --git a/apps/web/src/custom/composerModelSlotCompact.ts b/apps/web/src/custom/composerModelSlotCompact.ts new file mode 100644 index 000000000000..4b768e901759 --- /dev/null +++ b/apps/web/src/custom/composerModelSlotCompact.ts @@ -0,0 +1,21 @@ +/** + * Model-picker / traits-adjacent compact threshold for the fork composer. + * + * Mode-row ⋯ collapse lives in the `composerFooterLayout` override (400/520). + * The model trigger still uses upstream's 620/780 so expanding modes early + * does not also grow the right-hand model control in the 400–620 band. + * See `.fork/customizations.yaml#fork-composer-shell`. + */ + +export const COMPOSER_MODEL_SLOT_COMPACT_BREAKPOINT_PX = 620; +export const COMPOSER_MODEL_SLOT_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; + +export function shouldUseCompactComposerModelSlot( + width: number | null, + options?: { hasWideActions?: boolean }, +): boolean { + const breakpoint = options?.hasWideActions + ? COMPOSER_MODEL_SLOT_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX + : COMPOSER_MODEL_SLOT_COMPACT_BREAKPOINT_PX; + return width !== null && width < breakpoint; +} diff --git a/apps/web/src/overrides/components/composerFooterLayout.ts b/apps/web/src/overrides/components/composerFooterLayout.ts new file mode 100644 index 000000000000..29e29691a5ad --- /dev/null +++ b/apps/web/src/overrides/components/composerFooterLayout.ts @@ -0,0 +1,36 @@ +// Fork shadow of upstream composerFooterLayout — see +// `.fork/customizations.yaml#fork-composer-shell`. +// +// These constants drive mode-row ⋯ collapse only (ComposerFooterModeControls +// vs CompactComposerControlsMenu). Upstream's 620/780 packed mode + model into +// one in-box footer; the fork's denser below-surface mode chips still fit well +// below those widths. Model-picker compaction stays on upstream's numbers via +// `apps/web/src/custom/composerModelSlotCompact.ts` so one visual complaint +// does not widen the right slot at the same time. Keep the wide-actions +// threshold under the form's max-w-3xl (768px) so the expanded mode row remains +// reachable at the form's natural ceiling. + +export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 400; +export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 520; +export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = + COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; + +export function shouldUseCompactComposerFooter( + width: number | null, + options?: { hasWideActions?: boolean }, +): boolean { + const breakpoint = options?.hasWideActions + ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX + : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; + return width !== null && width < breakpoint; +} + +export function shouldUseCompactComposerPrimaryActions( + width: number | null, + options?: { hasWideActions?: boolean }, +): boolean { + if (!options?.hasWideActions) { + return false; + } + return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; +} diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 244839f59c76..58675e551009 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -592,11 +592,21 @@ /* Upstream nests the PR pill with the branch trigger at gap-1 (4px), while the strip itself is 8px between checkout and that pair — so checkout→PR reads loose and PR→branch reads tight. Same 8px on every direct flex child keeps - the three chips (and env→checkout when both show) evenly spaced. */ + the three chips (and env→checkout when both show) evenly spaced. + + Upstream also gives the checkout group and branch wrapper `flex-1`, and the + branch `justify-end` below `md` (plus `md:ml-auto`). That splits the strip + into halves and parks the branch at the end of its half — a large empty gap + while "Current checkout" truncates. The fork's chips sit in one packed + leading cluster; neutralize the grow/end alignment so gap stays 8px. */ :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-context-row] .chat-composer-context-strip > .flex { + flex: 0 1 auto; + justify-content: flex-start; + margin-inline-start: 0; + max-width: 100%; gap: 8px; } diff --git a/assets/fork/app-icon.svg b/assets/fork/app-icon.svg new file mode 100644 index 000000000000..36bc38d2fdb1 --- /dev/null +++ b/assets/fork/app-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/fork/n3-macos-1024.png b/assets/fork/n3-macos-1024.png index 89b22a6f07c9..48efb37a2940 100644 Binary files a/assets/fork/n3-macos-1024.png and b/assets/fork/n3-macos-1024.png differ diff --git a/assets/fork/n3-universal-1024.png b/assets/fork/n3-universal-1024.png index 86a76ced2588..29c8fd301f2a 100644 Binary files a/assets/fork/n3-universal-1024.png and b/assets/fork/n3-universal-1024.png differ diff --git a/assets/fork/n3-web-apple-touch-180.png b/assets/fork/n3-web-apple-touch-180.png new file mode 100644 index 000000000000..7ff1fbcbcb1c Binary files /dev/null and b/assets/fork/n3-web-apple-touch-180.png differ diff --git a/assets/fork/n3-web-favicon-16x16.png b/assets/fork/n3-web-favicon-16x16.png new file mode 100644 index 000000000000..db3f5ed2e99f Binary files /dev/null and b/assets/fork/n3-web-favicon-16x16.png differ diff --git a/assets/fork/n3-web-favicon-32x32.png b/assets/fork/n3-web-favicon-32x32.png new file mode 100644 index 000000000000..943a285ba056 Binary files /dev/null and b/assets/fork/n3-web-favicon-32x32.png differ diff --git a/assets/fork/n3-web-favicon.ico b/assets/fork/n3-web-favicon.ico new file mode 100644 index 000000000000..3a8f7938a8f7 Binary files /dev/null and b/assets/fork/n3-web-favicon.ico differ diff --git a/assets/fork/n3-windows.ico b/assets/fork/n3-windows.ico index ed0db214a19a..f968d6bb13df 100644 Binary files a/assets/fork/n3-windows.ico and b/assets/fork/n3-windows.ico differ diff --git a/scripts/apply-web-brand-assets.ts b/scripts/apply-web-brand-assets.ts index 30e6bc325b6c..7fe72b2082a5 100644 --- a/scripts/apply-web-brand-assets.ts +++ b/scripts/apply-web-brand-assets.ts @@ -11,6 +11,7 @@ import { resolveWebAssetBrandForChannel, resolveWebIconOverrides, WEB_ASSET_CHANNELS, + type IconOverride, type WebAssetBrand, } from "./lib/brand-assets.ts"; @@ -20,16 +21,16 @@ const WEB_ASSET_BRANDS = [ "production", ] as const satisfies ReadonlyArray; -export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( - brand: WebAssetBrand, - targetDirectory: string, +/** Copy an explicit list of web icon overrides into the repo-relative targets. */ +export const applyWebIconOverrides = Effect.fn("applyWebIconOverrides")(function* ( + overrides: ReadonlyArray, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); yield* Effect.forEach( - resolveWebIconOverrides(brand, targetDirectory), + overrides, (override) => fs.copyFile( path.join(repoRoot, override.sourceRelativePath), @@ -39,6 +40,13 @@ export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( ); }); +export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( + brand: WebAssetBrand, + targetDirectory: string, +) { + yield* applyWebIconOverrides(resolveWebIconOverrides(brand, targetDirectory)); +}); + export const applyWebBrandAssetsCommand = Command.make( "apply-web-brand-assets", { diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d965e34408fa..aab295749c69 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -30,10 +30,10 @@ import { resolveDesktopRuntimeDependencies, resolveFffNativeDependencies, resolveBuildOptions, + FORK_WEB_ICON_ASSETS, resolveDesktopBuildIconAssets, resolveDesktopProductName, resolveDesktopUpdateChannel, - resolveDesktopWebAssetBrand, resolveResourceMonitorRustTargets, resourceMonitorExecutableName, resolveGitHubPublishConfig, @@ -44,6 +44,7 @@ import { STAGE_INSTALL_ARGS, WINDOWS_ASAR_UNPACK, } from "./build-desktop-artifact.ts"; +import { resolveWebIconOverridesFromSources } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; function mockProcess(exitCode: number) { @@ -96,7 +97,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { // fork:end fork-app-identity }); - it("uses the fork's placeholder artwork for every channel", () => { + it("uses the fork's green-grid artwork for every channel", () => { // fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity const forkIconAssets = { macIconPng: "assets/fork/n3-macos-1024.png", @@ -111,9 +112,38 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { // fork:end fork-app-identity }); - it("switches the bundled splash and favicon branding for nightly versions", () => { - assert.equal(resolveDesktopWebAssetBrand("0.0.17"), "production"); - assert.equal(resolveDesktopWebAssetBrand("0.0.17-nightly.20260413.42"), "nightly"); + it("bundles the fork splash and favicon instead of upstream channel art", () => { + // fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity + // Packaging always copies FORK_WEB_ICON_ASSETS through the shared brand + // override builder — no per-channel web brand selection. + assert.deepStrictEqual(FORK_WEB_ICON_ASSETS, { + faviconIco: "assets/fork/n3-web-favicon.ico", + favicon16Png: "assets/fork/n3-web-favicon-16x16.png", + favicon32Png: "assets/fork/n3-web-favicon-32x32.png", + appleTouchIconPng: "assets/fork/n3-web-apple-touch-180.png", + }); + assert.deepStrictEqual( + resolveWebIconOverridesFromSources(FORK_WEB_ICON_ASSETS, "apps/server/dist/client"), + [ + { + sourceRelativePath: "assets/fork/n3-web-favicon.ico", + targetRelativePath: "apps/server/dist/client/favicon.ico", + }, + { + sourceRelativePath: "assets/fork/n3-web-favicon-16x16.png", + targetRelativePath: "apps/server/dist/client/favicon-16x16.png", + }, + { + sourceRelativePath: "assets/fork/n3-web-favicon-32x32.png", + targetRelativePath: "apps/server/dist/client/favicon-32x32.png", + }, + { + sourceRelativePath: "assets/fork/n3-web-apple-touch-180.png", + targetRelativePath: "apps/server/dist/client/apple-touch-icon.png", + }, + ], + ); + // fork:end fork-app-identity }); it.effect("resolves GitHub desktop publish config from Effect config", () => diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index ff328ce772b0..275e4b686a3f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -10,12 +10,13 @@ import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; -import { applyWebBrandAssets } from "./apply-web-brand-assets.ts"; // fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity -// BRAND_ASSET_PATHS is no longer imported: the fork's desktop icons come from -// FORK_DESKTOP_ICON_ASSETS below instead of upstream's per-channel art. +// Packaged splash/favicon use FORK_WEB_ICON_ASSETS through the shared +// applyWebIconOverrides / resolveWebIconOverridesFromSources path — no twin +// copy pipeline in this file. +import { applyWebIconOverrides } from "./apply-web-brand-assets.ts"; +import { resolveWebIconOverridesFromSources } from "./lib/brand-assets.ts"; // fork:end fork-app-identity -import { resolveWebAssetBrandForChannel, type WebAssetBrand } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -39,13 +40,19 @@ const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; // Distinct bundle id so macOS treats a fork build as a different application // from an installed upstream release rather than a replacement for it. const DESKTOP_APP_ID = "com.t3tools.t3code.fork"; -// Fork-owned placeholder art (an "N" lettermark) generated into assets/fork, -// consumed by resolveDesktopBuildIconAssets below. +// Fork-owned green grid mark from Figma (t3-fork 157:4036 for web favicon; +// desktop PNGs/ICO share the same mark family), kept in assets/fork. const FORK_DESKTOP_ICON_ASSETS = { macIconPng: "assets/fork/n3-macos-1024.png", linuxIconPng: "assets/fork/n3-universal-1024.png", windowsIconIco: "assets/fork/n3-windows.ico", } as const; +export const FORK_WEB_ICON_ASSETS = { + faviconIco: "assets/fork/n3-web-favicon.ico", + favicon16Png: "assets/fork/n3-web-favicon-16x16.png", + favicon32Png: "assets/fork/n3-web-favicon-32x32.png", + appleTouchIconPng: "assets/fork/n3-web-apple-touch-180.png", +} as const; // fork:end fork-app-identity const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; @@ -1489,16 +1496,13 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } -export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { - return resolveWebAssetBrandForChannel(resolveDesktopUpdateChannel(version)); -} - // fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity -// Placeholder fork art: an "N" lettermark on every channel for now, so a fork -// build is visually distinct from upstream in the Dock and /Applications. -// Upstream's per-channel art stays untouched in assets/prod and assets/nightly -// for clean merges; the version argument keeps upstream's call shape and goes -// unused until the fork ships channel-specific art. +// Fork green-grid art on every channel so a fork build is visually distinct +// from upstream in the Dock and /Applications. Upstream's per-channel art +// stays untouched in assets/prod and assets/nightly for clean merges; the +// version argument keeps upstream's call shape and goes unused until the fork +// ships channel-specific art. resolveDesktopWebAssetBrand was deleted — it +// had no production caller after packaging switched to FORK_WEB_ICON_ASSETS. export function resolveDesktopBuildIconAssets(_version: string): DesktopBuildIconAssets { return FORK_DESKTOP_ICON_ASSETS; } @@ -1849,9 +1853,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } - const webAssetBrand = resolveDesktopWebAssetBrand(appVersion); - yield* applyWebBrandAssets(webAssetBrand, "apps/server/dist/client"); - yield* Effect.log(`[desktop-artifact] Applied ${webAssetBrand} web client branding.`); + // fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity + // Splash + favicon ship the fork mark via the shared brand-assets copy path. + yield* applyWebIconOverrides( + resolveWebIconOverridesFromSources(FORK_WEB_ICON_ASSETS, "apps/server/dist/client"), + ); + yield* Effect.log("[desktop-artifact] Applied fork web client branding."); + // fork:end fork-app-identity yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index 8265ad681802..35e230460cc2 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -7,6 +7,7 @@ import { resolveWebAssetBrandForChannel, resolveWebAssetBrandForPackageVersion, resolveWebIconOverrides, + resolveWebIconOverridesFromSources, } from "./brand-assets.ts"; describe("brand-assets", () => { @@ -31,6 +32,37 @@ describe("brand-assets", () => { ]); }); + it("builds the same override list from an explicit source map", () => { + expect( + resolveWebIconOverridesFromSources( + { + faviconIco: "assets/fork/n3-web-favicon.ico", + favicon16Png: "assets/fork/n3-web-favicon-16x16.png", + favicon32Png: "assets/fork/n3-web-favicon-32x32.png", + appleTouchIconPng: "assets/fork/n3-web-apple-touch-180.png", + }, + "apps/server/dist/client", + ), + ).toEqual([ + { + sourceRelativePath: "assets/fork/n3-web-favicon.ico", + targetRelativePath: "apps/server/dist/client/favicon.ico", + }, + { + sourceRelativePath: "assets/fork/n3-web-favicon-16x16.png", + targetRelativePath: "apps/server/dist/client/favicon-16x16.png", + }, + { + sourceRelativePath: "assets/fork/n3-web-favicon-32x32.png", + targetRelativePath: "apps/server/dist/client/favicon-32x32.png", + }, + { + sourceRelativePath: "assets/fork/n3-web-apple-touch-180.png", + targetRelativePath: "apps/server/dist/client/apple-touch-icon.png", + }, + ]); + }); + it("maps server build web assets to development icons", () => { expect(DEVELOPMENT_ICON_OVERRIDES[0]).toEqual({ sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco, diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 2dcc6ccd6ce9..7de52dfc5a0e 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -50,13 +50,17 @@ export interface IconOverride { readonly targetRelativePath: string; } -const WEB_ICON_TARGET_FILENAMES = { +export const WEB_ICON_TARGET_FILENAMES = { faviconIco: "favicon.ico", favicon16Png: "favicon-16x16.png", favicon32Png: "favicon-32x32.png", appleTouchIconPng: "apple-touch-icon.png", } as const; +export type WebIconSourcePaths = { + readonly [K in keyof typeof WEB_ICON_TARGET_FILENAMES]: string; +}; + const WEB_ICON_SOURCE_PATHS_BY_BRAND = { development: { faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, @@ -76,13 +80,13 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = { favicon32Png: BRAND_ASSET_PATHS.productionWebFavicon32Png, appleTouchIconPng: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, }, -} as const satisfies Record>; +} as const satisfies Record; -export function resolveWebIconOverrides( - brand: WebAssetBrand, +/** Build favicon/splash copy pairs from an arbitrary source map into `targetDirectory`. */ +export function resolveWebIconOverridesFromSources( + sourcePaths: WebIconSourcePaths, targetDirectory: string, ): ReadonlyArray { - const sourcePaths = WEB_ICON_SOURCE_PATHS_BY_BRAND[brand]; return [ { sourceRelativePath: sourcePaths.faviconIco, @@ -103,6 +107,13 @@ export function resolveWebIconOverrides( ]; } +export function resolveWebIconOverrides( + brand: WebAssetBrand, + targetDirectory: string, +): ReadonlyArray { + return resolveWebIconOverridesFromSources(WEB_ICON_SOURCE_PATHS_BY_BRAND[brand], targetDirectory); +} + export const DEVELOPMENT_ICON_OVERRIDES = resolveWebIconOverrides("development", "dist/client"); export const DEVELOPMENT_PUBLIC_ICON_OVERRIDES = resolveWebIconOverrides(