From 395fd98b89d6a3d0129ba78a632629f102775d59 Mon Sep 17 00:00:00 2001 From: msegec Date: Sun, 23 Aug 2026 15:29:42 +0800 Subject: [PATCH 1/2] feat(web): add project sidebar accents Project icons are easy to miss in a busy sidebar. Checked-in accent colors keep each project's thread rows identifiable across idle, hover, and selected states. --- apps/server/src/assets/AssetAccess.test.ts | 3 ++ apps/server/src/assets/AssetAccess.ts | 14 +++++- .../project/ProjectFaviconResolver.test.ts | 45 +++++++++++++++++++ .../src/project/ProjectFaviconResolver.ts | 37 ++++++++++----- apps/web/src/assets/assetUrls.ts | 12 ++++- .../src/components/ProjectFavicon.test.tsx | 23 +++++++++- apps/web/src/components/ProjectFavicon.tsx | 27 +++++++++-- apps/web/src/components/Sidebar.tsx | 26 +++++++++-- apps/web/src/index.css | 21 +++++++++ apps/web/src/projectAccent.test.ts | 29 ++++++++++++ apps/web/src/projectAccent.ts | 22 +++++++++ docs/README.md | 2 +- docs/user/project-settings.md | 30 ++++++++++++- packages/contracts/src/assets.ts | 2 + packages/contracts/src/t3ProjectFile.test.ts | 27 ++++++++++- packages/contracts/src/t3ProjectFile.ts | 27 +++++++++++ packages/shared/src/t3ProjectFile.test.ts | 4 ++ t3.json | 1 + 18 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/projectAccent.test.ts create mode 100644 apps/web/src/projectAccent.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index aa47a78238bb..223a779e1645 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -220,12 +220,14 @@ describe("AssetAccess", () => { const updatedFavicon = "b"; expect(updatedFavicon).toHaveLength(initialFavicon.length); yield* fileSystem.writeFileString(faviconPath, initialFavicon); + yield* fileSystem.writeFileString(path.join(root, "t3.json"), '{ "accentColor": "#1688f0" }'); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); expect(faviconResult.sourcePath).toBe("favicon.svg"); + expect(faviconResult.projectAccent).toBe("#1688f0"); expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); expect( yield* issueAssetUrl({ @@ -427,6 +429,7 @@ describe("AssetAccess", () => { cause: platformCause, }); const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolveAccent: () => Effect.succeed(null), resolvePath: () => Effect.fail(resolutionCause), }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 232a41e5a9c8..5a34ce0c423e 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,4 +1,4 @@ -import type { AssetResource } from "@t3tools/contracts"; +import type { AssetResource, ProjectAccent } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, @@ -195,6 +195,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let claims: AssetClaims; let fileName: string; let sourcePath: string | undefined; + let projectAccent: ProjectAccent | undefined; switch (input.resource._tag) { case "workspace-file": { @@ -306,6 +307,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + projectAccent = yield* faviconResolver.resolveAccent(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + Effect.map((color) => color ?? undefined), + ); const faviconPath = yield* faviconResolver .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) .pipe( @@ -424,6 +435,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), + ...(projectAccent !== undefined ? { projectAccent } : {}), }; }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c610781ea9be..552a1acff5c5 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -48,6 +48,51 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => ); it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { + describe("resolveAccent", () => { + it.effect("reads the validated t3.json accent color", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "#1688f0" }'); + + const accentColor = yield* resolver.resolveAccent(cwd); + expect(accentColor).toBe("#1688f0"); + }), + ); + + it.effect("reads exact accent colors for every row state", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "t3.json", + '{ "accentColor": { "idle": "#071525", "hover": "#102b46", "selected": "#173b60" } }', + ); + + const accentColor = yield* resolver.resolveAccent(cwd); + expect(accentColor).toEqual({ + idle: "#071525", + hover: "#102b46", + selected: "#173b60", + }); + }), + ); + + it.effect("returns null when the project file is missing or invalid", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + const missingAccentColor = yield* resolver.resolveAccent(cwd); + expect(missingAccentColor).toBeNull(); + yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "blue" }'); + const invalidAccentColor = yield* resolver.resolveAccent(cwd); + expect(invalidAccentColor).toBeNull(); + }), + ); + }); + describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => Effect.gen(function* () { diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 9d9a5bddc791..9182dcc9bd87 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,6 +6,7 @@ * * @module ProjectFaviconResolver */ +import type { ProjectAccent } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -96,6 +97,9 @@ export class ProjectFaviconResolver extends Context.Service< cwd: string, faviconPath?: string, ) => Effect.Effect; + readonly resolveAccent: ( + cwd: string, + ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -129,6 +133,18 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const normalizeWorkspaceRoot = (cwd: string) => + workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); + const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); return [path.join("public", clean), clean]; @@ -181,16 +197,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd, faviconPath) { - const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "normalize-workspace", - workspaceRoot: cwd, - cause, - }), - ), - ); + const projectCwd = yield* normalizeWorkspaceRoot(cwd); // A grouped project's saved path can be absent from one checkout. Use it // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { @@ -267,7 +274,15 @@ export const make = Effect.gen(function* () { return null; }); - return ProjectFaviconResolver.of({ resolvePath }); + const resolveAccent: ProjectFaviconResolver["Service"]["resolveAccent"] = Effect.fn( + "ProjectFaviconResolver.resolveAccent", + )(function* (cwd) { + const projectCwd = yield* normalizeWorkspaceRoot(cwd); + const projectFile = yield* projectFileLoader.load(projectCwd); + return Option.isSome(projectFile) ? (projectFile.value.accentColor ?? null) : null; + }); + + return ProjectFaviconResolver.of({ resolveAccent, resolvePath }); }); export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index f8c0b5ae75f7..54e6484fa910 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, ProjectAccent } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useMemo } from "react"; @@ -12,7 +12,12 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; + | { + readonly _tag: "Success"; + readonly url: string; + readonly sourcePath?: string; + readonly projectAccent?: ProjectAccent; + }; export function useAssetUrlState( environmentId: EnvironmentId, @@ -38,6 +43,9 @@ export function useAssetUrlState( _tag: "Success", url, ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + ...(result.value.projectAccent !== undefined + ? { projectAccent: result.value.projectAccent } + : {}), }; } diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bbeeda4bc7fb..ad3d8cfa46e8 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -59,7 +59,7 @@ vi.mock("../assets/assetUrls", () => ({ }, })); -import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFavicon, projectAccentFromAsset } from "./ProjectFavicon"; type ProjectFaviconImageProps = { readonly cacheKey: string; @@ -143,4 +143,25 @@ describe("ProjectFavicon", () => { path: "brand/icon.svg", }); }); + + it("reads simple and advanced accents from the shared project asset", () => { + expect( + projectAccentFromAsset({ + _tag: "Success", + url: testState.faviconUrl, + projectAccent: "#1688f0", + }), + ).toBe("#1688f0"); + expect( + projectAccentFromAsset({ + _tag: "Success", + url: testState.faviconUrl, + projectAccent: { + idle: "#071525", + hover: "#102b46", + selected: "#173b60", + }, + }), + ).toEqual({ idle: "#071525", hover: "#102b46", selected: "#173b60" }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..6217864d7ca9 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectAccent } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, @@ -11,14 +11,29 @@ import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); -export function ProjectFavicon(input: { +interface ProjectFaviconInput { environmentId: EnvironmentId; cwd: string; faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; -}) { +} + +export function ProjectFavicon(input: ProjectFaviconInput) { const state = useProjectFaviconAsset(input); + return renderProjectFavicon(input, state); +} + +export function ProjectFaviconFromAsset( + input: ProjectFaviconInput & { readonly state: ReturnType }, +) { + return renderProjectFavicon(input, input.state); +} + +function renderProjectFavicon( + input: ProjectFaviconInput, + state: ReturnType, +) { const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; @@ -51,6 +66,12 @@ export function useProjectFaviconAsset(input: { }); } +export function projectAccentFromAsset( + state: ReturnType, +): ProjectAccent | null { + return state._tag === "Success" ? (state.projectAccent ?? null) : null; +} + function ProjectFaviconFallback({ className, icon: Icon, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7a80559d390d..a5d98d620283 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -163,7 +163,12 @@ import { snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; -import { ProjectFavicon } from "./ProjectFavicon"; +import { + ProjectFavicon, + ProjectFaviconFromAsset, + projectAccentFromAsset, + useProjectFaviconAsset, +} from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { @@ -172,6 +177,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { projectAccentRowStyle } from "../projectAccent"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -785,6 +791,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const projectFaviconAsset = useProjectFaviconAsset({ + environmentId: thread.environmentId, + cwd: props.projectCwd ?? "", + faviconPath: props.projectFaviconPath, + }); + const projectAccent = projectAccentFromAsset(projectFaviconAsset); const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -1228,6 +1240,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { role="button" tabIndex={0} data-testid="sidebar-row-slim" + data-project-accent={projectAccent === null ? undefined : "true"} + data-project-accent-state={props.isActive || isSelected ? "selected" : "idle"} + style={projectAccentRowStyle(projectAccent)} aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} onClick={handleClick} @@ -1246,12 +1261,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0", )} > - {title} @@ -1389,6 +1405,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { role="button" tabIndex={0} data-testid="sidebar-row-card" + data-project-accent={projectAccent === null ? undefined : "true"} + data-project-accent-state={props.isActive || isSelected ? "selected" : "idle"} + style={projectAccentRowStyle(projectAccent)} aria-busy={isRegeneratingTitle || undefined} className={rowSurfaceClassName} onClick={handleClick} @@ -1400,11 +1419,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { >
- {props.projectTitle ? ( { + it("sets one source color for generated state tints", () => { + expect(projectAccentRowStyle("#1688f0")).toEqual({ + "--project-accent-color": "#1688f0", + }); + }); + + it("sets exact colors for every advanced state", () => { + expect( + projectAccentRowStyle({ + idle: "#071525", + hover: "#102b46", + selected: "#173b60", + }), + ).toEqual({ + "--project-accent-idle": "#071525", + "--project-accent-hover": "#102b46", + "--project-accent-selected": "#173b60", + }); + }); + + it("does not add accent styles without project configuration", () => { + expect(projectAccentRowStyle(null)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/projectAccent.ts b/apps/web/src/projectAccent.ts new file mode 100644 index 000000000000..63f89f5ba12d --- /dev/null +++ b/apps/web/src/projectAccent.ts @@ -0,0 +1,22 @@ +import type { ProjectAccent } from "@t3tools/contracts"; +import type { CSSProperties } from "react"; + +export interface ProjectAccentRowStyle extends CSSProperties { + "--project-accent-color"?: string; + "--project-accent-idle"?: string; + "--project-accent-hover"?: string; + "--project-accent-selected"?: string; +} + +export function projectAccentRowStyle( + accent: ProjectAccent | null, +): ProjectAccentRowStyle | undefined { + if (accent === null) return undefined; + return typeof accent === "string" + ? { "--project-accent-color": accent } + : { + "--project-accent-idle": accent.idle, + "--project-accent-hover": accent.hover, + "--project-accent-selected": accent.selected, + }; +} diff --git a/docs/README.md b/docs/README.md index 622d81064387..8881bff54c90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) -- [Customize a project icon](./user/project-settings.md) +- [Customize project appearance](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 56675408fab8..457265b3aaa5 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,4 +1,6 @@ -# Customize a project icon +# Customize project appearance + +## Project icon T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files. @@ -14,3 +16,29 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa each checkout in the project group and appears on your connected clients. To use automatic detection again, select **Automatic**. + +## Sidebar accent + +Add `accentColor` to `t3.json` to tint every sidebar thread row for the project. A single color +generates restrained idle, hover, and selected tints: + +```json +{ + "accentColor": "#1688f0" +} +``` + +For exact control, set all three row colors: + +```json +{ + "accentColor": { + "idle": "#071525", + "hover": "#102b46", + "selected": "#173b60" + } +} +``` + +Colors must use six-digit hex notation. Exact colors replace the generated tints, so choose values +that remain readable with your light and dark sidebar themes. diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bfc2c9472aaa..137ad0f7b896 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -6,6 +6,7 @@ import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, ProjectFaviconPath, } from "./orchestration.ts"; +import { ProjectAccent } from "./t3ProjectFile.ts"; const ASSET_PATH_MAX_LENGTH = 1024; @@ -37,6 +38,7 @@ export const AssetCreateUrlResult = Schema.Struct({ sourcePath: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), ), + projectAccent: Schema.optional(ProjectAccent), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index ed19c6d69887..c4da0cd440af 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -10,6 +10,7 @@ describe("T3ProjectFile", () => { const decoded = decode({ $schema: "https://t3.codes/schema/t3.json", iconPath: "assets/logo.svg", + accentColor: "#1688f0", scripts: [ { name: "Dev", @@ -24,6 +25,7 @@ describe("T3ProjectFile", () => { }); expect(decoded.iconPath).toBe("assets/logo.svg"); + expect(decoded.accentColor).toBe("#1688f0"); expect(decoded.scripts).toHaveLength(2); expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" }); }); @@ -33,16 +35,39 @@ describe("T3ProjectFile", () => { expect(decode({ futureField: true })).toEqual({}); }); - it("trims icon paths and script fields", () => { + it("trims icon paths, accent colors, and script fields", () => { const decoded = decode({ iconPath: " assets/logo.svg ", + accentColor: " #1688f0 ", scripts: [{ name: " Dev ", command: " pnpm dev " }], }); expect(decoded.iconPath).toBe("assets/logo.svg"); + expect(decoded.accentColor).toBe("#1688f0"); expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" }); }); + it("rejects invalid accent colors", () => { + expect(() => decode({ accentColor: "blue" })).toThrow(); + expect(() => decode({ accentColor: "#1688f0cc" })).toThrow(); + }); + + it("accepts exact idle, hover, and selected accent colors", () => { + expect( + decode({ + accentColor: { + idle: "#071525", + hover: "#102b46", + selected: "#173b60", + }, + }).accentColor, + ).toEqual({ idle: "#071525", hover: "#102b46", selected: "#173b60" }); + }); + + it("requires every exact accent state", () => { + expect(() => decode({ accentColor: { idle: "#071525", hover: "#102b46" } })).toThrow(); + }); + it("rejects scripts without a command", () => { expect(() => decode({ scripts: [{ name: "Dev" }] })).toThrow(); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5062a1a370b5..6bd6f9f9ccdf 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -12,6 +12,8 @@ export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json"; const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512; const T3_PROJECT_FILE_MAX_SCRIPTS = 50; +const PROJECT_ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const PROJECT_ACCENT_COLOR_INPUT_PATTERN = /^\s*#[0-9a-fA-F]{6}\s*$/; // Annotations go on the encoded (string) side so they survive into the // published JSON Schema; decoding still trims and re-validates non-emptiness. @@ -59,6 +61,30 @@ export const T3ProjectFileScript = Schema.Struct({ }); export type T3ProjectFileScript = typeof T3ProjectFileScript.Type; +const ProjectAccentColorInput = Schema.String.annotate({ + description: 'Six-digit hex color (e.g. "#1688f0").', +}).check(Schema.isNonEmpty(), Schema.isPattern(PROJECT_ACCENT_COLOR_INPUT_PATTERN)); +export const ProjectAccentColor = ProjectAccentColorInput.pipe( + Schema.decodeTo( + Schema.String.check(Schema.isPattern(PROJECT_ACCENT_COLOR_PATTERN)), + SchemaTransformation.trim(), + ), +); +export type ProjectAccentColor = typeof ProjectAccentColor.Type; + +export const ProjectAccentPalette = Schema.Struct({ + idle: ProjectAccentColor, + hover: ProjectAccentColor, + selected: ProjectAccentColor, +}); +export type ProjectAccentPalette = typeof ProjectAccentPalette.Type; + +export const ProjectAccent = Schema.Union([ProjectAccentColor, ProjectAccentPalette]).annotate({ + description: + "Project sidebar thread-row accent. Set one hex color for generated state tints, or set idle, hover, and selected colors for exact control.", +}); +export type ProjectAccent = typeof ProjectAccent.Type; + export const T3ProjectFile = Schema.Struct({ $schema: Schema.optionalKey( Schema.String.annotate({ @@ -74,6 +100,7 @@ export const T3ProjectFile = Schema.Struct({ T3_PROJECT_FILE_PATH_MAX_LENGTH, ), ), + accentColor: Schema.optionalKey(ProjectAccent), defaultThreadEnvMode: Schema.optionalKey( ThreadEnvMode.annotate({ description: diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index a1986ff35f9b..51f42965ce44 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -25,6 +25,7 @@ describe("buildT3ProjectFileJsonSchema", () => { string, { description?: string; + anyOf?: ReadonlyArray; items?: { properties: Record; required: ReadonlyArray }; } >; @@ -33,12 +34,15 @@ describe("buildT3ProjectFileJsonSchema", () => { expect(Object.keys(schema.properties).sort()).toEqual([ "$schema", + "accentColor", "defaultThreadEnvMode", "iconPath", "scripts", ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); + expect(schema.properties.accentColor?.description).toContain("sidebar thread-row accent"); + expect(JSON.stringify(schema.properties.accentColor?.anyOf?.[0])).toContain("#[0-9a-fA-F]{6}"); expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start"); const script = schema.properties.scripts?.items; diff --git a/t3.json b/t3.json index 007e8f961948..420d98dbf43f 100644 --- a/t3.json +++ b/t3.json @@ -1,6 +1,7 @@ { "$schema": "https://t3.codes/schema/t3.json", "iconPath": "assets/dev/blueprint-web-apple-touch-180.png", + "accentColor": "#1688f0", "scripts": [ { "name": "Setup Worktree", From f2082f3025d2461c1b1239c34a8a152c1bee9a66 Mon Sep 17 00:00:00 2001 From: msegec Date: Sun, 23 Aug 2026 16:09:34 +0800 Subject: [PATCH 2/2] fix(web): keep project accents working in the mobile sheet and multi-select The accent rules required a [data-app-sidebar] ancestor that the mobile sheet sidebar never renders, the bare :hover left sticky tints on touch pointers, and routed-active plus multi-selected rows collapsed into one surface. Drop the ancestor scope, gate :hover behind (hover: hover), and emit distinct active and selected states mixed over their own row tokens. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 14 +++++++++++--- apps/web/src/index.css | 25 ++++++++++++++++++++++--- apps/web/src/projectAccent.test.ts | 14 +++++++++++++- apps/web/src/projectAccent.ts | 9 +++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a5d98d620283..b4e4943f4b1a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,7 +177,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { projectAccentRowStyle } from "../projectAccent"; +import { projectAccentRowState, projectAccentRowStyle } from "../projectAccent"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -1241,7 +1241,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { tabIndex={0} data-testid="sidebar-row-slim" data-project-accent={projectAccent === null ? undefined : "true"} - data-project-accent-state={props.isActive || isSelected ? "selected" : "idle"} + data-project-accent-state={projectAccentRowState( + projectAccent, + props.isActive, + isSelected, + )} style={projectAccentRowStyle(projectAccent)} aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} @@ -1406,7 +1410,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { tabIndex={0} data-testid="sidebar-row-card" data-project-accent={projectAccent === null ? undefined : "true"} - data-project-accent-state={props.isActive || isSelected ? "selected" : "idle"} + data-project-accent-state={projectAccentRowState( + projectAccent, + props.isActive, + isSelected, + )} style={projectAccentRowStyle(projectAccent)} aria-busy={isRegeneratingTitle || undefined} className={rowSurfaceClassName} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 519f6fdd84d5..863b27257ad3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1541,21 +1541,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -[data-app-sidebar] [data-project-accent] { +/* No [data-app-sidebar] ancestor scope: the mobile sheet sidebar renders + without that attribute. Keep the state rules after the hover rule so + active and selected surfaces stay stable under the pointer. */ +[data-project-accent] { background-color: var( --project-accent-idle, color-mix(in srgb, var(--project-accent-color) 4%, transparent) ); } -[data-app-sidebar] [data-project-accent]:is(:hover, :focus-visible) { +[data-project-accent]:focus-visible { background-color: var( --project-accent-hover, color-mix(in srgb, var(--project-accent-color) 9%, var(--sidebar-row-hover)) ); } -[data-app-sidebar] [data-project-accent][data-project-accent-state="selected"] { +@media (hover: hover) { + [data-project-accent]:hover { + background-color: var( + --project-accent-hover, + color-mix(in srgb, var(--project-accent-color) 9%, var(--sidebar-row-hover)) + ); + } +} + +[data-project-accent][data-project-accent-state="selected"] { + background-color: var( + --project-accent-selected, + color-mix(in srgb, var(--project-accent-color) 13%, var(--sidebar-row-selected)) + ); +} + +[data-project-accent][data-project-accent-state="active"] { background-color: var( --project-accent-selected, color-mix(in srgb, var(--project-accent-color) 13%, var(--sidebar-row-active)) diff --git a/apps/web/src/projectAccent.test.ts b/apps/web/src/projectAccent.test.ts index 3d7247198778..2254df4a6885 100644 --- a/apps/web/src/projectAccent.test.ts +++ b/apps/web/src/projectAccent.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vite-plus/test"; -import { projectAccentRowStyle } from "./projectAccent"; +import { projectAccentRowState, projectAccentRowStyle } from "./projectAccent"; + +describe("projectAccentRowState", () => { + it("keeps the routed thread and multi-selection as distinct states", () => { + expect(projectAccentRowState("#1688f0", true, true)).toBe("active"); + expect(projectAccentRowState("#1688f0", false, true)).toBe("selected"); + expect(projectAccentRowState("#1688f0", false, false)).toBe("idle"); + }); + + it("does not add a state attribute without project configuration", () => { + expect(projectAccentRowState(null, true, false)).toBeUndefined(); + }); +}); describe("projectAccentRowStyle", () => { it("sets one source color for generated state tints", () => { diff --git a/apps/web/src/projectAccent.ts b/apps/web/src/projectAccent.ts index 63f89f5ba12d..c8614b527114 100644 --- a/apps/web/src/projectAccent.ts +++ b/apps/web/src/projectAccent.ts @@ -8,6 +8,15 @@ export interface ProjectAccentRowStyle extends CSSProperties { "--project-accent-selected"?: string; } +export function projectAccentRowState( + accent: ProjectAccent | null, + isActive: boolean, + isSelected: boolean, +): "active" | "selected" | "idle" | undefined { + if (accent === null) return undefined; + return isActive ? "active" : isSelected ? "selected" : "idle"; +} + export function projectAccentRowStyle( accent: ProjectAccent | null, ): ProjectAccentRowStyle | undefined {