import { ArticleIcon, MusicNotesIcon, PlayIcon, QuestionIcon, } from '@phosphor-icons/react' import type { Meta, StoryFn } from '@storybook/react' import React from 'react' import type { LinkAttachmentVariant } from './components/_shared/CardShell' import { HEX_PATTERN, toCssHex } from './components/_shared/hex' import LinkAttachment from '.' const IMAGE_THUMBNAIL = '/image-thumbnail.jpg' const AUDIO_THUMBNAIL = '/audio-thumbnail.jpg' const AUDIO_SOURCE = '/audio-source.mp3' const VIDEO_SOURCE = '/video-source.mp4' const meta: Meta = { title: 'LinkAttachment', parameters: { layout: 'fullscreen', // The single LinkApps story is a large matrix of every link-app card // crossed with featured/classic layouts and locked/unlocked states. // It's intended as a design-review canvas, not a regression target — // a single pixel change in any cell would diff the whole thing. Skip // it in Chromatic; the per-app/state behavior is exercised // implicitly through the message-attachment stories that use these // cards. chromatic: { disableSnapshot: true }, }, } export default meta const Table = ({ children }: { children: React.ReactNode }) => (
{children}
) const TableHead = ({ columns }: { columns: string[] }) => ( {columns.map((column) => ( {column} ))} ) const RowLabel = ({ children }: { children: React.ReactNode }) => ( {children} ) const APP_ICON_BASE = 'inline-flex size-4 items-center justify-center overflow-hidden rounded-[4px]' const SpotifyBadge = () => ( ) const TikTokBadge = () => ( ) const FaqBadge = () => ( ) const FormBadge = () => ( ) const LINK_APPS: Array<{ key: string appIcon: React.ReactNode title: string description: string url?: string ctaLabel?: string }> = [ { key: 'spotify', appIcon: , title: 'My Playlist', description: 'A handpicked workout mix I made for my clients.', url: 'tr.ee/briemix', }, { key: 'tiktok', appIcon: , title: 'My TikTok', description: 'New form-check clips every week — follow along.', url: 'tr.ee/brietok', }, { key: 'faq', appIcon: , title: 'Brie’s FAQ', description: 'Get answers on my process and what to expect.', ctaLabel: 'View FAQs', }, { key: 'form', appIcon: , title: 'Fitness Assessment Fillout', description: 'Share information about your journey and I’ll work on...', ctaLabel: 'Complete form', }, ] type LinkAppLayout = 'featured' | 'classic' type LinkAppState = 'Received' | 'Sent' | 'Composer' const LINK_APP_ROWS: Array<{ layout: LinkAppLayout; state: LinkAppState }> = [ { layout: 'featured', state: 'Received' }, { layout: 'featured', state: 'Sent' }, { layout: 'featured', state: 'Composer' }, { layout: 'classic', state: 'Received' }, { layout: 'classic', state: 'Sent' }, { layout: 'classic', state: 'Composer' }, ] const renderLinkAppCard = ( layout: LinkAppLayout, state: LinkAppState, app: (typeof LINK_APPS)[number] ) => { const { key, appIcon, title, description, url, ctaLabel } = app const thumbnailUrl = layout === 'featured' ? IMAGE_THUMBNAIL : undefined const ctaWithHandler = ctaLabel ? { label: ctaLabel, onClick: () => alert(`Tapped ${ctaLabel}`) } : undefined const ctaSilent = ctaLabel ? { label: ctaLabel } : undefined if (state === 'Received') { return ( ) } if (state === 'Sent') { return ( ) } return ( alert(`Dismissed ${key}`)} /> ) } /** * "LinkApps" section of the Figma board — link previews with a brand * badge prefixing the title and either a URL footer (Spotify, TikTok) * or a CTA button (FAQ, Form). Each app is shown in both the * **Featured** (hero image) and **Classic** (compact, no hero) layouts * across all three messaging states. * * For chat **document / image / video / audio attachments**, see the * `MessageAttachment` stories instead. */ export const LinkApps: StoryFn = () => ( key)} /> {LINK_APP_ROWS.map(({ layout, state }) => { const label = `${layout === 'featured' ? 'Featured' : 'Classic'} (${state})` return ( {label} {LINK_APPS.map((app) => ( ))} ) })}
{renderLinkAppCard(layout, state, app)}
) /* ────────────────────────────────────────────────────────────────── Accent chin (MES-1349) ────────────────────────────────────────────────────────────────── */ const ACCENTS: Array<{ label: string; accentColor?: string }> = [ // The Figma mock's own chin colour. Painted raw it measures 3.76:1 against // the sender's white ink, which is why the mock cannot simply be reproduced; // derived, its hue survives at a lightness both inks clear. The shipped sent // card is therefore darker than the mock on purpose (MES-1388). { label: '#738883 (mock)', accentColor: '#738883' }, { label: '#AA9F93', accentColor: '#AA9F93' }, { label: '#FFF099', accentColor: '#FFF099' }, { label: '#767676', accentColor: '#767676' }, { label: '#7008E7', accentColor: '#7008E7' }, { label: '#1e2330', accentColor: '#1e2330' }, { label: 'none (fallback)', accentColor: undefined }, { label: 'malformed', accentColor: 'rebeccapurple' }, ] const ACCENT_CARD = { title: 'Yoga on Ice with Brie Nala — where ice meets inner calm.', url: 'tr.ee/briewebsite', thumbnailUrl: IMAGE_THUMBNAIL, } /** * CTA emphasis — `cta.variant`. `primary` (default) is the filled button; * `secondary` is the subtle/grey button used by the official-channel cards. * Shown on both surfaces, and over an accent-derived chin: the translucent * `secondary` fills (`bg-white/15`, `bg-black/[0.06]`) composite against the * extracted colour, whereas the opaque `primary` fill is accent-independent — * the bottom rows are the Chromatic guard for that legibility difference. */ export const CtaEmphasis: StoryFn = () => { const cta = (label: string, variant?: 'primary' | 'secondary') => ({ label, onClick: () => alert(`Tapped ${label}`), ...(variant ? { variant } : {}), }) return ( Received (light)Sent (dark)Received + accent chinSent + accent chin
) } /** * The dominant-colour card and its WCAG AA gate (MES-1349, corrected MES-1388). * * Each column is one extracted accent. Ink is *fixed per side* — `text-white` * when sent, `text-black/90` when received — so contrast decides whether the * accent may paint at all rather than which text colour to use. An accent under * 4.5:1 against that side's ink is discarded for the plain message bubble fill * (`#1e2330` sent, `#f1f0ee` received), so a card without a usable colour reads * like the text bubbles around it. * * Each column is a synthetic base colour chosen to probe the derivation, not a * colour taken from the hero — in production `accent_color` is extracted from * the attachment image itself, so a photograph's own hue is what arrives here. * Every column paints on both sides: the derivation moves lightness, never hue, * so a base is never refused for contrast. * * The bottom rows cover the states where the accent is deliberately ignored: * `classic` has no image to extract from, and `loading` has no image yet. */ export const AccentChin: StoryFn = () => ( label)} /> Sent (last in run) {ACCENTS.map(({ label, accentColor }) => ( ))} Sent (mid-run, no tail) {ACCENTS.map(({ label, accentColor }) => ( ))} Received {ACCENTS.map(({ label, accentColor }) => ( ))} Composer {ACCENTS.map(({ label, accentColor }) => ( ))} 1-line title (hero 188px) {ACCENTS.map(({ label, accentColor }) => ( ))} Accent ignored
undefined} />
undefined} />
) // Unlike the LinkApps canvas above, this matrix *is* the regression target for // the accent geometry and the contrast flip — snapshot it. AccentChin.parameters = { chromatic: { disableSnapshot: false } } /** A link preview whose clip is playable audio, with cover artwork attached. */ const AUDIO_PREVIEW = { mimeType: 'audio/mpeg', sourceUrl: AUDIO_SOURCE, thumbnailUrl: AUDIO_THUMBNAIL, } /** * Accents that must **not** paint, because the hero image they claim to have * been extracted from is not on screen (MES-1349). * * Every card here is handed `#7008E7` — a violent purple, picked so a * regression is impossible to miss — and every card must render its own * variant fill instead. `useChinPalette` gates the accent on `CardThumbnail` * actually mounting the image, so: * * - **Playable audio** replaces the hero wholesale with the native `