Skip to content

feat(web,mobile): environment icons can be an emoji, a monogram, or colored - #3

Open
amanthanvi wants to merge 8 commits into
env-icons/03-curatedfrom
env-icons/04-rich
Open

amanthanvi wants to merge 8 commits into
env-icons/03-curatedfrom
env-icons/04-rich

Conversation

@amanthanvi

@amanthanvi amanthanvi commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

What changed

Both renderers draw the emoji and monogram variants and apply a chosen color to a named icon.

  • Web: emoji and monograms render as a span sized like a bare svg, since the menu and button rules that size icons match only svg. The option-slot helper caches its wrapper components on the icon object, which the resolver hands back by reference, so a filter menu rebuilt on each keystroke keeps one component identity per environment and its icon subtree does not remount. Keying on the object rather than a rendered string also means the image variant two layers up does not put a 32 KB data URL in a map key.
  • Mobile: emoji and monograms get an explicit width, because emoji advance widths vary per glyph and platform and these sit in flex rows. A monogram draws both characters at every size, as web does, so the same machine never reads K on the phone and K8 on the desktop. The grapheme split never uses Intl.Segmenter, which Hermes does not ship, and it counts the way the contract's monogram bound counts, so two tiles show exactly what the picker accepted. The theme generator gains one 600/400 pair per pickable color.
  • A chosen color replaces the caller's resting tint. Rows draw connection state beside the glyph (a dot, the subtitle, or dimming the whole row), never on it, so a red icon never reads as a failed one. This precedence is new. A project's color wins unconditionally today.
  • Emoji, monograms, and images are hidden from assistive technology like the svg glyphs are; the row's label already names the machine. A locked environment prints the reason beneath the disabled menu item, since a disabled item takes no pointer events and nothing hover-based could carry it.
  • The web picker becomes a dialog opened from the row menu. Fourteen ids plus swatches and a text input do not fit a radio submenu. The dialog is mounted beside the menu, not inside it, because a menu popup unmounts its children the moment an item is clicked.
  • One rule decides what a pick from the curated grid stores. A machine kind goes through the shared reference detection returns, which keeps the row memoization working and encodes to the bare string an older server accepts. A role has neither property and stays a named icon. The write rule and the grid preview each held a copy of that rule, and the mobile picker two layers up adds two more, so it moved into contracts beside the reference cache it depends on.
  • The monogram bound is enforced in the picker and not only in the schema. An environment has no decider to catch what the picker lets through, so this keeps that error away from the user. Every decision the dialog makes is a pure function with tests.
  • The emoji list and parser move to a Lucide-free module; projectIconOptions.ts reads the whole Lucide name list at import, and the environment picker should not.

Why

Fourteen named glyphs still cannot say which of three dev boxes is which, and a machine's role often has no icon at all.

Stacked on #2. Layer 4 of 6.

Opened on the fork because GitHub only accepts a base branch that lives in the base repository; it will be re-targeted to pingdotgg/t3code once its base merges. The entry point upstream is pingdotgg#15511.

UI changes

menu

dialog

database violet

emoji

monogram

after emoji

after monogram

Verification

Two sandbox servers paired into one web client, with state seeded from a snapshot and never the live install:

  • Database + Violet saved to the remote server's settings.json as {"kind":"icon","name":"database","color":"violet"}; the row's svg carried lucide-database … text-violet-600 dark:text-violet-400.
  • Emoji saved as {"kind":"emoji","emoji":"🚀"}; the row rendered the sized span.
  • Monogram: the field prefilled from the label, three letters marked it invalid and disabled Save, and k8 saved as {"kind":"monogram","text":"K8","color":"teal"} with the svg text K8 in teal.
  • Reopening the dialog restored the current variant each time. Picking the detected kind wrote null and the key left the file.
  • Unit: EnvironmentIconPicker.test.ts, projectIconOptions.test.ts, mobile grapheme.test.ts, environmentMonogram.test.ts, and the theme generator freshness test.
  • Typecheck green for web and mobile.

