From 80811f3d06404837d50a867446643156819a1b00 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Thu, 22 Oct 2026 12:00:41 -0400 Subject: [PATCH 1/2] Unify attachment cards with neutral icon tiles Co-authored-by: Codex Signed-off-by: Arjun Mahanti --- src/bundled/voice-notes/VoiceNoteCard.tsx | 16 +- src/bundled/voice-notes/VoiceNotes.module.css | 68 +--- .../messages/ComposerAttachments.module.css | 5 +- src/features/messages/ComposerAttachments.tsx | 5 +- src/features/messages/FileAttachment.tsx | 9 +- src/features/messages/FileTypeIcon.module.css | 38 +- src/features/messages/Messages.module.css | 21 +- tests/fixtures/attachment-playground.html | 2 + .../fixtures/attachment-playground/README.md | 37 ++ tests/fixtures/attachment-playground/data.ts | 170 ++++++++ tests/fixtures/attachment-playground/main.tsx | 379 ++++++++++++++++++ .../fixtures/attachment-playground/styles.css | 82 ++++ .../attachment-playground/tsconfig.json | 4 + 13 files changed, 734 insertions(+), 102 deletions(-) create mode 100644 tests/fixtures/attachment-playground.html create mode 100644 tests/fixtures/attachment-playground/README.md create mode 100644 tests/fixtures/attachment-playground/data.ts create mode 100644 tests/fixtures/attachment-playground/main.tsx create mode 100644 tests/fixtures/attachment-playground/styles.css create mode 100644 tests/fixtures/attachment-playground/tsconfig.json diff --git a/src/bundled/voice-notes/VoiceNoteCard.tsx b/src/bundled/voice-notes/VoiceNoteCard.tsx index c7dae4ca16..f7e9e0326a 100644 --- a/src/bundled/voice-notes/VoiceNoteCard.tsx +++ b/src/bundled/voice-notes/VoiceNoteCard.tsx @@ -1,4 +1,3 @@ -import { useMediaCorners } from "../../features/messages/use-media-corners"; import { useMediaElementSource } from "../../features/messages/use-media-element-source"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; @@ -52,8 +51,6 @@ function Player({ source: string | undefined; unavailable: boolean; }) { - const corners = useMediaCorners(); - const playCorners = useMediaCorners(); const audio = useRef(null); const active = useConversationPresentation(); useEffect(() => { @@ -157,19 +154,12 @@ function Player({ } } return ( -
+
Voice note - - + path { - fill: var(--voice-card-background); - stroke: var(--voice-card-border); - stroke-width: 2px; - transition: - fill 150ms var(--easing-state), - stroke 150ms var(--easing-state); -} .play { display: inline-flex; border-radius: var(--radius-icon-button); -} -.play[data-smooth-corners] > button { - clip-path: var(--media-corner-clip); + background: color-mix( + in srgb, + var(--voice-card-background) 90%, + var(--text-standard) + ); } .play:has(:focus-visible) { outline: 2px solid var(--border-focus); @@ -110,8 +86,12 @@ } .play { flex-shrink: 0; - width: 36px; - height: 36px; + width: calc(36px + 2 * var(--space-1h)); + height: calc(36px + 2 * var(--space-1h)); + margin-block: calc(-1 * var(--space-1h)); + margin-inline-start: calc(-1 * var(--space-2)); + margin-inline-end: calc(-1 * var(--space-1)); + border-radius: calc(var(--radius-icon-button) - var(--space-half) - 1px); } .content { flex: 1; @@ -279,33 +259,13 @@ animation: none; } .card, - .cardSurface > path, .details > *, .playIcon > svg { transition: none; } } -/* Composer drafts share the enclosing composer's circular inset geometry. */ +/* Draft and sent voice notes share the same card and inset play geometry. */ [data-voice-draft] .card { margin: 0; - border-radius: var(--radius-icon-button); - background: var(--voice-card-background); - border-color: var(--voice-card-border); -} -[data-voice-draft] .card .cardSurface { - display: none; -} - -[data-voice-draft] .play { - /* Expand into the chip padding without moving the text/waveform column. */ - width: calc(36px + 2 * var(--space-1h)); - height: calc(36px + 2 * var(--space-1h)); - margin-block: calc(-1 * var(--space-1h)); - margin-inline-start: calc(-1 * var(--space-2)); - margin-inline-end: calc(-1 * var(--space-1)); - border-radius: calc(var(--radius-icon-button) - var(--space-half) - 1px); -} -[data-voice-draft] .play[data-smooth-corners] > button { - clip-path: none; } diff --git a/src/features/messages/ComposerAttachments.module.css b/src/features/messages/ComposerAttachments.module.css index 1c630839b5..5f9a36ebe6 100644 --- a/src/features/messages/ComposerAttachments.module.css +++ b/src/features/messages/ComposerAttachments.module.css @@ -27,7 +27,7 @@ .item { display: flex; align-items: center; - gap: calc(var(--space-5) / 2); + gap: calc(var(--space-5) / 2 + var(--space-1)); padding: var(--space-2) calc(var(--space-5) / 2); margin-block: 0; box-sizing: border-box; @@ -41,8 +41,7 @@ position: relative; max-width: 100%; } -.preview, -.fileIcon { +.preview { width: var(--size-control); height: var(--size-control); flex-shrink: 0; diff --git a/src/features/messages/ComposerAttachments.tsx b/src/features/messages/ComposerAttachments.tsx index ec9073b518..af6b71ab50 100644 --- a/src/features/messages/ComposerAttachments.tsx +++ b/src/features/messages/ComposerAttachments.tsx @@ -6,6 +6,7 @@ import { AttachmentView } from "../conversation/AttachmentView"; import { useConversationPresentation } from "../conversation/ConversationPresentation"; import { useRef, useState } from "react"; import { FileTypeIcon } from "./FileTypeIcon"; +import fileIconStyles from "./FileTypeIcon.module.css"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { AnimatePresence, @@ -298,10 +299,10 @@ function AttachmentItem({ )} ) : ( - + - + + + {displayName} @@ -140,7 +143,9 @@ function UnavailableFileAttachment({ }) { return ( - + + + {displayName} File unavailable diff --git a/src/features/messages/FileTypeIcon.module.css b/src/features/messages/FileTypeIcon.module.css index dfcf75f3d5..23dbdd1d4e 100644 --- a/src/features/messages/FileTypeIcon.module.css +++ b/src/features/messages/FileTypeIcon.module.css @@ -1,26 +1,24 @@ +.slot { + display: inline-flex; + flex-shrink: 0; + width: calc(36px + 2 * var(--space-1h)); + height: calc(36px + 2 * var(--space-1h)); + margin-block: calc(-1 * var(--space-1h)); + margin-inline-start: calc(-1 * var(--space-2)); + margin-inline-end: calc(-1 * var(--space-1)); + border-radius: calc(var(--radius-icon-button) - var(--space-half) - 1px); +} .icon { display: inline-grid; place-items: center; flex-shrink: 0; - width: calc(var(--space-8) + var(--space-1)); - height: calc(var(--space-8) + var(--space-1)); - border-radius: var(--radius-control); - background: var(--surface-panel); + width: calc(36px + 2 * var(--space-1h)); + height: calc(36px + 2 * var(--space-1h)); + border-radius: calc(var(--radius-icon-button) - var(--space-half) - 1px); + background: color-mix( + in srgb, + var(--surface-inset) 90%, + var(--text-standard) + ); color: var(--text-standard); } -.icon[data-kind="pdf"] { - background: var(--file-pdf-surface); - color: var(--file-pdf-ink); -} -.icon[data-kind="sheet"] { - background: var(--affordance-success); - color: var(--text-success); -} -.icon[data-kind="archive"] { - background: var(--affordance-warning); - color: var(--text-warning); -} -.icon[data-kind="code"] { - background: var(--affordance-selected); - color: var(--text-link); -} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 4a69a260e6..07a5ab0a68 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -921,13 +921,15 @@ html[data-keyboard-navigation] .attachmentImage:focus-visible { text-align: start; display: inline-flex; align-items: center; - gap: var(--space-2); - width: 21rem; + gap: calc(var(--space-5) / 2 + var(--space-1)); + width: 18rem; + box-sizing: border-box; + min-height: calc(36px + 2 * var(--space-2) + 2px); max-width: 100%; margin-top: var(--space-2); - padding: var(--space-2) var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-container); + padding: var(--space-2) calc(var(--space-5) / 2); + border: 1px solid var(--border-standard); + border-radius: var(--radius-icon-button); background: var(--surface-inset); color: var(--text-standard); text-decoration: none; @@ -964,7 +966,7 @@ html[data-keyboard-navigation] .fileAttachment:focus-visible { min-width: 0; max-width: 100%; display: grid; - gap: var(--space-half); + gap: 0; } .fileAttachmentName, .fileAttachmentMeta { @@ -973,10 +975,13 @@ html[data-keyboard-navigation] .fileAttachment:focus-visible { white-space: nowrap; } .fileAttachmentName { - font-weight: var(--type-weight-medium); + font-size: var(--text-label-sm); + font-weight: var(--text-label-sm--font-weight); + line-height: var(--text-label-sm--line-height); + letter-spacing: var(--text-label-sm--letter-spacing); } .fileAttachmentMeta { - color: var(--text-muted); + color: var(--text-subtle); font-size: var(--text-caption); line-height: var(--text-caption--line-height); letter-spacing: var(--text-caption--letter-spacing); diff --git a/tests/fixtures/attachment-playground.html b/tests/fixtures/attachment-playground.html new file mode 100644 index 0000000000..ff24e19267 --- /dev/null +++ b/tests/fixtures/attachment-playground.html @@ -0,0 +1,2 @@ + +Buzz · Attachment playground
diff --git a/tests/fixtures/attachment-playground/README.md b/tests/fixtures/attachment-playground/README.md new file mode 100644 index 0000000000..8ff3918d76 --- /dev/null +++ b/tests/fixtures/attachment-playground/README.md @@ -0,0 +1,37 @@ +# Attachment playground + +Open `/tests/fixtures/attachment-playground.html` on the existing desktop Vite server. +No second server, account, upload, or relay session is started by the page. + +Uses production `ComposerAttachments`, composer CSS, `MessageRow`, `AudioAttachment`, +`VoiceNoteCard`, and shared PanelHeader, FullPageSurface, Select, and Button. +Controls compare light/dark, six canonical accents, low/high contrast, +sent/received rows, wide/narrow frames, and 100%/200% text. Theme preferences +are confined to iframe documents and never saved to app preferences. + +File groups cover all five FileTypeIcon branches plus office and unknown files, +long names, images/animations, video, audio, voice notes, media-only/mixed strips, +queued/preparing/uploading/ready/error, disabled removal, and missing sources. +Upload states are simulated; retry resolves locally and reset restores samples. +Audio uses a generated two-second tone. Sent audio players are mounted directly +because MessageRow requires a native/broker media source for inline audio. +The animated WebP fixture currently exercises decoder fallback in the in-app +browser. File links are illustrative; normal activation in message rows is intercepted. +Composer review uses production behavior and can offer the illustrative external URL. + +Check fixture types with: + +```sh +bin/pnpm exec tsc --project tests/fixtures/attachment-playground/tsconfig.json --noEmit +bin/pnpm exec biome check tests/fixtures/attachment-playground tests/fixtures/attachment-playground.html +``` + +Initial manual checks: light/dark rendering, section selection with keyboard, +retry recovery, removal, narrow frames at 200% text, and all-accent frame inventory. +This fixture does not validate uploads, native IPC, persistence, or full app integration. +Current iteration: monochrome file glyphs sit in neutral 90/10 inset tiles in both +composer and sent-file cards. Sent cards share composer sizing, type, and shape; +voice-note play tiles use the same geometry in drafts and messages. Composer +previews use the shared bare IconButton variant. +Checked this iteration: light/dark composer and sent-file rendering, sent voice-note +keyboard playback, fixture typecheck, formatting, and 37 focused component tests. diff --git a/tests/fixtures/attachment-playground/data.ts b/tests/fixtures/attachment-playground/data.ts new file mode 100644 index 0000000000..ad717635da --- /dev/null +++ b/tests/fixtures/attachment-playground/data.ts @@ -0,0 +1,170 @@ +import type { Attachment } from "../../../src/features/relay/contracts"; +import type { DraftAttachment } from "../../../src/features/messages/attachment-draft"; +import { encodeVoiceNoteWav } from "../../../src/bundled/voice-notes/voiceNoteWav"; +import image from "../design-system/assets/avatar.png"; +import gif from "../attachment-media/animated.gif"; +import webp from "../attachment-media/animated.webp"; +import apng from "../attachment-media/animated.png"; +import video from "../message-gallery/assets/sample.mp4"; + +// Generated local tone, never a microphone recording or network resource. +const wav = encodeVoiceNoteWav( + [Float32Array.from({ length: 48000 }, (_, i) => Math.sin(i / 24) * 0.08)], + 24000, +); +const audio = URL.createObjectURL( + new Blob([new Uint8Array(wav)], { type: "audio/wav" }), +); +window.addEventListener("pagehide", () => URL.revokeObjectURL(audio), { + once: true, +}); +if (import.meta.hot) import.meta.hot.dispose(() => URL.revokeObjectURL(audio)); +export const waveform = [10, 20, 60, 90, 40, 20, 50, 80, 100, 30, 10, 60, 20]; +export type Sample = { label: string; attachment: Attachment; source?: string }; +const file = (label: string, name: string, mime: string): Sample => ({ + label, + attachment: { + url: `https://example.invalid/${encodeURIComponent(name)}`, + name, + mime, + kind: "file", + size: 963584, + }, + source: `https://example.invalid/${encodeURIComponent(name)}`, +}); +export const files: [Sample, ...Sample[]] = [ + file( + "Archive · screenshot reproduction", + "PR-Screenshots-1470-1473-review-assets.zip", + "application/zip", + ), + file("PDF", "Product brief.pdf", "application/pdf"), + file( + "Spreadsheet", + "Quarterly forecast.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + file("Code", "attachment-preview.tsx", "text/plain"), + file("Text", "Meeting notes.txt", "text/plain"), + file( + "Document", + "Research findings.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + file( + "Presentation", + "Design review.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + file("Unknown file", "backup.bin", "application/octet-stream"), + file( + "Long filename", + "A very long filename with spaces and 日本語 that must truncate gracefully without hiding the controls.csv", + "text/csv", + ), +]; +export const mediaSamples: Sample[] = [ + { + label: "Image · PNG", + attachment: { + url: "https://fixture.test/image", + name: "Still image.png", + mime: "image/png", + kind: "image", + }, + source: image, + }, + { + label: "Animated GIF", + attachment: { + url: "https://fixture.test/gif", + name: "Animation.gif", + mime: "image/gif", + kind: "image", + }, + source: gif, + }, + { + label: "Animated WebP · decoder fallback in WebKit", + attachment: { + url: "https://fixture.test/webp", + name: "Animation.webp", + mime: "image/webp", + kind: "image", + }, + source: webp, + }, + { + label: "Animated PNG", + attachment: { + url: "https://fixture.test/apng", + name: "Animation.png", + mime: "image/png", + kind: "image", + }, + source: apng, + }, + { + label: "Video", + attachment: { + url: "https://fixture.test/video", + name: "Screen recording.mp4", + mime: "video/mp4", + kind: "video", + }, + source: video, + }, + { + label: "Audio · two-second tone", + attachment: { + url: "https://fixture.test/audio", + name: "Sample tone.wav", + mime: "audio/wav", + kind: "audio", + duration: 2, + }, + source: audio, + }, + { + label: "Voice note · two-second tone", + attachment: { + url: "https://fixture.test/voice", + name: "Voice note.wav", + mime: "audio/wav", + kind: "audio", + voiceNote: true, + waveform, + duration: 2, + }, + source: audio, + }, +]; +export function draft( + sample: Sample, + status: DraftAttachment["status"] = "ready", +): DraftAttachment { + const a = sample.attachment; + return { + id: sample.label, + file: new File(["Local specimen"], a.name ?? "File", { + type: a.mime ?? "application/octet-stream", + }), + status, + uploaded: { + url: a.url, + name: a.name ?? "File", + type: a.mime ?? "application/octet-stream", + size: a.size ?? 102400, + sha256: "a".repeat(64), + }, + ...(a.voiceNote ? { voice: { duration: 2, waveform } } : {}), + ...(status === "uploading" + ? { transfer: { sent: 42000, total: 100000 } } + : {}), + ...(status === "error" + ? { error: "Upload failed. Retry this local sample." } + : {}), + }; +} +export const media = (url: string) => + [...files, ...mediaSamples].find((s) => s.attachment.url === url)?.source; diff --git a/tests/fixtures/attachment-playground/main.tsx b/tests/fixtures/attachment-playground/main.tsx new file mode 100644 index 0000000000..c6daacbd25 --- /dev/null +++ b/tests/fixtures/attachment-playground/main.tsx @@ -0,0 +1,379 @@ +import "../../../src/shared/styles/globals.css"; +import "@fontsource-variable/inter/wght.css"; +import "@fontsource/jetbrains-mono/400.css"; +import { StrictMode, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ComposerAttachments } from "../../../src/features/messages/ComposerAttachments"; +import { AudioAttachment } from "../../../src/features/messages/AudioAttachment"; +import { MessageRow } from "../../../src/features/messages/MessageRow"; +import { VoiceNoteCard } from "../../../src/bundled/voice-notes/VoiceNoteCard"; +import type { + AttachmentRenderer, + ContributionReader, +} from "../../../src/features/conversation/contracts"; +import type { DraftAttachment } from "../../../src/features/messages/attachment-draft"; +import { Button } from "../../../src/shared/design-system/ui/Button"; +import { Select } from "../../../src/shared/design-system/ui/Select"; +import { + PanelHeader, + PanelHeaderLabel, +} from "../../../src/shared/design-system/ui/PanelHeader"; +import { FullPageSurface } from "../../../src/shared/design-system/ui/FullPageSurface"; +import { ToastProvider } from "../../../src/shared/design-system/ui/Toast"; +import { useKeyboardFocusVisibility } from "../../../src/shared/design-system/useKeyboardFocusVisibility"; +import { ACCENTS, parseAccent } from "../../../src/shared/theme/accent"; +import messageStyles from "../../../src/features/messages/Messages.module.css"; +import { files, mediaSamples, draft, media, type Sample } from "./data"; +import "./styles.css"; + +const params = new URLSearchParams(location.search); +const mode = params.get("mode") === "dark" ? "dark" : "light"; +document.documentElement.dataset.colorMode = mode; +document.documentElement.classList.toggle("dark", mode === "dark"); +document.documentElement.dataset.accent = parseAccent(params.get("accent")); +document.documentElement.dataset.colorLevel = + params.get("contrast") === "high" ? "high" : "touch"; +document.documentElement.style.setProperty( + "--buzz-text-scale", + params.get("scale") === "2" ? "2" : "1", +); +const voiceEntries: ReturnType< + ContributionReader["snapshot"] +> = [ + { + id: "voice", + key: "playground/voice", + pluginId: "playground", + revision: "1", + title: "Voice note", + matches: (a) => a.voiceNote === true, + component: ({ attachment, source, onRemove }) => ( + + ), + }, +]; +const renderers: ContributionReader = { + snapshot: () => voiceEntries, + subscribe: () => () => {}, +}; +const emptyEntries: readonly never[] = []; +const emptyReader = { snapshot: () => emptyEntries, subscribe: () => () => {} }; +const samples = [...files, ...mediaSamples]; +const archive = files[0]; + +function Draft({ + initial, + disabled = false, +}: { + initial: DraftAttachment[]; + disabled?: boolean; +}) { + const [items, setItems] = useState(initial); + return ( +
+
+
+ setItems((xs) => xs.filter((x) => x.id !== id))} + retry={(id) => + setItems((xs) => + xs.map((x) => + x.id === id ? { ...x, status: "ready", error: undefined } : x, + ), + ) + } + /> +

Reply in thread

+
+
+ {!items.length && ( + + )} +
+ ); +} + +function Sent({ + sample, + unavailable = false, + outgoing, +}: { + sample: Sample; + unavailable?: boolean; + outgoing: boolean; +}) { + const [notice, setNotice] = useState(""); + // MessageRow requires a broker/native source for inline audio. These local + // samples exercise the same players directly without a live media endpoint. + if (sample.attachment.kind === "audio" && !unavailable && sample.source) { + return sample.attachment.voiceNote ? ( + + ) : ( + + ); + } + return ( + <> + undefined : media} + onOpenLink={() => { + setNotice("Local specimen — no external file opened."); + return true; + }} + /> + {notice && ( +

+ {notice} +

+ )} + + ); +} + +function Specimens() { + useKeyboardFocusVisibility(); + const [revision, reset] = useState(0); + const group = params.get("group") ?? "all"; + const outgoing = params.get("direction") !== "received"; + return ( +
+
+

+ {parseAccent(params.get("accent"))} · {mode} +

+ +
+
+ {(group === "all" || group === "composer") && ( +
+

Composer

+ {samples.map((sample) => ( +
+

{sample.label}

+ +
+ ))} +
+

Media-only strip

+ draft(s))} /> +
+
+

+ Mixed strip · compact media +

+ draft(s)), + ]} + /> +
+
+ )} + {(group === "all" || group === "messages") && ( +
+

+ {outgoing ? "Sent" : "Received"} messages +

+ {samples.map((sample) => ( +
+

{sample.label}

+ +
+ ))} +
+ )} + {(group === "all" || group === "states") && ( +
+

States

+ {( + ["queued", "preparing", "uploading", "ready", "error"] as const + ).map((status) => ( +
+

{status}

+ +
+ ))} +
+

Disabled composer

+ +
+ {[archive, ...mediaSamples].map((sample) => ( +
+

+ Unavailable · {sample.label} +

+ +
+ ))} +
+ )} +
+
+ ); +} + +function Choice({ + label, + value, + values, + change, +}: { + label: string; + value: string; + values: readonly string[]; + change(value: string): void; +}) { + return ( +