diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index aa47a78238bb..265c70e499fe 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -427,6 +427,7 @@ describe("AssetAccess", () => { cause: platformCause, }); const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolveAccent: () => Effect.succeed(null), resolvePath: () => Effect.fail(resolutionCause), }); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..fd93b0b6a5e7 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -31,6 +31,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import { @@ -117,7 +118,9 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( - Layer.provide(orchestrationHttpApiLayer), + Layer.provide( + orchestrationHttpApiLayer.pipe(Layer.provide(ProjectFaviconResolver.layerLive)), + ), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { @@ -138,6 +141,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ProjectFaviconResolver.layerLive.pipe(Layer.provide(NodeServices.layer))), Layer.provide(ServerConfig.layer(config)), ); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index f7147106c7a9..c3740ae4ee18 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -18,6 +18,7 @@ import { } from "../auth/http.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ProjectAccents from "../project/ProjectAccents.ts"; export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, @@ -51,13 +52,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* projectionSnapshotQuery + const snapshot = yield* projectionSnapshotQuery .getShellSnapshot() .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), ), ); + // Clients prefer this snapshot over the WS one, so it has to carry + // accents too or the sidebar repaints a round trip after it loads. + const projects = yield* ProjectAccents.withProjectAccents(snapshot.projects); + return { ...snapshot, projects }; }), ) .handle( diff --git a/apps/server/src/project/ProjectAccents.test.ts b/apps/server/src/project/ProjectAccents.test.ts new file mode 100644 index 000000000000..d34f642a711e --- /dev/null +++ b/apps/server/src/project/ProjectAccents.test.ts @@ -0,0 +1,90 @@ +import type { ProjectAccent } from "@t3tools/contracts"; +import { it, describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import * as ProjectAccents from "./ProjectAccents.ts"; +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; + +const resolverReturning = ( + accentByRoot: Readonly>, +): ProjectFaviconResolver.ProjectFaviconResolver["Service"] => + ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.succeed(null), + resolveAccent: (cwd) => Effect.succeed(accentByRoot[cwd] ?? null), + }); + +const failingResolver: ProjectFaviconResolver.ProjectFaviconResolver["Service"] = + ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.succeed(null), + resolveAccent: (cwd) => + Effect.fail( + new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause: new Error("unreadable checkout"), + }), + ), + }); + +describe("ProjectAccents", () => { + it.effect("attaches each project's accent to the payload that creates its rows", () => + Effect.gen(function* () { + const projects = [ + { id: "a", workspaceRoot: "/repos/one" }, + { id: "b", workspaceRoot: "/repos/two" }, + { id: "c", workspaceRoot: "/repos/three" }, + ]; + + const decorated = yield* ProjectAccents.withProjectAccents(projects).pipe( + Effect.provideService( + ProjectFaviconResolver.ProjectFaviconResolver, + resolverReturning({ + "/repos/one": "#1688f0", + "/repos/two": { idle: "#071525", active: "#245181", selected: "#173b60" }, + }), + ), + ); + + expect(decorated).toEqual([ + { id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" }, + { + id: "b", + workspaceRoot: "/repos/two", + accent: { idle: "#071525", active: "#245181", selected: "#173b60" }, + }, + // Written as an explicit null, never omitted: a cleared accentColor in + // t3.json must reach the client as null on the next snapshot or + // project event rather than leave the old value. + { id: "c", workspaceRoot: "/repos/three", accent: null }, + ]); + }), + ); + + it.effect("keeps an unreadable checkout in the snapshot without an accent", () => + Effect.gen(function* () { + const decorated = yield* ProjectAccents.withProjectAccents([ + { id: "a", workspaceRoot: "/repos/gone" }, + ]).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, failingResolver), + ); + + expect(decorated).toEqual([{ id: "a", workspaceRoot: "/repos/gone", accent: null }]); + }), + ); + + it.effect("decorates a single project the same way", () => + Effect.gen(function* () { + const decorated = yield* ProjectAccents.withProjectAccent({ + id: "a", + workspaceRoot: "/repos/one", + }).pipe( + Effect.provideService( + ProjectFaviconResolver.ProjectFaviconResolver, + resolverReturning({ "/repos/one": "#1688f0" }), + ), + ); + + expect(decorated).toEqual({ id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" }); + }), + ); +}); diff --git a/apps/server/src/project/ProjectAccents.ts b/apps/server/src/project/ProjectAccents.ts new file mode 100644 index 000000000000..450486d0f961 --- /dev/null +++ b/apps/server/src/project/ProjectAccents.ts @@ -0,0 +1,54 @@ +/** + * ProjectAccents - decorates client-facing project payloads with the accent + * declared in each checkout's `t3.json`. + * + * The accent is read from disk each time a snapshot or project-upserted + * payload is assembled, rather than projected: `t3.json` is a checked-in file + * the user edits by hand, and an event-sourced copy would go stale the moment + * someone pulls a branch. There is no file watcher, so an edited `accentColor` + * appears on the next snapshot or project event, not the moment the file is + * saved. The accent rides on the project record instead of on the favicon + * asset URL so sidebar rows know their accent in the same paint that creates + * them. Fetching it separately repaints the whole list a round trip later, + * which reads as a flash. + * + * @module ProjectAccents + */ +import type { ProjectAccent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; + +interface AccentTarget { + readonly workspaceRoot: string; +} + +type WithAccent = T & { readonly accent: ProjectAccent | null }; + +/** + * A project whose checkout cannot be read still belongs in the payload, so an + * unreadable workspace resolves to "no accent" rather than failing the + * snapshot. The accent is always written, never omitted: a cleared + * `accentColor` in `t3.json` must reach the client as null on the next + * snapshot or project event, not leave the previous value in place. + */ +const resolveAccent = Effect.fn("ProjectAccents.resolveAccent")(function* (workspaceRoot: string) { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + return yield* resolver.resolveAccent(workspaceRoot).pipe(Effect.orElseSucceed(() => null)); +}); + +/** Attach `accent` to one project record. */ +export const withProjectAccent = Effect.fn("ProjectAccents.withProjectAccent")(function* < + T extends AccentTarget, +>(project: T) { + const accent = yield* resolveAccent(project.workspaceRoot); + return { ...project, accent } as WithAccent; +}); + +/** + * Attach `accent` to every project in a shell snapshot. + */ +export const withProjectAccents = (projects: ReadonlyArray) => + Effect.forEach(projects, withProjectAccent, { + concurrency: 16, + }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c610781ea9be..789c6d28c8a2 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", "active": "#245181", "selected": "#173b60" } }', + ); + + const accentColor = yield* resolver.resolveAccent(cwd); + expect(accentColor).toEqual({ + idle: "#071525", + active: "#245181", + 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..ede6f44e4d6f 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,24 @@ 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); + +/** + * The resolver with its own dependencies satisfied, for the several places + * that assemble project payloads and only need the service itself. + */ +export const layerLive = layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0a31bf376dae..d5eeb36788c1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,7 +65,6 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; -import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -346,10 +345,7 @@ const WorkspaceLayerLive = Layer.mergeAll( WorkspaceFileSystemLayerLive, ); -const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( - Layer.provide(WorkspacePaths.layer), - Layer.provide(T3ProjectFileLoader.layer), -); +const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layerLive; const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), @@ -466,6 +462,9 @@ export const makeRoutesLayer = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), + // Both transports resolve project accents from t3.json when they assemble a + // shell payload, so the resolver is shared rather than per-route. + Layer.provide(ProjectFaviconResolverLayerLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55b0be07c667..db07b7965543 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,6 +105,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; +import * as ProjectAccents from "./project/ProjectAccents.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -611,9 +612,7 @@ const makeWsRpcLayer = ( }); }; - const toShellStreamEvent = ( - event: OrchestrationEvent, - ): Effect.Effect, never, never> => { + const toShellStreamEvent = (event: OrchestrationEvent) => { switch (event.type) { case "project.created": case "project.meta-updated": @@ -668,32 +667,37 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => Option.none()), ); - const projectUpsertOrRemove = ( - projectId: ProjectId, - sequence: number, - ): Effect.Effect, never, never> => + const projectUpsertOrRemove = (projectId: ProjectId, sequence: number) => retryShellProjectionRead( "project", projectId, projectionSnapshotQuery.getProjectShellById(projectId), ).pipe( - Effect.map( - Option.flatMap((project) => - Option.match(project, { + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeedNone, + onSome: Option.match({ onNone: () => - Option.some({ + Effect.succeedSome({ kind: "project-removed" as const, sequence, projectId, }), + // The upsert carries the accent for the same reason the + // snapshot does: a project that changes must not hand the + // client a record the rows then have to repaint. onSome: (nextProject) => - Option.some({ - kind: "project-upserted" as const, - sequence, - project: nextProject, - }), + ProjectAccents.withProjectAccent(nextProject).pipe( + Effect.map((project) => + Option.some({ + kind: "project-upserted" as const, + sequence, + project, + }), + ), + ), }), - ), + }), ), ); @@ -749,9 +753,7 @@ const makeWsRpcLayer = ( // and drops any `sequence <= snapshotSequence` — never skips a coalesced // item. The refetch runs with bounded concurrency (order-preserving). const SHELL_REFETCH_CONCURRENCY = 8; - const coalesceShellEvents = ( - events: ReadonlyArray, - ): Effect.Effect, never, never> => + const coalesceShellEvents = (events: ReadonlyArray) => Effect.gen(function* () { if (events.length === 0) { return []; @@ -775,9 +777,7 @@ const makeWsRpcLayer = ( // traffic so it can't serialize the shell stream behind per-event DB reads. const SHELL_COALESCE_WINDOW = Duration.millis(50); const SHELL_COALESCE_MAX_CHUNK = 512; - const coalesceShellStream = ( - stream: Stream.Stream, - ): Stream.Stream => + const coalesceShellStream = (stream: Stream.Stream) => stream.pipe( Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), Stream.mapEffect(coalesceShellEvents), @@ -791,9 +791,7 @@ const makeWsRpcLayer = ( // A completion marker is queued alongside raw live events so it cannot // overtake an event still waiting in the coalescing window. Split each // batch at markers and coalesce only the event segments on either side. - const coalesceShellLiveInputs = ( - inputs: ReadonlyArray, - ): Effect.Effect, never, never> => + const coalesceShellLiveInputs = (inputs: ReadonlyArray) => Effect.gen(function* () { const output: Array = []; let pendingEvents: Array = []; @@ -813,9 +811,7 @@ const makeWsRpcLayer = ( return output; }); - const coalesceShellLiveStream = ( - stream: Stream.Stream, - ): Stream.Stream => + const coalesceShellLiveStream = (stream: Stream.Stream) => stream.pipe( Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), Stream.mapEffect(coalesceShellLiveInputs), @@ -1300,6 +1296,13 @@ const makeWsRpcLayer = ( const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( + // Accents ride on the snapshot so sidebar rows never paint once + // without them and once with them. See ProjectAccents. + Effect.flatMap((snapshot) => + ProjectAccents.withProjectAccents(snapshot.projects).pipe( + Effect.map((projects) => ({ ...snapshot, projects })), + ), + ), Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), ), diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..c377aef253f5 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -11,13 +11,15 @@ 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); const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7a80559d390d..104abefe4e50 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,7 +30,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { ProjectAccent, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -172,6 +172,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { projectAccentRowState, projectAccentRowStyle } from "../projectAccent"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -723,6 +724,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { environmentLabel: string | null; projectCwd: string | null; projectFaviconPath: string | null; + projectAccent: ProjectAccent | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; @@ -785,6 +787,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const projectAccent = props.projectAccent; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -1102,12 +1105,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", isInFlight && !props.isActive && !isSelected && - "opacity-70 transition-opacity hover:opacity-100", + "opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100", ); const title = isRenaming ? ( @@ -1140,7 +1143,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : "text-foreground/90", ) : cn( - "truncate group-hover/sidebar-row:text-foreground", + "truncate group-hover/sidebar-row:text-foreground group-focus-visible/sidebar-row:text-foreground", props.isActive || isWoke ? "text-foreground" : isUnread @@ -1223,11 +1226,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { > ; @@ -1614,6 +1634,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onSelect: () => void; }) { const { thread } = props; + const projectAccent = props.projectAccent; // Same details tooltip as the regular rows: a search hit is still a thread, // and the hover card is how you disambiguate identically-titled results. const gitCwd = thread.worktreePath ?? props.projectCwd; @@ -1651,6 +1672,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
  • + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.accent ?? null, + ]), + ), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3601,6 +3648,10 @@ export default function Sidebar() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectAccent={ + projectAccentByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, @@ -3714,6 +3765,10 @@ export default function Sidebar() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectAccent={ + projectAccentByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null + } projectTitle={ projectDisplayNameByKey.get( `${thread.environmentId}:${thread.projectId}`, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..2329865b894c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1541,6 +1541,47 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +/* No [data-app-sidebar] ancestor scope: the mobile sheet sidebar renders + without that attribute. The gradient never reacts to hover: sweeping the + pointer down the list must not pulse each row's tint, so hover feedback + stays the flat row wash the Tailwind row classes already apply and the + gradient only changes with route state. Selected and active rows retain + their flat surface fill beneath a gradient that holds full strength across + the right quarter and fades toward that fill on the left. The mix + percentages run stronger than a full-card wash would need because the fade + halves the perceived tint. */ +[data-project-accent] { + background-image: linear-gradient( + to right, + transparent, + var(--project-accent-idle, color-mix(in srgb, var(--project-accent-color) 9%, transparent)) 75% + ); +} + +[data-project-accent][data-project-accent-state="selected"] { + background-image: linear-gradient( + to right, + transparent, + var( + --project-accent-selected, + color-mix(in srgb, var(--project-accent-color) 22%, var(--sidebar-row-selected)) + ) + 75% + ); +} + +[data-project-accent][data-project-accent-state="active"] { + background-image: linear-gradient( + to right, + transparent, + var( + --project-accent-active, + color-mix(in srgb, var(--project-accent-color) 22%, var(--sidebar-row-active)) + ) + 75% + ); +} + /* Theme files are expressed in app color roles and mapped to the existing semantic surface tokens here. Keep this block after the sidebar-version compatibility overrides so both navigation implementations receive the diff --git a/apps/web/src/projectAccent.test.ts b/apps/web/src/projectAccent.test.ts new file mode 100644 index 000000000000..ffb34f84506a --- /dev/null +++ b/apps/web/src/projectAccent.test.ts @@ -0,0 +1,88 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { projectAccentRowState, projectAccentRowStyle } from "./projectAccent"; + +const readAppStyles = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stylesheetPath = yield* path.fromFileUrl(new URL("./index.css", import.meta.url)); + return yield* fileSystem.readFileString(stylesheetPath); +}); + +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", () => { + expect(projectAccentRowStyle("#1688f0")).toEqual({ + "--project-accent-color": "#1688f0", + }); + }); + + it("sets exact colors for every advanced state", () => { + expect( + projectAccentRowStyle({ + idle: "#071525", + active: "#245181", + selected: "#173b60", + }), + ).toEqual({ + "--project-accent-idle": "#071525", + "--project-accent-active": "#245181", + "--project-accent-selected": "#173b60", + }); + }); + + it("does not add accent styles without project configuration", () => { + expect(projectAccentRowStyle(null)).toBeUndefined(); + }); +}); + +it.layer(NodeServices.layer)("project accent row interaction", (it) => { + it.effect("uses distinct exact colors for active and selected rows", () => + Effect.gen(function* () { + const appStyles = yield* readAppStyles; + + expect(appStyles).toMatch( + /data-project-accent-state="active"[\s\S]*?var\(\s*--project-accent-active/, + ); + expect(appStyles).toMatch( + /data-project-accent-state="selected"[\s\S]*?var\(\s*--project-accent-selected/, + ); + }), + ); + + it.effect("preserves the active and selected row surface fills", () => + Effect.gen(function* () { + const appStyles = yield* readAppStyles; + const stateRules = appStyles.matchAll( + /\[data-project-accent\]\[data-project-accent-state="(?:active|selected)"\]\s*\{([^}]+)\}/g, + ); + + expect([...stateRules].map((match) => match[1])).not.toContainEqual( + expect.stringContaining("background-color: transparent"), + ); + }), + ); + + it.effect("keeps the accent gradient stable while a row is hovered", () => + Effect.gen(function* () { + const appStyles = yield* readAppStyles; + + expect(appStyles).not.toMatch(/\[data-project-accent\][^,{]*:hover/); + }), + ); +}); diff --git a/apps/web/src/projectAccent.ts b/apps/web/src/projectAccent.ts new file mode 100644 index 000000000000..2cfb81e8f17b --- /dev/null +++ b/apps/web/src/projectAccent.ts @@ -0,0 +1,31 @@ +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-active"?: string; + "--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 { + if (accent === null) return undefined; + return typeof accent === "string" + ? { "--project-accent-color": accent } + : { + "--project-accent-idle": accent.idle, + "--project-accent-active": accent.active, + "--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..af2cba951f5a 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,31 @@ 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. The tint is +strongest at the right edge of the row and fades out toward the left. A single color generates +restrained idle, active, and selected tints: + +```json +{ + "accentColor": "#1688f0" +} +``` + +For exact control, set all three row colors: + +```json +{ + "accentColor": { + "idle": "#7ea7d8", + "active": "#3d7ec4", + "selected": "#5c93cd" + } +} +``` + +Colors must use six-digit hex notation. Exact colors replace the generated tints and the same +values apply to both the light and dark themes, so pick mid-strength colors that stay readable +behind text on each. diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index af4fefaccf59..f5829cb3105d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -243,6 +243,38 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; +const PROJECT_ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const PROJECT_ACCENT_COLOR_INPUT_PATTERN = /^\s*#[0-9a-fA-F]{6}\s*$/; + +// Declared here rather than beside the rest of the t3.json schema because a +// project's accent travels on the project record: `t3ProjectFile.ts` already +// imports from this module, so the reverse direction would cycle. +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; + +// No hover entry: hover feedback is the flat row wash, never a gradient +// change, so sweeping the pointer down the list cannot pulse row tints. +export const ProjectAccentPalette = Schema.Struct({ + idle: ProjectAccentColor, + active: 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, active, and selected colors for exact control.", +}); +export type ProjectAccent = typeof ProjectAccent.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, @@ -254,6 +286,10 @@ export const OrchestrationProject = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), + // Read live from the checkout's t3.json when the record is assembled, not + // persisted: sidebar rows must know their accent in the same paint that + // creates them, or the whole list repaints when it arrives. + accent: Schema.optional(Schema.NullOr(ProjectAccent)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -451,6 +487,8 @@ export const OrchestrationProjectShell = Schema.Struct({ defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), + // See OrchestrationProject.accent: resolved live, never persisted. + accent: Schema.optional(Schema.NullOr(ProjectAccent)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index ed19c6d69887..74cd35c2639e 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 colors for every accent state", () => { + expect( + decode({ + accentColor: { + idle: "#071525", + active: "#245181", + selected: "#173b60", + }, + }).accentColor, + ).toEqual({ idle: "#071525", active: "#245181", selected: "#173b60" }); + }); + + it("requires every exact accent state", () => { + expect(() => decode({ accentColor: { idle: "#071525" } })).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..8c5bcbcb7699 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadEnvMode } from "./environment.ts"; -import { ProjectScriptIcon } from "./orchestration.ts"; +import { ProjectAccent, ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ export const T3_PROJECT_FILE_NAME = "t3.json"; @@ -74,6 +74,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",