A device check is still missing for two-character monogram legibility at the 10 to 12 point thread-list sizes, which no typecheck can hold.

An adversarial review of this layer found and fixed, before opening: the lock reason had become invisible on a disabled menu item; the emoji span's responsive size variant survived the class merge and re-grew every icon to 16 px on desktop; mobile collapsed monograms to one character while web drew both; and emoji were read aloud by screen readers.

Claude Fable 5.1 via Claude Code

Summary by Sourcery

Enable users to distinguish connected environments with customizable icons, emojis, monograms, and colors across web and mobile.

New Features:

  • Add cross-platform environment icon support for curated icons, emojis, monograms, and optional colors.
  • Provide a web dialog for selecting and saving environment icon variants, with server capability checks and automatic-detection reset behavior.

Bug Fixes:

  • Keep connection status styling separate from environment icon colors and improve accessibility and locked-state messaging.
  • Ensure monograms render consistently across web and mobile and remain within the picker’s one- or two-character limit.

Enhancements:

  • Centralize environment icon selection and write rules in shared contracts and picker logic.
  • Cache web icon wrapper components and isolate emoji utilities from Lucide icon loading to preserve rendering performance.
  • Add adaptive theme colors for mobile environment icons in light and dark modes.

Documentation:

  • Document environment icon customization, persistence across devices, automatic detection reset, and server compatibility.

Tests:

  • Add coverage for picker decisions, emoji validation, monogram normalization and grapheme handling, and generated theme color freshness.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Sep 20, 2026
@sourcery-ai

sourcery-ai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Introduces synchronized environment icon customization across web and mobile, supporting colored named icons, emoji, and validated two-character monograms; shared contracts govern persistence and legacy compatibility, while renderer-specific sizing, accessibility, caching, capability locks, lazy loading, and adaptive themes keep the UI consistent.

Sequence diagram for saving a customized environment icon

sequenceDiagram
    actor User
    participant Dialog as EnvironmentIconPickerDialog
    participant Logic as EnvironmentIconPickerLogic
    participant Settings as EnvironmentSettings
    participant Server
    participant Row as EnvironmentConnectionRow

    User->>Dialog: Choose icon, emoji, or monogram
    Dialog->>Logic: resolveEnvironmentIconDialogWrite()
    Logic-->>Dialog: write icon or invalid reason
    alt valid selection
        Dialog->>Settings: onSelect(icon)
        Settings->>Server: updateSettings()
        Server-->>Settings: Persist environment icon
        Settings-->>Row: resolveEnvironmentIcon()
        Row-->>User: Render customized icon
    else invalid monogram or emoji
        Dialog-->>User: Show validation reason
    end
Loading

Flow diagram for environment icon selection and compatibility

flowchart LR
    Current[Current environment icon] --> Dialog[Picker dialog]
    Dialog --> Choice{Selected variant}
    Choice --> Named[Named icon]
    Choice --> Emoji[Emoji]
    Choice --> Monogram[One or two character monogram]
    Named --> Detect{Matches detected machine kind?}
    Detect -->|Yes| Null[Store null for automatic detection]
    Detect -->|No| Reference[environmentIconForCuratedId]
    Emoji --> Rich[Store rich icon object]
    Monogram --> Validate[Normalize and validate bound]
    Validate --> Rich
    Null --> Server[Environment server settings]
    Reference --> Server
    Rich --> Server
Loading

File-Level Changes

Change Details Files
Add shared environment icon contracts and persistence rules for curated icons, colors, emojis, and monograms.
  • Centralize curated-id conversion while preserving machine-kind detection reset and legacy string encoding.
  • Validate and normalize emoji and monogram writes, including the two-character bound and optional colors.
  • Document synchronization, server capability requirements, and automatic detection behavior.
