Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
24 changes: 24 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,27 @@
- apps/web/src/main.tsx
verify:
- apps/web/src/__fork_guards__/forkMarker.test.ts

- id: phosphor-duotone-icons
intent: >
The app's iconography is Phosphor at the duotone weight, not lucide.
Rather than rewriting ~89 import sites across the hottest upstream files
(ChatView, SidebarV2, ChatComposer), "lucide-react" is aliased to a
fork-owned shim that re-exports Phosphor under lucide's export names. Call
sites keep importing "lucide-react" verbatim, so upstream can add, move, or
delete icon usage freely and this fork inherits it. Glyphs with no enclosed
area (carets, checks, arrows, spinners) are pinned to the bold weight,
since duotone is indistinguishable there and reads too light against
lucide's 2px stroke. The shim also reproduces lucide's `lucide lucide-<name>`
SVG classes, because upstream tests assert them to identify which icon
rendered — honouring the contract keeps those tests passing unmodified
instead of forcing Tier 4 edits into upstream test files.
tier: 1
files:
- apps/web/src/custom/icons/lucide-phosphor.tsx
shadows: []
watch:
- apps/web/vite.config.ts
- apps/web/tsconfig.json
verify:
- apps/web/src/__fork_guards__/phosphorIcons.test.ts
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@formkit/auto-animate": "^0.9.0",
"@legendapp/list": "3.2.0",
"@lexical/react": "^0.41.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "catalog:",
"@pierre/trees": "1.0.0-beta.4",
"@t3tools/client-runtime": "workspace:*",
Expand Down
105 changes: 105 additions & 0 deletions apps/web/src/__fork_guards__/phosphorIcons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Fork guard — see `.fork/README.md` §4b and
* `.fork/customizations.yaml#phosphor-duotone-icons`.
*
* The icon swap is invisible at every call site: upstream code still imports
* from "lucide-react", and only the two alias entries redirect that to the
* Phosphor shim. That is exactly the §4 failure mode — drop either alias in a
* sync and the app silently renders lucide again, with no conflict and no
* error. These tests turn that into a red one.
*/

import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import { describe, expect, it } from "vite-plus/test";

const webRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL(".", import.meta.url)), "../..");
const SHIM_PATH = "src/custom/icons/lucide-phosphor.tsx";

function read(relativePath: string): string {
return NodeFS.readFileSync(NodePath.join(webRoot, relativePath), "utf8");
}

function sourceFiles(dir: string): string[] {
return NodeFS.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = NodePath.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : [];
});
}

/** Named bindings pulled from "lucide-react" anywhere under `apps/web/src`. */
function importedLucideNames(): string[] {
const names = new Set<string>();
for (const file of sourceFiles(NodePath.join(webRoot, "src"))) {
const source = NodeFS.readFileSync(file, "utf8");
for (const match of source.matchAll(
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"lucide-react"/g,
)) {
for (const binding of (match[1] ?? "").split(",")) {
const name = binding
.trim()
.replace(/^type\s+/, "")
.split(/\s+as\s+/)[0]
?.trim();
if (name) names.add(name);
}
}
}
return [...names];
}

describe("fork guard: phosphor-duotone-icons", () => {
it("keeps the bundler alias pointing lucide-react at the shim", () => {
const config = read("vite.config.ts");
expect(config).toContain('"lucide-react": NodeURL.fileURLToPath(');
expect(config).toContain(SHIM_PATH);
});

it("keeps the TypeScript path mapping in step with the bundler alias", () => {
const tsconfig = read("tsconfig.json");
expect(tsconfig).toContain(`"lucide-react": ["./${SHIM_PATH}"]`);
});

it("exports every lucide binding the app imports", () => {
const shim = read(SHIM_PATH);
const exported = new Set([
...[...shim.matchAll(/export const (\w+)/g)].map((m) => m[1]),
...[...shim.matchAll(/export type (\w+)/g)].map((m) => m[1]),
]);
// A missing name means an upstream sync introduced a lucide icon the shim
// has no mapping for. Fix: add a line to the table in the shim.
expect(importedLucideNames().filter((name) => !exported.has(name))).toEqual([]);
});

it("renders Phosphor rather than lucide, defaulting to the duotone weight", () => {
const shim = read(SHIM_PATH);
expect(shim).toContain('from "@phosphor-icons/react"');
expect(shim).toContain('"duotone"');
// The shim must not re-import the package it stands in for — that would
// alias lucide-react to itself and recurse.
expect(shim).not.toMatch(/^\s*(?:import|export)\b[^\n]*from "lucide-react"/m);
});

it("stamps lucide's class names so upstream icon assertions still pass", () => {
const shim = read(SHIM_PATH);
// Upstream identifies icons in tests by these classes (e.g. `lucide-x` in
// MessagesTimeline.test.tsx). Drop them and those tests fail with a diff
// that points at upstream code rather than at this shim.
expect(shim).toContain("`lucide lucide-${lucideName}`");
// Every entry in the table has to carry a name, or its icon renders
// unclassed while the rest stay covered.
expect([...shim.matchAll(/export const \w+ = icon\((.)/g)].filter((m) => m[1] !== '"')).toEqual(
[],
);
});

it("keeps lucide-react installed only as the alias target's public name", () => {
const pkg = JSON.parse(read("package.json")) as {
dependencies?: Record<string, string>;
};
expect(pkg.dependencies?.["@phosphor-icons/react"]).toBeTruthy();
});
});
Loading
Loading