From 10e55aa3fb240d73d3aa9c19f93af16ee9e39616 Mon Sep 17 00:00:00 2001 From: Tseka Luk Date: Fri, 21 Aug 2026 02:03:26 +0800 Subject: [PATCH 1/4] feat(ui): restyle five surfaces onto one visual system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings, the release-notes dialog, project home, the project directory and a new first-run picker were each drifting: four competing row containers, two nav rails sharing no tokens, two loading skeletons for the same grid, and radii, type steps and muted fills invented per file. This lands one contract — spacing rhythm, radii, type scale, button hierarchy — and moves all five onto it. Layout and visuals only. No data flow, hook, state shape or handler changed; parity was checked mechanically against HEAD rather than by eye (declarations, hook-call histogram, every onClick/onChange/onSelect/onSubmit/onKeyDown, and the values — not just the count — of all 21 aria-labels). Two bugs surfaced by rendering rather than reading: - The project-directory header was broken at ≥1100px. `order-last w-full max-w-xl` does not wrap: per Flexbox §9.3 the flex base size is clamped by max-width, so it fitted on line one and the description rendered beside the title, under the brandmark and squeezing the primary action. The measure cap moved to an inner span so `basis-full` can break the line. - Adding a section header to Updates wrapped a region named "Updates" in one named "Updates and support" — duplicate nested landmarks, and an ambiguous `getByRole('region', {name:'Updates'})`. The wrapper is a plain div now; its two children were already the landmarks. The onboarding picker's tile descriptions reused the home composer preset ids, which are prompt *prefixes* — they rendered as half-sentences trailing off mid-clause ("Design a responsive web experience for"). They have their own ids now. Its dialog copy also claimed Cutout uses the selection "to decide what to show you first", which is not true of anything yet; softened while the id is still new. The picker ships unreferenced by design: `initializeWorkAreasOnboarding` returns shouldOpen:false unconditionally and nothing imports the dialog. When it opens, and what the stored areas affect, are still product decisions. 43 new message ids translated into all four shipped locales. Visual baselines regenerated for the widths that intentionally changed. Known gaps left alone deliberately: ModelSlot/ProviderForm/ProviderDirectory still carry pre-existing contract violations and need their own pass; error states have no shared pattern; the amber warning literals have no achromatic token to move to. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/home/ProjectHome.test.ts | 57 + src/components/home/ProjectHome.tsx | 1137 +++++++++-------- .../onboarding/AreasOfWorkDialog.test.tsx | 168 +++ .../onboarding/AreasOfWorkDialog.tsx | 232 ++++ .../onboarding/areas-of-work.test.ts | 182 +++ src/components/onboarding/areas-of-work.ts | 91 ++ .../release-notes/WhatsNewDialog.test.ts | 11 + .../release-notes/WhatsNewDialog.tsx | 150 ++- src/components/settings/AboutFooter.tsx | 2 +- src/components/settings/SettingsDialog.tsx | 6 +- src/components/settings/SettingsSidebar.tsx | 4 +- .../settings/sections/AiSection.tsx | 16 +- .../settings/sections/ArchivedSection.tsx | 66 +- .../settings/sections/GeneralSection.test.tsx | 2 +- .../settings/sections/GeneralSection.tsx | 39 +- .../settings/sections/IntegrationsSection.tsx | 150 +-- .../sections/PersonalizationSection.tsx | 310 +++-- .../settings/sections/RecoverySection.tsx | 34 +- .../settings/sections/SpeechSection.tsx | 577 +++++---- .../settings/sections/UpdatesSection.tsx | 443 ++++--- .../sections/UpdatesSupportSection.tsx | 22 +- src/hooks/queries/work-areas.ts | 35 + src/locales/en/messages.po | 222 ++++ src/locales/es/messages.po | 222 ++++ src/locales/fr/messages.po | 222 ++++ src/locales/ja/messages.po | 222 ++++ src/locales/zh-CN/messages.po | 222 ++++ src/services/work-areas-prefs.local.ts | 118 ++ .../desktop-chrome/connect-provider-dark.png | Bin 41289 -> 45343 bytes .../desktop-chrome/connect-provider-light.png | Bin 41322 -> 45243 bytes .../mobile-chrome/connect-provider-dark.png | Bin 30116 -> 30392 bytes .../mobile-chrome/connect-provider-light.png | Bin 29949 -> 30160 bytes .../design-os.spec.ts/desktop-chrome/home.png | Bin 49223 -> 64211 bytes .../design-os.spec.ts/mobile-chrome/home.png | Bin 27706 -> 39617 bytes .../home-composer-surface-dark.png | Bin 8014 -> 8204 bytes .../home-composer-surface-light.png | Bin 7716 -> 8152 bytes .../home-composer-surface-dark.png | Bin 7067 -> 7135 bytes .../home-composer-surface-light.png | Bin 6745 -> 7148 bytes .../local-recovery-settings-collapsed.png | Bin 48108 -> 62895 bytes .../local-recovery-settings.png | Bin 55995 -> 70572 bytes .../local-recovery-settings-collapsed.png | Bin 38829 -> 50822 bytes .../mobile-chrome/local-recovery-settings.png | Bin 45631 -> 56223 bytes .../desktop-chrome/personalization-dark.png | Bin 63003 -> 59137 bytes .../desktop-chrome/personalization-light.png | Bin 62163 -> 59082 bytes .../mobile-chrome/personalization-dark.png | Bin 53953 -> 48472 bytes .../mobile-chrome/personalization-light.png | Bin 52836 -> 47786 bytes .../desktop-chrome/integration-icons-dark.png | Bin 130341 -> 126394 bytes .../integration-icons-light.png | Bin 130234 -> 126419 bytes .../provider-directory-dark.png | Bin 105713 -> 110114 bytes .../provider-directory-light.png | Bin 105018 -> 108426 bytes .../mobile-chrome/integration-icons-dark.png | Bin 74980 -> 74407 bytes .../mobile-chrome/integration-icons-light.png | Bin 74770 -> 74996 bytes .../mobile-chrome/provider-directory-dark.png | Bin 102963 -> 107291 bytes .../provider-directory-light.png | Bin 102303 -> 105869 bytes .../desktop-chrome/speech-settings-dark.png | Bin 66735 -> 77876 bytes .../desktop-chrome/speech-settings-light.png | Bin 66669 -> 78741 bytes .../mobile-chrome/speech-settings-dark.png | Bin 53269 -> 55719 bytes .../mobile-chrome/speech-settings-light.png | Bin 53379 -> 56838 bytes tests/visual/areas-of-work.spec.ts | 87 ++ tests/visual/fixtures/areas-of-work.html | 12 + tests/visual/fixtures/areas-of-work.tsx | 102 ++ 61 files changed, 3875 insertions(+), 1288 deletions(-) create mode 100644 src/components/onboarding/AreasOfWorkDialog.test.tsx create mode 100644 src/components/onboarding/AreasOfWorkDialog.tsx create mode 100644 src/components/onboarding/areas-of-work.test.ts create mode 100644 src/components/onboarding/areas-of-work.ts create mode 100644 src/hooks/queries/work-areas.ts create mode 100644 src/services/work-areas-prefs.local.ts create mode 100644 tests/visual/areas-of-work.spec.ts create mode 100644 tests/visual/fixtures/areas-of-work.html create mode 100644 tests/visual/fixtures/areas-of-work.tsx diff --git a/src/components/home/ProjectHome.test.ts b/src/components/home/ProjectHome.test.ts index b3131f7b..48b2042c 100644 --- a/src/components/home/ProjectHome.test.ts +++ b/src/components/home/ProjectHome.test.ts @@ -61,6 +61,63 @@ describe('ProjectRow', () => { }) }) +function renderHomeMarkup() { + return renderToStaticMarkup(createElement( + SettingsUIProvider, + { value: { open: vi.fn() } }, + createElement( + TooltipProvider, + null, + createElement(I18nProvider, { i18n }, createElement(ProjectHome, { + activeProjectId: null, + projects: [], + loadState: 'ready', + loadError: null, + onOpenProject: vi.fn(), + onArchiveProject: vi.fn(), + onRestoreProject: vi.fn(), + onDeleteProject: vi.fn(), + onRenameProject: vi.fn(), + onPinProject: vi.fn(), + onStartWithBrief: vi.fn(), + onImportBoard: vi.fn(), + onRetryProjects: vi.fn(), + })), + ), + )) +} + +describe('ProjectHome Start lockup and Continue working', () => { + it('anchors the hero with the labelled Cutout symbol above the headline', () => { + const html = renderHomeMarkup() + const markStart = html.indexOf('data-cutout-brand="symbol"') + const headlineStart = html.indexOf('What will we design?') + + expect(markStart).toBeGreaterThan(-1) + expect(html).toContain('aria-label="Cutout"') + expect(markStart).toBeLessThan(headlineStart) + }) + + it('gives the composer the taller textarea floor', () => { + const html = renderHomeMarkup() + + expect(html).toContain('min-h-36') + expect(html).toContain('sm:min-h-32') + expect(html).not.toContain('min-h-32 w-full resize-none') + }) + + it('keeps Continue working on screen with an empty state when nothing is in progress', () => { + const html = renderHomeMarkup() + + expect(html).toContain('Continue working') + expect(html).toContain('id="home-continue-heading"') + expect(html).toContain('aria-labelledby="home-continue-heading"') + expect(html).toContain('text-xs font-medium tracking-wide text-muted-foreground uppercase') + expect(html).toContain('Nothing in progress yet') + expect(html).toContain('border-dashed') + }) +}) + describe('ProjectHome outcome-first start', () => { it('resets and refocuses the mounted Home composer when a new-task signal repeats the route',async()=>{host=document.createElement('div');document.body.append(host);root=createRoot(host);const render=(signal:number)=>act(()=>root?.render(createElement(SettingsUIProvider,{value:{open:vi.fn()}},createElement(TooltipProvider,null,createElement(I18nProvider,{i18n},createElement(ProjectHome,{resetToStartSignal:signal,activeProjectId:null,projects:[],loadState:'ready',loadError:null,onOpenProject:vi.fn(),onArchiveProject:vi.fn(),onRestoreProject:vi.fn(),onDeleteProject:vi.fn(),onRenameProject:vi.fn(),onPinProject:vi.fn(),onStartWithBrief:vi.fn(),onImportBoard:vi.fn(),onRetryProjects:vi.fn()}))))));render(0);const textarea=host.querySelector('textarea') as HTMLTextAreaElement,web=host.querySelector('button[aria-label="Web"]') as HTMLButtonElement;act(()=>web.click());expect(textarea.value).not.toBe('');render(1);await act(settleFocus);const reset=host.querySelector('textarea') as HTMLTextAreaElement;expect(reset.value).toBe('');expect(host.querySelector('button[aria-label="Web"]')?.getAttribute('aria-pressed')).toBe('false');expect(document.activeElement).toBe(reset)}) it('keeps presets selection-only and creates exactly once on submit',async()=>{const view=mountHome(),web=view.host.querySelector('button[aria-label="Web"]') as HTMLButtonElement,textarea=view.host.querySelector('textarea') as HTMLTextAreaElement;act(()=>{web.click();web.click();web.click()});await act(settleFocus);expect(document.activeElement).toBe(textarea);expect(view.onStartWithBrief).not.toHaveBeenCalled();expect(textarea.value).toBe('Design a responsive web experience for ');expect(web.getAttribute('aria-pressed')).toBe('true');act(()=>web.click());expect(textarea.value).toBe('');expect(web.getAttribute('aria-pressed')).toBe('false');act(()=>web.click());act(()=>textarea.closest('form')?.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})));expect(view.onStartWithBrief).toHaveBeenCalledTimes(1);expect(view.onStartWithBrief).toHaveBeenCalledWith('Design a responsive web experience for',[])}) diff --git a/src/components/home/ProjectHome.tsx b/src/components/home/ProjectHome.tsx index 83692629..10e0670c 100644 --- a/src/components/home/ProjectHome.tsx +++ b/src/components/home/ProjectHome.tsx @@ -1,12 +1,12 @@ import { lazy, Suspense, + type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState, - type RefObject, } from "react"; import { Trans, useLingui } from "@lingui/react/macro"; import { withViewTransition } from "@/lib/view-transition"; @@ -19,31 +19,48 @@ import { ChevronLeft, ChevronRight, Clock3, + FileText, FolderOpen, Globe, Images, LayoutGrid, Library, Lightbulb, - List, Link2, - FileText, + List, Monitor, MoreHorizontal, Palette, Pencil, - Plus, Pin, PinOff, - X, + Plus, Search, - SearchX, Smartphone, Trash2, type LucideIcon, + X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { LocalProjectSummary } from "@/services/local/project-repository.local"; +import { cn } from "@/lib/utils"; +import { sortProjects } from "./project-order"; +import { ConnectorMenu } from "@/components/integrations/ConnectorMenu"; +import { CutoutBrandMark } from "@/components/brand/CutoutBrandMark"; +import { SidebarAccount } from "./SidebarAccount"; +import type { DesktopUpdateController } from "@/updater/service"; +import { Input } from "@/components/ui/input"; +import { filterProjects } from "./project-search"; import { AlertDialog, AlertDialogAction, @@ -54,8 +71,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, @@ -64,25 +79,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { LocalProjectSummary } from "@/services/local/project-repository.local"; -import { cn } from "@/lib/utils"; -import { sortProjects } from "./project-order"; -import { filterProjects } from "./project-search"; -import { ConnectorMenu } from "@/components/integrations/ConnectorMenu"; -import { SidebarAccount } from "./SidebarAccount"; -import type { DesktopUpdateController } from "@/updater/service"; + +export { ProjectCard, ProjectRow }; const GlobalLibraryView = lazy(() => import("@/components/library/GlobalLibraryView").then((module) => ({ @@ -90,26 +88,9 @@ const GlobalLibraryView = lazy(() => })), ); -type HomeSection = - "start" | "library" | "drafts" | "projects" | "archived"; - function isDraftProject(project: LocalProjectSummary) { return project.status === "Draft" || project.status === "Empty"; } -type ProjectLoadState = "loading" | "ready" | "error"; -type DirectoryLayout = "grid" | "list"; - -const DIRECTORY_LAYOUT_KEY = "cutout.home.directory-layout"; - -function readDirectoryLayout(): DirectoryLayout { - try { - return localStorage.getItem(DIRECTORY_LAYOUT_KEY) === "list" - ? "list" - : "grid"; - } catch { - return "grid"; - } -} interface ProjectHomeProps { readonly initialSection?: Extract; @@ -346,12 +327,21 @@ export function ProjectHome({ function LibraryLoadingState() { return ( -
+
- -
- {Array.from({ length: 6 }, (_, index) => ( - + +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + +
+
))}
@@ -450,8 +440,8 @@ function WorkspaceHeader({ ] as const; return ( -
-
+
+
- + {sectionLabels[section]}
-
+
{presets.map((preset) => ( ))} @@ -760,7 +759,7 @@ function StartWorkspace({ id: "home.brief_placeholder", message: "Describe the result you want...", })} - className="min-h-32 w-full resize-none rounded-none border-0 bg-transparent px-5 pt-4 pb-2 text-base shadow-none outline-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:min-h-28 sm:text-lg" + className="min-h-36 w-full resize-none rounded-none border-0 bg-transparent px-5 pt-4 pb-2 text-base shadow-none outline-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:min-h-32 sm:text-lg" />
- {recentProjects.length ? ( -
-
-

- {t({ - id: "home.continue_heading", - message: "Continue working", - })} -

- - {t({ id: "home.local_projects", message: "Local projects" })} - -
-
+
+
+

+ {t({ + id: "home.continue_heading", + message: "Continue working", + })} +

+ + {t({ id: "home.local_projects", message: "Local projects" })} + +
+ {recentProjects.length ? ( +
{recentProjects.slice(0, 4).map((project) => ( ))}
-
- ) : null} + ) : ( +
+ +

+ {t({ + id: "home.continue_empty_title", + message: "Nothing in progress yet", + })} +

+

+ {t({ + id: "home.continue_empty_description", + message: + "Projects you start from the brief above collect here, ready to pick back up.", + })} +

+
+ )} +
@@ -896,7 +921,7 @@ function AttachmentThumbnail({ file }: { readonly file: File }) { return () => URL.revokeObjectURL(next); }, [file]); if (url) - return ; + return ; return file.type.startsWith("video/") ? ( ) : ( @@ -904,428 +929,220 @@ function AttachmentThumbnail({ file }: { readonly file: File }) { ); } -function ProjectDirectory({ - activeProjectId, - section, - projects, - loadState, - loadError, - onOpenProject, - onArchiveProject, - onRestoreProject, - onDeleteProject, - onRenameProject, - onPinProject, - onRetryProjects, - onStartNew, -}: { - readonly activeProjectId: string | null; - readonly section: Exclude; - readonly projects: readonly LocalProjectSummary[]; - readonly loadState: ProjectLoadState; - readonly loadError: string | null; - readonly onOpenProject: (id: string) => void; - readonly onArchiveProject: (id: string) => Promise; - readonly onRestoreProject: (id: string) => void; - readonly onDeleteProject: (id: string) => void; - readonly onRenameProject: ( - project: LocalProjectSummary, - name: string, - ) => void; - readonly onPinProject: ( - project: LocalProjectSummary, - pinned: boolean, - ) => void; - readonly onRetryProjects: () => void; - readonly onStartNew: () => void; -}) { - const { t, i18n } = useLingui(); - const [query, setQuery] = useState(""); - const [layout, setLayout] = useState(readDirectoryLayout); - const archived = section === "archived"; - const title = archived - ? t({ id: "home.archived_projects", message: "Archived projects" }) - : section === "drafts" - ? t({ id: "home.drafts", message: "Drafts" }) - : t({ id: "home.your_projects", message: "Your projects" }); - const matchedProjects = useMemo( - () => filterProjects(projects, query), - [projects, query], - ); - const selectLayout = (next: DirectoryLayout) => { - setLayout(next); - try { - localStorage.setItem(DIRECTORY_LAYOUT_KEY, next); - } catch { - // best-effort persistence only - } - }; - - return ( -
-
-

- {t({ id: "home.workspace", message: "Workspace" })} -

-
-

{title}

- {loadState === "ready" && projects.length ? ( -
- - setQuery(event.target.value)} - placeholder={t({ - id: "home.search_projects", - message: "Search projects...", - })} - aria-label={t({ - id: "home.search_projects", - message: "Search projects...", - })} - className="h-9 pl-8" - /> -
- ) : null} -
- - -
- {!archived ? ( - - ) : null} -
-
-
- {loadState === "loading" ? ( - - ) : loadState === "error" ? ( - - ) : !projects.length ? ( - - ) : !matchedProjects.length ? ( - setQuery("")} /> - ) : layout === "grid" ? ( -
- {matchedProjects.map((project) => ( - onOpenProject(project.id)} - onArchive={() => onArchiveProject(project.id)} - onRestore={() => onRestoreProject(project.id)} - onDelete={() => onDeleteProject(project.id)} - onRename={(name) => onRenameProject(project, name)} - onPin={(pinned) => onPinProject(project, pinned)} - /> - ))} -
- ) : ( -
- {matchedProjects.map((project) => ( - onOpenProject(project.id)} - onArchive={() => onArchiveProject(project.id)} - onRestore={() => onRestoreProject(project.id)} - onDelete={() => onDeleteProject(project.id)} - onRename={(name) => onRenameProject(project, name)} - onPin={(pinned) => onPinProject(project, pinned)} - /> - ))} -
- )} -
-
- ); -} - -export function ProjectRow({ +function ProjectListItem({ project, - locale, active, + compact = false, + card = false, onOpen, onArchive, - onRestore, - onDelete, onRename, onPin, }: { readonly project: LocalProjectSummary; - readonly locale: string; readonly active: boolean; + readonly compact?: boolean; + readonly card?: boolean; readonly onOpen: () => void; readonly onArchive: () => Promise; - readonly onRestore: () => void; - readonly onDelete: () => void; readonly onRename: (name: string) => void; readonly onPin: (pinned: boolean) => void; }) { return (
- - - {project.assetCount} - - - - {formatProjectDate(project.updatedAt, locale)} - - +
+ +
); } -export function ProjectCard({ - project, - locale, +function NavItem({ + icon: Icon, + label, + count, + badge, active, - onOpen, - onArchive, - onRestore, - onDelete, - onRename, - onPin, + compact, + onClick, }: { - readonly project: LocalProjectSummary; - readonly locale: string; + readonly icon: LucideIcon; + readonly label: string; + readonly count?: number; + readonly badge?: string; readonly active: boolean; - readonly onOpen: () => void; - readonly onArchive: () => Promise; - readonly onRestore: () => void; - readonly onDelete: () => void; - readonly onRename: (name: string) => void; - readonly onPin: (pinned: boolean) => void; + readonly compact: boolean; + readonly onClick: () => void; }) { - const { t } = useLingui(); - const pinned = Boolean(project.pinnedAt); - const archived = Boolean(project.archivedAt); - return ( -
- - {!archived ? ( -
+ + {label} + + {badge ? ( + - -
+ {badge} + ) : null} -
- - - - {project.name} - - - {t({ - id: "home.edited_date", - message: `Edited ${formatProjectDate(project.updatedAt, locale)}`, - })} - + {count !== undefined ? ( + + {count} -
- -
-
-
+ ) : null} + ); } -function CardThumbnail({ thumbnail }: { readonly thumbnail: Blob }) { - const url = useObjectUrl(thumbnail); - - return url ? ( - - ) : null; +function projectCounts(projects: readonly LocalProjectSummary[]) { + const active = projects.filter((project) => !project.archivedAt); + return { + active: active.length, + drafts: active.filter(isDraftProject).length, + archived: projects.filter((project) => Boolean(project.archivedAt)).length, + }; } -function ProjectListItem({ +export type HomeSection = + "start" | "library" | "drafts" | "projects" | "archived"; +export type ProjectLoadState = "loading" | "ready" | "error"; + +function ProjectRow({ project, + locale, active, - compact = false, - card = false, onOpen, onArchive, + onRestore, + onDelete, onRename, onPin, }: { readonly project: LocalProjectSummary; + readonly locale: string; readonly active: boolean; - readonly compact?: boolean; - readonly card?: boolean; readonly onOpen: () => void; readonly onArchive: () => Promise; + readonly onRestore: () => void; + readonly onDelete: () => void; readonly onRename: (name: string) => void; readonly onPin: (pinned: boolean) => void; }) { return (
-
- -
+ + + {project.assetCount} + + + + {formatProjectDate(project.updatedAt, locale)} + +
); } +function CardThumbnail({ thumbnail }: { readonly thumbnail: Blob }) { + const url = useObjectUrl(thumbnail); + + return url ? ( + + ) : null; +} + function ProjectActions({ project, compact = false, @@ -1401,7 +1218,7 @@ function ProjectActions({ message: `More actions for ${project.name}`, })} > - + @@ -1586,7 +1403,7 @@ function ProjectMark({ ) : ( @@ -1651,43 +1468,368 @@ function ProjectStatusDescription({ return `${statusMessages[project.status]} · ${project.assetCount} ${assetLabel}`; } -function DirectorySkeleton() { - return ( -
- {Array.from({ length: 4 }, (_, index) => ( -
- -
- - -
-
- ))} -
- ); +function formatProjectDate(timestamp: number, locale: string) { + return new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + }).format(new Date(timestamp)); } -function DirectoryError({ - error, - onRetry, -}: { +type DirectoryLayout = "grid" | "list"; + +const DIRECTORY_LAYOUT_KEY = "cutout.home.directory-layout"; + +function readDirectoryLayout(): DirectoryLayout { + try { + return localStorage.getItem(DIRECTORY_LAYOUT_KEY) === "list" + ? "list" + : "grid"; + } catch { + return "grid"; + } +} + +function ProjectDirectory({ + activeProjectId, + section, + projects, + loadState, + loadError, + onOpenProject, + onArchiveProject, + onRestoreProject, + onDeleteProject, + onRenameProject, + onPinProject, + onRetryProjects, + onStartNew, +}: { + readonly activeProjectId: string | null; + readonly section: Exclude; + readonly projects: readonly LocalProjectSummary[]; + readonly loadState: ProjectLoadState; + readonly loadError: string | null; + readonly onOpenProject: (id: string) => void; + readonly onArchiveProject: (id: string) => Promise; + readonly onRestoreProject: (id: string) => void; + readonly onDeleteProject: (id: string) => void; + readonly onRenameProject: ( + project: LocalProjectSummary, + name: string, + ) => void; + readonly onPinProject: ( + project: LocalProjectSummary, + pinned: boolean, + ) => void; + readonly onRetryProjects: () => void; + readonly onStartNew: () => void; +}) { + const { t, i18n } = useLingui(); + const [query, setQuery] = useState(""); + const [layout, setLayout] = useState(readDirectoryLayout); + const archived = section === "archived"; + const title = archived + ? t({ id: "home.archived_projects", message: "Archived projects" }) + : section === "drafts" + ? t({ id: "home.drafts", message: "Drafts" }) + : t({ id: "home.your_projects", message: "Your projects" }); + const description = archived + ? t({ + id: "gallery.archived_description", + message: + "Work you have set aside. Restore a project to bring it back into the workspace.", + }) + : section === "drafts" + ? t({ + id: "gallery.drafts_description", + message: + "Ideas that have not produced results yet. Pick one up where you left off.", + }) + : t({ + id: "gallery.projects_description", + message: + "Everything you have made in this workspace. Pinned work stays at the top.", + }); + const matchedProjects = useMemo( + () => filterProjects(projects, query), + [projects, query], + ); + const selectLayout = (next: DirectoryLayout) => { + setLayout(next); + try { + localStorage.setItem(DIRECTORY_LAYOUT_KEY, next); + } catch { + // best-effort persistence only + } + }; + + return ( +
+
+

+ {t({ id: "home.workspace", message: "Workspace" })} +

+
+

{title}

+
+ {!archived ? ( + + ) : null} +
+ {/* `basis-full` is what breaks the description onto its own line; + the measure cap lives on the inner span so max-width cannot clamp + the flex basis back down and cancel the wrap. */} +

+ {description} +

+
+ +
+
+ {loadState === "ready" && projects.length ? ( +
+ + setQuery(event.target.value)} + placeholder={t({ + id: "home.search_projects", + message: "Search projects...", + })} + aria-label={t({ + id: "home.search_projects", + message: "Search projects...", + })} + className="pl-8" + /> +
+ ) : null} +
+ + +
+
+
+ {loadState === "loading" ? ( + + ) : loadState === "error" ? ( + + ) : !projects.length ? ( + + ) : !matchedProjects.length ? ( + setQuery("")} /> + ) : layout === "grid" ? ( +
+ {matchedProjects.map((project) => ( + onOpenProject(project.id)} + onArchive={() => onArchiveProject(project.id)} + onRestore={() => onRestoreProject(project.id)} + onDelete={() => onDeleteProject(project.id)} + onRename={(name) => onRenameProject(project, name)} + onPin={(pinned) => onPinProject(project, pinned)} + /> + ))} +
+ ) : ( +
+ {matchedProjects.map((project) => ( + onOpenProject(project.id)} + onArchive={() => onArchiveProject(project.id)} + onRestore={() => onRestoreProject(project.id)} + onDelete={() => onDeleteProject(project.id)} + onRename={(name) => onRenameProject(project, name)} + onPin={(pinned) => onPinProject(project, pinned)} + /> + ))} +
+ )} +
+
+ ); +} + +function ProjectCard({ + project, + locale, + active, + onOpen, + onArchive, + onRestore, + onDelete, + onRename, + onPin, +}: { + readonly project: LocalProjectSummary; + readonly locale: string; + readonly active: boolean; + readonly onOpen: () => void; + readonly onArchive: () => Promise; + readonly onRestore: () => void; + readonly onDelete: () => void; + readonly onRename: (name: string) => void; + readonly onPin: (pinned: boolean) => void; +}) { + const { t } = useLingui(); + const pinned = Boolean(project.pinnedAt); + const archived = Boolean(project.archivedAt); + + return ( +
+ + {!archived ? ( +
+ +
+ ) : null} +
+ + + + {project.name} + + + {t({ + id: "home.edited_date", + message: `Edited ${formatProjectDate(project.updatedAt, locale)}`, + })} + + +
+ +
+
+
+ ); +} + +function DirectorySkeleton() { + return ( +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function DirectoryError({ + error, + onRetry, +}: { readonly error: string; readonly onRetry: () => void; }) { const { t } = useLingui(); return ( -
+
- -
-

+ +
+

{t({ id: "home.load_failed_title", message: "Could not load projects", })}

-

{error}

+

{error}

); } - -function NavItem({ - icon: Icon, - label, - count, - badge, - active, - compact, - onClick, -}: { - readonly icon: LucideIcon; - readonly label: string; - readonly count?: number; - readonly badge?: string; - readonly active: boolean; - readonly compact: boolean; - readonly onClick: () => void; -}) { - return ( - - ); -} - -function projectCounts(projects: readonly LocalProjectSummary[]) { - const active = projects.filter((project) => !project.archivedAt); - return { - active: active.length, - drafts: active.filter(isDraftProject).length, - archived: projects.filter((project) => Boolean(project.archivedAt)).length, - }; -} - -function formatProjectDate(timestamp: number, locale: string) { - return new Intl.DateTimeFormat(locale, { - month: "short", - day: "numeric", - }).format(new Date(timestamp)); -} diff --git a/src/components/onboarding/AreasOfWorkDialog.test.tsx b/src/components/onboarding/AreasOfWorkDialog.test.tsx new file mode 100644 index 00000000..1871bb0d --- /dev/null +++ b/src/components/onboarding/AreasOfWorkDialog.test.tsx @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { setupI18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; + +import { AreasOfWorkDialog } from "./AreasOfWorkDialog"; +import { WORK_AREA_CAP, type WorkAreaId } from "./areas-of-work"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; +const i18n = setupI18n(); +i18n.loadAndActivate({ locale: "en", messages: {} }); + +function tiles(): HTMLButtonElement[] { + const group = document.body.querySelector('[role="group"]'); + return [...(group?.querySelectorAll("button") ?? [])] as HTMLButtonElement[]; +} + +function counter(): HTMLElement | null { + return document.body.querySelector('[aria-live="polite"]'); +} + +describe("AreasOfWorkDialog", () => { + let host: HTMLDivElement; + let root: Root; + let onConfirm: Mock<(areas: readonly WorkAreaId[]) => void>; + let onSkip: Mock<() => void>; + let onOpenChange: Mock<(open: boolean) => void>; + + function render(initialAreas?: readonly WorkAreaId[]) { + act(() => + root.render( + + + , + ), + ); + } + + beforeEach(() => { + onConfirm = vi.fn(); + onSkip = vi.fn(); + onOpenChange = vi.fn(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it("renders the six areas as multi-select tiles inside a labelled group", () => { + render(); + const group = document.body.querySelector('[role="group"]'); + expect(group?.getAttribute("aria-label")).toBe("Areas of work"); + expect(tiles()).toHaveLength(6); + for (const tile of tiles()) { + expect(tile.getAttribute("aria-pressed")).toBe("false"); + } + expect(document.body.textContent).toContain("Web"); + expect(document.body.textContent).toContain("Mobile app"); + expect(document.body.textContent).toContain("Poster"); + }); + + it("reflects the persisted selection when it opens", () => { + render(["mobile", "poster"]); + const pressed = tiles() + .filter((tile) => tile.getAttribute("aria-pressed") === "true") + .map((tile) => tile.textContent); + expect(pressed).toHaveLength(2); + expect(pressed.join(" ")).toContain("Mobile app"); + expect(pressed.join(" ")).toContain("Poster"); + expect(counter()?.textContent).toBe("2 of 3 selected"); + }); + + it("toggles a tile on and off and keeps the live counter in step", () => { + render(); + expect(counter()?.textContent).toBe("0 of 3 selected"); + act(() => tiles()[0]!.click()); + expect(tiles()[0]!.getAttribute("aria-pressed")).toBe("true"); + expect(counter()?.textContent).toBe("1 of 3 selected"); + act(() => tiles()[0]!.click()); + expect(tiles()[0]!.getAttribute("aria-pressed")).toBe("false"); + expect(counter()?.textContent).toBe("0 of 3 selected"); + }); + + it("disables — rather than hides — unselected tiles once the cap is reached", () => { + render(); + for (let index = 0; index < WORK_AREA_CAP; index += 1) { + act(() => tiles()[index]!.click()); + } + expect(tiles()).toHaveLength(6); + expect(counter()?.textContent).toBe("3 of 3 selected"); + for (let index = 0; index < WORK_AREA_CAP; index += 1) { + expect(tiles()[index]!.disabled).toBe(false); + } + for (let index = WORK_AREA_CAP; index < 6; index += 1) { + expect(tiles()[index]!.disabled).toBe(true); + expect(tiles()[index]!.className).toContain("disabled:opacity-50"); + } + // De-selecting frees a slot again. + act(() => tiles()[0]!.click()); + expect(tiles()[5]!.disabled).toBe(false); + }); + + it("confirms the selection in click order and closes", () => { + render(); + act(() => tiles()[2]!.click()); + act(() => tiles()[0]!.click()); + const continueButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent === "Continue", + ); + act(() => continueButton!.click()); + expect(onConfirm).toHaveBeenCalledWith(["miniapp", "web"]); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("skips without confirming a selection", () => { + render(); + act(() => tiles()[1]!.click()); + const skipButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent === "Skip for now", + ); + act(() => skipButton!.click()); + expect(onSkip).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("keeps the brand aside off the mobile bottom sheet", () => { + render(); + const aside = document.body.querySelector("aside"); + expect(aside?.className).toContain("hidden"); + expect(aside?.className).toContain("sm:flex"); + }); +}); + +describe("AreasOfWorkDialog trigger policy", () => { + it("is never auto-opened by the component itself", () => { + const source = readFileSync( + join(process.cwd(), "src/components/onboarding/AreasOfWorkDialog.tsx"), + "utf8", + ); + // `open` is entirely caller-driven: no internal default-open state. + expect(source).toContain(" void; + readonly initialAreas?: readonly WorkAreaId[]; + readonly onConfirm: (areas: readonly WorkAreaId[]) => void; + readonly onSkip?: () => void; + readonly restoreFocusTo?: HTMLElement | null; +}) { + const { t } = useLingui(); + const [selected, setSelected] = useState( + () => initialAreas ?? [], + ); + + // Re-seed from the persisted selection on each closed→open transition, so a + // cancelled session never leaks into the next one. Keyed on the transition + // rather than on `initialAreas` identity, so a caller passing a fresh array + // literal every render cannot wipe an in-flight selection. + const wasOpenRef = useRef(false); + useEffect(() => { + if (open && !wasOpenRef.current) setSelected(initialAreas ?? []); + wasOpenRef.current = open; + }, [open, initialAreas]); + + // Labels reuse the Home composer preset ids; the descriptions do not. Those + // presets are composer *prompt prefixes* ("Design a responsive web experience + // for ") — reusing them here rendered half-sentences that trail off mid-clause. + // A tile description has to name the area, so these ids are their own. + const areaCopy: Record = { + web: { + label: t({ id: "home.preset_web", message: "Web" }), + description: t({ + id: "onboarding.area_web_description", + message: "Responsive sites and web apps", + }), + }, + mobile: { + label: t({ id: "home.preset_mobile", message: "Mobile app" }), + description: t({ + id: "onboarding.area_mobile_description", + message: "iOS and Android app interfaces", + }), + }, + miniapp: { + label: t({ id: "home.preset_miniapp", message: "Mini program" }), + description: t({ + id: "onboarding.area_miniapp_description", + message: "WeChat and in-app mini programs", + }), + }, + desktop: { + label: t({ id: "home.preset_desktop", message: "Desktop" }), + description: t({ + id: "onboarding.area_desktop_description", + message: "Dense, tool-like desktop workspaces", + }), + }, + brand: { + label: t({ id: "home.preset_brand", message: "Brand kit" }), + description: t({ + id: "onboarding.area_brand_description", + message: "Logo, palette, type and assets", + }), + }, + poster: { + label: t({ id: "home.preset_poster", message: "Poster" }), + description: t({ + id: "onboarding.area_poster_description", + message: "Key visuals, posters and social art", + }), + }, + }; + + const selectedCount = selected.length; + const maxCount = WORK_AREA_CAP; + + return ( + + { + if (!restoreFocusTo?.isConnected) return; + event.preventDefault(); + restoreFocusTo.focus(); + }} + > + + +
+ + + What do you work on? + + + + Pick up to three areas you work in most. You can change them + later in Settings. + + + + +
+
+ {WORK_AREA_IDS.map((id) => { + const Icon = WORK_AREA_ICONS[id]; + const copy = areaCopy[id]; + const isSelected = selected.includes(id); + return ( + + ); + })} +
+
+ + +

+ + {selectedCount} of {maxCount} selected + +

+
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/onboarding/areas-of-work.test.ts b/src/components/onboarding/areas-of-work.test.ts new file mode 100644 index 00000000..5f63a616 --- /dev/null +++ b/src/components/onboarding/areas-of-work.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + WORK_AREA_CAP, + WORK_AREA_ICONS, + WORK_AREA_IDS, + isWorkAreaBlocked, + normalizeWorkAreas, + toggleWorkArea, + type WorkAreaId, +} from "./areas-of-work"; +import { + DEFAULT_WORK_AREAS_STATE, + WORK_AREAS_STORAGE_KEY, + initializeWorkAreasOnboarding, + readWorkAreasState, + saveWorkAreas, + selectedWorkAreas, +} from "@/services/work-areas-prefs.local"; + +function memoryStorage(seed?: Record) { + const map = new Map(Object.entries(seed ?? {})); + return { + map, + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + }; +} + +describe("areas-of-work vocabulary", () => { + it("is exactly the six Home composer areas, each with an icon", () => { + expect([...WORK_AREA_IDS]).toEqual([ + "web", + "mobile", + "miniapp", + "desktop", + "brand", + "poster", + ]); + expect(new Set(WORK_AREA_IDS).size).toBe(WORK_AREA_IDS.length); + for (const id of WORK_AREA_IDS) expect(WORK_AREA_ICONS[id]).toBeTruthy(); + }); + + it("caps the selection at three", () => { + expect(WORK_AREA_CAP).toBe(3); + }); +}); + +describe("toggleWorkArea", () => { + it("adds in selection order and removes on a second toggle", () => { + let selected: readonly WorkAreaId[] = []; + selected = toggleWorkArea(selected, "brand"); + selected = toggleWorkArea(selected, "web"); + expect(selected).toEqual(["brand", "web"]); + selected = toggleWorkArea(selected, "brand"); + expect(selected).toEqual(["web"]); + }); + + it("refuses to add past the cap but still allows removal", () => { + const full: readonly WorkAreaId[] = ["web", "mobile", "miniapp"]; + expect(toggleWorkArea(full, "poster")).toEqual(full); + expect(toggleWorkArea(full, "mobile")).toEqual(["web", "miniapp"]); + }); + + it("blocks only unselected tiles once the cap is reached", () => { + const full: readonly WorkAreaId[] = ["web", "mobile", "miniapp"]; + expect(isWorkAreaBlocked(full, "poster")).toBe(true); + expect(isWorkAreaBlocked(full, "web")).toBe(false); + expect(isWorkAreaBlocked(["web"], "poster")).toBe(false); + }); +}); + +describe("normalizeWorkAreas", () => { + it("drops unknown ids, de-duplicates and clamps to the cap", () => { + expect( + normalizeWorkAreas(["web", "web", "nope", "mobile", "brand", "poster"]), + ).toEqual(["web", "mobile", "brand"]); + }); +}); + +describe("work-areas persistence", () => { + let storage: ReturnType; + + beforeEach(() => { + storage = memoryStorage(); + }); + + it("reads nothing from a cold profile", () => { + expect(readWorkAreasState(storage)).toBeUndefined(); + expect(selectedWorkAreas(storage)).toEqual([]); + }); + + it("round-trips a confirmed selection under a versioned sibling key", () => { + const saved = saveWorkAreas(storage, ["mobile", "brand"]); + expect(saved).toEqual({ + protocol: "cutout.work-areas.v1", + areas: ["mobile", "brand"], + acknowledged: true, + }); + expect(storage.map.has(WORK_AREAS_STORAGE_KEY)).toBe(true); + expect(selectedWorkAreas(storage)).toEqual(["mobile", "brand"]); + }); + + it("records a skip as an acknowledged empty selection", () => { + expect(saveWorkAreas(storage, []).acknowledged).toBe(true); + expect(selectedWorkAreas(storage)).toEqual([]); + }); + + it("clamps an over-cap write instead of persisting it", () => { + expect( + saveWorkAreas(storage, ["web", "mobile", "miniapp", "desktop"]).areas, + ).toEqual(["web", "mobile", "miniapp"]); + }); + + it("degrades to the default on unparseable or invalid stored blobs", () => { + const broken = memoryStorage({ [WORK_AREAS_STORAGE_KEY]: "{not json" }); + expect(readWorkAreasState(broken)).toBeUndefined(); + + const wrongShape = memoryStorage({ + [WORK_AREAS_STORAGE_KEY]: JSON.stringify({ + protocol: "cutout.work-areas.v1", + areas: ["web", "unknown-area"], + acknowledged: true, + }), + }); + expect(readWorkAreasState(wrongShape)).toBeUndefined(); + expect(selectedWorkAreas(wrongShape)).toEqual([]); + + const extraKey = memoryStorage({ + [WORK_AREAS_STORAGE_KEY]: JSON.stringify({ + ...DEFAULT_WORK_AREAS_STATE, + surprise: true, + }), + }); + expect(readWorkAreasState(extraKey)).toBeUndefined(); + }); + + it("survives a storage that throws", () => { + const hostile = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + }; + expect(readWorkAreasState(hostile)).toBeUndefined(); + expect(() => saveWorkAreas(hostile, ["web"])).not.toThrow(); + }); +}); + +describe("initializeWorkAreasOnboarding", () => { + it("seeds the key on a cold profile and still refuses to open", () => { + const storage = memoryStorage(); + const decision = initializeWorkAreasOnboarding({ storage }); + expect(decision.shouldOpen).toBe(false); + expect(decision.state).toEqual(DEFAULT_WORK_AREAS_STATE); + expect(readWorkAreasState(storage)).toEqual(DEFAULT_WORK_AREAS_STATE); + }); + + it("never auto-opens, even on a seeded-but-unacknowledged profile", () => { + const storage = memoryStorage(); + initializeWorkAreasOnboarding({ storage }); + // Second launch: the key exists and `acknowledged` is still false. No + // trigger policy has been chosen, so the gate must stay shut. + const second = initializeWorkAreasOnboarding({ storage }); + expect(second.state.acknowledged).toBe(false); + expect(second.shouldOpen).toBe(false); + }); + + it("returns the acknowledged state once a selection exists", () => { + const storage = memoryStorage(); + saveWorkAreas(storage, ["poster"]); + const decision = initializeWorkAreasOnboarding({ storage }); + expect(decision.shouldOpen).toBe(false); + expect(decision.state).toEqual({ + protocol: "cutout.work-areas.v1", + areas: ["poster"], + acknowledged: true, + }); + }); +}); diff --git a/src/components/onboarding/areas-of-work.ts b/src/components/onboarding/areas-of-work.ts new file mode 100644 index 00000000..fbccc187 --- /dev/null +++ b/src/components/onboarding/areas-of-work.ts @@ -0,0 +1,91 @@ +/** + * Areas-of-work vocabulary for the first-run onboarding modal. + * + * The six areas mirror the Home composer presets one-for-one and deliberately + * REUSE their lingui ids, so onboarding owes zero new label translations and + * can never drift into a second product taxonomy. Declared locally on purpose: + * `ProjectHome.tsx` owns its own copy and this module must never import from + * it, so the two surfaces stay independently editable. + * + * The selection is capped at three. The cap is the whole reason the dialog + * shows an "n of 3" counter — it is a real constraint, not decoration. + * + * CONSUMPTION IS INERT: the stored selection may reorder or prefill UI and + * nothing else. It must never install, enable, gate or route a Design Profile + * (see `src/design-profile-platform/scenario-routing.ts`). + */ +import { + Blocks, + Globe, + Images, + Monitor, + Palette, + Smartphone, + type LucideIcon, +} from "lucide-react"; +import { z } from "zod"; + +export const WORK_AREA_IDS = [ + "web", + "mobile", + "miniapp", + "desktop", + "brand", + "poster", +] as const; + +export type WorkAreaId = (typeof WORK_AREA_IDS)[number]; + +export const workAreaIdSchema = z.enum(WORK_AREA_IDS); + +/** Selection cap. The reference's "n / 3" counter is grounded in this. */ +export const WORK_AREA_CAP = 3; + +/** Same lucide glyphs the Home composer presets use, keyed by area id. */ +export const WORK_AREA_ICONS: Record = { + web: Globe, + mobile: Smartphone, + miniapp: Blocks, + desktop: Monitor, + brand: Palette, + poster: Images, +}; + +/** + * Toggle an area, honouring the cap. + * + * Deselecting always works. Selecting past `WORK_AREA_CAP` is a no-op — the UI + * additionally disables over-cap tiles, so this is the belt to that braces. + * Order is preserved (selection order), which is what any future "surface my + * areas first" ordering would read. + */ +export function toggleWorkArea( + selected: readonly WorkAreaId[], + id: WorkAreaId, +): readonly WorkAreaId[] { + if (selected.includes(id)) return selected.filter((value) => value !== id); + if (selected.length >= WORK_AREA_CAP) return selected; + return [...selected, id]; +} + +/** True when the tile must render `disabled` (over cap, not already chosen). */ +export function isWorkAreaBlocked( + selected: readonly WorkAreaId[], + id: WorkAreaId, +): boolean { + return !selected.includes(id) && selected.length >= WORK_AREA_CAP; +} + +/** De-duplicate, drop unknown ids and clamp to the cap. */ +export function normalizeWorkAreas( + areas: readonly string[], +): readonly WorkAreaId[] { + const seen = new Set(); + for (const area of areas) { + const parsed = workAreaIdSchema.safeParse(area); + if (!parsed.success) continue; + seen.add(parsed.data); + if (seen.size >= WORK_AREA_CAP) break; + } + return [...seen]; +} diff --git a/src/components/release-notes/WhatsNewDialog.test.ts b/src/components/release-notes/WhatsNewDialog.test.ts index 5cd33320..652010bb 100644 --- a/src/components/release-notes/WhatsNewDialog.test.ts +++ b/src/components/release-notes/WhatsNewDialog.test.ts @@ -20,6 +20,17 @@ describe("What's New dialog contract", () => { expect(source).toContain("restoreFocusTo.focus()"); }); + it("uses the two-column dialog shell with a brand panel that collapses on mobile", () => { + expect(source).toContain("sm:max-w-3xl"); + expect(source).toContain("sm:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]"); + expect(source).toContain("