From 1e03e8f4ff03771e73a0ffc35f2ae3b72fadea27 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 7 Oct 2026 20:51:05 +0900 Subject: [PATCH] refactor: migrate UI to Tailwind and unify chat with shadcn --- .oxlintrc.json | 136 +- README.md | 21 +- docs/vitals-preview.md | 3 +- e2e/integrations/deck.test.ts | 20 +- e2e/integrations/home.test.ts | 2 +- e2e/integrations/inbox.test.ts | 6 +- e2e/integrations/research.test.ts | 15 +- e2e/integrations/support.test.ts | 15 +- e2e/integrations/url-state.test.ts | 32 +- e2e/integrations/vitals.test.ts | 13 +- e2e/integrations/webmcp.test.ts | 2 +- src/base.css | 75 -- src/components/app-shell.tsx | 17 +- src/components/ui/input-group.tsx | 2 +- src/components/ui/input.tsx | 9 +- src/components/ui/sidebar.tsx | 35 +- src/components/ui/textarea.tsx | 28 +- src/features/auth/auth-page.tsx | 46 +- src/features/auth/auth.css | 136 -- src/features/auth/onboarding-page.tsx | 80 +- .../connections/connection-manager.css | 55 - .../connections/connection-manager.tsx | 40 +- src/features/decks/column-editor.tsx | 21 +- src/features/decks/deck-column.tsx | 65 +- src/features/decks/deck-page.tsx | 73 +- src/features/decks/deck-switcher.tsx | 23 +- src/features/decks/decks.css | 313 ----- src/features/decks/mastodon-source-editor.tsx | 26 +- src/features/decks/research-deck-skeleton.tsx | 24 +- .../decks/research-post-card.test.tsx | 8 +- src/features/decks/research-post-card.tsx | 50 +- src/features/decks/research-post-skeleton.tsx | 2 +- src/features/decks/twitter-source-editor.tsx | 31 +- src/features/home/daily-brief.tsx | 17 +- src/features/home/execution-workbench.css | 333 ----- src/features/home/execution-workbench.tsx | 193 ++- src/features/home/home-page.tsx | 75 +- src/features/home/home.css | 254 ---- src/features/inbox/inbox-page.tsx | 81 +- src/features/inbox/inbox.css | 148 --- src/features/journal/journal-page.tsx | 52 +- src/features/journal/journal.css | 129 -- src/features/research/citation-navigation.css | 10 - src/features/research/research-markdown.tsx | 63 +- src/features/research/research-panel.css | 197 --- src/features/research/research-panel.test.tsx | 17 +- src/features/research/research-panel.tsx | 270 ++-- src/features/support/conversation-detail.tsx | 244 ++-- src/features/support/support-page.tsx | 106 +- src/features/support/support.css | 573 --------- src/features/vitals/environment-panel.tsx | 362 ++++-- src/features/vitals/vitals-page.tsx | 317 +++-- src/features/vitals/vitals.css | 1145 ----------------- src/styles.css | 265 +++- src/ui.css | 82 -- tokens.css | 56 - 56 files changed, 2014 insertions(+), 4399 deletions(-) delete mode 100644 src/base.css delete mode 100644 src/features/auth/auth.css delete mode 100644 src/features/connections/connection-manager.css delete mode 100644 src/features/decks/decks.css delete mode 100644 src/features/home/execution-workbench.css delete mode 100644 src/features/home/home.css delete mode 100644 src/features/inbox/inbox.css delete mode 100644 src/features/journal/journal.css delete mode 100644 src/features/research/citation-navigation.css delete mode 100644 src/features/research/research-panel.css delete mode 100644 src/features/support/support.css delete mode 100644 src/features/vitals/vitals.css delete mode 100644 src/ui.css delete mode 100644 tokens.css diff --git a/.oxlintrc.json b/.oxlintrc.json index 3c40cc7..b90dc13 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -19,7 +19,9 @@ "react-perf", "jsx-a11y" ], - "jsPlugins": ["@shadcn/lint"], + "jsPlugins": [ + "@shadcn/lint" + ], "rules": { "no-console": "error", "no-unused-vars": [ @@ -48,7 +50,9 @@ "shadcn/no-arbitrary-values": [ "error", { - "allow": ["layout"] + "allow": [ + "layout" + ] } ], "shadcn/no-inline-styles": "error", @@ -56,95 +60,114 @@ "shadcn/no-restyle": [ "error", { - "allow": ["layout"], + "allow": [ + "layout" + ], "contracts": [ { "pattern": "^Sidebar$", "allow": [ "layout", "border-r", - "transition-none", - "inbox-sidebar", - "inbox-list-panel", - "support-sidebar", - "support-list" + "border-r-0", + "border-border", + "transition-none" ] }, { "pattern": "^SidebarHeader$", - "allow": ["layout", "py-4", "gap-4", "border-b", "p-5"] + "allow": [ + "layout", + "py-4", + "gap-4", + "border-b", + "p-5" + ] }, { "pattern": "^SidebarGroup$", - "allow": ["layout", "px-3"] + "allow": [ + "layout", + "px-3" + ] }, { "pattern": "^SidebarFooter$", - "allow": ["layout", "px-3", "pb-4"] + "allow": [ + "layout", + "px-3", + "pb-4" + ] }, { "pattern": "^SidebarMenuButton$", - "allow": ["layout", "p-0"] + "allow": [ + "layout", + "p-0" + ] }, { "pattern": "^Label$", - "allow": ["layout", "deck-checkbox"] - }, - { - "pattern": "^Textarea$", - "allow": ["layout", "text-sm", "leading-7", "journal-summary"] - }, - { - "pattern": "^MessageScroller$", - "allow": ["layout", "research-conversation"] + "allow": [ + "layout", + "gap-2", + "text-sm", + "text-xs", + "text-muted-foreground" + ] }, { "pattern": "^MessageScrollerContent$", - "allow": ["layout", "gap-5", "p-4"] + "allow": [ + "layout", + "gap-5", + "p-4" + ] }, { - "pattern": "^Marker$", - "allow": ["layout", "research-tool-message"] - }, - { - "pattern": "^Message$", - "allow": ["layout", "research-message"] - }, - { - "pattern": "^Collapsible$", - "allow": ["layout", "research-account-picker"] + "pattern": "^(Marker|Collapsible)$", + "allow": [ + "layout", + "text-research-label", + "text-muted-foreground" + ] }, { "pattern": "^SidebarProvider$", "allow": [ "layout", - "workspace", - "home-workspace", - "inbox-workspace", - "vital-workspace", - "support-workspace", - "journal-workspace" + "bg-vital-bg", + "text-vital-text", + "font-arial" ] }, { "pattern": "^Button$", - "allow": ["layout", "auth-submit", "inbox-back", "support-back"] - }, - { - "pattern": "^SidebarInset$", - "allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"] + "allow": [ + "layout", + "px-2" + ] }, { "pattern": "^Input$", - "allow": ["layout", "pl-9"] + "allow": [ + "layout", + "pl-9" + ] }, { "pattern": "^SidebarContent$", - "allow": ["layout", "gap-0"] + "allow": [ + "layout", + "gap-0" + ] }, { "pattern": "^Badge$", - "allow": ["layout", "deck-source-label"] + "allow": [ + "layout", + "gap-1" + ] } ] } @@ -154,13 +177,17 @@ }, "overrides": [ { - "files": ["scripts/**"], + "files": [ + "scripts/**" + ], "rules": { "no-console": "off" } }, { - "files": ["src/components/ui/**"], + "files": [ + "src/components/ui/**" + ], "rules": { "shadcn/no-arbitrary-values": "off", "shadcn/no-restyle": "off", @@ -168,8 +195,13 @@ } }, { - "files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"], - "plugins": ["vitest"], + "files": [ + "src/**/*.{test,spec}.{ts,tsx}", + "tests/**/*.{ts,tsx}" + ], + "plugins": [ + "vitest" + ], "rules": { "typescript/unbound-method": "error", "vitest/expect-expect": "error", @@ -179,7 +211,9 @@ } }, { - "files": ["e2e/**/*.{ts,tsx}"], + "files": [ + "e2e/**/*.{ts,tsx}" + ], "rules": { "react/rules-of-hooks": "off" } diff --git a/README.md b/README.md index 4e3f59e..1bfffbc 100644 --- a/README.md +++ b/README.md @@ -262,22 +262,25 @@ shadcn dialogs with Escape handling and focus restoration. Column menus contain editing, ordering, and deletion; refresh stays available directly in each header. Shared UI components live in `src/components/ui`; `components.json` configures -the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble, -Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved -conversations. The icon rail switches between chat and deck management; +the registry and theme entrypoint. Research and Messages follow the +[shadcn chatbot template](https://github.com/shadcn-ui/chatbot-template), composing +MessageScroller, Message, Bubble and InputGroup without separate chat primitives. +Research uses Marker and Streamdown for tool progress and streamed Markdown, +with reconnectable snapshot SSE and saved conversations. Enter sends a research +message; Shift+Enter inserts a line break and IME composition does not submit. The icon rail switches between chat and deck management; closing the mobile sheet or crossing the desktop breakpoint preserves chat -drafts, selected accounts and live deck updates. Theme colors are declared in -`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens, -base styles and feature styles in that order, so the UI lint rules can resolve -application classes through the same stylesheet used by the app. +drafts, selected accounts and live deck updates. All application styling uses Tailwind utilities in the components. +`src/styles.css` contains the Tailwind imports, shared theme tokens, and base +utilities; there are no feature stylesheets. Named pixel spacing tokens preserve +the compact layouts alongside the existing rem scale. Tests use accessible +roles and explicit data attributes rather than styling classes. `pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through `oxlint-tsgolint`, including the [@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The `shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and class helpers, including shared UI components. Layout values remain available -for the sidebar and deck. Component contracts allow only the existing workspace -styles; Next.js-specific rules are omitted because this app uses TanStack Start. +for the sidebar and deck. Component contracts allow only the required shared-component treatments; Next.js-specific rules are omitted because this app uses TanStack Start. `pnpm format` formats files with Oxfmt defaults, and `pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports of registry components for composition. Additional design rules can be configured diff --git a/docs/vitals-preview.md b/docs/vitals-preview.md index 916ee2a..ad4a6a7 100644 --- a/docs/vitals-preview.md +++ b/docs/vitals-preview.md @@ -15,7 +15,8 @@ remains a visual placeholder, not inferred medical content. as the anatomy illustration source and clipped in CSS. Source media: https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg The rest of the interface is HTML/CSS with SVG data charts. Reference-specific -colors are scoped to `--vital-*` tokens in `vitals.css`. +colors use `--color-vital-*` Tailwind theme tokens in `src/styles.css`. +Layout, responsive states, and chart styling use utilities in the components. Sections, tabs, history, chart filters, and detail dialogs are URL state and restore on reload or browser Back/Forward. Simulated climate control values diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index 0695d64..73800ff 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -74,18 +74,18 @@ test("creates, switches, renames and deletes persisted deck profiles", async ({ await page.getByRole("button", { name: "Create deck", exact: true }).click(); await page.getByLabel("New deck name").fill("別の調査"); await page.getByRole("button", { name: "Create", exact: true }).click(); - await expect(page.locator(".deck-column")).toHaveCount(0); + await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0); await addColumn(page, "別の観点", "second-deck", "alternate"); await showDeckManagement(page); await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" }); await closeMobileSidebar(page); - await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]); await page.reload(); await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); await showDeckManagement(page); await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" }); await closeMobileSidebar(page); - await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["別の観点"]); await showDeckManagement(page); await page.getByRole("button", { name: "Delete deck", exact: true }).click(); await page.getByRole("button", { name: "Delete", exact: true }).click(); @@ -108,11 +108,17 @@ test("reads user and list timelines within columns and reorders, deletes and res await expect(list.getByText("list page 2")).toBeVisible(); await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click(); - await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ + "リストの投稿", + "アカウント", + ]); await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click(); await page.getByRole("button", { name: "Undo" }).click(); - await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ + "リストの投稿", + "アカウント", + ]); await page.reload(); await expect(user.getByText("alternate profile page 1")).toBeVisible(); await expect(list.getByText("list page 1")).toBeVisible(); @@ -149,7 +155,7 @@ test("keeps the deck and editor accessible within narrow viewports", async ({ expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe( true, ); - const board = await page.locator(".deck-board").boundingBox(); + const board = await page.locator("[data-testid='deck-board']").boundingBox(); expect(board).not.toBeNull(); expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901); } @@ -184,7 +190,7 @@ test("closes the column editor with Escape and restores focus without applying e await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0); await expect(opener).toBeFocused(); - await expect(page.locator(".deck-column")).toHaveCount(0); + await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0); await opener.click(); await expect(page.getByLabel("Column name", { exact: true })).toHaveValue(""); }); diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index e1c2678..df66d89 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -154,7 +154,7 @@ test("keeps completed actions in their original positions with visible outcomes" }, testInfo) => { await page.goto("/"); await page.waitForLoadState("networkidle"); - const titles = page.locator(".execution-flow-title"); + const titles = page.locator("[data-testid='execution-flow-title']"); const before = await titles.allTextContents(); await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); await expect( diff --git a/e2e/integrations/inbox.test.ts b/e2e/integrations/inbox.test.ts index 558f67f..31baa72 100644 --- a/e2e/integrations/inbox.test.ts +++ b/e2e/integrations/inbox.test.ts @@ -3,7 +3,7 @@ import { expect, test } from "../fixtures"; test("selects articles and keeps the reader focused on their content", async ({ page }) => { await page.goto("/inbox"); await page.waitForLoadState("networkidle"); - const rows = page.locator(".inbox-article-row"); + const rows = page.locator("[data-testid='inbox-article-row']"); await expect(rows).toHaveCount(9); const articleTitle = "Context for an agent can be brief"; await rows.filter({ hasText: articleTitle }).click(); @@ -33,7 +33,7 @@ test("selects articles and keeps the reader focused on their content", async ({ test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => { await page.goto("/inbox"); await page.waitForLoadState("networkidle"); - const rows = page.locator(".inbox-article-row"); + const rows = page.locator("[data-testid='inbox-article-row']"); await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz"); await expect(rows).toHaveCount(0); await expect(page.getByText("No articles found", { exact: true })).toBeVisible(); @@ -58,7 +58,7 @@ test("keeps article navigation readable without horizontal overflow", async ({ await page.setViewportSize({ width, height: 850 }); await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.locator(".inbox-article-row").first().click(); + await page.locator("[data-testid='inbox-article-row']").first().click(); const reader = page.getByRole("article", { name: "Thinking in the browser: a new workflow with AI agents", exact: true, diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index 0f8b7f4..a3d7535 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -100,7 +100,7 @@ test("restores the conversation and growing temporary deck from reconnect snapsh "background-color", "rgba(0, 0, 0, 0)", ); - await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]); run.deckVersion = 2; run.status = "complete"; run.messages.push({ @@ -124,7 +124,10 @@ test("restores the conversation and growing temporary deck from reconnect snapsh await showResearch(page); await expect(conversation).toContainText("既存の観点を使って調査して"); await expect(conversation).toContainText("比較する観点を追加しました。"); - await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ + "最初の観点", + "比較の観点", + ]); await closeMobileSidebar(page); await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); await showResearch(page); @@ -260,15 +263,15 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0); await chat.getByRole("link", { name: "根拠の投稿" }).click(); const card = page.locator( - '.research-post[data-post-url="https://twitter.com/author/status/123456789"]', + '[data-testid="research-post"][data-post-url="https://twitter.com/author/status/123456789"]', ); await expect(card).toBeVisible(); - await expect(card).toHaveClass(/citation-highlight/); + await expect(card).toHaveAttribute("data-citation-highlight", "true"); await expect(card).toBeFocused(); await expect(card).toContainText("Post saved during research"); await expect(card).toContainText("AIが調査時に取得した引用本文"); expect( - await page.locator(".deck-board").evaluate((element) => element.scrollLeft), + await page.locator("[data-testid='deck-board']").evaluate((element) => element.scrollLeft), ).toBeGreaterThan(0); expect(page.context().pages()).toHaveLength(1); await expect(page).toHaveURL(/citation=twitter%3A123456789/); @@ -283,7 +286,7 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col await expect(card).toBeVisible(); await expect(card).toHaveClass(/citation-highlight/); await expect(page).toHaveURL(/citation=twitter%3A123456789/); - await expect(page.locator(".deck-column")).toHaveCount(3); + await expect(page.locator("[data-testid='deck-column']")).toHaveCount(3); expect(submitted).toEqual([]); expect((await a11y().analyze()).violations).toEqual([]); }); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index 84d95e0..ec889f4 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -9,16 +9,16 @@ test("reviews an edited reply and keeps the simulated result in history", async }) => { await page.goto("/support"); await page.waitForLoadState("networkidle"); - const rows = page.locator(".support-row"); + const rows = page.locator("[data-testid='support-row']"); await expect(rows).toHaveCount(3); await rows.filter({ hasText: reviewSubject }).click(); - await page.locator(".conversation-suggestions summary:visible").click(); + await page.locator("[data-testid='conversation-suggestions'] summary:visible").click(); await page.getByRole("button", { name: /^Polite/ }).click(); const reply = page.getByRole("textbox", { name: "Reply", exact: true }); await expect(reply).toHaveValue(/Thank you for sharing/); const edited = "I reviewed the materials. Let’s discuss the first-time experience."; await reply.fill(edited); - await page.locator(".conversation-scheduling summary:visible").click(); + await page.locator("[data-testid='conversation-scheduling'] summary:visible").click(); const slot = "Mon, Oct 5, 14:00–14:30"; await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot); await page.getByRole("button", { name: "Add to reply" }).click(); @@ -37,15 +37,16 @@ test("reviews an edited reply and keeps the simulated result in history", async await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0); await page.getByRole("button", { name: "Archive", exact: true }).click(); await rows.filter({ hasText: reviewSubject }).click(); - await expect(page.locator(".support-outcome")).toContainText(edited); - await expect(page.locator(".support-outcome")).toContainText(slot); + await expect(page.locator("[data-testid='support-outcome']")).toContainText(edited); + await expect(page.locator("[data-testid='support-outcome']")).toContainText(slot); }); test("defers a conversation and restores it to the current queue", async ({ page }) => { await page.goto("/support"); await page.waitForLoadState("networkidle"); - const rows = page.locator(".support-row"); + const rows = page.locator("[data-testid='support-row']"); await rows.filter({ hasText: coffeeSubject }).click(); + await expect(rows.filter({ hasText: coffeeSubject })).toHaveAttribute("aria-current", "true"); await page .getByRole("textbox", { name: "Reply", exact: true }) .fill("土曜の予定を確認してから返事するね。"); @@ -117,7 +118,7 @@ test("fits communication and journal interactions in narrow screens", async ({ await page.goto("/support"); await page.waitForLoadState("networkidle"); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.locator(".support-row").first().click(); + await page.locator("[data-testid='support-row']").first().click(); await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); diff --git a/e2e/integrations/url-state.test.ts b/e2e/integrations/url-state.test.ts index f59cdeb..9455c69 100644 --- a/e2e/integrations/url-state.test.ts +++ b/e2e/integrations/url-state.test.ts @@ -2,19 +2,22 @@ import { expect, test } from "../fixtures"; test("restores article selection with browser history and reload", async ({ page }) => { await page.goto("/inbox", { waitUntil: "networkidle" }); - const rows = page.locator(".inbox-article-row"); - const heading = await rows.nth(1).locator(".inbox-row-title").innerText(); + const rows = page.locator("[data-testid='inbox-article-row']"); + const heading = await rows.nth(1).locator("[data-testid='inbox-row-title']").innerText(); await rows.nth(1).click(); - await expect(page.locator(".inbox-article-title")).toHaveText(heading); + await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading); const selectedUrl = page.url(); expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy(); await page.reload({ waitUntil: "networkidle" }); - await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? ""); + await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? ""); await page.goBack(); - await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false"); + await expect(page.locator("[data-testid='inbox-workspace']")).toHaveAttribute( + "data-reading", + "false", + ); await page.goForward(); await expect(page).toHaveURL(selectedUrl); - await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? ""); + await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? ""); }); test("restores search and replaces typing history", async ({ page }) => { @@ -24,10 +27,10 @@ test("restores search and replaces typing history", async ({ page }) => { await search.pressSequentially("browser"); await expect(search).toHaveValue("browser"); await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser"); - const count = await page.locator(".inbox-article-row").count(); + const count = await page.locator("[data-testid='inbox-article-row']").count(); await page.reload({ waitUntil: "networkidle" }); await expect(search).toHaveValue("browser"); - await expect(page.locator(".inbox-article-row")).toHaveCount(count); + await expect(page.locator("[data-testid='inbox-article-row']")).toHaveCount(count); await page.goBack(); await expect(page).toHaveURL("http://127.0.0.1:4173/"); }); @@ -47,16 +50,21 @@ test("restores account dialog without losing underlying article", async ({ page test("restores a message view and conversation from its URL", async ({ page }) => { await page.goto("/support", { waitUntil: "networkidle" }); await page.getByRole("button", { name: "All", exact: true }).click(); - const row = page.locator(".support-row").nth(1); + const row = page.locator("[data-testid='support-row']").nth(1); const heading = await row.locator("span").first().innerText(); await row.click(); - await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading); + await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(heading); const url = page.url(); expect(new URL(url).searchParams.get("view")).toBe("all"); expect(new URL(url).searchParams.get("conversation")).toBeTruthy(); await page.reload({ waitUntil: "networkidle" }); - await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? ""); - await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true"); + await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText( + heading ?? "", + ); + await expect(page.locator("[data-testid='support-workspace']")).toHaveAttribute( + "data-reading", + "true", + ); }); test("keeps a deep link through login", async ({ page, context }) => { diff --git a/e2e/integrations/vitals.test.ts b/e2e/integrations/vitals.test.ts index 8b0547b..66ba00a 100644 --- a/e2e/integrations/vitals.test.ts +++ b/e2e/integrations/vitals.test.ts @@ -4,7 +4,7 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => { await page.goto("/vitals"); await page.waitForLoadState("networkidle"); await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible(); - const systems = page.locator(".vital-system-card"); + const systems = page.locator("[data-testid='vital-system-card']"); await expect(systems).toHaveCount(8); await page .getByRole("group", { name: "Body systems", exact: true }) @@ -24,11 +24,11 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => { await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Close", exact: true }).last().click(); await page.getByRole("button", { name: "Lab history", exact: true }).click(); - await expect(page.locator(".vital-history")).toContainText("Jul 28"); - await expect(page.locator(".vital-history")).toContainText("93"); + await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28"); + await expect(page.locator("[data-testid='vital-history']")).toContainText("93"); await page.reload(); await page.waitForLoadState("networkidle"); - await expect(page.locator(".vital-history")).toContainText("Jul 28"); + await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28"); await page.getByRole("button", { name: "Systems", exact: true }).click(); await page .getByRole("group", { name: "Body systems", exact: true }) @@ -61,7 +61,10 @@ test("explores environment charts and simulated climate controls", async ({ page await expect( page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), ).toBeVisible(); - await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click(); + await page + .locator("[data-testid='vital-metric']") + .filter({ hasText: "Room temperature" }) + .click(); const dialog = page.getByRole("dialog", { name: "Room temperature" }); await expect(dialog).toContainText("64.0"); await page.reload(); diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 77d8739..1429b5e 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -234,7 +234,7 @@ test("rejects invalid input without changing the active view", async ({ page }) expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe( true, ); - await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]); + await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]); } expect(savedCount()).toEqual({ count: 0 }); }); diff --git a/src/base.css b/src/base.css deleted file mode 100644 index 755cfed..0000000 --- a/src/base.css +++ /dev/null @@ -1,75 +0,0 @@ -:root { - --canvas: var(--color-canvas); - --surface: var(--color-paper-2); - --paper: var(--color-paper); - --ink: var(--color-ink); - --accent-hover: var(--color-accent-hover); - --control-border: var(--color-control-rule); - --error: var(--color-error); - color: var(--ink); - background: var(--canvas); - color-scheme: dark; - font-family: var(--font-body); - font-size: 14px; - font-synthesis: none; -} -* { - box-sizing: border-box; -} -html, -body { - min-width: 320px; - overflow-x: clip; - background: var(--canvas); -} -body { - margin: 0; -} -button, -input, -select { - font: inherit; -} -a { - color: var(--primary); - text-underline-offset: 0.18em; -} -a:active { - color: var(--accent-hover); -} -:focus-visible { - outline: var(--rule-active) solid var(--color-focus); - outline-offset: 2px; -} -h1 { - margin: 0; - min-width: 0; - font-family: var(--font-display); - font-size: var(--text-display-s); - font-weight: 700; - line-height: 1.3; - overflow-wrap: anywhere; -} -.handle { - display: block; - color: var(--muted-foreground); - font-size: var(--text-xs); -} - -.workspace .reader { - overflow: hidden; -} -.sidebar-note { - color: var(--muted-foreground); - font-size: var(--text-xs); -} -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - /* Respect reduced motion over registry component animation utilities. */ - animation-duration: 0.01ms !important; - /* Respect reduced motion over registry component transition utilities. */ - transition-duration: 0.01ms !important; - } -} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 824684f..5e2e787 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,5 +1,5 @@ import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react"; -import { type CSSProperties, type ReactNode, useState } from "react"; +import { type ReactNode, useState } from "react"; import { Sidebar, SidebarInset, @@ -22,15 +22,7 @@ type ShellProps = { export function AppShell(props: ShellProps) { return ( - + @@ -106,7 +98,10 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell Research Research workspace -
+
{children}
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx index e32d84a..314ee06 100644 --- a/src/components/ui/input-group.tsx +++ b/src/components/ui/input-group.tsx @@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", className, )} {...props} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 87e169c..0560695 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -2,13 +2,20 @@ import { Input as InputPrimitive } from "@base-ui/react/input"; import { cn } from "cn"; import type * as React from "react"; -function Input({ className, type, ...props }: React.ComponentProps<"input">) { +function Input({ + className, + type, + variant = "default", + ...props +}: React.ComponentProps<"input"> & { variant?: "default" | "measurement" }) { return ( & { @@ -115,15 +111,8 @@ function SidebarProvider({
@@ -574,11 +558,6 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean; }) { - // Random width between 50 to 90%. - const [width] = React.useState(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }); - return (
{showIcon && } - +
); } diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index fd2fe42..8166f90 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,14 +1,32 @@ +import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "cn"; import type * as React from "react"; -function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { +const textareaVariants = cva( + "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + { + variants: { + variant: { + default: "", + composer: + "resize-y border-0 bg-transparent p-px-8 text-ui-16 leading-relaxed-copy shadow-none md:text-ui-16 dark:bg-transparent", + note: "bg-background text-ui-12 md:text-ui-12 dark:bg-background", + summary: "text-ui-13 md:text-ui-13", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +function Textarea({ + className, + variant, + ...props +}: React.ComponentProps<"textarea"> & VariantProps) { return (