I spent eleven days telling coding agents to use the same Card component.
On October 8, I asked Codex to look at the scattered UI in Char. On October 19, I opened a Claude Code session with: "why is the clean up card like this? it does not seem to use shared card component".
Somewhere between those messages, we had defined the components, written stories, and asked for the migration across desktop, mobile, and web. I was still finding exceptions.
I'm building Char, where you work with an assistant called Charlie. An email can lead to a conversation, and that conversation can produce something you need to approve. We needed a consistent way to represent those interactions wherever they appeared.
A Card is where the conversation and work happen. It has room for a preview, the conversation, and a composer. A Chip is a compact reference to something, like a person or a Page, with a preview when you need more context. A Prompt sits inside a Card when Charlie needs guidance or approval.
Card.Header means the same thing in a design discussion and an import. Prompt.Clarification collects guidance; Prompt.Confirmation lets you review a prepared action before approving it.
I like being able to point at something and use its actual name.
Initially, I grouped the components by where they appeared. Cards and Chips lived in the editor. Panels lived in the Deck. Then I asked why an expanded Card and a Panel couldn't share an implementation, with the host deciding the width.
The place something appeared had become part of its identity. Once I questioned that, we could share more of the component.
Chips made the details harder to ignore. I asked to see all the variants together in a Storybook grid. Previews that were supposed to have the same shape didn't. Titles repeated information already visible in the Chip. A person preview showed a generic organization icon where we already had an organization Chip with its own avatar.
Putting the examples next to each other gave me something concrete to correct. It also made me decide what should stay different: a GitHub pull request has its own states, and its Chip should respect them.
With Prompts, I kept asking to see the real Card with the composer present. An isolated confirmation could look fine while the composed version had duplicate borders or awkward spacing. I wanted to review the thing a person would actually use.
These are screenshots from those reviews, two days apart. The blue annotations are mine; both show the same example, Charlie reorganizing Home, while we were still changing it.
October 16: the draft had its own header and toolbar inside another frame. The example had no composer.
October 18: Confirm moved into the Prompt header, the repeated draft toolbar was gone, and the composer stayed below the preview. I was still marking spacing problems.
In code, the difference looks like this. These are simplified examples of the composition, not verbatim snapshots of those two builds. preview, conversation, and composer stand in for the feature's content.
Before, each surface could assemble its own version of the same idea:
<section className="rounded-2xl border p-4"> <h2>Reorganize Home</h2> <div className="mt-3">{conversation}</div> <div className="mt-3 rounded-xl border p-3"> <h3>Reorganize Home</h3> <div className="mt-2 rounded-lg border p-3">{preview}</div> <button disabled={applying} onClick={applyChanges}> Confirm </button> </div> <div className="mt-4">{composer}</div></section>Nothing about writing that JSX is difficult. The problem is writing it again for the next surface. Each copy gets to decide how many borders to draw, where the title goes, and how the approval sits above the composer. Fixing one copy leaves the others alone.
With the shared parts, the same example becomes:
import { Card } from "@char/ui/card";import { Prompt } from "@char/ui/prompt"; <Card.Panel width="host" hasBody> <Card.Header title="Reorganize Home" label="Reorganize Home" /> <Card.Body> {conversation} <Card.BodyPrompt> <Prompt.Confirmation title="Apply these changes?" disabled={applying} onConfirm={applyChanges} > {preview} </Prompt.Confirmation> </Card.BodyPrompt> </Card.Body> <Card.Composer>{composer}</Card.Composer></Card.Panel>;The feature still supplies the preview and decides what happens when you confirm. The shared parts own the frame, body spacing, approval controls, and composer inset. width="host" lets the place hosting the Card set its width. Card.BodyPrompt keeps the approval above the composer while the conversation scrolls.
Now, changing the confirmation's button treatment means changing one implementation for every surface that uses Prompt.Confirmation. Its keyboard shortcut and disabled state come along with it. I can ask an agent to fix the Prompt and name the same thing I am looking at in Storybook.
But a Storybook example could also bypass the shared component.
We had a shared component and competing implementations of it. Each new exception meant another place to explain the same decision.
That is why we started connecting our design docs to the way agents work.
Our root design.md is an index. It points to the relevant component rules and product concepts, with app-specific guidance for desktop and mobile. Sharing a Card doesn't mean a phone and a desktop should have the same interaction.
Storybook shows the components and their states using production code. We can open an example, interact with it, and discuss the exact part that needs to change. The code remains the source of truth when the docs and the app disagree.
The design skill tells the agent how to use both. Before writing UI, find the existing component and inspect its consumers. Reuse it if it fits. Extend it if a state is missing. Create something new only after checking that nothing covers the need.
It also tells the agent where to make the fix. A shared problem belongs in the shared component; a mobile interaction belongs in the mobile composition. Changes to the experience should update the relevant docs and stories together.
I don't want to paste eleven days of corrections into the next session. I want the next session to find the decisions where it does the work.
On October 22, I found another reason to question the abstraction. A Clean up Card was showing up when there was nothing to clean up. I asked Claude Code why we still had a DataCard, a CleanupCard, and a ScheduleCard. Were they all built on the shared Card?
They could share the frame and still make their own decisions about everything inside it. The JSX above helps with borders and spacing, but each feature can still decide what counts as content, how to show a result, and whether the conversation is available.
That morning I explained why I kept coming back to this: the experience we build for a built-in Card should be reproducible when someone creates their own custom Schedule.
That gives me a more useful question to ask during a review. Could a person make this Card themselves by writing instructions, choosing when it runs, and configuring its appearance? If it needs a private component or a capability that only our built-in Schedule gets, we still have work to do.
Briefing exposed the difference. Its Upcoming Card had a calendar view with its own behavior. We considered keeping that view and letting Schedules select it. I came back to a simpler requirement: every Card should basically be a chat. Briefing should read the relevant apps and return its results through the same path as a custom Schedule. I also dropped the separate This week and Next week views. One Briefing Card was enough.
The boundary in the code is becoming clearer. Card.* provides the visual parts. DataCard composes them from a common CardData shape: title, icon, content, Prompts, actions, and the conversation. An email and a briefing can supply different content without owning different versions of the Card's behavior.
The output of a run needs a shared shape too. The current implementation has Charlie call finish_run with a message, list blocks, suggested actions, and a flag for whether anything is worth showing. The tool validates that answer before saving it. The Card renders the saved result.
A briefing can return calendar events as a list. A custom Schedule checking pull requests can return a list of reviews. The items differ, but both use the same list rendering. Suggested actions become Prompts through the same path. We can still ask for a short briefing in the instructions; the output structure gives the renderer something more dependable than prose to interpret.
Even hiding an empty Card crossed this boundary. We added a shared check for whether there was anything to show, then found that a Schedule's description or a run's sources could keep a Card visible after the run had explicitly asked to hide it. The fix had to carry that decision through the data feeding the renderer, not just the visible component.
This also changes how I want to build our defaults. A Schedule can now set its Card's header actions, and the built-ins are moving their buttons there too. If Briefing needs access to an app, the question becomes how a Schedule gets that access. Each built-in becomes a way to exercise the capabilities we give people.
Later in the session, I asked for an evening recap: what I did that day, what remained unfinished, and a little encouragement from Charlie. The default could be six in the evening, with a time the person can change. That is a useful next test. We should be able to describe the work in a Schedule and show its answer in the existing Card. We still have to build the recap and make its information available, but its existence shouldn't require inventing an EveningRecapCard.
We're still migrating the remaining paths. The shared renderer exists; that does not mean every Card has finished moving onto it, or that every built-in can already be recreated through the Schedule editor. The docs and skills haven't stopped drift. They give us a place to put the stronger rule as we discover it.
Abstraction is beautiful when I can make a decision once and have it apply everywhere that decision belongs. I started with the frame around a Card. Now I'm asking whether the person using Char can build what we built with it.
