authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 17:06:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 17:06:56-07:00
log7a368fe7c45a45c284f1b064e440a2d696452b07
tree6a14a28817be756049caf69aff4dd46bed74766c
parentb12c92f695a6b12116fb2cda40d610bdbe2a9566
signature Signed by PGP key B5690EEEBB952194

refactor: give project monograms their own icon variant (#11993)


9 files changed, 215 insertions(+), 98 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+7-2
...@@ -4371,14 +4371,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -4371,14 +4371,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
4371 type: "project.meta.update",4371 type: "project.meta.update",
4372 commandId: CommandId.make("cmd-monogram-save"),4372 commandId: CommandId.make("cmd-monogram-save"),
4373 projectId,4373 projectId,
4374 projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },4374 projectIcon: { kind: "monogram", text: "T3", color: "violet" },
4375 });4375 });
4376 const saved = yield* sql<{4376 const saved = yield* sql<{
4377 readonly icon: string | null;4377 readonly icon: string | null;
4378 }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;4378 }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`;
4379 assert.deepEqual(saved, [4379 assert.deepEqual(saved, [
4380 { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' },4380 { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogramText":"T3"}' },
4381 ]);4381 ]);
4382 const persisted = yield* sql<{ readonly icon: string }>`
4383 SELECT json_extract(payload_json, '$.projectIcon') AS icon FROM orchestration_events
4384 WHERE command_id = ${CommandId.make("cmd-monogram-save")}
4385 `;
4386 assert.deepEqual(persisted, saved);
4382 yield* engine.dispatch({4387 yield* engine.dispatch({
4383 type: "project.meta.update",4388 type: "project.meta.update",
4384 commandId: CommandId.make("cmd-monogram-clear"),4389 commandId: CommandId.make("cmd-monogram-clear"),
apps/server/src/orchestration/decider.projectScripts.test.ts+29
...@@ -248,6 +248,35 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {...@@ -248,6 +248,35 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
248 name: "alarm-clock",248 name: "alarm-clock",
249 color: "violet",249 color: "violet",
250 });250 });
251
252 for (const text of ["T3", "e\u0301", "किखि", "क्ष्म", "\u1100\u1161\u11a8"]) {
253 const monogram = { kind: "monogram", text, color: "violet" } as const;
254 const result = yield* decideOrchestrationCommand({
255 command: {
256 type: "project.meta.update",
257 commandId: CommandId.make("cmd-monogram"),
258 projectId: asProjectId("project-favicon"),
259 projectIcon: monogram,
260 },
261 readModel,
262 });
263 const updated = Array.isArray(result) ? result[0] : result;
264 expect(updated.payload).toMatchObject({ projectIcon: monogram });
265 }
266 for (const text of ["ABC", "किखिगि"]) {
267 const failure = yield* Effect.flip(
268 decideOrchestrationCommand({
269 command: {
270 type: "project.meta.update",
271 commandId: CommandId.make("cmd-monogram-invalid"),
272 projectId: asProjectId("project-favicon"),
273 projectIcon: { kind: "monogram", text, color: "violet" },
274 },
275 readModel,
276 }),
277 );
278 expect(failure).toMatchObject({ _tag: "OrchestrationCommandInvariantError" });
279 }
251 }),280 }),
252 );281 );
253282
apps/server/src/orchestration/decider.ts+11
...@@ -47,6 +47,8 @@ import {...@@ -47,6 +47,8 @@ import {
47import { projectEvent } from "./projector.ts";47import { projectEvent } from "./projector.ts";
48import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";48import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
4949
50const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
51
50const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);52const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
5153
52const nowIso = Effect.map(DateTime.now, DateTime.formatIso);54const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
...@@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
264 command,266 command,
265 projectId: command.projectId,267 projectId: command.projectId,
266 });268 });
269 if (
270 command.projectIcon?.kind === "monogram" &&
271 Array.from(monogramSegmenter.segment(command.projectIcon.text)).length > 2
272 ) {
273 return yield* new OrchestrationCommandInvariantError({
274 commandType: command.type,
275 detail: "Project monograms must contain at most two characters.",
276 });
277 }
267 if (command.scripts !== undefined) {278 if (command.scripts !== undefined) {
268 // Persisted IDs predate shortcut validation. Let users edit or remove them279 // Persisted IDs predate shortcut validation. Let users edit or remove them
269 // without allowing another invalid ID to enter the project.280 // without allowing another invalid ID to enter the project.
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+6-1
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 OrchestrationEventMetadata,9 OrchestrationEventMetadata,
10 OrchestrationEventType,10 OrchestrationEventType,
11 ProjectId,11 ProjectId,
12 ProjectIconOverride,
12 ThreadId,13 ThreadId,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import * as SqlClient from "effect/unstable/sql/SqlClient";15import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -29,6 +30,7 @@ import {...@@ -29,6 +30,7 @@ import {
29 type OrchestrationEventStoreShape,30 type OrchestrationEventStoreShape,
30} from "../Services/OrchestrationEventStore.ts";31} from "../Services/OrchestrationEventStore.ts";
3132
33const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride);
32const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent);34const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
33const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);35const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
34const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata);36const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata);
...@@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () {...@@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () {
263 actorKind: inferActorKind(event),265 actorKind: inferActorKind(event),
264 occurredAt: event.occurredAt,266 occurredAt: event.occurredAt,
265 commandId: event.commandId,267 commandId: event.commandId,
266 payloadJson: event.payload,268 payloadJson:
269 "projectIcon" in event.payload && event.payload.projectIcon
270 ? { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) }
271 : event.payload,
267 metadataJson: event.metadata,272 metadataJson: event.metadata,
268 }).pipe(273 }).pipe(
269 Effect.mapError(274 Effect.mapError(
apps/web/src/components/ProjectFavicon.tsx+2-2
...@@ -43,10 +43,10 @@ export function ProjectFavicon(input: {...@@ -43,10 +43,10 @@ export function ProjectFavicon(input: {
43 faviconPath: project.faviconPath,43 faviconPath: project.faviconPath,
44 }),44 }),
45 );45 );
46 if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) {46 if (project.projectIcon?.kind === "monogram") {
47 return (47 return (
48 <ProjectMonogram48 <ProjectMonogram
49 text={project.projectIcon.monogram}49 text={project.projectIcon.text}
50 color={project.projectIcon.color}50 color={project.projectIcon.color}
51 className={input.className}51 className={input.className}
52 />52 />
apps/web/src/components/settings/ProjectIconPickerDialog.tsx+5-11
...@@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({...@@ -54,9 +54,7 @@ export function ProjectIconPickerDialog({
54 readonly onSelect: (icon: ProjectIconOverride) => void;54 readonly onSelect: (icon: ProjectIconOverride) => void;
55}) {55}) {
56 const automatic = deriveProjectIdentity(projectName);56 const automatic = deriveProjectIdentity(projectName);
57 const [mode, setMode] = useState<ProjectIconOverride["kind"] | "monogram">(57 const [mode, setMode] = useState<ProjectIconOverride["kind"]>(current?.kind ?? "lucide");
58 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
59 );
60 const [iconName, setIconName] = useState<IconName>(58 const [iconName, setIconName] = useState<IconName>(
61 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,59 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
62 );60 );
...@@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({...@@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({
64 current && current.kind !== "emoji" ? current.color : automatic.color,62 current && current.kind !== "emoji" ? current.color : automatic.color,
65 );63 );
66 const [letters, setLetters] = useState(64 const [letters, setLetters] = useState(
67 current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,65 current?.kind === "monogram" ? current.text : automatic.monogram,
68 );66 );
69 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");67 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
70 const [query, setQuery] = useState("");68 const [query, setQuery] = useState("");
...@@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({...@@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({
7371
74 useEffect(() => {72 useEffect(() => {
75 if (open && !previousOpenRef.current) {73 if (open && !previousOpenRef.current) {
76 setMode(74 setMode(current?.kind ?? "lucide");
77 current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"),
78 );
79 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);75 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
80 setColor(current && current.kind !== "emoji" ? current.color : automatic.color);76 setColor(current && current.kind !== "emoji" ? current.color : automatic.color);
81 setLetters(77 setLetters(current?.kind === "monogram" ? current.text : automatic.monogram);
82 current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram,
83 );
84 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");78 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
85 setQuery("");79 setQuery("");
86 setCustomEmoji("");80 setCustomEmoji("");
...@@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({...@@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({
96 if (mode === "monogram" && !validMonogram) return;90 if (mode === "monogram" && !validMonogram) return;
97 onSelect(91 onSelect(
98 mode === "monogram"92 mode === "monogram"
99 ? { kind: "lucide", name: DEFAULT_ICON, monogram, color }93 ? { kind: "monogram", text: monogram, color }
100 : mode === "lucide"94 : mode === "lucide"
101 ? { kind: "lucide", name: iconName, color }95 ? { kind: "lucide", name: iconName, color }
102 : { kind: "emoji", emoji },96 : { kind: "emoji", emoji },
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6-4
...@@ -435,10 +435,12 @@ function ProjectDetail({...@@ -435,10 +435,12 @@ function ProjectDetail({
435 title="Project icon"435 title="Project icon"
436 description={436 description={
437 projectIcon?.kind === "lucide"437 projectIcon?.kind === "lucide"
438 ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}`438 ? `${projectIcon.name} · ${projectIcon.color}`
439 : projectIcon?.kind === "emoji"439 : projectIcon?.kind === "monogram"
440 ? projectIcon.emoji440 ? `${projectIcon.text} · ${projectIcon.color}`
441 : (faviconPath ?? "Automatic")441 : projectIcon?.kind === "emoji"
442 ? projectIcon.emoji
443 : (faviconPath ?? "Automatic")
442 }444 }
443 resetAction={445 resetAction={
444 group.memberProjects.some(446 group.memberProjects.some(
packages/contracts/src/orchestration.test.ts+99-42
...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";...@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
5import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";5import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts";
66
7import {7import {
8 ProjectIconOverride,
8 DEFAULT_PROVIDER_INTERACTION_MODE,9 DEFAULT_PROVIDER_INTERACTION_MODE,
9 DEFAULT_RUNTIME_MODE,10 DEFAULT_RUNTIME_MODE,
10 type ChatImageAttachment,11 type ChatImageAttachment,
...@@ -17,9 +18,8 @@ import {...@@ -17,9 +18,8 @@ import {
17 OrchestrationGetTurnDiffInput,18 OrchestrationGetTurnDiffInput,
18 OrchestrationLatestTurn,19 OrchestrationLatestTurn,
19 ProjectCreatedPayload,20 ProjectCreatedPayload,
20 OrchestrationProjectShell,
21 ProjectIconColor,
22 ProjectMetaUpdatedPayload,21 ProjectMetaUpdatedPayload,
22 OrchestrationProjectShell,
23 OrchestrationProposedPlan,23 OrchestrationProposedPlan,
24 OrchestrationSession,24 OrchestrationSession,
25 OrchestrationThread,25 OrchestrationThread,
...@@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts";...@@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts";
42const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);42const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
43const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);43const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput);
44const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);44const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
45// The icon shape understood by clients released before monograms.
46const legacyProjectIcon = Schema.Union([
47 Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }),
48 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
49]);
50const decodeLegacyProjectShell = Schema.decodeUnknownEffect(
51 Schema.Struct({
52 ...OrchestrationProjectShell.fields,
53 projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)),
54 }),
55);
56const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
57const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);45const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
58const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);46const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
59const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);47const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
...@@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>...@@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
1508 }),1496 }),
1509);1497);
15101498
1511it.effect("older clients decode monogram projects as their fallback icon", () =>
1512 Effect.gen(function* () {
1513 const encoded = yield* encodeProjectShell({
1514 id: ProjectId.make("project-monogram"),
1515 title: "Monogram",
1516 workspaceRoot: "/tmp/monogram",
1517 defaultModelSelection: null,
1518 scripts: [],
1519 createdAt: "2026-01-01T00:00:00.000Z",
1520 updatedAt: "2026-01-01T00:00:00.000Z",
1521 projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" },
1522 });
1523 const decoded = yield* decodeLegacyProjectShell(encoded);
1524 assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" });
1525 }),
1526);
1527
1528it.effect("project monograms validate text and palette colors", () =>1499it.effect("project monograms validate text and palette colors", () =>
1529 Effect.gen(function* () {1500 Effect.gen(function* () {
1530 for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {1501 for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {
1531 const projectIcon = {1502 const projectIcon = {
1532 kind: "lucide",1503 kind: "monogram",
1533 name: "folder-code",
1534 color: "violet",1504 color: "violet",
1535 monogram: text,1505 text,
1536 } as const;1506 } as const;
1537 const command = yield* decodeOrchestrationCommand({1507 const command = yield* decodeOrchestrationCommand({
1538 type: "project.meta.update",1508 type: "project.meta.update",
...@@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () =>...@@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () =>
1542 });1512 });
1543 assert.strictEqual(command.type, "project.meta.update");1513 assert.strictEqual(command.type, "project.meta.update");
1544 if (command.type === "project.meta.update")1514 if (command.type === "project.meta.update")
1545 assert.deepEqual(command.projectIcon, projectIcon);1515 assert.deepEqual(command.projectIcon, { kind: "monogram", text, color: "violet" });
1546 }1516 }
1547 for (const projectIcon of [1517 for (const projectIcon of [
1548 { kind: "lucide", name: "folder-code", monogram: "", color: "blue" },1518 { kind: "monogram", text: "", color: "blue" },
1549 { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" },1519 { kind: "monogram", text: "\u0301", color: "blue" },
1550 { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" },1520 { kind: "monogram", text: "A B", color: "blue" },
1551 { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" },1521 { kind: "monogram", text: "🚀", color: "blue" },
1552 { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" },1522 { kind: "monogram", text: "T3", color: "ultraviolet" },
1553 { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" },
1554 { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" },
1555 ]) {1523 ]) {
1556 const result = yield* Effect.exit(1524 const result = yield* Effect.exit(
1557 decodeOrchestrationCommand({1525 decodeOrchestrationCommand({
...@@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects...@@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects
1586 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);1554 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
1587 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false);1555 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false);
1588});1556});
1557
1558const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride);
1559const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride);
1560
1561// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram.
1562const decodeOldIcon = Schema.decodeUnknownEffect(
1563 Schema.Union([
1564 Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }),
1565 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
1566 ]),
1567);
1568const decodeNightlyIcon = Schema.decodeUnknownEffect(
1569 Schema.Union([
1570 Schema.Struct({
1571 kind: Schema.Literal("lucide"),
1572 name: Schema.String,
1573 color: Schema.String,
1574 // Fail if this field is ever sent; old validators must never see the new text.
1575 monogram: Schema.optional(Schema.Never),
1576 }),
1577 Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }),
1578 ]),
1579);
1580
1581it.effect("sends monograms as fallback icons that old and nightly clients can decode", () =>
1582 Effect.gen(function* () {
1583 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
1584 for (const text of ["T3", "क्ष्म", "e\u0301"]) {
1585 const monogram = { kind: "monogram", text, color: "violet" } as const;
1586 const wire = yield* encodeProjectIcon(monogram);
1587 assert.deepEqual(wire, { ...fallback, monogramText: text });
1588 assert.deepEqual(yield* decodeOldIcon(wire), fallback);
1589 assert.deepEqual(yield* decodeNightlyIcon(wire), fallback);
1590 assert.deepEqual(yield* decodeProjectIcon(wire), monogram);
1591 assert.deepEqual(yield* decodeProjectIcon(monogram), monogram);
1592 assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram);
1593 }
1594 for (const icon of [
1595 { kind: "lucide", name: "alarm-clock", color: "blue" },
1596 { kind: "emoji", emoji: "🚀" },
1597 ] as const) {
1598 assert.deepEqual(yield* decodeProjectIcon(icon), icon);
1599 assert.deepEqual(yield* encodeProjectIcon(icon), icon);
1600 }
1601 }),
1602);
1603
1604const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell);
1605const encodeClientCommand = Schema.encodeEffect(ClientOrchestrationCommand);
1606const decodeLegacyShell = Schema.decodeUnknownEffect(
1607 Schema.Struct({
1608 ...OrchestrationProjectShell.fields,
1609 projectIcon: Schema.optional(
1610 Schema.NullOr(
1611 Schema.Struct({
1612 kind: Schema.Literal("lucide"),
1613 name: Schema.String,
1614 color: Schema.String,
1615 }),
1616 ),
1617 ),
1618 }),
1619);
1620
1621it.effect("encodes compatible icons inside snapshots and client commands", () =>
1622 Effect.gen(function* () {
1623 const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const;
1624 const shell = yield* encodeProjectShell({
1625 id: ProjectId.make("monogram"),
1626 title: "Monogram",
1627 workspaceRoot: "/tmp/monogram",
1628 defaultModelSelection: null,
1629 scripts: [],
1630 projectIcon,
1631 createdAt: "2026-01-01T00:00:00.000Z",
1632 updatedAt: "2026-01-01T00:00:00.000Z",
1633 });
1634 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
1635 assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback);
1636 const command = yield* encodeClientCommand({
1637 type: "project.meta.update",
1638 projectId: ProjectId.make("monogram"),
1639 commandId: CommandId.make("monogram"),
1640 projectIcon,
1641 });
1642 if (command.type !== "project.meta.update") throw new Error("Unexpected command");
1643 assert.deepEqual(yield* decodeNightlyIcon(command.projectIcon), fallback);
1644 }),
1645);
packages/contracts/src/orchestration.ts+50-36
...@@ -459,48 +459,62 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(...@@ -459,48 +459,62 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(
459459
460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
461461
462// Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module462// Grapheme-count validation belongs to the server command boundary, not snapshot decoding.
463// loads at app startup, so `new Intl.Segmenter()` crashes mobile on launch.
464// Count grapheme clusters with a small approximation instead: a code point
465// continues the current cluster when it is a combining mark, joiner,
466// variation selector, or emoji modifier, or follows a ZWJ. This must stay
467// runtime-independent (not `typeof Intl.Segmenter` feature detection) so the
468// shared contract validates identically on server, web, and mobile.
469const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u;
470
471const countGraphemes = (text: string): number => {
472 let clusters = 0;
473 let prevJoiner = false;
474 for (const char of text) {
475 if (clusters > 0 && (prevJoiner || GRAPHEME_CONTINUATION.test(char))) {
476 prevJoiner = char === "\u200d";
477 continue;
478 }
479 clusters += 1;
480 prevJoiner = char === "\u200d";
481 }
482 return clusters;
483};
484
485export const ProjectMonogramText = TrimmedNonEmptyString.check(463export const ProjectMonogramText = TrimmedNonEmptyString.check(
486 Schema.isMaxLength(32),464 Schema.isMaxLength(32),
487 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),465 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
488 Schema.makeFilter((text) => countGraphemes(text) <= 2),
489);466);
490467
468const ProjectLucideIcon = Schema.Struct({
469 kind: Schema.Literal("lucide"),
470 name: ProjectLucideIconName,
471 color: ProjectIconColor,
472});
473const ProjectEmojiIcon = Schema.Struct({
474 kind: Schema.Literal("emoji"),
475 emoji: ProjectEmoji,
476});
477const ProjectMonogramIcon = Schema.Struct({
478 kind: Schema.Literal("monogram"),
479 text: ProjectMonogramText,
480 color: ProjectIconColor,
481});
482const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]);
483const ProjectLucideIconWire = Schema.Struct({
484 ...ProjectLucideIcon.fields,
485 monogramText: Schema.optional(ProjectMonogramText),
486 monogram: Schema.optional(ProjectMonogramText),
487});
488
489// Older peers only know lucide/emoji. Keep monograms out of their validated
490// `monogram` field too: old grapheme counters can reject otherwise valid text.
491export const ProjectIconOverride = Schema.Union([491export const ProjectIconOverride = Schema.Union([
492 Schema.Struct({492 ProjectLucideIconWire,
493 kind: Schema.Literal("lucide"),493 ProjectEmojiIcon,
494 name: ProjectLucideIconName,494 ProjectMonogramIcon,
495 color: ProjectIconColor,495]).pipe(
496 // Older clients ignore this field and render the named Lucide icon instead.496 Schema.decodeTo(
497 monogram: Schema.optional(ProjectMonogramText),497 ProjectIcon,
498 }),498 SchemaTransformation.transform({
499 Schema.Struct({499 decode: (icon): typeof ProjectIcon.Type => {
500 kind: Schema.Literal("emoji"),500 if (icon.kind !== "lucide") return icon;
501 emoji: ProjectEmoji,501 const text = icon.monogramText ?? icon.monogram;
502 }),502 return text === undefined
503]);503 ? { kind: "lucide", name: icon.name, color: icon.color }
504 : { kind: "monogram", text, color: icon.color };
505 },
506 encode: (icon) =>
507 icon.kind === "monogram"
508 ? {
509 kind: "lucide" as const,
510 name: "folder-code",
511 color: icon.color,
512 monogramText: icon.text,
513 }
514 : icon,
515 }),
516 ),
517);
504export type ProjectIconOverride = typeof ProjectIconOverride.Type;518export type ProjectIconOverride = typeof ProjectIconOverride.Type;
505519
506export const OrchestrationProject = Schema.Struct({520export const OrchestrationProject = Schema.Struct({