feat(components): one fixed template for the conversation share card - #697
Conversation
The card was an appearance editor — six backdrops, five footer layouts, three frame paddings, a three-way palette, four toggles and a numeric code-collapse field — and almost none of its ~1000 reachable combinations produced an image worth sending. It is now one template whose only variable is the device: a phone form (360pt card inside a 16pt backdrop) and a desktop form (560pt inside 32pt), derived from `useIsMobile()` with no control for it, and light/dark as the single remaining switch. Every band shares one gutter, so the title, both speakers and the caption stand on one left edge. Turns are left-aligned — a shared image has no reader for whom the right side means "me" — and the grouping that alignment used to carry moved into an unequal vertical rhythm: the gap between two exchanges is twice the gap binding a prompt to its reply. All padding lives in one `LAYOUT` table rather than in the markup. Defects the option matrix had been hiding, found by screenshotting the stories: the default footer printed no Lody mark anywhere on the image (the sign-off now prints on the backdrop below the card); the QR code always encoded the product home page, because no caller ever passed `shareUrl` (removed, along with the `assetsReady` export gate it needed); the code block's language label permanently masked a wrapped first line; its copy control rendered opaque and was captured into the PNG; and the preview pane's percentage height left its scroller unbounded, so a tall card painted straight over the action row. Code wrapping is unconditional now — an image has no horizontal scrollbar — which retired the `MutationObserver` that rewrote Shiki's code DOM to clip over-tall blocks. The card keeps no height limit and never elides a selected message. The preview is a dialog on desktop and a bottom drawer on a handset, carrying the same one switch and two actions in both. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3a24691a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `.${CODE_SCOPE} .markdown-renderer [data-streamdown=code-block-body] pre` + | ||
| '{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;}', |
There was a problem hiding this comment.
Override the diff renderer's max-content width
When a selected assistant message contains a fenced diff block with a long line, MarkdownRenderer routes it through MarkdownDiffBlock, whose stylesheet retains width: max-content on the pre (src/tailwind/index.css:344-346). Overriding only min-width here therefore leaves the block sized to its longest unwrapped line, so it expands beyond the fixed-width card and is clipped by the card's overflow-hidden instead of wrapping. Override/reset width for this renderer as well so exported diffs preserve every line.
AGENTS.md reference: packages/components/src/components/AGENTS.md:L105-L108
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in bee90c51.
You were right that overriding min-width alone was not enough: width: max-content from index.css:344 survived, and max-content under pre-wrap is still the widest line, so the block kept its full unwrapped width and the card's overflow-hidden clipped it.
width: auto now joins the same scoped rule. It is a no-op for an ordinary code block (which sets no width), and the diff block then fills the container like any other block element, so the per-line add/remove backgrounds still span the full width.
Measured in a browser at the post size:
pre width |
past the card's right edge | |
|---|---|---|
| fixed | 502px | −29px (inside) |
width: max-content restored, as a negative control |
991px | +460px |
Stories now carry a diff fence at both card sizes. None did before — which is why the screenshots that caught three other wrapping defects missed this one.
Reverts one cut from the previous commit. Deleting all six grounds and hardcoding the signature gradient treated every appearance option as noise, and that was wrong about one of them: `welcome` is a hand-built export-safe still of the opening ceremony's shallow-water field — the live scene is a WebGL shader a DOM capture cannot serialize — and it lives nowhere else in the product, so removing it destroyed an asset rather than an option. `aurora`, `ocean` and `sunset` survive in the usage card regardless. The distinction that holds is between a choice that changes how a card reads — footer layout, padding, which turns hang where — and one that only changes what it reads against. The former is the editor the redesign removes; the latter is taste and belongs to the user. So the preview now carries exactly two controls, palette and ground, and neither can alter the template. `welcome` is also the only pale ground, so the sign-off inks dark on it rather than white. `none` keeps its place in the set: without a ground there is nothing to print the sign-off on and nothing to inset the card from, so the card becomes the whole image, drops its drop shadow, and takes `lody.ai` onto the second line of the caption's left column — the same fallback the usage card already documents, at no extra height. The desktop action row splits into two thin rows so a status line never has to squeeze the fixed-width controls; the handset drawer wraps the palette and the swatches onto one centred row above the actions. The seven backdrop locale keys come back with their original translations. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ount The mat's size comes back as the third and last choice, asked as a destination rather than as a measurement. The control it replaces offered "Compact", "Regular" and "Spacious" — three words for a number nobody exporting an image can judge. Nobody knows whether they want 32pt or 56pt of gradient, and nobody has to: they do know whether this is going into a message thread or onto a feed. A card read inside a thread is already the thing the reader is looking at, so its ground is a thin bleed. A card posted alone has to hold itself off whatever is behind it, so its ground is a real mat. Both take the same fraction of the card's width in either form — about a twentieth and a tenth — so the choice means the same thing on a handset as on a desktop, and the two values per format live in the same `LAYOUT` table as every other dimension. `none` leaves no mat to size, so the control goes inert rather than disappearing, which would relayout the row on a swatch click. `post` is the default: a share card is a poster until its author says otherwise. The desktop footer becomes controls over actions — three fixed-width controls and two buttons do not share a line — and the handset drawer wraps the three onto two centred rows above the actions. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit left the card incoherent: the user stated where the image was going, and that sized only the mat while the card's width still came from `useIsMobile()`. A desktop user sending a card into a group chat got a 560pt card in a thin bleed; a handset user posting to a feed got a 360pt card in a deep mat. Both are combinations nobody wants, and neither user could say so. Width and mat answer the same question, so they collapse into one control. `chat` is 360pt in a 16pt bleed — a handset's own content width, read inside a conversation the reader is already looking at, where the mat is mostly wasted height. `post` is 560pt in a 56pt mat — room for a ~70-character line of prose and a genuine line of code, on a ground that holds the card off whatever is behind it. `ChatShareCardFormat` is gone; `LAYOUT` is keyed by destination and carries every dimension of both cards. The two mats are deliberately not the same fraction of their card, about 4% and 10%: a message wants the least wasted height that still reads as a card, a post wants presentation. The device now decides nothing about the image. It seeds the opening guess at the destination — handset to `chat`, desktop to `post` — which one tap overrides, and it still chooses dialog versus drawer. Type sizes stay shared across both, so a chat card and a post card set the same words at the same size. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two earlier commits were wrong about this in opposite directions and both are corrected here. The first deleted the padding control and derived the mat from the card's size, arguing that nobody can judge 32pt against 56pt. That argument describes a settings form. It dissolves next to a live preview: the person does not read the value, they drag and watch the picture, which is a better answer than any name can give. Direct manipulation is exactly where exposing the quantity is right, and quantising a continuous dimension into named buckets there is the product deciding for someone who can already see the result. The second bound the mat to the width and claimed "a 560pt card in a thin bleed and a 360pt card in a deep mat are combinations nobody wants". That was an assertion and it was false in both halves: a wide card flush is what pasting into a README wants, and a narrow card in a deep mat is what a phone post wants. Width is a content decision about the measure, the mat is a presentation decision about the frame, and they are independent. So: size stays a two-way switch asked as where the image is going, and the mat is a slider over 0–96px on the template's own 4px grid, with the pixel count read back beside it. Size seeds the mat with an ordinary value for that size, which is what makes them read as "pick a starting point, then adjust". Picking a size always re-seeds — no hidden "has the user touched it" bit, because a flag that sometimes keeps a value is harder to predict than a preset that always resets. Zero is reachable. Below `MIN_SIGN_OFF_MAT` the ground stops being a margin, so the sign-off moves into the caption — the same fallback a card with no ground takes — and the drop shadow goes with it, since a shadow needs a ground to fall on. `ui/slider.tsx` is new and is the native range input rather than a library: the platform supplies keyboard stepping, Home/End, the ARIA role and value, and an OS-correct touch target. Two global rules in `tailwind/index.css` had to be worked around and are recorded in the primitive's `AGENTS.md`. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sm` is also `text-xs`, which every other control in that footer already uses, so at the default size `Copy image` and `Export PNG` were the only `text-sm` thing in it — the row read heavier than the four controls it sits under. 36px/14px becomes 32px/12px, and the icons drop to 14px with them. The drawer keeps the default size. There the two buttons are the primary touch targets rather than the last row of a control cluster, and `h-9` is already under the 44pt guidance without shrinking it further. The split is deliberate; do not unify it for consistency. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nation
Copying or saving the card now closes the preview and drops the message
selection behind it. Leaving a surface armed behind a task the user has
finished is just state they have to clear by hand.
Dismissing the preview still retains the selection — that is the case
where someone wants to go back and adjust it — so the two are told apart
by a completion callback rather than by `onOpenChange`, which cannot.
Doing this exposed a defect in `exportShareImage`: it swallowed a
cancelled native save dialog and resolved exactly like a successful one.
Closing on "export resolved" would have torn the flow down under someone
who had only backed out of the file picker, against a guarantee the Spec
already made. It now returns `{ saved }`, and the dialog completes only on
a real save. A browser download has no cancel signal to read and always
reports a save, which is honest: the browser owns the transfer from the
click onward.
The callback carries which action finished, because the two leave
different feedback behind. A save has the native dialog or the browser's
download UI; a copy has nothing once the preview is gone, so the host
toasts for it and only for it.
`cancelShareImageSelection` joins the chat surface handle. Confirming a
selection deliberately does not clear it — the preview can be reopened
against the same one — so only finishing the share or an explicit Cancel
ends it.
Model: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One content conflict, in `packages/components/src/ui/AGENTS.md`: both sides appended a new section at the end of the file. Kept both — main's Scroll area note and this branch's Slider note are independent topics. The merge also hit the AGENTS.md size gate, which is not a text conflict but is a conflict over a shared budget. `components/AGENTS.md` grew from 7222 to 8159 bytes on main (a new Conversation access section), leaving 33 bytes under the 8192 hard limit; this branch's 881-byte Share cards section could no longer fit, and neither could a bare link line. Rather than compress rules this branch does not own, the share card takes the scope every other topic cluster in `components/` already has: `chat-share-card.tsx` moves into `components/share-card/` with its own AGENTS.md and CLAUDE.md symlink. `components/AGENTS.md` returns to exactly main's 8159 bytes, so this branch is size-neutral on it and main's own budget problem stays main's to solve. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12d7901d07
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -1 +1 @@ | |||
| Subproject commit 345cd07a530f4cfbf6f398e823963e45128e4a69 | |||
| Subproject commit ce194fd6766292df0726680ccbb3a1737c609b5c | |||
There was a problem hiding this comment.
Restore the current DeepSeek extension pin
This resets acp-extension-dsh from 345cd07 to the much older ce194fd, while retaining the host migration in apps/cli/src/agent/deepseek-harness-runtime.ts to the newer profile API and dsh --profile launcher. That migration originally advanced the extension beyond ce194fd, and 345cd07 additionally contains the install-time build fix, so a fresh checkout can no longer build or launch the pinned DeepSeek closure consistently. Keep the parent gitlink rather than including this unrelated downgrade.
Useful? React with 👍 / 👎.
…lipping it Codex review, P1. Making code wrap in the card takes two declarations, not one, and only the first was there. A fenced `diff` renders through `MarkdownDiffBlock`, whose `pre` sets `width: max-content` so a wide patch can scroll inside the app (`tailwind/index.css:344`). The card's scoped rule relaxed only `min-width`, leaving that `width` standing — and `max-content` under `pre-wrap` is still the widest line. So a diff block kept its full unwrapped width, pushed past the card's fixed edge, and was clipped by the card's own `overflow-hidden` rather than wrapping. The claim "code never overflows a share card" was false for that one fence type. `width: auto` joins the same rule. It is a no-op for an ordinary code block, which sets no width, and the diff block then fills the container like any other block element, so the per-line add/remove backgrounds still span the full width. Measured in a browser at the post size: the diff `pre` is 502px and ends 29px inside the card's right edge, with `scrollWidth === clientWidth`. Restoring `width: max-content` as a negative control puts it back to 991px and 460px past the edge, which is the reported bug. Stories now carry a `diff` fence at both sizes. None did before, which is exactly why the screenshots that caught three other wrapping defects did not catch this one. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Desktop PR smoke regression failed on commit
Recordings not attached
The Actions artifact retains the complete trace, screenshots, logs, and runtime evidence. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bee90c5163
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -1 +1 @@ | |||
| Subproject commit 80205d81b9de02f4171564a7c3aaaebfd9d91ddd | |||
| Subproject commit ee292887b25c85d5f3afdd48ae04260e3d7409aa | |||
There was a problem hiding this comment.
Preserve the repaired ACP core pin
For a fresh root-workspace checkout, this resets acp-extension-core from 80205d8 to ee29288, even though the ancestor commit 8c429a8 (fix: acp core) explicitly restored 80205d8 after ee29288 had been introduced. The CLI and shared packages consume their ACP capability contract directly from this workspace submodule, and this PR makes no matching consumer changes, so the unrelated reset reintroduces the deliberately reverted contract revision; retain the parent gitlink instead.
AGENTS.md reference: AGENTS.md:L46-L48
Useful? React with 👍 / 👎.
Static checks, Tests and Desktop E2E all failed on the same thing: `acp-extension-dsh/profile` had no `DEEPSEEK_HARNESS_PROFILE_FILENAMES`, `DEEPSEEK_HARNESS_PROFILE_NAME`, `createDeepSeekHarnessNpxSpecifiers` or `createDeepSeekHarnessProfileFiles`, so `apps/cli` failed to typecheck and four of its tests threw "is not a function". Desktop E2E failed at its build step with the identical errors. Nothing in this branch touches the CLI or those submodules. The merge commit dropped two gitlinks back to the merge base: acp-extension-core 80205d81 -> ee292887 acp-extension-dsh 345cd07a -> ce194fd6 `git merge` had resolved both correctly — only one side changed them. The `git add -A` used to stage the conflict resolution then re-staged every submodule from the working tree, which still had the base commits checked out because `git submodule update` had not been run, silently reverting the merge's own result. Staging a merge with `add -A` is unsafe whenever submodules are in the tree. Both pointers go back to exactly main's, since this branch has no opinion about either. Verified locally after `pnpm install` rebuilt the dsh `dist/`: workspace typecheck exits 0, and `tests/agent-setting.test.ts` plus `src/agent/deepseek-harness-runtime.test.ts` are green (39/39). The full CLI suite is 263/265 files; the one failure is `gh-shim-script`, which needs a real gh broker and never passes in this environment. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Related issue
Problem / pressure
"Share as image" shipped as an appearance editor rather than a design. Six
backdrops × five footer layouts × three frame paddings × a three-way palette ×
four toggles × a numeric code-collapse field is roughly a thousand reachable
combinations, and almost none of them produce an image worth sending. The option
surface also hid real defects rather than exposing them:
exif) printed the agent's name and no Lody markanywhere on the image.
https://lody.ai, because no callerever passed
shareUrl— a code pointing at the home page, not the conversation.px-5andpx-6, and userturns were hung from the right edge, an IM convention that means nothing to a
third party reading a picture.
with every control above the thing being previewed.
Summary
The card is now one fixed template in two sizes, and the preview carries four
choices, none of which can change its layout: the card's size, how much
ground shows around it, which ground, and which palette.
The device that does the exporting decides nothing about the image. It seeds the
opening guess at the destination and chooses dialog versus drawer; that is all.
chatpostThe mat is a slider,
0–96on the template's own 4px grid, so the exported widthis
card + 2 × mat: 392 at the seeded chat value, 672 at the seeded post value,560 flush, 752 wide open.
The two forms differ in measure and margin only. Type sizes are shared and
pinned rather than read from the reader's conversation font setting, so two cards
taken from two devices set the same words at the same size.
On the backdrop (second commit)
The first commit deleted all six grounds and hardcoded the signature gradient, on
the reasoning that a choice which cannot make the card better is only noise. That
was wrong about one of them.
welcomeis a hand-built export-safe still of theopening ceremony's shallow-water field — the live scene is a WebGL shader a DOM
capture cannot serialize — and it exists nowhere else in the product, so
deleting it destroyed an asset rather than removing an option. (
aurora,oceanand
sunsetsurvive in the usage card either way.)The distinction that actually holds is between a choice that changes how a card
reads — footer layout, padding, which turns hang where — and one that only
changes what it reads against. The former is the editor this PR removes; the
latter is taste and belongs to the user. So all six grounds are back as a swatch
row, and the template is still fixed.
Two consequences fall out of that rather than being bolted on:
welcomeis the only pale ground, so the sign-off inks dark on it instead ofwhite.
nonehas nothing to print the sign-off on and nothing to inset the card from,so the card becomes the whole image, drops its drop shadow, and takes
lody.aionto the second line of the caption's left column — the same fallback the usage
card already documents, at zero extra height.
The mat, twice wrong (commits three to five)
This took three passes and the first two were both wrong, in opposite directions.
Pass one deleted the
Compact | Regular | Spaciouscontrol and derived the matfrom the card's size, arguing that nobody can judge 32pt against 56pt. That
argument describes a settings form. It dissolves next to a live preview: the person
does not read the value, they drag and watch the picture, which is a better answer
than any name can give. Direct manipulation is exactly the case where exposing the
quantity is right.
Pass two bound the mat to the width and claimed that "a 560pt card in a thin
bleed and a 360pt card in a deep mat are combinations nobody wants". That was an
assertion, and false in both halves: a wide card flush is what pasting into a
README or a Notion doc wants, and a narrow card in a deep mat is what a phone post
wants. Width is a content decision — it sets the measure, so it decides whether a
line of code survives without wrapping. The mat is a presentation decision about
the frame. They are independent.
Pass three, here, splits them:
is what the measure depends on and what the person exporting knows.
chatis360pt,
postis 560pt.the pixel count read back beside it so a look is reproducible.
Size seeds the mat with an ordinary value for that size, which is what makes
the pair read as "pick a starting point, then adjust" rather than as two unrelated
knobs. Picking a size always re-seeds, with no hidden "has the user touched the
slider" bit: a flag that sometimes keeps a value and sometimes does not is harder
to predict than a preset that always resets, and the preview shows the result
either way.
Zero is reachable. Below
MIN_SIGN_OFF_MAT(12) the ground stops being a margin,so the sign-off moves into the caption — the same fallback a card with no ground
takes — and the drop shadow goes with it, because a shadow needs a ground to fall
on. Verified in a browser: at
mat: 0the card's computedbox-shadowis theinset ring only, at 56 it also carries
rgba(2,10,18,.6) 0 28px 70px -20px, andexactly one
lody.aiexists in every case.An earlier commit had also derived the width from
useIsMobile(); that is gonetoo.
ChatShareCardFormatno longer exists. The device now seeds only the openingsize guess (handset →
chat, desktop →post), which one tap overrides. Typesizes stay shared, so a chat card and a post card set the same words at the same
size.
Layout
Every band shares one
gutter, turns are left-aligned, and the grouping thatalignment used to carry moved into an unequal vertical rhythm: the gap between
two exchanges is twice the gap binding a prompt to the reply that answers it.
That is what lets the card work without speaker labels. All padding lives in one
LAYOUTtable instead of in the markup.Fixed on the way, all found by screenshotting the stories:
and it costs the conversation no height.
assetsReadyexport gateit was the only reason for.
which retired a
MutationObserverthat rewrote Shiki's code DOM after everyrender to clip over-tall blocks.
language label, which only works in the app because a long line scrolls out
from under it.
captured into the exported PNG — a button in a picture.
against a flex item, so its scroller was unbounded and a tall card painted
straight over the action row.
Per the requester's decision the card keeps no height limit and never elides
a selected message: an image that silently drops messages the user picked states
something other than what they picked, and publishing a whole conversation for
reading is what a share link is for.
Finishing the share ends it
Copying or saving now closes the preview and drops the message selection
behind it. Leaving a surface armed behind a task the user has finished is just
state they have to clear by hand.
Dismissing the preview still retains the selection — that is the case where
someone wants to go back and adjust it — so the two are told apart by a
completion callback rather than by
onOpenChange, which cannot tell them apart.That exposed a defect in
exportShareImage. It swallowed a cancelled nativesave dialog and resolved exactly like a successful one:
Closing on "export resolved" would therefore have torn the flow down under
someone who had only backed out of the file picker — against a guarantee
specs/chat-share-image.mdalready made ("Canceling the save dialog preserves thepreview"). A browser download has no cancel signal to read and always reports a
save, which is honest: the browser owns the transfer from the click onward.
The callback carries which action finished, because the two leave different
feedback behind. A save has the native dialog or the browser's download UI; a
copy has nothing once the preview is gone, so the host toasts for it and only for
it — otherwise a successful copy and a silent no-op would look identical.
Visual explanation
Card structure, both forms (the ground is part of the exported PNG):
Control surface, before → after:
Footer, desktop — the status gets its own thin row so it never squeezes the
fixed-width controls:
ChatShareCardAPI diff:ChatShareCardFormat('phone' | 'desktop', derived fromuseIsMobile()) existedbetween the first and third commits and is now deleted: it was a guess at the
destination, and the destination is asked directly.
Before / after
Compact / Regular / Spaciouspaddingsmon desktop (32px/12px), default in the drawer for touchexportShareImagereturns{ saved }; cancel changes nothingpx-5/px-6mixed between bands; user turns hung rightgutterper format; every band on one left edgespace-y-4between all messagesreplyGapbinds a turn pair,exchangeGap(2×) separates pairsnonebackdrop dropped the only brand mark with itnonemoveslody.aiinto the caption, no extra heighthttps://lody.ailody.aiwordmarkMutationObserverrewriting Shiki's code DOM per renderTest plan
Automated
pnpm typecheck,pnpm lint— pass (lint: 0 errors).pnpm lint:i18n— pass; the 7 backdrop keys are restored with their originaltranslations, and both languages are complete.
pnpm check:public-boundary,pnpm check:code-collab-imports,pnpm check:platform-boundaries— pass.pnpm run docs check—errors: [].NODE_ENV=development pnpm --filter @lody/components test— 474/476 filesgreen, twice, once per commit. The two failures are
control-plane-mirrorand
conversation-view-hooks; both are timing-sensitive, both pass when re-runalone, and neither is reachable from anything this change touches.
tests/share-image-export.test.tsgained coverage for the new contract: acancelled native save resolves
{ saved: false }(the guard that keeps acancelled file picker from tearing the flow down), a completed one resolves
{ saved: true }, and the browser path always reports a save.tests/message-selection.test.tsxstill passes — selection semantics areotherwise unchanged.
Manual (static Storybook + Playwright)
Sessions/ChatShareCard: desktop × light/dark, phone × light/dark, bothdestinations in both forms, untitled, a code block whose signature line is far
wider than either card, and every ground that needs its own judgement —
welcomeagainst both card palettes,sunset, andnoneagainst both palettes.post672 = 560 + 2×56,chat392 = 360 + 2×16,
mat: 0560 = the bare card,mat: 96752. The same numberscome out whichever viewport the story is rendered at.
ArrowRightfrom 56 landson 96 (step 4, clamped at max) and the card's computed padding follows to
96px,so the control is operable without a pointer.
matching the 12px the toggles already use; 36px at 14px in the drawer.
getComputedStyle(el, '::-webkit-slider-thumb')returns thehost input's box in Chromium, not the shadow part, so slider styling was checked
from screenshots rather than from measurements.
Sessions/ChatShareImageDialogat 1280×900 and 390×844 — verified the previewscrolls inside its pane in both, and that the handset action row does not
collide (the palette pill and both buttons overlapped on one 390pt line before
the fix). Re-shot after the backdrop commit: the swatch row fits one line
beside the palette toggle at both widths, and again after the destination
commit that the three controls wrap onto two centred rows on a handset without
colliding.
label's bottom edge, and the copy control computes
display: noneinside thecard.
welcomerenders with a legible dark sign-off and thatnonerenders shadowless withlody.aifolded into the caption.Deliberately not done
unchanged and still covered, and a test asserting class strings or jsdom
geometry would not detect a visual regression.
pnpm checkwas not run end-to-end here: it does not setNODE_ENV, so thecomponent suite fails wholesale with
act is not a functionon this machine.Each of its stages was run individually instead, as listed above.
real handset.
Docs
specs/chat-share-image.mdupdated (staysdraft), plus the now-stale sentencein
specs/usage-share-image.mdthat cited this card as justifying a large setof appearance controls.
.agents/notes/implemented/feature/2026-09-14-chat-share-card-fixed-template.md,which records the backdrop removal and its reversal as a correction rather than
quietly landing the final state.
packages/components/src/components/AGENTS.mdgained the fixed-templateinvariant so the option matrix does not grow back, and
packages/components/src/ui/AGENTS.mdgained the two Tailwind traps the sliderprimitive had to work around.
Instructions for reviewing agents
handleShareImageCompletedinsession-detail.tsxresolvesthe chat surface by the session the card was built from, not the active tab —
the export is async and the user can move on while a save dialog is up. In
chat-share-image-dialog.tsxthe completion is gated onsaved; anunconditional call there silently reintroduces the cancelled-save bug and no
test at the component level would catch it.
chat-share-card.tsx— theLAYOUTtable is the wholepadding system, and the scoped
CODE_CSSdeliberately avoids!importantbecause the app's code styles live in
@layer components, where a layeredimportant declaration outranks an unlayered one; that is why both rules land
on the
prerather than the code-block body. Check thebackdrop === 'none'branch: it must not reintroduce a second footer layout. In
chat-share-image-dialog.tsx, check the render-phase reset (no effect) andthat
FitPreviewis bounded byflex-1+min-h-0in both the dialog andthe drawer.
real share URL through it; deriving the card format from the device with no
user override; left-aligning the human prompt instead of keeping the familiar
right-hung bubble; pinning body type at 15px rather than following the user's
conversation font size; keeping
nonein the backdrop set at the cost of oneextra sign-off placement.
typechecked and its export contract is unit-tested, but the end-to-end path
(menu → selection → preview → copy → selection cleared) was not exercised in
a running app; the stories compose the dialog standalone and do not go through
session-detail.ui/slider.tsxis the first slider inthe repo and its appearance rests on
::-webkit-slider-*/::-moz-range-*arbitrary variants — verified in Chromium only; Firefox and WebKit are unchecked.
Seeding size from the device is still a guess, just an overridable one. A pale
card on the pale
welcomeground at a small mat is the thinnest-contrastcombination the controls can reach.
CODE_LABEL_CLEARANCEis measuredagainst the label's current height in
src/tailwind/index.cssand will driftif that changes — there is no test pinning it.
LIGHT_BACKDROPSis a hand-keptset; a new pale ground added without registering it would print a white
sign-off on a white field. The handset drawer was verified only at a 390×844
emulated viewport, not on a real device. No pixel baselines exist, so a visual
regression here is caught by review, not by CI.
🤖 Generated with Claude Code