Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { AppText } from "./AppText";
import { Image } from "expo-image";
import { memo, useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { Path, Svg } from "react-native-svg";
import { withUniwind } from "uniwind";
import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
Expand All @@ -28,6 +30,7 @@ import {
} from "../lib/projectFaviconRequests";

const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
const TintedSvg = withUniwind(Svg);

/* ─── Component ──────────────────────────────────────────────────────── */
export const ProjectFavicon = memo(function ProjectFavicon(props: {
Expand Down Expand Up @@ -111,6 +114,26 @@ function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonl
}

const colors = projectIconColorClassNames(glyph.color);
if (glyph.kind === "lucide") {
return (
<TintedSvg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
colorClassName={colors.tint}
>
{glyph.paths.map((d) => (
<Path key={d} d={d} />
))}
</TintedSvg>
);
}

return (
<View
className={colors.background}
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/projectIcon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveProjectIconGlyph } from "./projectIcon";

describe("resolveProjectIconGlyph", () => {
it("draws the No project icon with its Lucide paths", () => {
expect(
resolveProjectIconGlyph(
{ kind: "lucide", name: "message-square-dashed", color: "gray" },
"No project",
),
).toMatchObject({ kind: "lucide", color: "gray" });
});

it("falls back to the monogram for a Lucide icon mobile has no symbol for", () => {
expect(
resolveProjectIconGlyph({ kind: "lucide", name: "rocket", color: "blue" }, "Launch Pad"),
).toEqual({ kind: "monogram", text: "LP", color: "blue" });
});
});
84 changes: 60 additions & 24 deletions apps/mobile/src/lib/projectIcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,31 @@ import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";

export type ProjectIconGlyph =
| { readonly kind: "emoji"; readonly emoji: string }
| { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor };
| { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }
| {
readonly kind: "lucide";
readonly paths: readonly string[];
readonly color: ProjectIconColor;
};

/**
* Stroke paths for the Lucide icons the server assigns on its own, copied from
* lucide-react so mobile draws the same shape as web.
*/
const LUCIDE_PATHS: Partial<Record<string, readonly string[]>> = {
"message-square-dashed": [
"M14 3h2",
"M16 19h-2",
"M2 12v-2",
"M2 16v5.286a.71.71 0 0 0 1.212.502l1.149-1.149",
"M20 19a2 2 0 0 0 2-2v-1",
"M22 10v2",
"M22 6V5a2 2 0 0 0-2-2",
"M4 3a2 2 0 0 0-2 2v1",
"M8 19h2",
"M8 3h2",
],
};

/**
* Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining
Expand Down Expand Up @@ -33,8 +57,8 @@ export function projectMonogram(projectName: string): string {

/**
* Picks what mobile draws for an assigned project icon. Mobile does not bundle
* the Lucide set, so a Lucide override keeps its color and falls back to the
* project's monogram instead of the folder glyph.
* the Lucide set, so a Lucide override keeps its color and draws the paths
* mobile carries for it, else the project's monogram instead of the folder glyph.
*/
export function resolveProjectIconGlyph(
projectIcon: ProjectIconOverride | null | undefined,
Expand All @@ -45,35 +69,47 @@ export function resolveProjectIconGlyph(
return { kind: "emoji", emoji: projectIcon.emoji };
case "monogram":
return { kind: "monogram", text: projectIcon.text, color: projectIcon.color };
case "lucide":
return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color };
case "lucide": {
const paths = LUCIDE_PATHS[projectIcon.name];
return paths
? { kind: "lucide", paths, color: projectIcon.color }
: { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color };
}
case undefined:
return null;
}
}

const PROJECT_ICON_COLOR_CLASSES: Record<
ProjectIconColor,
{ readonly text: string; readonly background: string }
{ readonly text: string; readonly tint: string; readonly background: string }
> = {
gray: { text: "text-gray-500", background: "bg-gray-500/15" },
red: { text: "text-red-500", background: "bg-red-500/15" },
orange: { text: "text-orange-500", background: "bg-orange-500/15" },
amber: { text: "text-amber-500", background: "bg-amber-500/15" },
yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" },
lime: { text: "text-lime-500", background: "bg-lime-500/15" },
green: { text: "text-green-500", background: "bg-green-500/15" },
emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" },
teal: { text: "text-teal-500", background: "bg-teal-500/15" },
cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" },
sky: { text: "text-sky-500", background: "bg-sky-500/15" },
blue: { text: "text-blue-500", background: "bg-blue-500/15" },
indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" },
violet: { text: "text-violet-500", background: "bg-violet-500/15" },
purple: { text: "text-purple-500", background: "bg-purple-500/15" },
fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" },
pink: { text: "text-pink-500", background: "bg-pink-500/15" },
rose: { text: "text-rose-500", background: "bg-rose-500/15" },
gray: { text: "text-gray-500", tint: "accent-gray-500", background: "bg-gray-500/15" },
red: { text: "text-red-500", tint: "accent-red-500", background: "bg-red-500/15" },
orange: { text: "text-orange-500", tint: "accent-orange-500", background: "bg-orange-500/15" },
amber: { text: "text-amber-500", tint: "accent-amber-500", background: "bg-amber-500/15" },
yellow: { text: "text-yellow-500", tint: "accent-yellow-500", background: "bg-yellow-500/15" },
lime: { text: "text-lime-500", tint: "accent-lime-500", background: "bg-lime-500/15" },
green: { text: "text-green-500", tint: "accent-green-500", background: "bg-green-500/15" },
emerald: {
text: "text-emerald-500",
tint: "accent-emerald-500",
background: "bg-emerald-500/15",
},
teal: { text: "text-teal-500", tint: "accent-teal-500", background: "bg-teal-500/15" },
cyan: { text: "text-cyan-500", tint: "accent-cyan-500", background: "bg-cyan-500/15" },
sky: { text: "text-sky-500", tint: "accent-sky-500", background: "bg-sky-500/15" },
blue: { text: "text-blue-500", tint: "accent-blue-500", background: "bg-blue-500/15" },
indigo: { text: "text-indigo-500", tint: "accent-indigo-500", background: "bg-indigo-500/15" },
violet: { text: "text-violet-500", tint: "accent-violet-500", background: "bg-violet-500/15" },
purple: { text: "text-purple-500", tint: "accent-purple-500", background: "bg-purple-500/15" },
fuchsia: {
text: "text-fuchsia-500",
tint: "accent-fuchsia-500",
background: "bg-fuchsia-500/15",
},
pink: { text: "text-pink-500", tint: "accent-pink-500", background: "bg-pink-500/15" },
rose: { text: "text-rose-500", tint: "accent-rose-500", background: "bg-rose-500/15" },
};

export function projectIconColorClassNames(color: ProjectIconColor) {
Expand Down
Loading