Skip to content

feat(components): one fixed template for the conversation share card - #697

Merged
wibus-wee merged 10 commits into
mainfrom
feat/zero-based-share-card-redesign
Sep 15, 2026
Merged

wibus-wee merged 10 commits into
mainfrom
feat/zero-based-share-card-redesign

Conversation

@wibus-wee

@wibus-wee wibus-wee commented Sep 14, 2026

Copy link
Copy Markdown
Member

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:

  • The default footer (exif) printed the agent's name and no Lody mark
    anywhere on the image
    .
  • The default QR code always encoded https://lody.ai, because no caller
    ever passed shareUrl — a code pointing at the home page, not the conversation.
  • Nothing in the card shared a left edge: bands mixed px-5 and px-6, and user
    turns were hung from the right edge, an IM convention that means nothing to a
    third party reading a picture.
  • The preview pane was half config panel; on a handset it was a vertical stack
    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.

chat post
card width 360 560
mat (seeded) 16 56
gutter 20 28
title 17 20
body 15 (shared) 15 (shared)

The mat is a slider, 0–96 on the template's own 4px grid, so the exported width
is 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. 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 exists nowhere else in the product, so
deleting it destroyed an asset rather than removing an option. (aurora, ocean
and sunset survive 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:

  • welcome is the only pale ground, so the sign-off inks dark on it instead of
    white.
  • none has 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 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 | Spacious 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 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:

  • Size stays a two-way switch, asked as where the image is going, because that
    is what the measure depends on and what the person exporting knows. chat is
    360pt, post is 560pt.
  • Padding is a slider over 0–96px, stepped on the template's own 4px grid, with
    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: 0 the card's computed box-shadow is the
inset ring only, at 56 it also carries rgba(2,10,18,.6) 0 28px 70px -20px, and
exactly one lody.ai exists in every case.

An earlier commit had also derived the width from useIsMobile(); that is gone
too. ChatShareCardFormat no longer exists. The device now seeds only the opening
size guess (handset → chat, desktop → post), which one tap overrides. Type
sizes 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 that
alignment 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
LAYOUT table instead of in the markup.

Fixed on the way, all found by screenshotting the stories:

  • Sign-off moved onto the ground below the card — Lody is on the image again,
    and it costs the conversation no height.
  • QR code removed rather than repaired, and with it the assetsReady export gate
    it was the only reason for.
  • Code soft-wrapping is unconditional (an image has no horizontal scrollbar),
    which retired a MutationObserver that rewrote Shiki's code DOM after every
    render to clip over-tall blocks.
  • A wrapped code block's first line was permanently masked by the floating
    language label, which only works in the app because a long line scrolls out
    from under it.
  • The code block's copy control rendered opaque in the preview and was being
    captured into the exported PNG
    — a button in a picture.
  • The preview pane took its height from a percentage that does not resolve
    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 native
save dialog
and resolved exactly like a successful one:

-    if (!result.saved && !result.canceled) throw new Error(...)
-    return;                       // cancel and success were indistinguishable
+    if (!result.saved && !result.canceled) throw new Error(...)
+    return { saved: result.saved === true };

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.md already made ("Canceling the save dialog preserves the
preview"). 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):

┌──────────────────────────── ground (16pt phone / 32pt desktop) ────┐
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ ← gutter →  Session title                     (2-line clamp)   │ │
│ │                                                                │ │
│ │             ▒▒ human prompt ▒▒▒▒▒▒▒▒▒▒▒▒▒  tinted, full width  │ │
│ │             ↕ replyGap (12 / 14)                               │ │
│ │             assistant prose, MarkdownRenderer                  │ │
│ │             ↕ exchangeGap (24 / 28)  ← twice the replyGap      │ │
│ │             ▒▒ human prompt ▒▒▒▒▒▒▒▒▒▒▒▒▒                      │ │
│ │             ↕ replyGap                                         │ │
│ │             assistant prose                                    │ │
│ │ ───────────────────────────────────────────────────────────────│ │
│ │ [agent] Claude Code            Sonnet 4.5 · ~12.4K tokens      │ │
│ │                                        2026-09-07 21:38        │ │
│ └────────────────────────────────────────────────────────────────┘ │
│                     [Lody] lody.ai        ← sign-off on the ground │
└────────────────────────────────────────────────────────────────────┘
   every band starts at the SAME gutter — title, both speakers, caption

   backdrop = 'none' → no ground, no shadow, and the sign-off folds in:
   │ [agent] Claude Code            Sonnet 4.5 · ~12.4K tokens │
   │         lody.ai                        2026-09-07 21:38   │

Control surface, before → after:

BEFORE  ChatShareImageDialog                 AFTER  ChatShareImageDialog
├── 320px fieldset                           ├── (no config column)
│   ├── Theme        app|light|dark          └── footer
│   ├── Background   none|lody|aurora            ├── [ Light | Dark ]
│   │                |ocean|sunset|welcome       ├── ⊘ ▮ ▯ ▮ ▮ ▮   ← same 6
│   ├── Backdrop padding  compact|regular        ├── Sharing to [Chat|Post]
│   │                     |spacious              ├── Padding ──●───── 56
│   │                     ↑ 3 words for          │       ↑ the preview answers
│   │                       a number               │         "how much" better
│   │                                             │         than a word can
│   │                                             ├── Copy image
│   │                                             └── Export PNG
│   ├── Footer  stacked|row|minimal
│   │           |canvas|exif    ← raw enum          desktop → Dialog
│   ├── Session title    switch                     handset → Drawer
│   ├── Date (EXIF)      switch
│   ├── QR code          switch
│   ├── Wrap long lines  switch
│   └── Collapse after   number input
└── preview (half the dialog)                      preview (whole dialog)

ChatShareCardProps:  8 appearance props  →  destination + mat + theme + backdrop
                     + a device-derived `format`  →  (gone)

Footer, desktop — the status gets its own thin row so it never squeezes the
fixed-width controls:

┌───────────────────────────────────────────────────────────────────┐
│ Sharing to [ Chat │ Post ]      Padding ──────●──────────  56     │ shape
│ ⊘ ▮ ▯ ▮ ▮ ▮        [ Light │ Dark ]                               │ surface
│ Image copied to clipboard              [Copy image][Export PNG]   │ actions
└───────────────────────────────────────────────────────────────────┘
  grouped by what they do to the image — its shape, then its surface — and the
  actions keep their own row so a status message never squeezes a control.
  The buttons take the `sm` size here (32px/12px): `sm` is also `text-xs`, which
  every other control in the footer already uses, so at the default size they
  were the only `text-sm` thing in it. The drawer keeps the default size — there
  they are the primary touch targets, and `h-9` is already under 44pt.

ChatShareCard API diff:

-  shareUrl?: string                     // never passed by any caller
-  code?: { wrap?, collapseAfter? }      // wrap is now unconditional
-  framePadding?: 'compact'|'regular'|'spacious'
-  footerVariant?: 'stacked'|'row'|'minimal'|'canvas'|'exif'
-  showQr?: boolean
-  theme?: 'light'|'dark'                // optional, fell back to app theme
-  onAssetsReadyChange?: (ready) => void // gated export on QR generation
+  destination: 'chat' | 'post'          // the card's own size (the measure)
+  mat: number                           // 0–96px of ground, step 4
+  theme: 'light' | 'dark'               // required; preview == export
+  backdrop: 'none'|'lody'|'welcome'|'aurora'|'ocean'|'sunset'

ChatShareCardFormat ('phone' | 'desktop', derived from useIsMobile()) existed
between the first and third commits and is now deleted: it was a guess at the
destination, and the destination is asked directly.

Before / after

Before After
~1000 reachable appearance combinations 1 template, 2 choices (palette + ground)
5 footer layouts, 4 toggles, a number input all gone
Compact / Regular / Spacious padding a 0–96px slider with a live preview and the value read back
card width came from the exporting device card width comes from where the image is going
width and padding could not vary independently independent: content decision vs presentation decision
action buttons outweighed the controls above them sm on desktop (32px/12px), default in the drawer for touch
copy/save left the preview open and the selection armed success closes the preview and clears the selection
a cancelled save was indistinguishable from a completed one exportShareImage returns { saved }; cancel changes nothing
px-5 / px-6 mixed between bands; user turns hung right one gutter per format; every band on one left edge
uniform space-y-4 between all messages replyGap binds a turn pair, exchangeGap (2×) separates pairs
default export carried no Lody mark sign-off printed on the ground below the card
none backdrop dropped the only brand mark with it none moves lody.ai into the caption, no extra height
QR always encoded https://lody.ai QR removed; legible lody.ai wordmark
wrapped code's first line hidden under the language label label cleared; full first line visible
code-block copy button captured into the PNG hidden inside the card
preview painted over the action row (tall cards) preview scrolls inside a bounded flex column
on a handset: config stack above a squeezed preview bottom drawer: preview first, both controls + two actions
MutationObserver rewriting Shiki's code DOM per render removed

Test plan

Automated

  • pnpm typecheck, pnpm lint — pass (lint: 0 errors).
  • pnpm lint:i18n — pass; the 7 backdrop keys are restored with their original
    translations, and both languages are complete.
  • pnpm check:public-boundary, pnpm check:code-collab-imports,
    pnpm check:platform-boundaries — pass.
  • pnpm run docs checkerrors: [].
  • NODE_ENV=development pnpm --filter @lody/components test474/476 files
    green
    , twice, once per commit. The two failures are control-plane-mirror
    and conversation-view-hooks; both are timing-sensitive, both pass when re-run
    alone, and neither is reachable from anything this change touches.
  • tests/share-image-export.test.ts gained coverage for the new contract: a
    cancelled native save resolves { saved: false } (the guard that keeps a
    cancelled 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.tsx still passes — selection semantics are
    otherwise unchanged.

Manual (static Storybook + Playwright)

  • Sessions/ChatShareCard: desktop × light/dark, phone × light/dark, both
    destinations in both forms, untitled, a code block whose signature line is far
    wider than either card, and every ground that needs its own judgement —
    welcome against both card palettes, sunset, and none against both palettes.
  • Measured the exported widths from the DOM: post 672 = 560 + 2×56, chat
    392 = 360 + 2×16, mat: 0 560 = the bare card, mat: 96 752. The same numbers
    come out whichever viewport the story is rendered at.
  • Drove the slider by keyboard in the real dialog: 12 × ArrowRight from 56 lands
    on 96 (step 4, clamped at max) and the card's computed padding follows to 96px,
    so the control is operable without a pointer.
  • Measured the action buttons in both shells: 32px tall at 12px in the dialog,
    matching the 12px the toggles already use; 36px at 14px in the drawer.
  • Note for reviewers: getComputedStyle(el, '::-webkit-slider-thumb') returns the
    host input's box in Chromium, not the shadow part, so slider styling was checked
    from screenshots rather than from measurements.
  • Sessions/ChatShareImageDialog at 1280×900 and 390×844 — verified the preview
    scrolls 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.
  • Measured in-browser: the wrapped first code line now clears the language
    label's bottom edge, and the copy control computes display: none inside the
    card.
  • Confirmed visually that welcome renders with a legible dark sign-off and that
    none renders shadowless with lody.ai folded into the caption.

Deliberately not done

  • No new unit tests. The card is presentational, the export pipeline it feeds is
    unchanged and still covered, and a test asserting class strings or jsdom
    geometry would not detect a visual regression.
  • pnpm check was not run end-to-end here: it does not set NODE_ENV, so the
    component suite fails wholesale with act is not a function on this machine.
    Each of its stages was run individually instead, as listed above.
  • No pixel baseline is established, and the drawer form was not exercised on a
    real handset.

Docs

  • specs/chat-share-image.md updated (stays draft), plus the now-stale sentence
    in specs/usage-share-image.md that cited this card as justifying a large set
    of appearance controls.
  • Bilingual Agent Note:
    .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.md gained the fixed-template
    invariant so the option matrix does not grow back, and
    packages/components/src/ui/AGENTS.md gained the two Tailwind traps the slider
    primitive had to work around.

Instructions for reviewing agents

  • Review focus: handleShareImageCompleted in session-detail.tsx resolves
    the 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.tsx the completion is gated on saved; an
    unconditional call there silently reintroduces the cancelled-save bug and no
    test at the component level would catch it. chat-share-card.tsx — the LAYOUT table is the whole
    padding system, and the scoped CODE_CSS deliberately avoids !important
    because the app's code styles live in @layer components, where a layered
    important declaration outranks an unlayered one; that is why both rules land
    on the pre rather than the code-block body. Check the backdrop === 'none'
    branch: it must not reintroduce a second footer layout. In
    chat-share-image-dialog.tsx, check the render-phase reset (no effect) and
    that FitPreview is bounded by flex-1 + min-h-0 in both the dialog and
    the drawer.
  • Decisions to challenge: removing the QR code outright instead of wiring a
    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 none in the backdrop set at the cost of one
    extra sign-off placement.
  • Plausible failures / evidence gaps: the close-and-clear wiring is
    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.tsx is the first slider in
    the 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 welcome ground at a small mat is the thinnest-contrast
    combination the controls can reach.
    CODE_LABEL_CLEARANCE is measured
    against the label's current height in src/tailwind/index.css and will drift
    if that changes — there is no test pinning it. LIGHT_BACKDROPS is a hand-kept
    set; 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

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T11:09:11.208076Z bee90c5 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment on lines +173 to +174
`.${CODE_SCOPE} .markdown-renderer [data-streamdown=code-block-body] pre` +
'{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;}',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

wibus-wee and others added 7 commits September 14, 2026 10:05
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>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/acp-extension-dsh Outdated
@@ -1 +1 @@
Subproject commit 345cd07a530f4cfbf6f398e823963e45128e4a69
Subproject commit ce194fd6766292df0726680ccbb3a1737c609b5c

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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>
@github-actions

Copy link
Copy Markdown
Contributor

Desktop PR smoke regression failed on commit bee90c5163f8d1b528129083c3dc1d8df3699c1d.

Recordings not attached

  • Run evidence: failure-index.json is missing

The Actions artifact retains the complete trace, screenshots, logs, and runtime evidence.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/acp-extension-core Outdated
@@ -1 +1 @@
Subproject commit 80205d81b9de02f4171564a7c3aaaebfd9d91ddd
Subproject commit ee292887b25c85d5f3afdd48ae04260e3d7409aa

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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>
@wibus-wee
wibus-wee merged commit 6da20a9 into main Sep 15, 2026
6 checks passed
@wibus-wee
wibus-wee deleted the feat/zero-based-share-card-redesign branch September 15, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant