From b9d03fc2c167ced53df58f285d8cd8770cb5d8f9 Mon Sep 17 00:00:00 2001 From: Christian Giangreco Date: Fri, 10 Jul 2026 11:30:51 +0200 Subject: [PATCH 1/5] Serve stack-specific default project favicons When a project has no favicon, detect the project's stack and serve a matching default icon instead of the generic folder: React/React Native projects (react or react-native in package.json dependencies) get a React icon, Gradle-based Android projects get an Android icon. Detection failures degrade to the existing generic fallback. Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.test.ts | 58 +++++++++++++++++++- apps/server/src/assets/AssetAccess.ts | 62 +++++++++++++++++++++- apps/server/src/http.ts | 8 +-- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index f790e71f5cd..362569cceb1 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -11,7 +11,12 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + issueAssetUrl, + resolveAsset, +} from "./AssetAccess.ts"; const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", @@ -235,7 +240,56 @@ describe("AssetAccess", () => { fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), ), - ).toEqual({ kind: "project-favicon-fallback" }); + ).toEqual({ kind: "project-favicon-fallback", svg: FALLBACK_PROJECT_FAVICON_SVG }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("serves stack-specific fallback icons for projects without a favicon", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const resolveFallbackSvg = Effect.fnUntraced(function* (root: string) { + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const resolved = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + expect(resolved?.kind).toBe("project-favicon-fallback"); + return resolved?.kind === "project-favicon-fallback" ? resolved.svg : ""; + }); + + const reactRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-react-", + }); + yield* fileSystem.writeFileString( + path.join(reactRoot, "package.json"), + '{"dependencies":{"react":"^19.0.0"}}', + ); + expect(yield* resolveFallbackSvg(reactRoot)).toContain( + 'data-fallback="project-favicon-react"', + ); + + const androidRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-android-", + }); + yield* fileSystem.writeFileString(path.join(androidRoot, "build.gradle"), ""); + expect(yield* resolveFallbackSvg(androidRoot)).toContain( + 'data-fallback="project-favicon-android"', + ); + + const plainRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-plain-", + }); + yield* fileSystem.writeFileString( + path.join(plainRoot, "package.json"), + '{"dependencies":{"express":"^5.0.0"}}', + ); + expect(yield* resolveFallbackSvg(plainRoot)).toBe(FALLBACK_PROJECT_FAVICON_SVG); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8d8ecbc2af3..242503bb5ea 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -41,6 +41,15 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; export const FALLBACK_PROJECT_FAVICON_SVG = ``; +const REACT_PROJECT_FAVICON_SVG = ``; +const ANDROID_PROJECT_FAVICON_SVG = ``; + +const ANDROID_PROJECT_MARKER_FILES = [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", +]; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -93,7 +102,53 @@ const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); export type ResolvedAsset = | { readonly kind: "file"; readonly path: string } - | { readonly kind: "project-favicon-fallback" }; + | { readonly kind: "project-favicon-fallback"; readonly svg: string }; + +const PackageJsonDependenciesJson = Schema.fromJsonString( + Schema.Struct({ + dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), +); +const decodePackageJsonDependencies = Schema.decodeUnknownOption(PackageJsonDependenciesJson); + +function parsePackageJsonDependencyNames(contents: string): ReadonlySet { + const parsed = Option.getOrNull(decodePackageJsonDependencies(contents)); + return new Set([ + ...Object.keys(parsed?.dependencies ?? {}), + ...Object.keys(parsed?.devDependencies ?? {}), + ]); +} + +// Detection failures degrade to the generic fallback icon rather than failing +// the asset request. +const resolveProjectFaviconFallbackSvg = Effect.fn("AssetAccess.resolveProjectFaviconFallbackSvg")( + function* (workspaceRoot: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const packageJsonContents = yield* fileSystem + .readFileString(path.join(workspaceRoot, "package.json")) + .pipe(Effect.orElseSucceed(() => null)); + if (packageJsonContents !== null) { + const dependencyNames = parsePackageJsonDependencyNames(packageJsonContents); + if (dependencyNames.has("react") || dependencyNames.has("react-native")) { + return REACT_PROJECT_FAVICON_SVG; + } + } + + for (const markerFile of ANDROID_PROJECT_MARKER_FILES) { + const markerExists = yield* fileSystem + .exists(path.join(workspaceRoot, markerFile)) + .pipe(Effect.orElseSucceed(() => false)); + if (markerExists) { + return ANDROID_PROJECT_FAVICON_SVG; + } + } + + return FALLBACK_PROJECT_FAVICON_SVG; + }, +); function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -392,7 +447,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.kind === "project-favicon") { if (claims.relativePath === null) { - return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + return { + kind: "project-favicon-fallback", + svg: yield* resolveProjectFaviconFallbackSvg(claims.workspaceRoot), + } satisfies ResolvedAsset; } const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..b3fff7b3d86 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { - ASSET_ROUTE_PREFIX, - FALLBACK_PROJECT_FAVICON_SVG, - resolveAsset, -} from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -197,7 +193,7 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Not Found", { status: 404 }); } if (asset.kind === "project-favicon-fallback") { - return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { + return HttpServerResponse.text(asset.svg, { status: 200, contentType: "image/svg+xml", headers: { From 7e1b784ef0a46a5c7cbfc04da2a1bc9d534a8f0a Mon Sep 17 00:00:00 2001 From: Christian Giangreco Date: Fri, 10 Jul 2026 12:02:50 +0200 Subject: [PATCH 2/5] Add stack-specific fallback icons for Next.js, Vue, Svelte, Angular, and social platforms Extends the stack-detection fallback to cover more common project types: - Next.js (detected before plain React, via 'next' dependency) - Angular (@angular/core), Svelte (svelte / @sveltejs/kit), Vue (vue / nuxt) - YouTube, TikTok, Instagram (detected by project directory name pattern) Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.test.ts | 108 ++++++++++++++++----- apps/server/src/assets/AssetAccess.ts | 29 +++++- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 362569cceb1..2f8f8c4dcaf 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -18,6 +18,18 @@ import { resolveAsset, } from "./AssetAccess.ts"; +type FallbackTag = + | "project-favicon" + | "project-favicon-react" + | "project-favicon-nextjs" + | "project-favicon-vue" + | "project-favicon-svelte" + | "project-favicon-angular" + | "project-favicon-android" + | "project-favicon-youtube" + | "project-favicon-tiktok" + | "project-favicon-instagram"; + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -249,7 +261,7 @@ describe("AssetAccess", () => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const resolveFallbackSvg = Effect.fnUntraced(function* (root: string) { + const resolveFallbackTag = Effect.fnUntraced(function* (root: string) { const result = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); @@ -260,36 +272,88 @@ describe("AssetAccess", () => { suffix.slice(separatorIndex + 1), ); expect(resolved?.kind).toBe("project-favicon-fallback"); - return resolved?.kind === "project-favicon-fallback" ? resolved.svg : ""; + if (resolved?.kind !== "project-favicon-fallback") return "project-favicon" as FallbackTag; + const match = /data-fallback="([^"]+)"/.exec(resolved.svg); + return (match?.[1] ?? "project-favicon") as FallbackTag; }); - const reactRoot = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-react-", - }); - yield* fileSystem.writeFileString( - path.join(reactRoot, "package.json"), - '{"dependencies":{"react":"^19.0.0"}}', - ); - expect(yield* resolveFallbackSvg(reactRoot)).toContain( - 'data-fallback="project-favicon-react"', - ); + const makePkgRoot = (prefix: string, deps: Record) => + Effect.gen(function* () { + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix }); + const entries = Object.entries(deps) + .map(([k, v]) => `"${k}":"${v}"`) + .join(","); + yield* fileSystem.writeFileString( + path.join(root, "package.json"), + `{"dependencies":{${entries}}}`, + ); + return root; + }); + + // Next.js takes priority over plain React + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-next-", { next: "^15.0.0", react: "^19.0.0" }), + ), + ).toBe("project-favicon-nextjs"); + + // Plain React + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-react-", { react: "^19.0.0" }), + ), + ).toBe("project-favicon-react"); + + // Angular + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-ng-", { "@angular/core": "^18.0.0" }), + ), + ).toBe("project-favicon-angular"); + // Svelte + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-svelte-", { svelte: "^5.0.0" }), + ), + ).toBe("project-favicon-svelte"); + + // Vue + expect( + yield* resolveFallbackTag(yield* makePkgRoot("t3-asset-favicon-vue-", { vue: "^3.0.0" })), + ).toBe("project-favicon-vue"); + + // Android (Gradle marker file) const androidRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-asset-favicon-android-", }); yield* fileSystem.writeFileString(path.join(androidRoot, "build.gradle"), ""); - expect(yield* resolveFallbackSvg(androidRoot)).toContain( - 'data-fallback="project-favicon-android"', - ); + expect(yield* resolveFallbackTag(androidRoot)).toBe("project-favicon-android"); - const plainRoot = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-plain-", + // YouTube (project name matching) + const ytRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-youtube-companion-", }); - yield* fileSystem.writeFileString( - path.join(plainRoot, "package.json"), - '{"dependencies":{"express":"^5.0.0"}}', - ); - expect(yield* resolveFallbackSvg(plainRoot)).toBe(FALLBACK_PROJECT_FAVICON_SVG); + expect(yield* resolveFallbackTag(ytRoot)).toBe("project-favicon-youtube"); + + // TikTok (project name matching) + const tiktokRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-tiktok-bot-", + }); + expect(yield* resolveFallbackTag(tiktokRoot)).toBe("project-favicon-tiktok"); + + // Instagram (project name matching) + const igRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-insta-poster-", + }); + expect(yield* resolveFallbackTag(igRoot)).toBe("project-favicon-instagram"); + + // Generic fallback for unrecognized stacks + expect( + yield* resolveFallbackTag( + yield* makePkgRoot("t3-asset-favicon-plain-", { express: "^5.0.0" }), + ), + ).toBe("project-favicon"); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 242503bb5ea..5b1ee0bde20 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -41,8 +41,15 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; export const FALLBACK_PROJECT_FAVICON_SVG = ``; +const NEXTJS_PROJECT_FAVICON_SVG = ``; const REACT_PROJECT_FAVICON_SVG = ``; +const VUE_PROJECT_FAVICON_SVG = ``; +const SVELTE_PROJECT_FAVICON_SVG = ``; +const ANGULAR_PROJECT_FAVICON_SVG = ``; const ANDROID_PROJECT_FAVICON_SVG = ``; +const YOUTUBE_PROJECT_FAVICON_SVG = ``; +const TIKTOK_PROJECT_FAVICON_SVG = ``; +const INSTAGRAM_PROJECT_FAVICON_SVG = ``; const ANDROID_PROJECT_MARKER_FILES = [ "settings.gradle", @@ -51,6 +58,10 @@ const ANDROID_PROJECT_MARKER_FILES = [ "build.gradle.kts", ]; +const YOUTUBE_PROJECT_NAME_PATTERNS = /\byt\b|youtube/i; +const TIKTOK_PROJECT_NAME_PATTERNS = /tiktok|tik[_-]?tok/i; +const INSTAGRAM_PROJECT_NAME_PATTERNS = /instagram|\binsta\b/i; + const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PREVIEW_ASSET_EXTENSIONS = new Set([ @@ -127,23 +138,31 @@ const resolveProjectFaviconFallbackSvg = Effect.fn("AssetAccess.resolveProjectFa const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const projectName = path.basename(workspaceRoot); + + if (YOUTUBE_PROJECT_NAME_PATTERNS.test(projectName)) return YOUTUBE_PROJECT_FAVICON_SVG; + if (TIKTOK_PROJECT_NAME_PATTERNS.test(projectName)) return TIKTOK_PROJECT_FAVICON_SVG; + if (INSTAGRAM_PROJECT_NAME_PATTERNS.test(projectName)) return INSTAGRAM_PROJECT_FAVICON_SVG; + const packageJsonContents = yield* fileSystem .readFileString(path.join(workspaceRoot, "package.json")) .pipe(Effect.orElseSucceed(() => null)); if (packageJsonContents !== null) { const dependencyNames = parsePackageJsonDependencyNames(packageJsonContents); - if (dependencyNames.has("react") || dependencyNames.has("react-native")) { + if (dependencyNames.has("next")) return NEXTJS_PROJECT_FAVICON_SVG; + if (dependencyNames.has("@angular/core")) return ANGULAR_PROJECT_FAVICON_SVG; + if (dependencyNames.has("svelte") || dependencyNames.has("@sveltejs/kit")) + return SVELTE_PROJECT_FAVICON_SVG; + if (dependencyNames.has("vue") || dependencyNames.has("nuxt")) return VUE_PROJECT_FAVICON_SVG; + if (dependencyNames.has("react") || dependencyNames.has("react-native")) return REACT_PROJECT_FAVICON_SVG; - } } for (const markerFile of ANDROID_PROJECT_MARKER_FILES) { const markerExists = yield* fileSystem .exists(path.join(workspaceRoot, markerFile)) .pipe(Effect.orElseSucceed(() => false)); - if (markerExists) { - return ANDROID_PROJECT_FAVICON_SVG; - } + if (markerExists) return ANDROID_PROJECT_FAVICON_SVG; } return FALLBACK_PROJECT_FAVICON_SVG; From 11a57af25fbf480b8add15a24ba51e67a783cd27 Mon Sep 17 00:00:00 2001 From: Christian Giangreco Date: Fri, 10 Jul 2026 12:15:37 +0200 Subject: [PATCH 3/5] Redraw Android fallback icon with cleaner proportions Replace the multi-path robot body with a minimal 3-element head: semicircle dome + rounded-bottom rectangle body, two short angled antennae, and two eye circles. Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 5b1ee0bde20..315f2350aa9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -46,7 +46,7 @@ const REACT_PROJECT_FAVICON_SVG = ``; const SVELTE_PROJECT_FAVICON_SVG = ``; const ANGULAR_PROJECT_FAVICON_SVG = ``; -const ANDROID_PROJECT_FAVICON_SVG = ``; +const ANDROID_PROJECT_FAVICON_SVG = ``; const YOUTUBE_PROJECT_FAVICON_SVG = ``; const TIKTOK_PROJECT_FAVICON_SVG = ``; const INSTAGRAM_PROJECT_FAVICON_SVG = ``; From d265a73faec9d9c1f13426e3f168cac7a4118297 Mon Sep 17 00:00:00 2001 From: Christian Giangreco Date: Fri, 10 Jul 2026 12:32:12 +0200 Subject: [PATCH 4/5] Add Expo detection and tighten Android head proportions Expo projects (mobile-first, no Gradle files at root) now get the Android icon via the `expo` dependency check. Also shrinks the body section of the Android SVG so it reads as a head rather than a full robot torso. Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 315f2350aa9..348321ee909 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -46,7 +46,7 @@ const REACT_PROJECT_FAVICON_SVG = ``; const SVELTE_PROJECT_FAVICON_SVG = ``; const ANGULAR_PROJECT_FAVICON_SVG = ``; -const ANDROID_PROJECT_FAVICON_SVG = ``; +const ANDROID_PROJECT_FAVICON_SVG = ``; const YOUTUBE_PROJECT_FAVICON_SVG = ``; const TIKTOK_PROJECT_FAVICON_SVG = ``; const INSTAGRAM_PROJECT_FAVICON_SVG = ``; @@ -154,8 +154,9 @@ const resolveProjectFaviconFallbackSvg = Effect.fn("AssetAccess.resolveProjectFa if (dependencyNames.has("svelte") || dependencyNames.has("@sveltejs/kit")) return SVELTE_PROJECT_FAVICON_SVG; if (dependencyNames.has("vue") || dependencyNames.has("nuxt")) return VUE_PROJECT_FAVICON_SVG; - if (dependencyNames.has("react") || dependencyNames.has("react-native")) - return REACT_PROJECT_FAVICON_SVG; + if (dependencyNames.has("expo") || dependencyNames.has("react-native")) + return ANDROID_PROJECT_FAVICON_SVG; + if (dependencyNames.has("react")) return REACT_PROJECT_FAVICON_SVG; } for (const markerFile of ANDROID_PROJECT_MARKER_FILES) { From 5e7467fef1f2fcbc5b4feefbd472355019c27925 Mon Sep 17 00:00:00 2001 From: Christian Giangreco Date: Fri, 10 Jul 2026 12:33:09 +0200 Subject: [PATCH 5/5] Widen and shorten Android antennae Spread the antenna tips further apart and reduce their length so they read more like the reference Android logo proportions. Co-Authored-By: Claude Fable 5 --- apps/server/src/assets/AssetAccess.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 348321ee909..4a7297495c6 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -46,7 +46,7 @@ const REACT_PROJECT_FAVICON_SVG = ``; const SVELTE_PROJECT_FAVICON_SVG = ``; const ANGULAR_PROJECT_FAVICON_SVG = ``; -const ANDROID_PROJECT_FAVICON_SVG = ``; +const ANDROID_PROJECT_FAVICON_SVG = ``; const YOUTUBE_PROJECT_FAVICON_SVG = ``; const TIKTOK_PROJECT_FAVICON_SVG = ``; const INSTAGRAM_PROJECT_FAVICON_SVG = ``;