packages/contracts/src/server.ts
apps/web/src/components/settings/EnvironmentIconPicker.logic.ts
apps/web/src/components/settings/EnvironmentIconPicker.test.ts
docs/user/project-settings.md
Render customized environment icons consistently across web and mobile.
  • Render emoji with fixed layout sizing and hide custom text from assistive technology where row labels already identify the environment.
  • Render two-character monograms with platform-appropriate sizing and grapheme handling without relying on Hermes Intl.Segmenter.
  • Apply selected icon colors independently from connection-state indicators, with light/dark adaptive theme mappings on mobile.
  • Cache web icon wrapper components by icon object identity to avoid remounts during picker/filter rerenders.
apps/web/src/components/EnvironmentMachineIcon.tsx
apps/web/src/components/Sidebar.tsx
apps/web/src/iconEmoji.ts
apps/web/src/components/EnvironmentMachineSymbol.tsx
apps/mobile/src/components/environmentMonogram.ts
apps/mobile/src/lib/grapheme.ts
apps/mobile/src/components/environmentMonogram.test.ts
apps/mobile/src/lib/grapheme.test.ts
apps/mobile/src/components/environmentIconColors.ts
apps/mobile/generated-uniwind-themes.css
apps/mobile/scripts/generate-uniwind-themes.mts
Replace the web icon submenu with a lazily loaded dialog-based picker.
  • Mount the dialog alongside the menu so selecting an item does not unmount it with the menu popup.
  • Provide curated icon, swatch, emoji, and monogram controls with previews, validation, capability locks, and save/cancel behavior.
  • Show lock reasons explicitly for disabled menu actions and gate rich icon variants on server support.
  • Extract emoji parsing and the emoji catalog from the Lucide-dependent project icon options module.
apps/web/src/components/settings/EnvironmentIconPicker.tsx
apps/web/src/components/settings/EnvironmentIconPickerDialog.tsx
apps/web/src/components/settings/ConnectionsSettings.tsx
apps/web/src/components/settings/ProjectIconPickerDialog.tsx
apps/web/src/projectIconOptions.ts
apps/web/src/projectIconOptions.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 8103351.

This comment will update automatically after the next completed run.

@amanthanvi
amanthanvi force-pushed the env-icons/04-rich branch 2 times, most recently from 9e87c26 to eaacbd5 Compare September 21, 2026 04:16
@amanthanvi
amanthanvi force-pushed the env-icons/04-rich branch 2 times, most recently from 8ce70d9 to 48cae25 Compare September 21, 2026 05:17
@amanthanvi

amanthanvi commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner Author

Pushed 48cae252ed. The mobile monogram renderer was dropping a character, and worse than dropping it.

firstGrapheme matched /^[\s\S]\p{M}*/u, so it took one code point plus combining marks and stopped. MonogramText also admits ZWJ and ZWNJ, and monogramCharacters slices the remainder and takes its first grapheme. For "A\u200dB" that produced ["A", "\u200d"]. The first tile drew the A, the second tile drew an invisible joiner, and the B was gone from the output entirely.

The pattern is now /^[\s\S][\p{M}\u200c\u200d]*/u, which matches what Intl.Segmenter returns for these inputs: ["A\u200d", "B"].

The fallback still splits a Devanagari conjunct that Intl.Segmenter keeps whole. That is the documented limit of counting code points, and the doc comment on the function says so rather than implying the two branches agree.

Tests in grapheme.test.ts and environmentMonogram.test.ts cover both joiners. Both stub Intl with vi.stubGlobal("Intl", Object.create(Intl, { Segmenter: { value: undefined } })) so the fallback runs whether or not the host provides the real thing. Five tests across the two files pass. Rebased onto 0bfd1cf743.

@amanthanvi

amanthanvi commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner Author

Rebased onto current main

Rebased the whole stack onto main at 76cc9b08f1, which was 52 commits past the previous base. All 20 commits replayed with no conflicts, and 12 of the stack's 74 files fall in the region main touched.

git range-diff against the pre-rebase tips shows 19 of the 20 commits carry an identical patch. The one that differs is refactor(web,mobile): environment renderers take the resolved icon, and the difference is context only. Main added closeOnClick to the environment MenuRadioItem in BranchToolbar.tsx, two lines above the prop this stack renames. Both changes are present in the rebased file.

A clean textual replay does not prove the stack still holds together, so I went looking for the hazard that would hide behind one. Layer 2 renames resolveEnvironmentMachineKind across 40 files, so a call site added upstream would compile against a symbol this stack deletes. The 52 commits add none.

Each layer is its own pull request, so I verified each one standing alone rather than only at the tip:

layer typecheck tests
01 contracts 4 packages, 0 errors 7 files, 290 tests
02 rename 5 packages, 0 errors 7 files, 291 tests
03 curated 5 packages, 0 errors 7 files, 296 tests
04 rich 5 packages, 0 errors 9 files, 303 tests
05 lucide 5 packages, 0 errors 10 files, 307 tests
06 image, mobile, detect 6 packages, 0 errors 11 files, 321 tests

No review thread was open when I rebased, so the force push moved commits rather than answers. Line comments from earlier rounds now anchor to the old SHAs.

@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 28 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@amanthanvi

amanthanvi commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner Author

Rebased onto current main

Rebased the stack onto main at f5ef0ddb90, 72 commits past the previous base. Two layers conflicted, and main also changed the lint rules this stack has to pass.

What main changed that this stack had to follow

shadcn/no-restyle is now a lint error rather than a counted warning (pingdotgg#13210), and the ceiling script is gone. Every layer here lints with 0 errors on the files it changes.

That made one commit unnecessary. fix(web): the icon dialog keeps its spacing off DialogPanel moved the dialog's flex column into a wrapper div to keep the old ceiling from growing. Main's own ProjectIconPickerDialog now puts flex min-h-0 flex-col on DialogPanel and lets the panel's built-in space-y-4 do the spacing, so this dialog does the same from the commit that introduces it. The wrapper and that commit are gone.

The layer 3 commit that deduplicates the icon submenu's lock row said it existed for the ceiling. It still removes a duplicated row, so it stays with a message that says only that.

Conflicts

Review findings

Sourcery reviewed the last push. One finding was real and is fixed on layer 6: the web dialog let Save run while an image was still encoding, which wrote the previous image and dropped the new pick. The other two have replies on the threads. One asks mobile to hide the emoji glyph from screen readers, but mobile glyphs have been labeled on main all along. The other asks to reject a photo whose dimensions the picker did not report, which is a trade-off the code already records.

Layer 6 also drops an isEnvironmentMachineKind import the dialog stopped using.

Per-layer verification

Each layer is its own pull request, so each was checked standing alone.

layer typecheck lint tests
01 contracts 4 packages, 0 errors 0 errors 4 files, 244 tests
02 rename 5 packages, 0 errors 0 errors 5 files, 276 tests
03 curated 5 packages, 0 errors 0 errors 6 files, 285 tests
04 rich 5 packages, 0 errors 0 errors 9 files, 296 tests
05 lucide 5 packages, 0 errors 0 errors 10 files, 300 tests
06 image, mobile, detect 5 packages, 0 errors 0 errors 12 files, 327 tests

Layer 1 runs one test fewer than last time because main removed one of its own tests from settings.test.ts (pingdotgg#13115). The 12 tests this stack adds are all still there.

The force push moved commits, so line comments from earlier rounds now anchor to the old SHAs.

@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="apps/mobile/src/lib/grapheme.ts" line_range="2" />
<code_context>
+/**
+ * First user-perceived character of `text`, taken as a base character with the
+ * combining marks and joiners that bind to it. That is right for the letters
+ * and digits a monogram is made of, and it is the count `isMonogramLength`
+ * holds the text to, so the tiles never disagree with the bound the picker
+ * enforced. `Intl.Segmenter` would be exact and Hermes does not ship it.
+ *
+ * The joiners matter because `MonogramText` admits them. Without them
</code_context>
<issue_to_address>
**nitpick:** The doc comment claims `firstGrapheme` returns a user-perceived character, but the implementation deliberately splits scripts such as Devanagari conjuncts that `Intl.Segmenter` treats as one grapheme; the comment therefore describes behavior the function does not provide.

**Suggested fix:** Describe this as the contract's simplified base-plus-mark/joiner segmentation rather than user-perceived grapheme segmentation.

```suggestion
 * First simplified base-plus-mark/joiner segment of `text`: a base character with the
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. If the icon schema, picker logic, or rendering is wrong, an environment can retain an incorrect icon that is visible across connected devices even after this code is reverted. The stored value is bounded and can be repaired by selecting another icon, but reverting alone does not remove existing overrides.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread apps/mobile/src/lib/grapheme.ts Outdated
@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 5 days and 9 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@amanthanvi

amanthanvi commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner Author

Rebased onto current main

Rebased the stack onto main at 568c9bc4d0, 50 commits past the previous base. Every commit replayed with no conflicts.

Main added four web lint errors in that range: shadcn/no-raw-colors, no-unknown-classes, require-static-classes, and no-arbitrary-values. None of them fires on this stack. The web icon colors come from projectIconColors, which main had already moved to theme tokens, and of the stack's three arbitrary values, rounded-[25%] and text-[length:80cqh] are on main's allowlist for project icons and sm:w-[32rem] is a dialog width, which the rule treats as layout.

Two changes follow main:

  • refactor(server): the container marker check recovers with orElseSucceed. chore: clear Effect language service suggestions pingdotgg/t3code#13536 replaced Effect.catch(() => Effect.succeed(...)) with Effect.orElseSucceed across the server, including this file's other helpers. The container check this stack added was the last call in the old form, and the Effect language service reported it.
  • docs(user): the mobile icon path goes through the environment page. feat(mobile): manage environment and provider updates pingdotgg/t3code#13302 made Settings → Environments open a page per environment instead of expanding the row. The mobile picker still sits in the row's expanded body, which that page shows under Connection, so the user doc now names the page and the section.

Each layer typechecks with 0 errors in every package it changes, lints with 0 errors, and passes its tests, from 4 files and 244 tests at layer 1 to 12 files and 327 tests at layer 6. The fix commits cited in earlier review replies have new SHAs, and those replies now point at them.

@amanthanvi

amanthanvi commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner Author

Rebased onto orchestrator V2

Rebased the stack onto main at 40596072eb, past the V2 rewrite (pingdotgg#2829). Upstream closed pingdotgg#12675 during that transition. Layer 1 is now pingdotgg#15511.

What the rebase had to adapt:

  • orchestration.ts is gone. Project icon schemas now live in project.ts, which imports the shared color, emoji, monogram, and Lucide-name leaves (layer 1).
  • V2 resolves environment glyphs in shared atoms, presentation.ts on web and thread-list-environments.ts on mobile. Both now carry the icon instead of a machine kind (layer 2).
  • Main added scheduled-task and mobile settings-filter call sites, and they draw the icon now. Native menus only take a symbol name, so anything other than a curated icon shows the generic server there.
  • The mobile theme CSS is regenerated from the merged generator script (layer 4).

Layer 1 has one new fix. An inline PNG must now carry all eight signature bytes, since Macroscope found that the old prefix check let a seven-byte value through.

Every layer passes typecheck, lint, format, and its own tests. At the top of the stack, I picked icons on web and in the iOS simulator against isolated dev state, and each client showed the other's pick.

@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Invalid custom emoji input can save a different value, and mobile decorative icons create duplicate screen-reader announcements.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Adds customizable environment icons across web and mobile, including emojis, monograms, and colors.

Changes:

  • Adds a web icon-picker dialog with compatibility checks and validation.
  • Extends web/mobile renderers for rich environment icons.
  • Adds shared selection logic, mobile theme colors, tests, and documentation.
File Description
packages/​contracts/​src/​server.ts Centralizes curated-icon resolution.
docs/​user/​project-settings.md Documents environment icon customization.
apps/​web/​src/​projectIconOptions.ts Moves emoji utilities out of Lucide-dependent code.
apps/​web/​src/​projectIconOptions.test.ts Updates emoji utility imports.
apps/​web/​src/​iconEmoji.ts Defines emoji choices and parsing.
apps/​web/​src/​components/​Sidebar.tsx Allows selected icon colors to override tint.
apps/​web/​src/​components/​settings/​ProjectIconPickerDialog.tsx Uses extracted emoji utilities.
apps/​web/​src/​components/​settings/​EnvironmentIconPickerDialog.tsx Implements the web picker dialog.
apps/​web/​src/​components/​settings/​EnvironmentIconPicker.tsx Replaces the submenu with a lazy-loaded dialog host.
apps/​web/​src/​components/​settings/​EnvironmentIconPicker.test.ts Tests picker write and validation rules.
apps/​web/​src/​components/​settings/​EnvironmentIconPicker.logic.ts Encapsulates picker compatibility and write logic.
apps/​web/​src/​components/​settings/​ConnectionsSettings.tsx Integrates the picker into connection rows.
apps/​web/​src/​components/​EnvironmentMachineIcon.tsx Renders colored, emoji, and monogram icons.
apps/​mobile/​src/​lib/​grapheme.ts Adds Hermes-compatible character splitting.
apps/​mobile/​src/​lib/​grapheme.test.ts Tests grapheme splitting.
apps/​mobile/​src/​components/​environmentMonogram.ts Splits monograms into display characters.
apps/​mobile/​src/​components/​environmentMonogram.test.ts Tests monogram rendering input.
apps/​mobile/​src/​components/​EnvironmentMachineSymbol.tsx Renders rich mobile environment icons.
apps/​mobile/​src/​components/​environmentIconColors.ts Maps icon colors to adaptive theme classes.
apps/​mobile/​scripts/​generate-uniwind-themes.mts Adds adaptive palette variables.
apps/​mobile/​generated-uniwind-themes.css Regenerates mobile theme output.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +67 to +70
<Text
accessibilityLabel={icon.emoji}
allowFontScaling={false}
numberOfLines={1}
Comment on lines +285 to +291
aria-invalid={customEmoji.trim().length > 0 && firstEmoji(customEmoji) === null}
placeholder="Paste an emoji"
onChange={(event) => {
const value = event.currentTarget.value;
setCustomEmoji(value);
const nextEmoji = firstEmoji(value);
if (nextEmoji) setEmoji(nextEmoji);
@sourcery-ai

sourcery-ai Bot commented Oct 4, 2026

Copy link
Copy Markdown

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

…olored

Fourteen named glyphs still cannot say which of three dev boxes is which,
and a machine's role often has no icon at all.

Both renderers now draw the emoji and monogram variants and apply a chosen
color to a named icon. On web, emoji and monograms render as a span sized
like a bare svg, since the menu and button rules that size icons match
only `svg`; the option-slot helper caches wrapper components by value so
a filter menu rebuilt each render keeps its component identity. On
mobile, emoji and monograms get an explicit width, because emoji advance
widths vary per glyph and platform and these sit in flex rows, and a
monogram draws both characters at every size as web does, so the same
machine never reads `K` on the phone and `K8` on the desktop. The mobile
grapheme split does not assume `Intl.Segmenter`, which nothing in the
repo proves this Hermes build ships.

Emoji, monograms, and images are hidden from assistive technology like
the svg glyphs are; the row's label already names the machine. A locked
environment prints the reason beneath the disabled menu item, since a
disabled item takes no pointer events and nothing hover-based could
carry it. The text variants carry a plain `size-4` so a caller's size
always wins. A responsive base like the menu's `size-4.5 sm:size-4`
would survive the class merge as a separate variant and re-grow every
icon at the breakpoint.

A chosen color replaces the caller's resting tint. Rows draw connection
state beside the glyph (a dot, the subtitle, or dimming the whole row),
never on it, so a red icon never reads as a failed one.

The web picker becomes a dialog opened from the row menu, since fourteen
ids plus color swatches and a text input do not fit a radio submenu. The
dialog is mounted beside the menu, not inside it, because a menu popup
unmounts its children the moment an item is clicked. The monogram bound
is enforced in the picker and not only the schema. An environment has no
decider to catch what the picker lets through, so this is what keeps that
error away from the user. Every decision the dialog makes is a pure
function with tests.

The emoji list and parser move to a Lucide-free module. The mobile theme
generator gains one 600/400 pair per pickable color, matching what web
draws in light and dark.

Claude Fable 5.1 via Claude Code
`MonogramText` admits ZWJ and ZWNJ, but the fallback `firstGrapheme` uses
when `Intl.Segmenter` is missing matched a base character and its combining
marks only. So "A‍B" split into "A" and the bare joiner. The second tile
drew an invisible character and the "B" was gone. `Intl.Segmenter` binds the
joiner to the character before it, so the two paths disagreed on text a user
can type into the picker.

Bind the joiners in the fallback too. A Devanagari conjunct still splits
where `Intl.Segmenter` keeps it whole, which the test now names as the known
limit rather than an incidental result.
`firstGrapheme` reached `Intl.Segmenter` through a type assertion that
restated the lib declaration. Reading the member directly keeps the same
`typeof` guard, which a Hermes build that defines the property as undefined
still needs.

Also rewrites four comments that used a colon as a mid-sentence connector.
…gram tile

The web picker built its own `Intl.Segmenter` at module scope to count
graphemes, so a runtime without `Intl.Segmenter` failed to evaluate the module
rather than falling back. Contracts already exports `isMonogramLength`, which
guards the constructor and counts code points minus combining marks otherwise,
so the picker calls that instead.

The mobile monogram tile set `accessibilityLabel` on a `View` without
`accessible`, which leaves the label unreachable. Every other branch in that
component is already reachable.

Also corrects the `monogramCharacters` comment, which claimed the function
returns the whole text when it cuts anything past two graphemes.

Model: Claude Opus 5 via T3 Code.
`environmentMachineIcon` built a string key for every icon so a module-level
`Map` could cache the wrapper component. `resolveEnvironmentIcon` already
returns a stored override by reference and memoizes a detected icon per machine
kind, so the icon object is stable for as long as the settings snapshot it came
from. A `WeakMap` keyed on it hits on the same renders the string key did, drops
the key builder, and holds nothing once the snapshot is replaced.

The plain curated case no longer skips the wrapper. That branch returned the
Lucide component itself, and the wrapper it avoided renders the same svg with
the same two props.

Also drops the `firstEmoji` and `PROJECT_EMOJIS` re-export this layer added to
`projectIconOptions.ts`. That module reads the whole Lucide name list at import
time, which is why the emoji helpers moved to `iconEmoji.ts`, so re-exporting
them from it points readers back at the module the split avoids. Its two
importers now read `iconEmoji.ts` directly.

Model: Claude Opus 5 via T3 Code.
…tores

A pick from the curated grid follows two rules. A machine kind has to go
through `environmentIconForMachineKind`, because rows are memoized on
the icon object and because that variant encodes to the bare string an
older server accepts. A role has neither property and stays a named
icon.

The ternary that carried both was copied at the write rule and again at the grid
preview, with only one copy commenting on the wire form, and the mobile picker
two layers up adds two more copies. Move it beside the reference cache it
depends on, where the comment can state both halves once.

Model: Claude Opus 5 via T3 Code.
`firstGrapheme` used `Intl.Segmenter` where it existed. This file only ever
runs on Hermes, which ships none, so that branch was live in vitest on Node and
dead in the app, which left the tests covering a path that never shipped. It is
also why `t3code/no-hermes-unsupported-apis` reports the constructor under
`apps/mobile`, at error severity.

Taking a base character with its combining marks and joiners is what the
app was already doing, and it matches how `isMonogramLength` counts, so
two tiles show exactly what the picker accepted. The stubs that shadowed
`Intl.Segmenter` go with it.
EnvironmentIconPickerHost renders nothing while closed, so the dialog
mounts fresh on every open and its useState initializers already start
from the current icon. The effect that re-applied the same state on open,
its ref, and the open prop that was always true are gone. The pattern came
from the project icon dialog, which stays mounted.

Claude Opus 5.5 via Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants