refactor: migrate UI to Tailwind and unify chat with shadcn

This commit is contained in:
2026-10-07 20:51:05 +09:00
parent f41d0c1964
commit 1e03e8f4ff
56 changed files with 2014 additions and 4399 deletions
+85 -51
View File
@@ -19,7 +19,9 @@
"react-perf", "react-perf",
"jsx-a11y" "jsx-a11y"
], ],
"jsPlugins": ["@shadcn/lint"], "jsPlugins": [
"@shadcn/lint"
],
"rules": { "rules": {
"no-console": "error", "no-console": "error",
"no-unused-vars": [ "no-unused-vars": [
@@ -48,7 +50,9 @@
"shadcn/no-arbitrary-values": [ "shadcn/no-arbitrary-values": [
"error", "error",
{ {
"allow": ["layout"] "allow": [
"layout"
]
} }
], ],
"shadcn/no-inline-styles": "error", "shadcn/no-inline-styles": "error",
@@ -56,95 +60,114 @@
"shadcn/no-restyle": [ "shadcn/no-restyle": [
"error", "error",
{ {
"allow": ["layout"], "allow": [
"layout"
],
"contracts": [ "contracts": [
{ {
"pattern": "^Sidebar$", "pattern": "^Sidebar$",
"allow": [ "allow": [
"layout", "layout",
"border-r", "border-r",
"transition-none", "border-r-0",
"inbox-sidebar", "border-border",
"inbox-list-panel", "transition-none"
"support-sidebar",
"support-list"
] ]
}, },
{ {
"pattern": "^SidebarHeader$", "pattern": "^SidebarHeader$",
"allow": ["layout", "py-4", "gap-4", "border-b", "p-5"] "allow": [
"layout",
"py-4",
"gap-4",
"border-b",
"p-5"
]
}, },
{ {
"pattern": "^SidebarGroup$", "pattern": "^SidebarGroup$",
"allow": ["layout", "px-3"] "allow": [
"layout",
"px-3"
]
}, },
{ {
"pattern": "^SidebarFooter$", "pattern": "^SidebarFooter$",
"allow": ["layout", "px-3", "pb-4"] "allow": [
"layout",
"px-3",
"pb-4"
]
}, },
{ {
"pattern": "^SidebarMenuButton$", "pattern": "^SidebarMenuButton$",
"allow": ["layout", "p-0"] "allow": [
"layout",
"p-0"
]
}, },
{ {
"pattern": "^Label$", "pattern": "^Label$",
"allow": ["layout", "deck-checkbox"] "allow": [
}, "layout",
{ "gap-2",
"pattern": "^Textarea$", "text-sm",
"allow": ["layout", "text-sm", "leading-7", "journal-summary"] "text-xs",
}, "text-muted-foreground"
{ ]
"pattern": "^MessageScroller$",
"allow": ["layout", "research-conversation"]
}, },
{ {
"pattern": "^MessageScrollerContent$", "pattern": "^MessageScrollerContent$",
"allow": ["layout", "gap-5", "p-4"] "allow": [
"layout",
"gap-5",
"p-4"
]
}, },
{ {
"pattern": "^Marker$", "pattern": "^(Marker|Collapsible)$",
"allow": ["layout", "research-tool-message"] "allow": [
}, "layout",
{ "text-research-label",
"pattern": "^Message$", "text-muted-foreground"
"allow": ["layout", "research-message"] ]
},
{
"pattern": "^Collapsible$",
"allow": ["layout", "research-account-picker"]
}, },
{ {
"pattern": "^SidebarProvider$", "pattern": "^SidebarProvider$",
"allow": [ "allow": [
"layout", "layout",
"workspace", "bg-vital-bg",
"home-workspace", "text-vital-text",
"inbox-workspace", "font-arial"
"vital-workspace",
"support-workspace",
"journal-workspace"
] ]
}, },
{ {
"pattern": "^Button$", "pattern": "^Button$",
"allow": ["layout", "auth-submit", "inbox-back", "support-back"] "allow": [
}, "layout",
{ "px-2"
"pattern": "^SidebarInset$", ]
"allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"]
}, },
{ {
"pattern": "^Input$", "pattern": "^Input$",
"allow": ["layout", "pl-9"] "allow": [
"layout",
"pl-9"
]
}, },
{ {
"pattern": "^SidebarContent$", "pattern": "^SidebarContent$",
"allow": ["layout", "gap-0"] "allow": [
"layout",
"gap-0"
]
}, },
{ {
"pattern": "^Badge$", "pattern": "^Badge$",
"allow": ["layout", "deck-source-label"] "allow": [
"layout",
"gap-1"
]
} }
] ]
} }
@@ -154,13 +177,17 @@
}, },
"overrides": [ "overrides": [
{ {
"files": ["scripts/**"], "files": [
"scripts/**"
],
"rules": { "rules": {
"no-console": "off" "no-console": "off"
} }
}, },
{ {
"files": ["src/components/ui/**"], "files": [
"src/components/ui/**"
],
"rules": { "rules": {
"shadcn/no-arbitrary-values": "off", "shadcn/no-arbitrary-values": "off",
"shadcn/no-restyle": "off", "shadcn/no-restyle": "off",
@@ -168,8 +195,13 @@
} }
}, },
{ {
"files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"], "files": [
"plugins": ["vitest"], "src/**/*.{test,spec}.{ts,tsx}",
"tests/**/*.{ts,tsx}"
],
"plugins": [
"vitest"
],
"rules": { "rules": {
"typescript/unbound-method": "error", "typescript/unbound-method": "error",
"vitest/expect-expect": "error", "vitest/expect-expect": "error",
@@ -179,7 +211,9 @@
} }
}, },
{ {
"files": ["e2e/**/*.{ts,tsx}"], "files": [
"e2e/**/*.{ts,tsx}"
],
"rules": { "rules": {
"react/rules-of-hooks": "off" "react/rules-of-hooks": "off"
} }
+12 -9
View File
@@ -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. editing, ordering, and deletion; refresh stays available directly in each header.
Shared UI components live in `src/components/ui`; `components.json` configures Shared UI components live in `src/components/ui`; `components.json` configures
the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble, the registry and theme entrypoint. Research and Messages follow the
Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved [shadcn chatbot template](https://github.com/shadcn-ui/chatbot-template), composing
conversations. The icon rail switches between chat and deck management; 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 closing the mobile sheet or crossing the desktop breakpoint preserves chat
drafts, selected accounts and live deck updates. Theme colors are declared in drafts, selected accounts and live deck updates. All application styling uses Tailwind utilities in the components.
`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens, `src/styles.css` contains the Tailwind imports, shared theme tokens, and base
base styles and feature styles in that order, so the UI lint rules can resolve utilities; there are no feature stylesheets. Named pixel spacing tokens preserve
application classes through the same stylesheet used by the app. 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 `pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through
`oxlint-tsgolint`, including the `oxlint-tsgolint`, including the
[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. 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 `shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
class helpers, including shared UI components. Layout values remain available class helpers, including shared UI components. Layout values remain available
for the sidebar and deck. Component contracts allow only the existing workspace 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.
styles; Next.js-specific rules are omitted because this app uses TanStack Start.
`pnpm format` formats files with Oxfmt defaults, and `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 `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 of registry components for composition. Additional design rules can be configured
+2 -1
View File
@@ -15,7 +15,8 @@ remains a visual placeholder, not inferred medical content.
as the anatomy illustration source and clipped in CSS. Source media: as the anatomy illustration source and clipped in CSS. Source media:
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific 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 Sections, tabs, history, chart filters, and detail dialogs are URL state and
restore on reload or browser Back/Forward. Simulated climate control values restore on reload or browser Back/Forward. Simulated climate control values
+13 -7
View File
@@ -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.getByRole("button", { name: "Create deck", exact: true }).click();
await page.getByLabel("New deck name").fill("別の調査"); await page.getByLabel("New deck name").fill("別の調査");
await page.getByRole("button", { name: "Create", exact: true }).click(); 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 addColumn(page, "別の観点", "second-deck", "alternate");
await showDeckManagement(page); await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" }); await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
await closeMobileSidebar(page); 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 page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
await showDeckManagement(page); await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" }); await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
await closeMobileSidebar(page); 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 showDeckManagement(page);
await page.getByRole("button", { name: "Delete deck", exact: true }).click(); await page.getByRole("button", { name: "Delete deck", exact: true }).click();
await page.getByRole("button", { name: "Delete", 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 expect(list.getByText("list page 2")).toBeVisible();
await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).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 list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click(); await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
await page.getByRole("button", { name: "Undo" }).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 page.reload();
await expect(user.getByText("alternate profile page 1")).toBeVisible(); await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(list.getByText("list 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( expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
true, 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).not.toBeNull();
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901); 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 page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0); await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
await expect(opener).toBeFocused(); 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 opener.click();
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue(""); await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
}); });
+1 -1
View File
@@ -154,7 +154,7 @@ test("keeps completed actions in their original positions with visible outcomes"
}, testInfo) => { }, testInfo) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); 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(); const before = await titles.allTextContents();
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
await expect( await expect(
+3 -3
View File
@@ -3,7 +3,7 @@ import { expect, test } from "../fixtures";
test("selects articles and keeps the reader focused on their content", async ({ page }) => { test("selects articles and keeps the reader focused on their content", async ({ page }) => {
await page.goto("/inbox"); await page.goto("/inbox");
await page.waitForLoadState("networkidle"); 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); await expect(rows).toHaveCount(9);
const articleTitle = "Context for an agent can be brief"; const articleTitle = "Context for an agent can be brief";
await rows.filter({ hasText: articleTitle }).click(); 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 }) => { test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
await page.goto("/inbox"); await page.goto("/inbox");
await page.waitForLoadState("networkidle"); 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 page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
await expect(rows).toHaveCount(0); await expect(rows).toHaveCount(0);
await expect(page.getByText("No articles found", { exact: true })).toBeVisible(); 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 page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); 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", { const reader = page.getByRole("article", {
name: "Thinking in the browser: a new workflow with AI agents", name: "Thinking in the browser: a new workflow with AI agents",
exact: true, exact: true,
+9 -6
View File
@@ -100,7 +100,7 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
"background-color", "background-color",
"rgba(0, 0, 0, 0)", "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.deckVersion = 2;
run.status = "complete"; run.status = "complete";
run.messages.push({ run.messages.push({
@@ -124,7 +124,10 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
await showResearch(page); await showResearch(page);
await expect(conversation).toContainText("既存の観点を使って調査して"); await expect(conversation).toContainText("既存の観点を使って調査して");
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 closeMobileSidebar(page);
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
await showResearch(page); 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 expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
await chat.getByRole("link", { name: "根拠の投稿" }).click(); await chat.getByRole("link", { name: "根拠の投稿" }).click();
const card = page.locator( 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).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/); await expect(card).toHaveAttribute("data-citation-highlight", "true");
await expect(card).toBeFocused(); await expect(card).toBeFocused();
await expect(card).toContainText("Post saved during research"); await expect(card).toContainText("Post saved during research");
await expect(card).toContainText("AIが調査時に取得した引用本文"); await expect(card).toContainText("AIが調査時に取得した引用本文");
expect( expect(
await page.locator(".deck-board").evaluate((element) => element.scrollLeft), await page.locator("[data-testid='deck-board']").evaluate((element) => element.scrollLeft),
).toBeGreaterThan(0); ).toBeGreaterThan(0);
expect(page.context().pages()).toHaveLength(1); expect(page.context().pages()).toHaveLength(1);
await expect(page).toHaveURL(/citation=twitter%3A123456789/); 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).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/); await expect(card).toHaveClass(/citation-highlight/);
await expect(page).toHaveURL(/citation=twitter%3A123456789/); 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(submitted).toEqual([]);
expect((await a11y().analyze()).violations).toEqual([]); expect((await a11y().analyze()).violations).toEqual([]);
}); });
+8 -7
View File
@@ -9,16 +9,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
}) => { }) => {
await page.goto("/support"); await page.goto("/support");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const rows = page.locator(".support-row"); const rows = page.locator("[data-testid='support-row']");
await expect(rows).toHaveCount(3); await expect(rows).toHaveCount(3);
await rows.filter({ hasText: reviewSubject }).click(); 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(); await page.getByRole("button", { name: /^Polite/ }).click();
const reply = page.getByRole("textbox", { name: "Reply", exact: true }); const reply = page.getByRole("textbox", { name: "Reply", exact: true });
await expect(reply).toHaveValue(/Thank you for sharing/); await expect(reply).toHaveValue(/Thank you for sharing/);
const edited = "I reviewed the materials. Let’s discuss the first-time experience."; const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
await reply.fill(edited); 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"; const slot = "Mon, Oct 5, 14:00–14:30";
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot); await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
await page.getByRole("button", { name: "Add to reply" }).click(); 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 expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
await page.getByRole("button", { name: "Archive", exact: true }).click(); await page.getByRole("button", { name: "Archive", exact: true }).click();
await rows.filter({ hasText: reviewSubject }).click(); await rows.filter({ hasText: reviewSubject }).click();
await expect(page.locator(".support-outcome")).toContainText(edited); await expect(page.locator("[data-testid='support-outcome']")).toContainText(edited);
await expect(page.locator(".support-outcome")).toContainText(slot); await expect(page.locator("[data-testid='support-outcome']")).toContainText(slot);
}); });
test("defers a conversation and restores it to the current queue", async ({ page }) => { test("defers a conversation and restores it to the current queue", async ({ page }) => {
await page.goto("/support"); await page.goto("/support");
await page.waitForLoadState("networkidle"); 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 rows.filter({ hasText: coffeeSubject }).click();
await expect(rows.filter({ hasText: coffeeSubject })).toHaveAttribute("aria-current", "true");
await page await page
.getByRole("textbox", { name: "Reply", exact: true }) .getByRole("textbox", { name: "Reply", exact: true })
.fill("土曜の予定を確認してから返事するね。"); .fill("土曜の予定を確認してから返事するね。");
@@ -117,7 +118,7 @@ test("fits communication and journal interactions in narrow screens", async ({
await page.goto("/support"); await page.goto("/support");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); 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(); await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
+20 -12
View File
@@ -2,19 +2,22 @@ import { expect, test } from "../fixtures";
test("restores article selection with browser history and reload", async ({ page }) => { test("restores article selection with browser history and reload", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" }); await page.goto("/inbox", { waitUntil: "networkidle" });
const rows = page.locator(".inbox-article-row"); const rows = page.locator("[data-testid='inbox-article-row']");
const heading = await rows.nth(1).locator(".inbox-row-title").innerText(); const heading = await rows.nth(1).locator("[data-testid='inbox-row-title']").innerText();
await rows.nth(1).click(); 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(); const selectedUrl = page.url();
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy(); expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" }); 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 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 page.goForward();
await expect(page).toHaveURL(selectedUrl); 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 }) => { 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 search.pressSequentially("browser");
await expect(search).toHaveValue("browser"); await expect(search).toHaveValue("browser");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("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 page.reload({ waitUntil: "networkidle" });
await expect(search).toHaveValue("browser"); 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 page.goBack();
await expect(page).toHaveURL("http://127.0.0.1:4173/"); 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 }) => { test("restores a message view and conversation from its URL", async ({ page }) => {
await page.goto("/support", { waitUntil: "networkidle" }); await page.goto("/support", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "All", exact: true }).click(); 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(); const heading = await row.locator("span").first().innerText();
await row.click(); 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(); const url = page.url();
expect(new URL(url).searchParams.get("view")).toBe("all"); expect(new URL(url).searchParams.get("view")).toBe("all");
expect(new URL(url).searchParams.get("conversation")).toBeTruthy(); expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" }); await page.reload({ waitUntil: "networkidle" });
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? ""); await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(
await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true"); heading ?? "",
);
await expect(page.locator("[data-testid='support-workspace']")).toHaveAttribute(
"data-reading",
"true",
);
}); });
test("keeps a deep link through login", async ({ page, context }) => { test("keeps a deep link through login", async ({ page, context }) => {
+8 -5
View File
@@ -4,7 +4,7 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
await page.goto("/vitals"); await page.goto("/vitals");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible(); 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 expect(systems).toHaveCount(8);
await page await page
.getByRole("group", { name: "Body systems", exact: true }) .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 expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Close", exact: true }).last().click(); await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Lab history", exact: true }).click(); await page.getByRole("button", { name: "Lab history", exact: true }).click();
await expect(page.locator(".vital-history")).toContainText("Jul 28"); await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28");
await expect(page.locator(".vital-history")).toContainText("93"); await expect(page.locator("[data-testid='vital-history']")).toContainText("93");
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); 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("button", { name: "Systems", exact: true }).click();
await page await page
.getByRole("group", { name: "Body systems", exact: true }) .getByRole("group", { name: "Body systems", exact: true })
@@ -61,7 +61,10 @@ test("explores environment charts and simulated climate controls", async ({ page
await expect( await expect(
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible(); ).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" }); const dialog = page.getByRole("dialog", { name: "Room temperature" });
await expect(dialog).toContainText("64.0"); await expect(dialog).toContainText("64.0");
await page.reload(); await page.reload();
+1 -1
View File
@@ -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( expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
true, true,
); );
await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]); await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]);
} }
expect(savedCount()).toEqual({ count: 0 }); expect(savedCount()).toEqual({ count: 0 });
}); });
-75
View File
@@ -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;
}
}
+6 -11
View File
@@ -1,5 +1,5 @@
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react"; import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { import {
Sidebar, Sidebar,
SidebarInset, SidebarInset,
@@ -22,15 +22,7 @@ type ShellProps = {
export function AppShell(props: ShellProps) { export function AppShell(props: ShellProps) {
return ( return (
<TooltipProvider delay={800}> <TooltipProvider delay={800}>
<SidebarProvider <SidebarProvider className="h-dvh min-h-0 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
className="workspace h-dvh min-h-0 overflow-hidden"
style={
{
"--sidebar-width": "25rem",
"--sidebar-width-icon": "4rem",
} as CSSProperties
}
>
<Workspace {...props} /> <Workspace {...props} />
</SidebarProvider> </SidebarProvider>
</TooltipProvider> </TooltipProvider>
@@ -106,7 +98,10 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell
<span className="text-sm font-medium">Research</span> <span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span> <span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header> </header>
<section className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks"> <section
className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
>
{children} {children}
</section> </section>
</SidebarInset> </SidebarInset>
+1 -1
View File
@@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
<fieldset <fieldset
data-slot="input-group" data-slot="input-group"
className={cn( className={cn(
"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-disabled:bg-input/50 has-disabled:opacity-50 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-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, className,
)} )}
{...props} {...props}
+8 -1
View File
@@ -2,13 +2,20 @@ import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "cn"; import { cn } from "cn";
import type * as React from "react"; 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 ( return (
<InputPrimitive <InputPrimitive
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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", "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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",
variant === "measurement" &&
"bg-background text-ui-20 tabular-nums md:text-ui-20 dark:bg-background",
className, className,
)} )}
{...props} {...props}
+3 -32
View File
@@ -18,9 +18,6 @@ import { Skeleton } from "#/components/ui/skeleton.tsx";
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx"; import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
import { useIsMobile } from "#/hooks/use-mobile.ts"; import { useIsMobile } from "#/hooks/use-mobile.ts";
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "min(25rem, 100vw)";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b"; const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
@@ -49,7 +46,6 @@ function SidebarProvider({
open: openProp, open: openProp,
onOpenChange: setOpenProp, onOpenChange: setOpenProp,
className, className,
style,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
@@ -115,15 +111,8 @@ function SidebarProvider({
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn( className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", "[--sidebar-width:16rem] [--sidebar-width-icon:3rem] group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className, className,
)} )}
{...props} {...props}
@@ -173,12 +162,7 @@ function Sidebar({
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" className="[--sidebar-width:min(25rem,100vw)] w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side} side={side}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
@@ -574,11 +558,6 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean; showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return ( return (
<div <div
data-slot="sidebar-menu-skeleton" data-slot="sidebar-menu-skeleton"
@@ -587,15 +566,7 @@ function SidebarMenuSkeleton({
{...props} {...props}
> >
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />} {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton <Skeleton className="h-4 max-w-3/4 flex-1" data-sidebar="menu-skeleton-text" />
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div> </div>
); );
} }
+23 -5
View File
@@ -1,14 +1,32 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn"; import { cn } from "cn";
import type * as React from "react"; 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<typeof textareaVariants>) {
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(textareaVariants({ variant }), className)}
"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",
className,
)}
{...props} {...props}
/> />
); );
+29 -17
View File
@@ -8,9 +8,9 @@ import { safeReturnTo } from "./return-to";
export function AuthFrame({ children }: { children: ReactNode }) { export function AuthFrame({ children }: { children: ReactNode }) {
return ( return (
<main className="auth-page"> <main className="grid min-h-svh place-items-center bg-background px-px-24 py-px-48 text-foreground max-[480px]:py-px-32">
<div className="auth-panel"> <div className="w-full max-w-[390px] min-w-0">
<div className="auth-brand"> <div className="mb-px-44 flex items-center gap-px-9 text-ui-13 text-muted-foreground max-[480px]:mb-px-36">
<LockKeyhole size={16} aria-hidden="true" /> <LockKeyhole size={16} aria-hidden="true" />
Personal Workspace Personal Workspace
</div> </div>
@@ -75,19 +75,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
return ( return (
<AuthFrame> <AuthFrame>
<header className="auth-heading"> <header className="mb-px-28">
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1> <h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
<p> {setup ? "Make yourself at home." : "Welcome back."}
</h1>
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
{setup {setup
? "Create the owner account for your personal workspace." ? "Create the owner account for your personal workspace."
: "Sign in to your personal workspace."} : "Sign in to your personal workspace."}
</p> </p>
</header> </header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}> <form onSubmit={submit} className="grid gap-px-20" aria-busy={pending}>
{setup && ( {setup && (
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="name">Your name</Label> <Label htmlFor="name">Your name</Label>
<Input <Input
className="h-[40px]"
id="name" id="name"
name="name" name="name"
autoComplete="given-name" autoComplete="given-name"
@@ -97,9 +100,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
/> />
</div> </div>
)} )}
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Email address</Label>
<Input <Input
className="h-[40px]"
id="email" id="email"
name="email" name="email"
type="email" type="email"
@@ -109,9 +113,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
disabled={pending} disabled={pending}
/> />
</div> </div>
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Input <Input
className="h-[40px]"
id="password" id="password"
name="password" name="password"
type="password" type="password"
@@ -123,16 +128,17 @@ function CredentialsForm({ setup }: { setup: boolean }) {
disabled={pending} disabled={pending}
/> />
{setup && ( {setup && (
<p id="password-hint" className="auth-hint"> <p id="password-hint" className="text-ui-12 leading-cozy text-muted-foreground">
Use 15–128 characters. A few memorable words work well. Use 15–128 characters. A few memorable words work well.
</p> </p>
)} )}
</div> </div>
{setup && ( {setup && (
<> <>
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="confirmPassword">Confirm password</Label> <Label htmlFor="confirmPassword">Confirm password</Label>
<Input <Input
className="h-[40px]"
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
@@ -143,9 +149,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
disabled={pending} disabled={pending}
/> />
</div> </div>
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="setupToken">Setup code</Label> <Label htmlFor="setupToken">Setup code</Label>
<Input <Input
className="h-[40px]"
id="setupToken" id="setupToken"
name="setupToken" name="setupToken"
type="password" type="password"
@@ -154,18 +161,23 @@ function CredentialsForm({ setup }: { setup: boolean }) {
aria-describedby="setup-hint" aria-describedby="setup-hint"
disabled={pending} disabled={pending}
/> />
<p id="setup-hint" className="auth-hint"> <p id="setup-hint" className="text-ui-12 leading-cozy text-muted-foreground">
Use the setup code provided by your server administrator. Use the setup code provided by your server administrator.
</p> </p>
</div> </div>
</> </>
)} )}
{error && ( {error && (
<p className="auth-error" role="alert"> <p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
{error} {error}
</p> </p>
)} )}
<Button type="submit" size="lg" disabled={pending} className="auth-submit"> <Button
type="submit"
size="lg"
disabled={pending}
className="mt-[4px] w-full justify-between"
>
{pending {pending
? setup ? setup
? "Creating account…" ? "Creating account…"
@@ -176,7 +188,7 @@ function CredentialsForm({ setup }: { setup: boolean }) {
<ArrowRight aria-hidden="true" /> <ArrowRight aria-hidden="true" />
</Button> </Button>
</form> </form>
<p className="auth-footnote"> <p className="mt-px-24 text-ui-12 leading-cozy text-muted-foreground">
{setup {setup
? "This workspace has one owner. Connections can be added later." ? "This workspace has one owner. Connections can be added later."
: "A private workspace. Access is limited to its owner."} : "A private workspace. Access is limited to its owner."}
-136
View File
@@ -1,136 +0,0 @@
.auth-page {
min-height: 100svh;
display: grid;
place-items: center;
padding: 48px 24px;
background: var(--background);
color: var(--foreground);
}
.auth-panel {
width: 100%;
max-width: 390px;
min-width: 0;
}
.auth-brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--muted-foreground);
font-size: 13px;
margin-bottom: 44px;
}
.auth-heading {
margin-bottom: 28px;
}
.auth-heading h1 {
font-size: 27px;
font-weight: 550;
letter-spacing: -0.8px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.auth-heading p {
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.6;
margin-top: 12px;
}
.auth-form {
display: grid;
gap: 20px;
}
.auth-field {
display: grid;
gap: 9px;
}
.auth-field input {
height: 40px;
}
.auth-hint,
.auth-footnote,
.auth-prototype {
font-size: 12px;
line-height: 1.6;
color: var(--muted-foreground);
}
.auth-submit {
width: 100%;
margin-top: 4px;
justify-content: space-between;
padding-inline: 14px;
}
.auth-footnote {
margin-top: 24px;
}
.auth-error {
color: var(--destructive);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-step {
color: var(--muted-foreground);
font-size: 12px;
margin-bottom: 16px;
}
.auth-overview {
list-style: none;
padding: 0;
margin: 0 0 28px;
display: grid;
gap: 24px;
}
.auth-overview li {
display: flex;
gap: 14px;
align-items: flex-start;
}
.auth-overview svg {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: var(--muted-foreground);
}
.auth-overview strong {
font-size: 14px;
font-weight: 500;
}
.auth-overview p {
font-size: 13px;
line-height: 1.6;
color: var(--muted-foreground);
margin-top: 4px;
}
.auth-prototype {
padding-top: 20px;
border-top: 1px solid var(--border);
}
.auth-actions {
margin-top: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.auth-footer {
margin-top: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.auth-footer span {
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.auth-page {
padding: 32px 24px;
}
.auth-brand {
margin-bottom: 36px;
}
}
+52 -28
View File
@@ -46,17 +46,22 @@ export function OnboardingPage() {
return ( return (
<AuthFrame> <AuthFrame>
<p className="auth-step">{step} of 2</p> <p className="mb-px-16 text-ui-12 text-muted-foreground">{step} of 2</p>
{step === 1 ? ( {step === 1 ? (
<> <>
<header className="auth-heading"> <header className="mb-px-28">
<h1>A little context, to start.</h1> <h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
<p>Your day, conversations, and reading, together in one place.</p> A little context, to start.
</h1>
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
Your day, conversations, and reading, together in one place.
</p>
</header> </header>
<form className="auth-form" onSubmit={next}> <form className="grid gap-px-20" onSubmit={next}>
<div className="auth-field"> <div className="grid gap-px-9">
<Label htmlFor="displayName">What should we call you?</Label> <Label htmlFor="displayName">What should we call you?</Label>
<Input <Input
className="h-[40px]"
id="displayName" id="displayName"
name="displayName" name="displayName"
value={name} value={name}
@@ -69,7 +74,7 @@ export function OnboardingPage() {
</div> </div>
<Button <Button
type="submit" type="submit"
className="auth-submit" className="mt-[4px] w-full justify-between"
size="lg" size="lg"
disabled={pending || !name.trim()} disabled={pending || !name.trim()}
> >
@@ -80,38 +85,57 @@ export function OnboardingPage() {
</> </>
) : ( ) : (
<> <>
<header className="auth-heading"> <header className="mb-px-28">
<h1>Start with today.</h1> <h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
<p>You can take it one thing at a time.</p> Start with today.
</h1>
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
You can take it one thing at a time.
</p>
</header> </header>
<ul className="auth-overview"> <ul className="m-0 mb-px-28 grid list-none gap-px-24 p-0">
<li> <li className="flex items-start gap-px-14">
<House aria-hidden="true" /> <House
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div> <div>
<strong>A place to begin</strong> <strong className="text-ui-14 font-medium">A place to begin</strong>
<p>A brief and flexible tasks help you find your next step.</p> <p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
A brief and flexible tasks help you find your next step.
</p>
</div> </div>
</li> </li>
<li> <li className="flex items-start gap-px-14">
<MessagesSquare aria-hidden="true" /> <MessagesSquare
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div> <div>
<strong>Room for the everyday</strong> <strong className="text-ui-14 font-medium">Room for the everyday</strong>
<p>Keep conversations and quick notes close at hand.</p> <p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
Keep conversations and quick notes close at hand.
</p>
</div> </div>
</li> </li>
<li> <li className="flex items-start gap-px-14">
<BookOpen aria-hidden="true" /> <BookOpen
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div> <div>
<strong>Follow your curiosity</strong> <strong className="text-ui-14 font-medium">Follow your curiosity</strong>
<p>Explore reading and research alongside your day.</p> <p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
Explore reading and research alongside your day.
</p>
</div> </div>
</li> </li>
</ul> </ul>
<p className="auth-prototype"> <p className="border-t border-border pt-px-20 text-ui-12 leading-cozy text-muted-foreground">
Home, contacts, notes, reading, and vitals currently use sample data. You can add Home, contacts, notes, reading, and vitals currently use sample data. You can add
connections from your profile menu whenever you’re ready. connections from your profile menu whenever you’re ready.
</p> </p>
<div className="auth-actions"> <div className="mt-px-26 flex items-center justify-between gap-px-12">
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}> <Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
Back Back
</Button> </Button>
@@ -123,12 +147,12 @@ export function OnboardingPage() {
</> </>
)} )}
{error && ( {error && (
<p className="auth-error" role="alert"> <p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
{error} {error}
</p> </p>
)} )}
<footer className="auth-footer"> <footer className="mt-px-32 flex items-center justify-between gap-px-12 text-ui-12 text-muted-foreground">
<span>{auth.owner?.email}</span> <span className="min-w-0 wrap-anywhere">{auth.owner?.email}</span>
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}> <Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
Sign out Sign out
</Button> </Button>
@@ -1,55 +0,0 @@
.connection-manager {
display: grid;
gap: 1rem;
}
.connection-manager-description,
.connection-manager-note {
margin: 0;
color: var(--muted-foreground);
font-size: 0.875rem;
}
.connection-manager-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.75rem;
}
.connection-manager-list > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-block: 0.75rem;
border-bottom: 1px solid var(--border);
}
.connection-manager-account {
display: grid;
gap: 0.25rem;
min-width: 0;
overflow-wrap: anywhere;
}
.connection-manager-account > span:not([data-slot="badge"]) {
font-size: 0.8rem;
color: var(--muted-foreground);
}
.connection-manager-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.connection-manager-add {
display: grid;
gap: 0.75rem;
}
.connection-manager-add h3 {
margin: 0;
font-size: 1rem;
}
@media (max-width: 480px) {
.connection-manager-list > li {
align-items: flex-start;
flex-direction: column;
}
}
+30 -10
View File
@@ -74,20 +74,33 @@ export function ConnectionManager({
} }
return ( return (
<section className="connection-manager" aria-label="Connected account management"> <section
<p className="connection-manager-description"> data-testid="connection-manager"
className="grid gap-4"
aria-label="Connected account management"
>
<p
data-testid="connection-manager-description"
className="m-0 text-research-body text-muted-foreground"
>
Connected accounts are available in columns on all your devices. Connected accounts are available in columns on all your devices.
</p> </p>
{error && <p role="alert">{error}</p>} {error && <p role="alert">{error}</p>}
{connections.length === 0 ? ( {connections.length === 0 ? (
<p>No connected accounts yet.</p> <p>No connected accounts yet.</p>
) : ( ) : (
<ul className="connection-manager-list"> <ul data-testid="connection-manager-list" className="m-0 grid list-none gap-3 p-0">
{connections.map((connection) => ( {connections.map((connection) => (
<li key={connection.id}> <li
<div className="connection-manager-account"> className="flex items-center justify-between gap-3 border-b py-3 max-[480px]:flex-col max-[480px]:items-start"
key={connection.id}
>
<div
data-testid="connection-manager-account"
className="grid min-w-0 gap-1 wrap-anywhere"
>
<strong>{connection.displayName}</strong> <strong>{connection.displayName}</strong>
<span> <span className="text-research-field text-muted-foreground">
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "} {connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
{new URL(connection.origin).hostname} {new URL(connection.origin).hostname}
</span> </span>
@@ -96,7 +109,10 @@ export function ConnectionManager({
</Badge> </Badge>
</div> </div>
{connection.platform === "mastodon" && ( {connection.platform === "mastodon" && (
<div className="connection-manager-actions"> <div
data-testid="connection-manager-actions"
className="flex flex-wrap justify-end gap-2"
>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
@@ -124,13 +140,14 @@ export function ConnectionManager({
</ul> </ul>
)} )}
<form <form
className="connection-manager-add" data-testid="connection-manager-add"
className="grid gap-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (origin) void connect(origin); if (origin) void connect(origin);
}} }}
> >
<h3>Add a Mastodon account</h3> <h3 className="m-0 text-base">Add a Mastodon account</h3>
{instances.isPending ? ( {instances.isPending ? (
<p role="status">Checking servers…</p> <p role="status">Checking servers…</p>
) : instances.isError ? ( ) : instances.isError ? (
@@ -164,7 +181,10 @@ export function ConnectionManager({
</> </>
)} )}
{hasTemporaryDecks && ( {hasTemporaryDecks && (
<p className="connection-manager-note"> <p
data-testid="connection-manager-note"
className="m-0 text-research-body text-muted-foreground"
>
Temporary decks will be lost when you leave to authorize. Save any decks you want to Temporary decks will be lost when you leave to authorize. Save any decks you want to
keep first. keep first.
</p> </p>
+14 -7
View File
@@ -33,7 +33,8 @@ export function ColumnEditor({
return ( return (
<form <form
className="deck-editor" data-testid="deck-editor"
className="m-0 p-0"
aria-label={column ? "Edit column" : "Add column"} aria-label={column ? "Edit column" : "Add column"}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
@@ -54,8 +55,8 @@ export function ColumnEditor({
await onSave(parsed.data); await onSave(parsed.data);
}} }}
> >
<div className="deck-editor-fields"> <div data-testid="deck-editor-fields" className="grid gap-4">
<Label htmlFor={`${formId}-title`}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-title`}>
Column name Column name
<Input <Input
id={`${formId}-title`} id={`${formId}-title`}
@@ -66,7 +67,7 @@ export function ColumnEditor({
required required
/> />
</Label> </Label>
<Label htmlFor={`${formId}-profile`}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-profile`}>
Connection profile Connection profile
<NativeSelect <NativeSelect
className="w-full" className="w-full"
@@ -102,13 +103,19 @@ export function ColumnEditor({
connectionId={validConnection ? connectionId : ""} connectionId={validConnection ? connectionId : ""}
onChange={setSource} onChange={setSource}
/> />
<p className="deck-help">This column uses the selected connection profile.</p> <p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
This column uses the selected connection profile.
</p>
{error && ( {error && (
<p role="alert" className="deck-error"> <p
role="alert"
data-testid="deck-error"
className="text-sm leading-body text-destructive wrap-anywhere"
>
{error} {error}
</p> </p>
)} )}
<div className="deck-actions"> <div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
<Button type="submit" disabled={!validConnection}> <Button type="submit" disabled={!validConnection}>
{column ? "Save changes" : "Add and search"} {column ? "Save changes" : "Add and search"}
</Button> </Button>
+51 -14
View File
@@ -58,11 +58,20 @@ export function ResearchColumn({
const headingId = `column-${column.id}`; const headingId = `column-${column.id}`;
const sourceDescription = describeSource(column.source); const sourceDescription = describeSource(column.source);
return ( return (
<section className="deck-column" aria-labelledby={headingId}> <section
<header className="deck-column-header"> data-testid="deck-column"
<div className="deck-column-title"> className="flex min-h-0 min-w-0 snap-start flex-col bg-background"
aria-labelledby={headingId}
>
<header
data-testid="deck-column-header"
className="relative shrink-0 border-b bg-card px-3 py-2"
>
<div data-testid="deck-column-title" className="flex items-center gap-2">
<PlatformIcon platform={column.source.platform} /> <PlatformIcon platform={column.source.platform} />
<h2 id={headingId}>{column.title}</h2> <h2 className="m-0 min-w-0 flex-1 truncate text-base leading-heading" id={headingId}>
{column.title}
</h2>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -114,26 +123,43 @@ export function ResearchColumn({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="deck-column-label"> <div
data-testid="deck-column-label"
className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
>
{connectionLoading ? ( {connectionLoading ? (
<Skeleton className="h-3 w-24 shrink-0" aria-hidden="true" /> <Skeleton className="h-3 w-24 shrink-0" aria-hidden="true" />
) : ( ) : (
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}> <span
className="min-w-0 wrap-anywhere"
title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}
>
{connectionLabel ?? "Connection not found"} {connectionLabel ?? "Connection not found"}
</span> </span>
)} )}
<Badge variant="outline" className="deck-source-label"> <Badge
variant="outline"
data-testid="deck-source-label"
className="inline-flex items-center gap-1 [&_svg]:size-[14px]"
>
<Icon name={column.source.kind} /> <Icon name={column.source.kind} />
{sourceDescription.label} {sourceDescription.label}
</Badge> </Badge>
</div> </div>
<p className="deck-query">{sourceDescription.query}</p> <p data-testid="deck-query" className="my-1 truncate text-xs text-muted-foreground">
<p className="deck-column-status" role="status"> {sourceDescription.query}
</p>
<p
data-testid="deck-column-status"
className="mt-1 mb-0 text-right text-xs leading-normal text-muted-foreground tabular-nums"
role="status"
>
{query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`} {query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
</p> </p>
</header> </header>
<section <section
className="deck-column-results" data-testid="deck-column-results"
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
tabIndex={0} tabIndex={0}
aria-label={`Posts in ${column.title}`} aria-label={`Posts in ${column.title}`}
@@ -157,7 +183,11 @@ export function ResearchColumn({
</> </>
) : null} ) : null}
{query.isError ? ( {query.isError ? (
<div className="deck-message" role="alert"> <div
data-testid="deck-message"
className="p-4 text-sm leading-body text-muted-foreground"
role="alert"
>
<p>{error.message}</p> <p>{error.message}</p>
{error.retryable ? ( {error.retryable ? (
<Button <Button
@@ -176,10 +206,15 @@ export function ResearchColumn({
</div> </div>
) : null} ) : null}
{!query.isPending && !query.isError && visiblePosts.length === 0 ? ( {!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">No posts match your filters.</p> <p data-testid="deck-message" className="p-4 text-sm leading-body text-muted-foreground">
No posts match your filters.
</p>
) : null} ) : null}
{query.hasNextPage && !query.isFetchNextPageError ? ( {query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message"> <div
data-testid="deck-message"
className="p-4 text-sm leading-body text-muted-foreground"
>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
@@ -191,7 +226,9 @@ export function ResearchColumn({
</div> </div>
) : null} ) : null}
{!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? ( {!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
<p className="deck-message">No more posts.</p> <p data-testid="deck-message" className="p-4 text-sm leading-body text-muted-foreground">
No more posts.
</p>
) : null} ) : null}
</section> </section>
</section> </section>
+57 -16
View File
@@ -113,7 +113,11 @@ export function DeckPage() {
} }
const storageErrorNotice = storageError ? ( const storageErrorNotice = storageError ? (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
role="alert"
>
{storageError} {storageError}
<Button <Button
variant="outline" variant="outline"
@@ -194,7 +198,7 @@ export function DeckPage() {
<Icon name="plus" /> <Icon name="plus" />
<span>Add column</span> <span>Add column</span>
</Button> </Button>
<p className="sidebar-note"> <p className="text-xs text-muted-foreground">
{deck.persisted {deck.persisted
? "Saved to server · Shared across devices" ? "Saved to server · Shared across devices"
: "Temporary view · This tab only"} : "Temporary view · This tab only"}
@@ -202,12 +206,17 @@ export function DeckPage() {
</> </>
} }
> >
<div className="deck-heading"> <div
data-testid="deck-heading"
className="[&>div]:min-w-0 flex shrink-0 items-center justify-between gap-3 min-h-[52px] px-3 py-2 md:min-h-[58px] md:px-4"
>
<div> <div>
{ready ? ( {ready ? (
<> <>
<h1>{deck.title}</h1> <h1 className="m-0 min-w-0 truncate text-md leading-compact font-bold">
<span className="deck-count"> {deck.title}
</h1>
<span data-testid="deck-count" className="text-xs text-muted-foreground">
{deck.columns.length} / {MAX_COLUMNS} columns {deck.columns.length} / {MAX_COLUMNS} columns
</span> </span>
</> </>
@@ -218,7 +227,10 @@ export function DeckPage() {
</div> </div>
)} )}
</div> </div>
<div className="deck-actions"> <div
data-testid="deck-actions"
className="flex flex-nowrap items-center gap-2 md:flex-wrap"
>
{deck.persisted ? ( {deck.persisted ? (
<Button <Button
variant="outline" variant="outline"
@@ -254,12 +266,19 @@ export function DeckPage() {
</div> </div>
</div> </div>
{saving ? ( {saving ? (
<p className="deck-save-status" role="status"> <p
data-testid="deck-save-status"
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
role="status"
>
Saving… Saving…
</p> </p>
) : null} ) : null}
{legacyAvailable ? ( {legacyAvailable ? (
<p className="deck-save-status"> <p
data-testid="deck-save-status"
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
>
Legacy decks are available in this browser. Legacy decks are available in this browser.
<Button <Button
variant="outline" variant="outline"
@@ -272,7 +291,11 @@ export function DeckPage() {
</p> </p>
) : null} ) : null}
{profiles.isError ? ( {profiles.isError ? (
<p role="alert" className="deck-error"> <p
role="alert"
data-testid="deck-error"
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
>
Unable to load connection profiles. Unable to load connection profiles.
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}> <Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
Retry Retry
@@ -280,17 +303,29 @@ export function DeckPage() {
</p> </p>
) : null} ) : null}
{oauthResult === "failed" ? ( {oauthResult === "failed" ? (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
role="alert"
>
Could not complete Mastodon authorization. Try again from Connected accounts. Could not complete Mastodon authorization. Try again from Connected accounts.
</p> </p>
) : null} ) : null}
{oauthResult === "connected" ? ( {oauthResult === "connected" ? (
<p className="deck-save-status" role="status"> <p
data-testid="deck-save-status"
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
role="status"
>
Mastodon account connected. You can now use it in a column. Mastodon account connected. You can now use it in a column.
</p> </p>
) : null} ) : null}
{profiles.data?.relayError ? ( {profiles.data?.relayError ? (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
role="alert"
>
{profiles.data.relayError} {profiles.data.relayError}
</p> </p>
) : null} ) : null}
@@ -298,7 +333,8 @@ export function DeckPage() {
{renaming ? ( {renaming ? (
<Dialog title="Rename deck" onClose={() => setRenaming(false)}> <Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form <form
className="deck-rename" data-testid="deck-rename"
className="m-0 flex flex-wrap items-end gap-3"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
const rawTitle = new FormData(event.currentTarget).get("title"); const rawTitle = new FormData(event.currentTarget).get("title");
@@ -307,7 +343,7 @@ export function DeckPage() {
if (await save({ ...deck, title })) setRenaming(false); if (await save({ ...deck, title })) setRenaming(false);
}} }}
> >
<label htmlFor="deck-title"> <label className="grid min-w-0 flex-[1_1_100%] gap-2 text-sm" htmlFor="deck-title">
Research topic / deck name Research topic / deck name
<Input <Input
id="deck-title" id="deck-title"
@@ -344,7 +380,11 @@ export function DeckPage() {
</Dialog> </Dialog>
) : null} ) : null}
{removed ? ( {removed ? (
<div className="deck-undo" role="status"> <div
data-testid="deck-undo"
className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm wrap-anywhere"
role="status"
>
<span>Deleted “{removed.column.title}”.</span> <span>Deleted “{removed.column.title}”.</span>
<Button <Button
variant="outline" variant="outline"
@@ -384,7 +424,8 @@ export function DeckPage() {
) : null} ) : null}
{ready && deck.columns.length > 0 ? ( {ready && deck.columns.length > 0 ? (
<section <section
className="deck-board" data-testid="deck-board"
className="grid min-h-0 flex-1 auto-cols-[min(328px,100%)] grid-flow-col justify-start gap-2 overflow-x-auto overflow-y-hidden px-2 pb-2 snap-x snap-proximity md:auto-cols-[328px]"
aria-label="Research columns" aria-label="Research columns"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
tabIndex={0} tabIndex={0}
+17 -6
View File
@@ -33,9 +33,14 @@ export function DeckSwitcher({
errorNotice?: ReactNode; errorNotice?: ReactNode;
}) { }) {
return ( return (
<div className="deck-workspaces"> <div data-testid="deck-workspaces" className="min-w-0">
<div className="deck-actions"> <div
<Label htmlFor="deck-selection">Deck profile</Label> data-testid="deck-actions"
className="flex flex-wrap items-stretch gap-2 [&>[data-slot=native-select-wrapper]]:flex-[1_1_100%]"
>
<Label className="w-full text-xs text-muted-foreground" htmlFor="deck-selection">
Deck profile
</Label>
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id="deck-selection" id="deck-selection"
@@ -55,6 +60,7 @@ export function DeckSwitcher({
</NativeSelect> </NativeSelect>
<Button <Button
variant="outline" variant="outline"
className="flex-1"
aria-label="Create deck" aria-label="Create deck"
title="Create deck" title="Create deck"
type="button" type="button"
@@ -68,6 +74,7 @@ export function DeckSwitcher({
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="flex-1"
aria-label="Delete deck" aria-label="Delete deck"
title="Delete deck" title="Delete deck"
type="button" type="button"
@@ -83,7 +90,8 @@ export function DeckSwitcher({
{dialog === "create" ? ( {dialog === "create" ? (
<Dialog title="Create deck" onClose={() => onDialogChange(null)}> <Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form <form
className="deck-rename" data-testid="deck-rename"
className="m-0 flex flex-wrap items-end gap-3"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
const title = newTitle.trim(); const title = newTitle.trim();
@@ -91,7 +99,7 @@ export function DeckSwitcher({
if (await onCreate(title)) onDialogChange(null); if (await onCreate(title)) onDialogChange(null);
}} }}
> >
<Label htmlFor="new-deck-title"> <Label className="grid min-w-0 flex-[1_1_100%] gap-2 text-sm" htmlFor="new-deck-title">
New deck name New deck name
<Input <Input
id="new-deck-title" id="new-deck-title"
@@ -112,7 +120,10 @@ export function DeckSwitcher({
) : null} ) : null}
{dialog === "delete" ? ( {dialog === "delete" ? (
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}> <Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo"> <div
data-testid="deck-undo"
className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm wrap-anywhere"
>
<p>Delete this deck and its column settings.</p> <p>Delete this deck and its column settings.</p>
<Button <Button
variant="destructive" variant="destructive"
-313
View File
@@ -1,313 +0,0 @@
.deck-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: var(--space-sm);
min-height: 58px;
padding: var(--space-xs) var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--muted-foreground);
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
overflow-y: hidden;
padding: 0 var(--space-xs) var(--space-xs);
scroll-snap-type: x proximity;
scrollbar-color: var(--control-border) var(--canvas);
}
.deck-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
position: relative;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--surface);
}
.deck-column-title {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.deck-column h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-base);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-source-label {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.deck-source-label svg {
width: 14px;
height: 14px;
}
.deck-query {
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-status {
margin: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
flex: 1;
overscroll-behavior-y: contain;
scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper);
}
.deck-message {
padding: var(--space-md);
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
padding: var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post strong {
font-size: var(--text-sm);
}
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
margin: var(--space-sm) 0;
padding: var(--space-sm);
border: var(--rule-hairline) solid var(--border);
border-radius: var(--radius-sm);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post a {
color: var(--primary);
text-underline-offset: 0.2em;
}
.research-media {
display: block;
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
.deck-editor {
margin: 0;
padding: 0;
}
.deck-editor-fields,
.deck-source-fields {
display: grid;
gap: var(--space-md);
}
.deck-source-fields {
margin-block: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin: 0;
}
.deck-rename label {
flex: 1 1 100%;
}
.deck-error {
color: var(--destructive);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.reader > .deck-error {
margin: 0;
padding: var(--space-xs) var(--space-md);
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.deck-undo p {
margin: 0;
}
.deck-empty {
margin: auto;
max-width: 32rem;
padding: var(--space-lg);
}
.deck-empty p {
color: var(--muted-foreground);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
.deck-workspaces {
min-width: 0;
}
.deck-workspaces > .deck-actions {
align-items: stretch;
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
flex: 1;
}
.deck-list-choices {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.app-deck .sidebar-add {
width: 100%;
}
@media (max-width: 767px) {
.deck-heading {
min-height: 52px;
padding-inline: var(--space-sm);
}
.deck-heading .deck-actions {
flex-wrap: nowrap;
}
.deck-board {
grid-auto-columns: min(328px, 100%);
}
}
.deck-count {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
.research-post header > div:not([data-slot="avatar"]) {
flex: 1;
}
.research-post header time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.research-post footer a {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.research-post footer svg {
width: 14px;
height: 14px;
}
+15 -11
View File
@@ -34,7 +34,7 @@ export function MastodonSourceEditor({
const id = useId(); const id = useId();
return ( return (
<> <>
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
Column type Column type
<NativeSelect <NativeSelect
className="w-full" className="w-full"
@@ -78,8 +78,8 @@ function MastodonSearchEditor({
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) { }: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
Mastodon search query Mastodon search query
<Input <Input
id={id} id={id}
@@ -90,7 +90,7 @@ function MastodonSearchEditor({
required required
/> />
</Label> </Label>
<p className="deck-help"> <p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
Full-text search depends on server settings. Zero results do not necessarily mean search is Full-text search depends on server settings. Zero results do not necessarily mean search is
supported. supported.
</p> </p>
@@ -104,8 +104,8 @@ function MastodonUserEditor({
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) { }: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
User User
<Input <Input
id={id} id={id}
@@ -126,8 +126,8 @@ function MastodonHashtagEditor({
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) { }: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
Hashtag Hashtag
<Input <Input
id={id} id={id}
@@ -151,8 +151,8 @@ function MastodonListEditor({
}) { }) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
List List
<Input <Input
id={id} id={id}
@@ -197,7 +197,11 @@ function MastodonListChoices({
</p> </p>
); );
return ( return (
<section className="deck-list-choices" aria-label="Lists for this account"> <section
data-testid="deck-list-choices"
className="flex flex-wrap gap-2"
aria-label="Lists for this account"
>
{lists.data.lists.length === 0 ? ( {lists.data.lists.length === 0 ? (
<p>This account has no lists.</p> <p>This account has no lists.</p>
) : ( ) : (
+20 -4
View File
@@ -3,10 +3,23 @@ import { ResearchPostSkeleton } from "./research-post-skeleton";
export function ResearchDeckSkeleton() { export function ResearchDeckSkeleton() {
return ( return (
<div className="deck-board" role="status" aria-label="Loading research"> <div
data-testid="deck-board"
className="grid min-h-0 flex-1 auto-cols-[min(328px,100%)] grid-flow-col justify-start gap-2 overflow-x-auto overflow-y-hidden px-2 pb-2 snap-x snap-proximity md:auto-cols-[328px]"
role="status"
aria-label="Loading research"
>
{["first", "second", "third"].map((column) => ( {["first", "second", "third"].map((column) => (
<div key={column} className="deck-column" aria-hidden="true"> <div
<div className="deck-column-header space-y-3"> key={column}
data-testid="deck-column"
className="flex min-h-0 min-w-0 snap-start flex-col bg-background"
aria-hidden="true"
>
<div
data-testid="deck-column-header"
className="relative shrink-0 border-b bg-card px-3 py-2 space-y-3"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-4 shrink-0" /> <Skeleton className="size-4 shrink-0" />
<Skeleton className="h-4 w-32" /> <Skeleton className="h-4 w-32" />
@@ -15,7 +28,10 @@ export function ResearchDeckSkeleton() {
<Skeleton className="h-3 w-40" /> <Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
</div> </div>
<div className="deck-column-results"> <div
data-testid="deck-column-results"
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
>
<ResearchPostSkeleton /> <ResearchPostSkeleton />
<ResearchPostSkeleton /> <ResearchPostSkeleton />
<ResearchPostSkeleton /> <ResearchPostSkeleton />
@@ -27,7 +27,7 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
article.scrollIntoView = scroll; article.scrollIntoView = scroll;
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />); rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
expect(article).toHaveFocus(); expect(article).toHaveFocus();
expect(article).toHaveClass("citation-highlight"); expect(article).toHaveAttribute("data-citation-highlight", "true");
expect(scroll).toHaveBeenCalledWith({ expect(scroll).toHaveBeenCalledWith({
block: "center", block: "center",
inline: "center", inline: "center",
@@ -46,11 +46,11 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
act(() => { act(() => {
vi.advanceTimersByTime(2500); vi.advanceTimersByTime(2500);
}); });
expect(article).not.toHaveClass("citation-highlight"); expect(article).not.toHaveAttribute("data-citation-highlight", "true");
rerender(<ResearchPostCard post={post} citationRequest={2} />); rerender(<ResearchPostCard post={post} citationRequest={2} />);
expect(article).toHaveClass("citation-highlight"); expect(article).toHaveAttribute("data-citation-highlight", "true");
rerender(<ResearchPostCard post={post} />); rerender(<ResearchPostCard post={post} />);
expect(article).not.toHaveClass("citation-highlight"); expect(article).not.toHaveAttribute("data-citation-highlight", "true");
}); });
it("waits for reveal before mounting CW content or sensitive media", () => { it("waits for reveal before mounting CW content or sensitive media", () => {
+35 -15
View File
@@ -46,22 +46,32 @@ export function ResearchPostCard({
<article <article
ref={article} ref={article}
tabIndex={-1} tabIndex={-1}
className={`research-post${highlighted ? " citation-highlight" : ""}`} data-testid="research-post"
data-citation-highlight={highlighted || undefined}
className="border-b p-3 wrap-anywhere focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 data-[citation-highlight=true]:bg-primary/14 data-[citation-highlight=true]:outline-2 data-[citation-highlight=true]:outline-primary data-[citation-highlight=true]:-outline-offset-2 [&_a]:text-primary [&_a]:underline-offset-2"
data-post-url={post.url} data-post-url={post.url}
> >
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null} {citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
{post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null} {post.boostedBy ? (
<header> <p className="block text-xs text-muted-foreground">Boosted by {post.boostedBy.name}</p>
) : null}
<header className="flex items-center gap-2">
<Avatar className="size-9 shrink-0"> <Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" /> <AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback> <AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
</Avatar> </Avatar>
<div> <div className="min-w-0 flex-1">
<strong>{post.author.name}</strong> <strong className="text-sm">{post.author.name}</strong>
<span className="handle">@{post.author.handle.replace(/^@/, "")}</span> <span className="block text-xs text-muted-foreground">
@{post.author.handle.replace(/^@/, "")}
</span>
</div> </div>
{post.createdAt ? ( {post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}> <time
className="block shrink-0 text-xs text-muted-foreground tabular-nums"
dateTime={post.createdAt}
title={post.createdAt}
>
{dateFormatter.format(new Date(post.createdAt))} {dateFormatter.format(new Date(post.createdAt))}
</time> </time>
) : null} ) : null}
@@ -84,19 +94,24 @@ export function ResearchPostCard({
<> <>
{post.html !== undefined ? ( {post.html !== undefined ? (
<div <div
className="research-post-text" data-testid="research-post-text"
className="my-2 text-sm leading-post whitespace-pre-wrap"
// Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization. // Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }} dangerouslySetInnerHTML={{ __html: post.html }}
/> />
) : ( ) : (
<p className="research-post-text"> <p
data-testid="research-post-text"
className="my-2 text-sm leading-post whitespace-pre-wrap"
>
<PostText text={post.text} /> <PostText text={post.text} />
</p> </p>
)} )}
{post.media?.map((media) => {post.media?.map((media) =>
media.type === "photo" ? ( media.type === "photo" ? (
<img <img
className="research-media" data-testid="research-media"
className="my-2 block max-h-80 w-full rounded-sm object-contain"
key={media.url} key={media.url}
src={media.url} src={media.url}
alt={media.alt ?? ""} alt={media.alt ?? ""}
@@ -109,9 +124,9 @@ export function ResearchPostCard({
), ),
)} )}
{post.quotedPost ? ( {post.quotedPost ? (
<blockquote> <blockquote className="my-3 rounded-sm border p-3">
<strong>{post.quotedPost.author.name}</strong> <strong className="text-sm">{post.quotedPost.author.name}</strong>
<p> <p className="my-2 text-sm leading-post whitespace-pre-wrap">
<PostText text={post.quotedPost.text} /> <PostText text={post.quotedPost.text} />
</p> </p>
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener"> <a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
@@ -121,8 +136,13 @@ export function ResearchPostCard({
) : null} ) : null}
</> </>
) : null} ) : null}
<footer> <footer className="mt-3 flex flex-wrap justify-between gap-2 text-xs text-muted-foreground">
<a href={post.url} target="_blank" rel="noreferrer noopener"> <a
className="inline-flex items-center gap-1 [&_svg]:size-[14px]"
href={post.url}
target="_blank"
rel="noreferrer noopener"
>
Open original post <Icon name="arrow" /> Open original post <Icon name="arrow" />
</a> </a>
</footer> </footer>
@@ -2,7 +2,7 @@ import { Skeleton } from "#/components/ui/skeleton";
export function ResearchPostSkeleton() { export function ResearchPostSkeleton() {
return ( return (
<div className="research-post" aria-hidden="true"> <div data-testid="research-post" className="border-b p-3 wrap-anywhere" aria-hidden="true">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="size-9 shrink-0" /> <Skeleton className="size-9 shrink-0" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
+20 -11
View File
@@ -38,7 +38,7 @@ export function TwitterSourceEditor({
const id = useId(); const id = useId();
return ( return (
<> <>
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
Column type Column type
<NativeSelect <NativeSelect
className="w-full" className="w-full"
@@ -77,8 +77,8 @@ function TwitterSearchEditor({
}: EditorProps<Extract<Source, { kind: "search" }>>) { }: EditorProps<Extract<Source, { kind: "search" }>>) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={`${id}-query`}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-query`}>
Twitter search query Twitter search query
<Input <Input
id={`${id}-query`} id={`${id}-query`}
@@ -89,7 +89,7 @@ function TwitterSearchEditor({
required required
/> />
</Label> </Label>
<Label htmlFor={`${id}-product`}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-product`}>
Sort by Sort by
<NativeSelect <NativeSelect
className="w-full" className="w-full"
@@ -106,8 +106,13 @@ function TwitterSearchEditor({
<NativeSelectOption value="Top">Top</NativeSelectOption> <NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect> </NativeSelect>
</Label> </Label>
<p className="deck-help">Use Twitter search operators such as from:, lang:, and since:.</p> <p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
<Label className="deck-checkbox"> Use Twitter search operators such as from:, lang:, and since:.
</p>
<Label
data-testid="deck-checkbox"
className="mb-3 flex min-h-[40px] items-center gap-2 text-sm"
>
<Checkbox <Checkbox
checked={source.following} checked={source.following}
onCheckedChange={(checked) => onChange({ ...source, following: checked })} onCheckedChange={(checked) => onChange({ ...source, following: checked })}
@@ -121,8 +126,8 @@ function TwitterSearchEditor({
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) { function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
User User
<Input <Input
id={id} id={id}
@@ -144,8 +149,8 @@ function TwitterListEditor({
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) { }: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
const id = useId(); const id = useId();
return ( return (
<div className="deck-source-fields"> <div data-testid="deck-source-fields" className="my-4 grid gap-4">
<Label htmlFor={id}> <Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
List List
<Input <Input
id={id} id={id}
@@ -185,7 +190,11 @@ function TwitterListChoices({
</p> </p>
); );
return ( return (
<section className="deck-list-choices" aria-label="Lists for this profile"> <section
data-testid="deck-list-choices"
className="flex flex-wrap gap-2"
aria-label="Lists for this profile"
>
{lists.data.length === 0 ? ( {lists.data.length === 0 ? (
<p>No lists found. You can enter a URL or ID instead.</p> <p>No lists found. You can enter a URL or ID instead.</p>
) : ( ) : (
+13 -4
View File
@@ -17,13 +17,20 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
); );
return ( return (
<section className="home-brief" aria-label="Daily brief"> <section
className="mt-px-20 max-w-[760px] text-ui-15 leading-brief wrap-anywhere"
aria-label="Daily brief"
>
<p> <p>
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."} {focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
{firstContact ? ( {firstContact ? (
<> <>
{" "} {" "}
<button type="button" onClick={() => onOpenContact(firstContact.id)}> <button
className="cursor-pointer underline decoration-border underline-offset-4 hover:decoration-foreground"
type="button"
onClick={() => onOpenContact(firstContact.id)}
>
Reply to {firstContact.name} Reply to {firstContact.name}
</button> </button>
{contacts.length > 1 {contacts.length > 1
@@ -35,12 +42,14 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
)} )}
</p> </p>
{resume ? ( {resume ? (
<p className="home-brief-secondary"> <p className="mt-px-8 text-ui-13 text-muted-foreground">
Your resume note: “ Your resume note: “
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}” {resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
</p> </p>
) : optional ? ( ) : optional ? (
<p className="home-brief-secondary">“{optional.title}” can wait until you have room.</p> <p className="mt-px-8 text-ui-13 text-muted-foreground">
“{optional.title}” can wait until you have room.
</p>
) : null} ) : null}
</section> </section>
); );
-333
View File
@@ -1,333 +0,0 @@
/* Flexible day flow: quiet activity blocks, with detail where the action happens. */
.execution-day {
min-width: 0;
}
.execution-flow-group {
margin-bottom: 30px;
}
.execution-flow-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.execution-flow-heading h2 {
font-size: 14px;
font-weight: 600;
}
.execution-flow-heading > span {
color: var(--muted-foreground);
font-size: 11px;
}
.execution-flow-items {
display: grid;
gap: 9px;
}
.execution-flow-card {
border: 1px solid var(--border);
border-radius: 16px;
background: var(--muted);
overflow: hidden;
}
.execution-flow-card[data-kind="focus"] {
background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
}
.execution-flow-card[data-kind="routine"] {
background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
}
.execution-flow-card[data-kind="reply"] {
background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
}
.execution-flow-card[data-kind="weight"] {
background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
}
.execution-flow-card[data-kind="optional"] {
background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
}
.execution-flow-card[data-expanded="true"] {
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
}
.execution-flow-card[data-status="done"] {
background: var(--background);
}
.execution-flow-card[data-status="done"] .execution-flow-title {
color: var(--muted-foreground);
}
.execution-flow-card[data-status="done"] .execution-complete,
.execution-flow-card[data-status="done"] .execution-flow-icon {
color: var(--color-success);
}
.execution-flow-row {
display: flex;
align-items: center;
gap: 13px;
padding: 16px 18px;
}
.execution-complete,
.execution-flow-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
height: 28px;
color: var(--muted-foreground);
}
.execution-complete {
cursor: pointer;
border-radius: 50%;
}
.execution-complete:disabled {
cursor: default;
opacity: 0.45;
}
.execution-complete:hover:not(:disabled) {
color: var(--foreground);
}
.execution-complete svg,
.execution-flow-icon svg {
width: 21px;
height: 21px;
stroke-width: 1.5;
}
.execution-flow-open {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
text-align: left;
gap: 5px;
cursor: pointer;
border-radius: 3px;
}
.execution-flow-title {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
}
.execution-flow-description {
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.5;
}
.execution-expand-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
color: var(--muted-foreground);
}
.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon {
transform: rotate(180deg);
}
.execution-inline-detail {
padding: 6px 22px 24px 55px;
}
.execution-inline-detail h2 {
display: none;
}
.execution-detail-symbol,
.execution-eyebrow {
display: none;
}
.execution-description {
font-size: 13px;
line-height: 1.8;
color: var(--muted-foreground);
max-width: 480px;
}
.execution-routine-progress {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 11px;
color: var(--muted-foreground);
margin: 20px 0 8px;
}
.execution-step-list {
display: grid;
}
.execution-step-list label {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
cursor: pointer;
border-top: 1px solid var(--border);
font-size: 13px;
}
.execution-step-list label > .execution-step-text {
flex: 1;
}
.execution-step-list label[data-checked="true"] > .execution-step-text {
text-decoration: line-through;
color: var(--muted-foreground);
}
.execution-step-number {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.execution-next-step {
padding: 14px 0;
margin-top: 14px;
border-top: 1px solid var(--border);
}
.execution-next-step > span {
font-size: 11px;
color: var(--muted-foreground);
}
.execution-next-step p {
font-size: 13px;
line-height: 1.75;
margin-top: 5px;
}
.execution-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.execution-resume-note {
border-top: 1px solid var(--border);
margin-top: 25px;
padding-top: 20px;
}
.execution-resume-note > label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 10px;
}
.execution-resume-note textarea {
min-height: 74px;
background: var(--background);
font-size: 12px;
}
.execution-note-footer {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
margin-top: 8px;
}
.execution-note-footer > span {
color: var(--muted-foreground);
font-size: 11px;
}
.execution-order {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 20px;
color: var(--muted-foreground);
}
.execution-order > span {
font-size: 11px;
margin-right: auto;
}
.execution-weight-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-top: 18px;
}
.execution-weight-form > label {
width: 100%;
font-size: 12px;
}
.execution-weight-input {
display: flex;
align-items: center;
gap: 10px;
max-width: 180px;
}
.execution-weight-input input {
background: var(--background);
font-size: 20px;
height: 42px;
font-variant-numeric: tabular-nums;
}
.execution-weight-input > span {
color: var(--muted-foreground);
font-size: 13px;
}
.execution-weight-form > p {
width: 100%;
color: var(--destructive);
font-size: 12px;
}
.execution-source-note {
font-size: 11px;
color: var(--muted-foreground);
margin-top: 20px;
}
.execution-flow-empty {
font-size: 12px;
color: var(--muted-foreground);
padding: 16px 0;
}
.execution-rest {
border-top: 1px solid var(--border);
padding-top: 20px;
}
.execution-rest summary {
cursor: pointer;
font-size: 12px;
color: var(--muted-foreground);
}
.execution-rest summary > span {
margin-left: 8px;
font-variant-numeric: tabular-nums;
}
.execution-rest-items {
margin-top: 12px;
}
.execution-rest-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
font-size: 13px;
}
.execution-rest-row > svg {
width: 17px;
height: 17px;
color: var(--muted-foreground);
}
.execution-rest-row > span {
flex: 1;
}
.execution-rest-row small {
display: block;
color: var(--muted-foreground);
font-size: 11px;
margin-top: 3px;
}
.execution-flow-open:focus-visible,
.execution-complete:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
}
@media (max-width: 767px) {
.execution-flow-row {
padding: 15px 14px;
gap: 10px;
}
.execution-flow-heading > span {
font-size: 10px;
}
.execution-inline-detail {
padding: 4px 18px 20px;
}
.execution-flow-title {
font-size: 13px;
}
.execution-flow-description {
font-size: 11px;
}
.execution-note-footer {
align-items: flex-start;
}
}
+128 -65
View File
@@ -49,7 +49,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
); );
} }
return ( return (
<section className="execution-day" aria-label="Today’s plan"> <section className="min-w-0" aria-label="Today’s plan">
{( {(
[ [
{ id: "focus", label: "Start here", subtitle: "Just the next thing" }, { id: "focus", label: "Start here", subtitle: "Just the next thing" },
@@ -57,11 +57,11 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" }, { id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
] as const ] as const
).map((section) => ( ).map((section) => (
<section className="execution-flow-group" key={section.id}> <section className="mb-px-30" key={section.id}>
<header className="execution-flow-heading"> <header className="mb-px-12 flex items-baseline justify-between gap-px-12">
<h2>{section.label}</h2> <h2 className="text-ui-14 font-semibold">{section.label}</h2>
</header> </header>
<div className="execution-flow-items"> <div className="grid gap-px-9">
{tasks {tasks
.filter((task) => task.section === section.id) .filter((task) => task.section === section.id)
.map((task) => { .map((task) => {
@@ -71,16 +71,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length; const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
return ( return (
<div <div
className="execution-flow-card" className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
key={task.id} key={task.id}
data-expanded={expanded} data-expanded={expanded}
data-kind={task.section} data-kind={task.section}
data-status={task.status} data-status={task.status}
> >
<div className="execution-flow-row"> <div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
<button <button
type="button" type="button"
className="execution-complete" className="flex h-[28px] basis-[24px] shrink-0 grow-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground enabled:hover:text-foreground disabled:cursor-default disabled:opacity-45 group-data-[status=done]/flow:text-success focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`} aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
aria-pressed={task.status === "done"} aria-pressed={task.status === "done"}
disabled={ disabled={
@@ -89,22 +89,27 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
onClick={() => finish(task.id)} onClick={() => finish(task.id)}
> >
{task.status === "done" ? ( {task.status === "done" ? (
<Check /> <Check className="size-[21px]" strokeWidth={1.5} />
) : task.status === "later" ? ( ) : task.status === "later" ? (
<Pause /> <Pause className="size-[21px]" strokeWidth={1.5} />
) : ( ) : (
<Circle /> <Circle className="size-[21px]" strokeWidth={1.5} />
)} )}
</button> </button>
<button <button
type="button" type="button"
className="execution-flow-open" className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
aria-expanded={expanded} aria-expanded={expanded}
aria-controls={`detail-${task.id}`} aria-controls={`detail-${task.id}`}
onClick={() => toggle(id)} onClick={() => toggle(id)}
> >
<span className="execution-flow-title">{task.title}</span> <span
<span className="execution-flow-description"> data-testid="execution-flow-title"
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
>
{task.title}
</span>
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
{task.status === "done" {task.status === "done"
? "Completed" ? "Completed"
: task.status === "later" : task.status === "later"
@@ -116,10 +121,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
: "At your own pace"} : "At your own pace"}
</span> </span>
</button> </button>
<ChevronDown className="execution-expand-icon" aria-hidden="true" /> <ChevronDown
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
aria-hidden="true"
/>
</div> </div>
{expanded && ( {expanded && (
<div className="execution-inline-detail" id={`detail-${task.id}`}> <div
className="pt-px-6 pr-px-22 pb-px-24 pl-px-55 max-md:px-px-18 max-md:pt-px-4 max-md:pb-px-20"
id={`detail-${task.id}`}
>
<TaskDetail task={task} onAction={() => onSelect("")} /> <TaskDetail task={task} onAction={() => onSelect("")} />
</div> </div>
)} )}
@@ -129,7 +140,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
{section.id === "focus" && {section.id === "focus" &&
replies.slice(0, 2).map((conversation) => ( replies.slice(0, 2).map((conversation) => (
<div <div
className="execution-flow-card" className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
key={conversation.id} key={conversation.id}
data-kind="reply" data-kind="reply"
data-status={ data-status={
@@ -139,23 +150,28 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
: statuses[conversation.id] : statuses[conversation.id]
} }
> >
<div className="execution-flow-row"> <div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
<span className="execution-flow-icon"> <span className="flex h-[28px] basis-[24px] shrink-0 grow-0 items-center justify-center text-muted-foreground group-data-[status=done]/flow:text-success">
{statuses[conversation.id] === "sent" || {statuses[conversation.id] === "sent" ||
statuses[conversation.id] === "dismissed" ? ( statuses[conversation.id] === "dismissed" ? (
<Check /> <Check className="size-[21px]" strokeWidth={1.5} />
) : ( ) : (
<MessageCircle /> <MessageCircle className="size-[21px]" strokeWidth={1.5} />
)} )}
</span> </span>
<Link <Link
to="/support" to="/support"
search={{ conversation: conversation.id, view: "all" }} search={{ conversation: conversation.id, view: "all" }}
className="execution-flow-open" className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
aria-label={`Open conversation with ${conversation.name}`} aria-label={`Open conversation with ${conversation.name}`}
> >
<span className="execution-flow-title">Reply to {conversation.name}</span> <span
<span className="execution-flow-description"> data-testid="execution-flow-title"
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
>
Reply to {conversation.name}
</span>
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
{statuses[conversation.id] === "sent" {statuses[conversation.id] === "sent"
? "Reply recorded · Preview only" ? "Reply recorded · Preview only"
: statuses[conversation.id] === "dismissed" : statuses[conversation.id] === "dismissed"
@@ -167,35 +183,55 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
: conversation.subject} : conversation.subject}
</span> </span>
</Link> </Link>
<ArrowUpRight className="execution-expand-icon" aria-hidden="true" /> <ArrowUpRight
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
aria-hidden="true"
/>
</div> </div>
</div> </div>
))} ))}
{section.id === "routine" && ( {section.id === "routine" && (
<div <div
className="execution-flow-card" className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
data-expanded={selectedId === "weight"} data-expanded={selectedId === "weight"}
data-kind="weight" data-kind="weight"
data-status={weightEntry ? "done" : "todo"} data-status={weightEntry ? "done" : "todo"}
> >
<div className="execution-flow-row"> <div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span> <span className="flex h-[28px] basis-[24px] shrink-0 grow-0 items-center justify-center text-muted-foreground group-data-[status=done]/flow:text-success">
{weightEntry ? (
<Check className="size-[21px]" strokeWidth={1.5} />
) : (
<Scale className="size-[21px]" strokeWidth={1.5} />
)}
</span>
<button <button
type="button" type="button"
className="execution-flow-open" className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
aria-expanded={selectedId === "weight"} aria-expanded={selectedId === "weight"}
aria-controls="detail-weight" aria-controls="detail-weight"
onClick={() => toggle("weight")} onClick={() => toggle("weight")}
> >
<span className="execution-flow-title">Record weight</span> <span
<span className="execution-flow-description"> data-testid="execution-flow-title"
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
>
Record weight
</span>
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"} {weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
</span> </span>
</button> </button>
<ChevronDown className="execution-expand-icon" aria-hidden="true" /> <ChevronDown
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
aria-hidden="true"
/>
</div> </div>
{selectedId === "weight" && ( {selectedId === "weight" && (
<div className="execution-inline-detail" id="detail-weight"> <div
className="pt-px-6 pr-px-22 pb-px-24 pl-px-55 max-md:px-px-18 max-md:pt-px-4 max-md:pb-px-20"
id="detail-weight"
>
<WeightDetail <WeightDetail
key={weightEntry?.id ?? "new"} key={weightEntry?.id ?? "new"}
entry={weightEntry} entry={weightEntry}
@@ -208,7 +244,9 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
{!tasks.some((task) => task.section === section.id && task.status === "todo") && {!tasks.some((task) => task.section === section.id && task.status === "todo") &&
(section.id !== "focus" || !replies.length) && (section.id !== "focus" || !replies.length) &&
section.id !== "routine" && ( section.id !== "routine" && (
<p className="execution-flow-empty">A little breathing room.</p> <p className="py-px-16 text-ui-12 text-muted-foreground">
A little breathing room.
</p>
)} )}
</div> </div>
</section> </section>
@@ -248,20 +286,20 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
} }
return ( return (
<> <>
<div className="execution-detail-symbol"> <div className="hidden">
<ListChecks /> <ListChecks />
</div> </div>
<p className="execution-eyebrow"> <p className="hidden">
{task.section === "routine" {task.section === "routine"
? "Your routine" ? "Your routine"
: task.section === "optional" : task.section === "optional"
? "If there’s room" ? "If there’s room"
: "One next step"} : "One next step"}
</p> </p>
<h2>{task.title}</h2> <h2 className="hidden">{task.title}</h2>
{steps.length > 0 ? ( {steps.length > 0 ? (
<> <>
<div className="execution-routine-progress"> <div className="mt-px-20 mb-px-8 flex justify-between gap-px-12 text-ui-11 text-muted-foreground">
<span> <span>
{checked} of {steps.length} steps {checked} of {steps.length} steps
</span> </span>
@@ -273,9 +311,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
: "At your pace"} : "At your pace"}
</span> </span>
</div> </div>
<div className="execution-step-list"> <div className="grid">
{steps.map((step, index) => ( {steps.map((step, index) => (
<label <label
className="group/step flex cursor-pointer items-center gap-px-12 border-t border-border py-px-14 text-ui-13"
key={step} key={step}
htmlFor={`execution-step-${task.id}-${index}`} htmlFor={`execution-step-${task.id}-${index}`}
data-checked={task.completedSteps?.includes(step) ?? false} data-checked={task.completedSteps?.includes(step) ?? false}
@@ -301,15 +340,19 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
) )
} }
/> />
<span className="execution-step-text">{step}</span> <span className="flex-1 group-data-[checked=true]/step:text-muted-foreground group-data-[checked=true]/step:line-through">
<span className="execution-step-number">{String(index + 1).padStart(2, "0")}</span> {step}
</span>
<span className="text-ui-11 text-muted-foreground tabular-nums">
{String(index + 1).padStart(2, "0")}
</span>
</label> </label>
))} ))}
</div> </div>
</> </>
) : ( ) : (
<> <>
<p className="execution-description"> <p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
{task.id === "review" {task.id === "review"
? "Look through the first-time experience. Leave a short note on anything that feels unclear." ? "Look through the first-time experience. Leave a short note on anything that feels unclear."
: task.section === "optional" : task.section === "optional"
@@ -317,14 +360,16 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
: "Choose a small first step. You can leave a note if you need to pause."} : "Choose a small first step. You can leave a note if you need to pause."}
</p> </p>
{task.id === "review" && ( {task.id === "review" && (
<div className="execution-next-step"> <div className="mt-px-14 border-t border-border py-px-14">
<span>Start here</span> <span className="text-ui-11 text-muted-foreground">Start here</span>
<p>Open the materials and look at the first screen.</p> <p className="mt-px-5 text-ui-13 leading-loose-copy">
Open the materials and look at the first screen.
</p>
</div> </div>
)} )}
</> </>
)} )}
<div className="execution-detail-actions"> <div className="mt-px-20 flex flex-wrap gap-px-8">
{task.status !== "done" && ( {task.status !== "done" && (
<Button <Button
onClick={() => setStatus("done")} onClick={() => setStatus("done")}
@@ -344,9 +389,13 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
</Button> </Button>
)} )}
</div> </div>
<section className="execution-resume-note" aria-label="Resume note"> <section className="mt-px-25 border-t border-border pt-px-20" aria-label="Resume note">
<label htmlFor={`resume-${task.id}`}>A place to pick up</label> <label className="mb-px-10 block text-ui-12 font-medium" htmlFor={`resume-${task.id}`}>
A place to pick up
</label>
<Textarea <Textarea
variant="note"
className="min-h-[74px]"
id={`resume-${task.id}`} id={`resume-${task.id}`}
placeholder="Where did you leave off?" placeholder="Where did you leave off?"
value={note} value={note}
@@ -355,8 +404,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
setNotice(""); setNotice("");
}} }}
/> />
<div className="execution-note-footer"> <div className="mt-px-8 flex items-center justify-between gap-px-10 max-md:items-start">
<span role="status">{notice || "Leave a sentence for your future self."}</span> <span className="text-ui-11 text-muted-foreground" role="status">
{notice || "Leave a sentence for your future self."}
</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -381,8 +432,8 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
</Button> </Button>
</div> </div>
</section> </section>
<div className="execution-order"> <div className="mt-px-20 flex items-center justify-end gap-px-6 text-muted-foreground">
<span>Order in your list</span> <span className="mr-auto text-ui-11">Order in your list</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -410,14 +461,16 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
const [error, setError] = useState(""); const [error, setError] = useState("");
return ( return (
<> <>
<div className="execution-detail-symbol"> <div className="hidden">
<Scale /> <Scale />
</div> </div>
<p className="execution-eyebrow">A quick check-in</p> <p className="hidden">A quick check-in</p>
<h2>Record your weight</h2> <h2 className="hidden">Record your weight</h2>
<p className="execution-description">Just a measurement. No target to hit.</p> <p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
Just a measurement. No target to hit.
</p>
<form <form
className="execution-weight-form" className="mt-px-18 flex flex-wrap items-end gap-px-12"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const value = Number(weight); const value = Number(weight);
@@ -441,9 +494,13 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
onAction(); onAction();
}} }}
> >
<label htmlFor="execution-weight">Weight</label> <label className="w-full text-ui-12" htmlFor="execution-weight">
<div className="execution-weight-input"> Weight
</label>
<div className="flex max-w-[180px] items-center gap-px-10">
<Input <Input
variant="measurement"
className="h-[42px]"
id="execution-weight" id="execution-weight"
inputMode="decimal" inputMode="decimal"
type="number" type="number"
@@ -459,10 +516,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
setError(""); setError("");
}} }}
/> />
<span>kg</span> <span className="text-ui-13 text-muted-foreground">kg</span>
</div> </div>
{error && ( {error && (
<p id="execution-weight-error" role="alert"> <p
className="w-full text-ui-12 text-destructive"
id="execution-weight-error"
role="alert"
>
{error} {error}
</p> </p>
)} )}
@@ -471,12 +532,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
</Button> </Button>
</form> </form>
{entry && ( {entry && (
<div className="execution-next-step"> <div className="mt-px-14 border-t border-border py-px-14">
<span>Recorded</span> <span className="text-ui-11 text-muted-foreground">Recorded</span>
<p>{entry.text} kg</p> <p className="mt-px-5 text-ui-13 leading-loose-copy">{entry.text} kg</p>
</div> </div>
)} )}
<p className="execution-source-note">Your measurement also appears in Journal.</p> <p className="mt-px-20 text-ui-11 text-muted-foreground">
Your measurement also appears in Journal.
</p>
</> </>
); );
} }
+49 -26
View File
@@ -136,24 +136,28 @@ export function HomePage() {
} }
return ( return (
<TooltipProvider delay={500}> <TooltipProvider delay={500}>
<SidebarProvider className="home-workspace"> <SidebarProvider className="h-dvh min-h-0 overflow-hidden">
<WorkspaceNavigation /> <WorkspaceNavigation />
<SidebarInset className="home-main"> <SidebarInset className="min-w-0 overflow-hidden">
<div className="home-scroll"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="home-content"> <div className="mx-auto max-w-[1200px] px-px-48 pt-px-40 pb-px-80 max-[1000px]:px-px-28 max-[1000px]:pb-px-40 max-md:px-px-20 max-md:py-px-32">
<header className="home-greeting"> <header className="mb-px-32">
<p className="text-sm text-muted-foreground">Today</p> <p className="text-sm text-muted-foreground">Today</p>
<h1>Good morning, {auth.owner?.name}</h1> <h1 className="mt-px-12 text-home-greeting font-book tracking-home leading-normal max-md:text-ui-25">
Good morning, {auth.owner?.name}
</h1>
<DailyBrief onOpenContact={openContact} /> <DailyBrief onOpenContact={openContact} />
</header> </header>
<form <form
className="home-composer" className="rounded-ui-20 border border-border bg-muted p-px-16 max-md:p-px-10"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
interpret(); interpret();
}} }}
> >
<Textarea <Textarea
variant="composer"
className="min-h-[130px] max-md:min-h-[150px]"
ref={input} ref={input}
aria-label="Home input" aria-label="Home input"
placeholder="What’s on your mind?" placeholder="What’s on your mind?"
@@ -174,7 +178,7 @@ export function HomePage() {
} }
}} }}
/> />
<div className="home-composer-footer"> <div className="flex items-center justify-between p-px-8">
<span className="text-xs text-muted-foreground">Tasks, replies, notes.</span> <span className="text-xs text-muted-foreground">Tasks, replies, notes.</span>
<Button <Button
type="submit" type="submit"
@@ -186,17 +190,22 @@ export function HomePage() {
</Button> </Button>
</div> </div>
</form> </form>
<div className="home-suggestions"> <div className="mt-px-16 flex flex-wrap gap-x-px-20 gap-y-px-8 max-md:gap-px-12">
{["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => ( {["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => (
<button type="button" key={text} onClick={() => suggest(text)}> <button
className="flex cursor-pointer items-center gap-px-6 text-left text-ui-12 text-muted-foreground"
type="button"
key={text}
onClick={() => suggest(text)}
>
{text} {text}
<ArrowUpRight className="size-3" /> <ArrowUpRight className="size-3" />
</button> </button>
))} ))}
</div> </div>
{proposal && ( {proposal && (
<div className="home-proposal"> <div className="mt-px-20 flex flex-wrap items-center gap-px-12 rounded-ui-12 border border-border p-px-16">
<p>{proposal.label}</p> <p className="min-w-[140px] flex-1 text-ui-14">{proposal.label}</p>
<Button onClick={() => apply(proposal.action)}>Apply</Button> <Button onClick={() => apply(proposal.action)}>Apply</Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -208,24 +217,31 @@ export function HomePage() {
</Button> </Button>
</div> </div>
)} )}
<p className="home-status" role="status"> <p className="min-h-[36px] pt-px-12 text-ui-12 text-muted-foreground" role="status">
{notice} {notice}
</p> </p>
<div className="home-columns"> <div className="mt-px-24 grid grid-cols-[minmax(0,2.5fr)_minmax(190px,1fr)] gap-px-40 max-[1000px]:gap-px-32 max-md:grid-cols-1 max-md:gap-px-24">
<ExecutionWorkbench <ExecutionWorkbench
selectedId={search.activity} selectedId={search.activity}
onSelect={(id) => updateSearch({ activity: id || undefined })} onSelect={(id) => updateSearch({ activity: id || undefined })}
/> />
<aside aria-label="Today’s context"> <aside aria-label="Today’s context">
<section className="home-section"> <section className="mb-px-36">
<header className="home-section-heading"> <header className="mb-px-12 flex items-center justify-between">
<h2>Journal</h2> <h2 className="text-ui-14 font-semibold">Journal</h2>
<Link to="/journal" aria-label="Open journal"> <Link
className="p-px-6 text-muted-foreground"
to="/journal"
aria-label="Open journal"
>
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</header> </header>
{currentEntries.slice(0, 2).map((entry) => ( {currentEntries.slice(0, 2).map((entry) => (
<p className="home-record" key={entry.id}> <p
className="flex flex-col gap-px-6 py-px-12 text-left text-ui-13 leading-relaxed-copy wrap-anywhere"
key={entry.id}
>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{entryKindLabels[entry.kind]} {entryKindLabels[entry.kind]}
</span> </span>
@@ -238,7 +254,7 @@ export function HomePage() {
{!currentEntries.length && ( {!currentEntries.length && (
<button <button
type="button" type="button"
className="home-context-row" className="flex w-full cursor-pointer items-center justify-between gap-px-10 py-px-12 text-left text-ui-13"
onClick={() => suggest("Note:")} onClick={() => suggest("Note:")}
> >
<span>Leave a note</span> <span>Leave a note</span>
@@ -246,17 +262,21 @@ export function HomePage() {
</button> </button>
)} )}
</section> </section>
<section className="home-section"> <section className="mb-px-36">
<header className="home-section-heading"> <header className="mb-px-12 flex items-center justify-between">
<h2>Reading</h2> <h2 className="text-ui-14 font-semibold">Reading</h2>
<Link to="/inbox" aria-label="Open reader"> <Link
className="p-px-6 text-muted-foreground"
to="/inbox"
aria-label="Open reader"
>
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</header> </header>
{articles.slice(0, 2).map((item) => ( {articles.slice(0, 2).map((item) => (
<button <button
type="button" type="button"
className="home-reading" className="flex w-full cursor-pointer flex-col gap-px-6 py-px-12 text-left text-ui-13 leading-relaxed-copy wrap-anywhere"
key={item.id} key={item.id}
onClick={() => setArticleId(item.id)} onClick={() => setArticleId(item.id)}
> >
@@ -265,7 +285,10 @@ export function HomePage() {
</button> </button>
))} ))}
</section> </section>
<Link to="/deck" className="home-deck-link"> <Link
to="/deck"
className="flex items-center justify-between text-ui-12 text-muted-foreground"
>
Explore in Research Explore in Research
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
-254
View File
@@ -1,254 +0,0 @@
/* Hallmark · daily workbench · neutral / quiet · P5 H5 E4 S5 R5 V4 */
.home-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.home-main {
min-width: 0;
overflow: hidden;
}
.home-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.home-content {
max-width: 1200px;
margin: auto;
padding: 40px 48px 80px;
}
.home-greeting {
margin-bottom: 32px;
}
.home-greeting h1 {
font-size: clamp(24px, 3vw, 34px);
font-weight: 550;
letter-spacing: -0.035em;
line-height: 1.5;
margin-top: 12px;
}
.home-composer {
border: 1px solid var(--border);
border-radius: 20px;
background: var(--muted);
padding: 16px;
}
.home-composer textarea {
min-height: 130px;
resize: vertical;
border: 0;
box-shadow: none;
background: transparent;
padding: 8px;
font-size: 16px;
line-height: 1.8;
}
.home-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
}
.home-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 16px;
}
.home-suggestions button {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.home-proposal {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
}
.home-proposal p {
flex: 1;
font-size: 14px;
min-width: 140px;
}
.home-status {
min-height: 36px;
padding-top: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.home-columns {
display: grid;
grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
gap: 40px;
margin-top: 24px;
}
.home-section {
margin-bottom: 36px;
}
.home-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-section-heading h2 {
font-size: 14px;
font-weight: 600;
}
.home-section-heading a {
color: var(--muted-foreground);
padding: 6px;
}
.home-task {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.home-check {
padding-top: 2px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-task-body {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 1.8;
}
.home-task-body summary {
margin-top: 6px;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-steps {
display: grid;
gap: 12px;
padding-top: 14px;
font-size: 13px;
}
.home-task-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 115px;
}
.home-rest summary {
font-size: 13px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-rest summary span {
margin-left: 8px;
}
.home-empty {
font-size: 12px;
color: var(--muted-foreground);
padding: 16px 0;
}
.home-context-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.home-record,
.home-reading {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
text-align: left;
font-size: 13px;
line-height: 1.8;
overflow-wrap: anywhere;
}
.home-reading {
width: 100%;
cursor: pointer;
}
.home-deck-link {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted-foreground);
}
@media (max-width: 1000px) {
.home-content {
padding: 40px 28px;
}
.home-columns {
gap: 32px;
}
.home-task-actions {
max-width: 70px;
}
}
@media (max-width: 767px) {
.home-content {
padding: 32px 20px;
}
.home-columns {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.home-greeting h1 {
font-size: 25px;
}
.home-composer {
padding: 10px;
}
.home-composer textarea {
min-height: 150px;
}
.home-task {
gap: 10px;
}
.home-task-actions {
max-width: 60px;
}
.home-suggestions {
gap: 12px;
}
}
.home-brief {
margin-top: 20px;
max-width: 760px;
font-size: 15px;
line-height: 1.95;
overflow-wrap: anywhere;
}
.home-brief button {
text-decoration: underline;
text-decoration-color: var(--border);
text-underline-offset: 4px;
cursor: pointer;
}
.home-brief button:hover {
text-decoration-color: var(--foreground);
}
.home-brief-secondary {
margin-top: 8px;
color: var(--muted-foreground);
font-size: 13px;
}
+65 -16
View File
@@ -29,10 +29,22 @@ export function InboxPage() {
return ( return (
<TooltipProvider delay={500}> <TooltipProvider delay={500}>
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}> <SidebarProvider
<Sidebar collapsible="none" className="inbox-sidebar"> data-testid="inbox-workspace"
className="group/inbox h-dvh min-h-0 overflow-hidden"
data-reading={readingMobile}
>
<Sidebar
collapsible="none"
data-testid="inbox-sidebar"
className="w-[420px] shrink-0 flex-row border-r border-border min-[768px]:max-[1100px]:w-[340px] max-md:w-full max-md:border-r-0 max-md:group-data-[reading=true]/inbox:hidden"
>
<WorkspaceNavigation /> <WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel"> <Sidebar
collapsible="none"
data-testid="inbox-list-panel"
className="w-auto min-w-0 flex-1"
>
<SidebarHeader className="gap-4 border-b p-5"> <SidebarHeader className="gap-4 border-b p-5">
<h1 className="text-base font-semibold">Reader</h1> <h1 className="text-base font-semibold">Reader</h1>
<div className="relative"> <div className="relative">
@@ -60,7 +72,10 @@ export function InboxPage() {
/> />
))} ))}
{visible.length === 0 && ( {visible.length === 0 && (
<div className="inbox-no-results"> <div
data-testid="inbox-no-results"
className="grid justify-items-start gap-px-12 px-px-20 py-px-32 text-ui-13 leading-relaxed-copy text-muted-foreground"
>
<p>No articles found</p> <p>No articles found</p>
<Button variant="outline" onClick={clearSearch}> <Button variant="outline" onClick={clearSearch}>
Clear search Clear search
@@ -70,12 +85,19 @@ export function InboxPage() {
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
</Sidebar> </Sidebar>
<SidebarInset className="inbox-reader"> <SidebarInset
<header className="inbox-reader-toolbar"> data-testid="inbox-reader"
className="h-full min-w-0 overflow-hidden max-md:hidden max-md:group-data-[reading=true]/inbox:flex"
>
<header
data-testid="inbox-reader-toolbar"
className="flex min-h-[60px] items-center gap-px-12 border-b border-border px-px-26 py-px-10 max-md:px-px-12"
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="inbox-back" data-testid="inbox-back"
className="hidden max-md:inline-flex"
aria-label="Back to articles" aria-label="Back to articles"
onClick={() => updateSearch({ article: undefined })} onClick={() => updateSearch({ article: undefined })}
> >
@@ -84,7 +106,8 @@ export function InboxPage() {
<span className="text-xs text-muted-foreground">{selected?.source}</span> <span className="text-xs text-muted-foreground">{selected?.source}</span>
</header> </header>
<section <section
className="inbox-reader-scroll" data-testid="inbox-reader-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
key={selected?.id} key={selected?.id}
aria-label="Article content" aria-label="Article content"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable.
@@ -110,28 +133,54 @@ function ArticleRow({
return ( return (
<button <button
type="button" type="button"
className="inbox-article-row" data-testid="inbox-article-row"
className="flex w-full cursor-pointer flex-col items-stretch gap-px-11 border-b border-border px-px-20 py-px-22 text-left hover:bg-sidebar-accent aria-current:bg-muted aria-current:inset-shadow-selection"
aria-current={selected ? "true" : undefined} aria-current={selected ? "true" : undefined}
onClick={onSelect} onClick={onSelect}
> >
<span className="inbox-row-meta"> <span
data-testid="inbox-row-meta"
className="flex min-w-0 items-center gap-px-7 text-ui-11 text-muted-foreground"
>
<span className="truncate">{article.source}</span> <span className="truncate">{article.source}</span>
<time className="ml-auto shrink-0">{article.date}</time> <time className="ml-auto shrink-0">{article.date}</time>
</span> </span>
<span className="inbox-row-title">{article.title}</span> <span
<span className="inbox-row-summary">{article.summary}</span> data-testid="inbox-row-title"
className="block text-ui-14 leading-chat font-semibold text-pretty"
>
{article.title}
</span>
<span
data-testid="inbox-row-summary"
className="line-clamp-2 text-ui-12 leading-relaxed-copy text-muted-foreground"
>
{article.summary}
</span>
</button> </button>
); );
} }
function ArticleReader({ article }: { article: Article }) { function ArticleReader({ article }: { article: Article }) {
return ( return (
<article className="inbox-article" aria-label={article.title}> <article
<h2 className="inbox-article-title">{article.title}</h2> data-testid="inbox-article"
<p className="inbox-byline text-xs text-muted-foreground"> className="mx-auto max-w-[800px] px-px-52 pt-px-54 pb-px-40 min-[768px]:max-[1100px]:px-px-28 min-[768px]:max-[1100px]:py-px-36 max-md:px-px-24 max-md:py-px-30"
aria-label={article.title}
>
<h2
data-testid="inbox-article-title"
className="m-0 text-reader-title leading-post font-demibold tracking-tight wrap-anywhere text-pretty"
>
{article.title}
</h2>
<p data-testid="inbox-byline" className="my-px-30 text-xs text-muted-foreground">
{article.author} · {article.date} {article.author} · {article.date}
</p> </p>
<div className="inbox-article-body"> <div
data-testid="inbox-article-body"
className="text-ui-15 leading-reader tracking-wide [&_p+p]:mt-px-24"
>
{article.paragraphs.map((paragraph) => ( {article.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p> <p key={paragraph}>{paragraph}</p>
))} ))}
-148
View File
@@ -1,148 +0,0 @@
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.inbox-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.inbox-sidebar {
width: 420px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.inbox-list-panel {
flex: 1;
min-width: 0;
width: auto;
}
.inbox-article-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
width: 100%;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.inbox-article-row:hover {
background: var(--sidebar-accent);
}
.inbox-article-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.inbox-row-meta {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted-foreground);
font-size: 11px;
}
.inbox-row-title {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.65;
text-wrap: pretty;
}
.inbox-row-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.8;
}
.inbox-reader {
min-width: 0;
height: 100%;
overflow: hidden;
}
.inbox-reader-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 10px 26px;
border-bottom: 1px solid var(--border);
}
.inbox-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.inbox-article {
max-width: 800px;
margin: 0 auto;
padding: 54px 52px 40px;
}
.inbox-article-title {
margin: 0;
font-size: clamp(24px, 2.4vw, 34px);
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.inbox-byline {
margin-block: 30px;
}
.inbox-article-body {
font-size: 15px;
line-height: 2.15;
letter-spacing: 0.015em;
}
.inbox-article-body p + p {
margin-top: 24px;
}
.inbox-no-results {
display: grid;
justify-items: start;
gap: 12px;
padding: 32px 20px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.8;
}
.inbox-back {
display: none;
}
@media (min-width: 768px) and (max-width: 1100px) {
.inbox-sidebar {
width: 340px;
}
.inbox-article {
padding: 36px 28px;
}
}
@media (max-width: 767px) {
.inbox-sidebar {
width: 100%;
border-right: 0;
}
.inbox-reader {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-sidebar {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-reader {
display: flex;
}
.inbox-back {
display: inline-flex;
}
.inbox-reader-toolbar {
padding-inline: 12px;
}
.inbox-article {
padding: 30px 24px;
}
}
+34 -18
View File
@@ -58,10 +58,10 @@ export function JournalPage() {
return ( return (
<TooltipProvider delay={500}> <TooltipProvider delay={500}>
<SidebarProvider className="journal-workspace"> <SidebarProvider className="h-dvh min-h-0 overflow-hidden">
<WorkspaceNavigation /> <WorkspaceNavigation />
<SidebarInset className="journal-main"> <SidebarInset className="min-w-0 overflow-hidden">
<header className="journal-header"> <header className="flex min-h-[68px] items-center justify-between gap-px-12 border-b border-border px-px-28 py-px-16 max-[600px]:flex-wrap max-[600px]:p-px-14">
<h1 className="text-base font-semibold">Journal</h1> <h1 className="text-base font-semibold">Journal</h1>
<Button <Button
variant="outline" variant="outline"
@@ -74,8 +74,8 @@ export function JournalPage() {
Summarize entries Summarize entries
</Button> </Button>
</header> </header>
<div className="journal-scroll"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="journal-column"> <div className="mx-auto w-full max-w-[760px] px-px-28 py-px-40 max-[600px]:px-px-14 max-[600px]:py-px-24">
<EntryEditor <EntryEditor
draft={draft} draft={draft}
onChange={setDraft} onChange={setDraft}
@@ -85,15 +85,19 @@ export function JournalPage() {
}} }}
submitLabel="Save entry" submitLabel="Save entry"
/> />
<section className="journal-entries" aria-label="Journal entries"> <section className="mt-px-36" aria-label="Journal entries">
{entries.length === 0 && <p className="journal-empty">No entries yet</p>} {entries.length === 0 && (
<p className="py-px-40 text-center text-ui-14 text-muted-foreground">
No entries yet
</p>
)}
{entries.map((entry) => ( {entries.map((entry) => (
<article <article
key={entry.id} key={entry.id}
className="journal-entry" className="border-t border-border py-px-20"
aria-label={`${entryKindLabels[entry.kind]} entry`} aria-label={`${entryKindLabels[entry.kind]} entry`}
> >
<div className="journal-entry-heading"> <div className="flex min-h-[28px] items-center gap-px-10">
<span className="text-sm font-medium">{entryKindLabels[entry.kind]}</span> <span className="text-sm font-medium">{entryKindLabels[entry.kind]}</span>
{superseded.has(entry.id) && ( {superseded.has(entry.id) && (
<span className="text-xs text-muted-foreground">Original</span> <span className="text-xs text-muted-foreground">Original</span>
@@ -112,7 +116,7 @@ export function JournalPage() {
</Button> </Button>
)} )}
</div> </div>
<p className="journal-entry-text"> <p className="my-px-12 text-ui-14 leading-relaxed-copy whitespace-pre-wrap wrap-anywhere">
{entry.text} {entry.text}
{entry.kind === "体重" ? " kg" : ""} {entry.kind === "体重" ? " kg" : ""}
</p> </p>
@@ -151,10 +155,15 @@ export function JournalPage() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select the entries to include. Nothing is sent externally. Select the entries to include. Nothing is sent externally.
</p> </p>
<div className="journal-review-list"> <div className="flex max-h-[220px] flex-col gap-px-16 overflow-auto p-px-4">
{current.map((entry) => ( {current.map((entry) => (
<label key={entry.id} className="journal-review-row" htmlFor={`review-${entry.id}`}> <label
key={entry.id}
className="flex items-start gap-px-12 wrap-anywhere"
htmlFor={`review-${entry.id}`}
>
<Checkbox <Checkbox
className="mt-[4px]"
id={`review-${entry.id}`} id={`review-${entry.id}`}
checked={selected.has(entry.id)} checked={selected.has(entry.id)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
@@ -180,7 +189,8 @@ export function JournalPage() {
</div> </div>
<Textarea <Textarea
aria-label="Entry summary" aria-label="Entry summary"
className="journal-summary" variant="summary"
className="min-h-[180px] max-h-[280px] overflow-y-auto"
readOnly readOnly
value={buildSummary(current.filter((entry) => selected.has(entry.id)))} value={buildSummary(current.filter((entry) => selected.has(entry.id)))}
/> />
@@ -207,16 +217,17 @@ function EntryEditor({
(draft.kind !== "体重" || (Number.isFinite(Number(draft.text)) && Number(draft.text) > 0)); (draft.kind !== "体重" || (Number.isFinite(Number(draft.text)) && Number(draft.text) > 0));
return ( return (
<form <form
className="journal-editor" className="flex flex-col gap-px-16"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (valid) onSubmit(); if (valid) onSubmit();
}} }}
> >
<fieldset className="journal-kind-switch" aria-label="Entry type"> <fieldset className="flex flex-wrap gap-px-4 max-[600px]:gap-0" aria-label="Entry type">
{kinds.map((kind) => ( {kinds.map((kind) => (
<Button <Button
key={kind} key={kind}
className="max-[600px]:px-2"
type="button" type="button"
variant={draft.kind === kind ? "secondary" : "ghost"} variant={draft.kind === kind ? "secondary" : "ghost"}
size="sm" size="sm"
@@ -242,6 +253,7 @@ function EntryEditor({
</div> </div>
) : ( ) : (
<Textarea <Textarea
className="min-h-[112px]"
aria-label="Entry text" aria-label="Entry text"
placeholder={ placeholder={
draft.kind === "服薬" draft.kind === "服薬"
@@ -262,10 +274,14 @@ function EntryEditor({
onChange={(event) => onChange({ ...draft, source: event.target.value })} onChange={(event) => onChange({ ...draft, source: event.target.value })}
/> />
)} )}
<div className="journal-editor-footer"> <div className="flex items-end justify-between gap-px-12 max-[600px]:flex-col max-[600px]:items-stretch">
<label className="journal-date-label" htmlFor={dateId}> <label
className="flex min-w-0 flex-col gap-px-8 text-ui-12 text-muted-foreground"
htmlFor={dateId}
>
When it happened (optional) When it happened (optional)
<Input <Input
className="max-w-full"
id={dateId} id={dateId}
type="datetime-local" type="datetime-local"
aria-label="When it happened" aria-label="When it happened"
@@ -273,7 +289,7 @@ function EntryEditor({
onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })} onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })}
/> />
</label> </label>
<Button type="submit" disabled={!valid}> <Button className="max-[600px]:self-end" type="submit" disabled={!valid}>
{submitLabel} {submitLabel}
</Button> </Button>
</div> </div>
-129
View File
@@ -1,129 +0,0 @@
.journal-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.journal-main {
min-width: 0;
overflow: hidden;
}
.journal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
}
.journal-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.journal-column {
width: min(100%, 760px);
margin: 0 auto;
padding: 40px 28px;
}
.journal-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.journal-kind-switch {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.journal-editor textarea {
min-height: 112px;
}
.journal-editor-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.journal-date-label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: var(--muted-foreground);
min-width: 0;
}
.journal-date-label input {
max-width: 100%;
}
.journal-entries {
margin-top: 36px;
}
.journal-empty {
padding: 40px 0;
color: var(--muted-foreground);
font-size: 14px;
text-align: center;
}
.journal-entry {
border-top: 1px solid var(--border);
padding: 20px 0;
}
.journal-entry-heading {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
}
.journal-entry-text {
margin: 12px 0;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.journal-review-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 220px;
overflow: auto;
padding: 4px;
}
.journal-review-row {
display: flex;
align-items: flex-start;
gap: 12px;
overflow-wrap: anywhere;
}
.journal-review-row [data-slot="checkbox"] {
margin-top: 4px;
}
.journal-summary {
min-height: 180px;
max-height: 280px;
overflow-y: auto;
font-size: 13px;
}
@media (max-width: 600px) {
.journal-header {
padding: 14px;
flex-wrap: wrap;
}
.journal-column {
padding: 24px 14px;
}
.journal-editor-footer {
align-items: stretch;
flex-direction: column;
}
.journal-editor-footer button {
align-self: flex-end;
}
.journal-kind-switch {
gap: 0;
}
.journal-kind-switch button {
padding-inline: 8px;
}
}
@@ -1,10 +0,0 @@
.research-post.citation-highlight {
outline: 2px solid var(--primary);
outline-offset: -2px;
background-color: color-mix(in srgb, var(--primary) 14%, transparent);
}
.research-post:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
+51 -12
View File
@@ -21,7 +21,10 @@ export function ResearchMarkdown({
onNavigateCitation?: (url: string) => boolean; onNavigateCitation?: (url: string) => boolean;
}) { }) {
return ( return (
<div className="research-markdown"> <div
data-testid="research-markdown"
className="min-w-0 wrap-anywhere motion-reduce:[&_[data-sd-animate]]:animate-none motion-reduce:[&_[data-sd-animate-marker]::marker]:animate-none"
>
<Streamdown <Streamdown
mode={isAnimating ? "streaming" : "static"} mode={isAnimating ? "streaming" : "static"}
isAnimating={isAnimating} isAnimating={isAnimating}
@@ -32,17 +35,47 @@ export function ResearchMarkdown({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
skipHtml skipHtml
components={{ components={{
p: ({ children }) => <p className="mt-1.5 mb-0 whitespace-normal">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
li: ({ children }) => <li className="not-first:mt-1">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="my-3 border-l-2 pl-3 text-muted-foreground">
{children}
</blockquote>
),
th: ({ children }) => <th className="border p-1.5 text-left">{children}</th>,
td: ({ children }) => <td className="border p-1.5 text-left">{children}</td>,
strong: ({ children }) => <strong>{children}</strong>, strong: ({ children }) => <strong>{children}</strong>,
pre: ({ children }) => <pre>{children}</pre>, pre: ({ children }) => (
code: ({ children, className }) => <code className={className}>{children}</code>, <pre className="max-w-full overflow-x-auto bg-muted p-3 text-research-label whitespace-pre">
h1: ({ children }) => <h3>{children}</h3>, {children}
h2: ({ children }) => <h4>{children}</h4>, </pre>
h3: ({ children }) => <h5>{children}</h5>, ),
h4: ({ children }) => <h6>{children}</h6>, code: ({ children, className }) => (
h5: ({ children }) => <h6>{children}</h6>, <code className={`wrap-anywhere ${className ?? ""}`}>{children}</code>
h6: ({ children }) => <h6>{children}</h6>, ),
h1: ({ children }) => (
<h3 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h3>
),
h2: ({ children }) => (
<h4 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h4>
),
h3: ({ children }) => (
<h5 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h5>
),
h4: ({ children }) => (
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
),
h5: ({ children }) => (
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
),
h6: ({ children }) => (
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
),
a: ({ href, children, title }) => ( a: ({ href, children, title }) => (
<a <a
className="text-primary underline underline-offset-2"
href={href} href={href}
title={title} title={title}
target="_blank" target="_blank"
@@ -65,7 +98,12 @@ export function ResearchMarkdown({
), ),
img: ({ src, alt }) => img: ({ src, alt }) =>
typeof src === "string" && src ? ( typeof src === "string" && src ? (
<a href={src} target="_blank" rel="noopener noreferrer"> <a
className="text-primary underline underline-offset-2"
href={src}
target="_blank"
rel="noopener noreferrer"
>
{alt || "Open image"} {alt || "Open image"}
</a> </a>
) : ( ) : (
@@ -73,12 +111,13 @@ export function ResearchMarkdown({
), ),
table: ({ children }) => ( table: ({ children }) => (
<section <section
className="research-markdown-table" data-testid="research-markdown-table"
className="my-3 max-w-full overflow-x-auto"
aria-label="Response table" aria-label="Response table"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need to scroll wide tables inside the narrow chat pane. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need to scroll wide tables inside the narrow chat pane.
tabIndex={0} tabIndex={0}
> >
<table>{children}</table> <table className="w-full border-collapse text-research-label">{children}</table>
</section> </section>
), ),
}} }}
-197
View File
@@ -1,197 +0,0 @@
.research-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--background);
overflow: hidden;
}
.research-heading {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.research-heading h2 {
margin: 0;
font-size: 1rem;
}
.research-context {
margin: 0 0 0.75rem;
color: var(--muted-foreground);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.research-history {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.research-history span {
flex-shrink: 0;
}
.research-history > [data-slot="native-select-wrapper"] {
flex: 1;
min-width: 0;
}
.research-panel > p {
padding: 0.5rem 1rem;
margin: 0;
font-size: 0.8rem;
}
.research-conversation {
flex: 1;
min-height: 80px;
}
.research-empty {
color: var(--muted-foreground);
font-size: 0.85rem;
line-height: 1.8;
}
.research-message {
font-size: 0.875rem;
line-height: 1.65;
overflow-wrap: anywhere;
}
.research-message p {
margin: 0.35rem 0 0;
white-space: pre-wrap;
}
.research-tool-message {
color: var(--muted-foreground);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.research-start-form {
flex-shrink: 0;
padding: 0.75rem;
border-top: 1px solid var(--border);
}
.research-start-form > fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
min-width: 0;
}
.research-start-form label {
font-size: 0.8rem;
}
.research-account-picker {
font-size: 0.75rem;
}
.research-accounts {
display: grid;
gap: 0.5rem;
border: 1px solid var(--border);
padding: 0.75rem;
min-width: 0;
max-height: 160px;
overflow: auto;
}
.research-accounts label {
display: flex;
gap: 0.5rem;
align-items: center;
overflow-wrap: anywhere;
}
.research-run {
flex-shrink: 0;
border-top: 1px solid var(--border);
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
max-height: 180px;
overflow-y: auto;
}
.research-run p {
margin: 0 0 0.5rem;
overflow-wrap: anywhere;
}
.research-report-path {
overflow-wrap: anywhere;
font-size: 0.75rem;
}
.research-markdown {
min-width: 0;
overflow-wrap: anywhere;
}
.research-message .research-markdown p {
white-space: normal;
}
.research-markdown h3,
.research-markdown h4,
.research-markdown h5,
.research-markdown h6 {
font-size: 1em;
font-weight: 600;
margin: 1em 0 0.4em;
}
.research-markdown ul,
.research-markdown ol {
padding-left: 1.4rem;
}
.research-markdown ul {
list-style: disc;
}
.research-markdown ol {
list-style: decimal;
}
.research-markdown li + li {
margin-top: 0.25rem;
}
.research-markdown blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 2px solid var(--border);
color: var(--muted-foreground);
}
.research-markdown pre {
max-width: 100%;
overflow-x: auto;
padding: 0.75rem;
background: var(--muted);
white-space: pre;
font-size: 0.75rem;
}
.research-markdown code {
overflow-wrap: anywhere;
}
.research-markdown-table {
overflow-x: auto;
max-width: 100%;
margin: 0.75rem 0;
}
.research-markdown table {
border-collapse: collapse;
width: 100%;
font-size: 0.75rem;
}
.research-markdown th,
.research-markdown td {
border: 1px solid var(--border);
padding: 0.4rem;
text-align: left;
}
.research-markdown a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 0.15em;
}
@media (prefers-reduced-motion: reduce) {
.research-markdown [data-sd-animate],
.research-markdown [data-sd-animate-marker]::marker {
animation: none;
}
}
+16 -1
View File
@@ -801,7 +801,7 @@ it("animates only the latest live assistant response and leaves prior replies st
}; };
const view = show(); const view = show();
expect(await screen.findByText("Earlier answer")).toBeVisible(); expect(await screen.findByText("Earlier answer")).toBeVisible();
const replies = view.container.querySelectorAll(".research-markdown"); const replies = view.container.querySelectorAll('[data-testid="research-markdown"]');
expect(replies).toHaveLength(2); expect(replies).toHaveLength(2);
expect(replies[0]?.querySelector("[data-sd-animate]")).toBeNull(); expect(replies[0]?.querySelector("[data-sd-animate]")).toBeNull();
expect(replies[1]?.querySelector("[data-sd-animate]")).not.toBeNull(); expect(replies[1]?.querySelector("[data-sd-animate]")).not.toBeNull();
@@ -815,3 +815,18 @@ it("animates only the latest live assistant response and leaves prior replies st
); );
await waitFor(() => expect(view.container.querySelector("[data-sd-animate]")).toBeNull()); await waitFor(() => expect(view.container.querySelector("[data-sd-animate]")).toBeNull());
}); });
it("submits with Enter while preserving Shift+Enter and IME composition", async () => {
show();
const input = await screen.findByLabelText("Message");
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: "Compare browser automation tools" } });
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(actions.start).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(1));
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({ topic: "Compare browser automation tools" }),
});
});
+154 -76
View File
@@ -1,8 +1,10 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start"; import { useServerFn } from "@tanstack/react-start";
import { ArrowUpIcon, SquareIcon } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { Bubble, BubbleContent } from "#/components/ui/bubble"; import { Bubble, BubbleContent } from "#/components/ui/bubble";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty";
import { Checkbox } from "#/components/ui/checkbox"; import { Checkbox } from "#/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
import { import {
@@ -12,7 +14,7 @@ import {
InputGroupTextarea, InputGroupTextarea,
} from "#/components/ui/input-group"; } from "#/components/ui/input-group";
import { Marker, MarkerContent } from "#/components/ui/marker"; import { Marker, MarkerContent } from "#/components/ui/marker";
import { Message, MessageContent, MessageHeader } from "#/components/ui/message"; import { Message, MessageContent } from "#/components/ui/message";
import { import {
MessageScroller, MessageScroller,
MessageScrollerButton, MessageScrollerButton,
@@ -291,15 +293,26 @@ export function ResearchPanel({
} }
const renderPanel: RenderResearchPanel = (onCitationNavigated) => ( const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="Research chat"> <aside
<header className="research-heading"> data-testid="research-panel"
<h2>Research chat</h2> className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background"
aria-label="Research chat"
>
<header
data-testid="research-heading"
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b p-4"
>
<h2 className="m-0 text-base">Research chat</h2>
<Button variant="ghost" size="sm" type="button" disabled={busy || !run} onClick={newChat}> <Button variant="ghost" size="sm" type="button" disabled={busy || !run} onClick={newChat}>
New chat New chat
</Button> </Button>
{(status.data?.history?.length ?? 0) > 0 && ( {(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history" htmlFor={historyId}> <label
<span>Chat history</span> data-testid="research-history"
className="flex w-full min-w-0 items-center gap-2 text-research-label text-muted-foreground [&>[data-slot=native-select-wrapper]]:min-w-0 [&>[data-slot=native-select-wrapper]]:flex-1"
htmlFor={historyId}
>
<span className="shrink-0">Chat history</span>
<NativeSelect <NativeSelect
id={historyId} id={historyId}
size="sm" size="sm"
@@ -320,12 +333,20 @@ export function ResearchPanel({
)} )}
</header> </header>
{reconnecting && ( {reconnecting && (
<p className="deck-save-status" role="status"> <p
data-testid="deck-save-status"
className="m-0 px-4 py-2 text-research-field leading-normal text-muted-foreground"
role="status"
>
Reconnecting to research updates. Processing continues on your home server. Reconnecting to research updates. Processing continues on your home server.
</p> </p>
)} )}
{status.isError && ( {status.isError && (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
role="alert"
>
Unable to load research status. Unable to load research status.
<Button type="button" onClick={() => void status.refetch()}> <Button type="button" onClick={() => void status.refetch()}>
Retry Retry
@@ -333,19 +354,31 @@ export function ResearchPanel({
</p> </p>
)} )}
{selectedId && status.isSuccess && !run && ( {selectedId && status.isSuccess && !run && (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
role="alert"
>
This conversation could not be found. This conversation could not be found.
</p> </p>
)} )}
{error && ( {error && (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
role="alert"
>
{error} {error}
</p> </p>
)} )}
{status.isPending ? ( {status.isPending ? (
<p role="status">Checking the research connection…</p> <p className="m-0 px-4 py-2 text-research-field" role="status">
Checking the research connection…
</p>
) : !status.data?.configured ? ( ) : !status.data?.configured ? (
<p role="status">The connection to Codex on your home server is not configured.</p> <p className="m-0 px-4 py-2 text-research-field" role="status">
The connection to Codex on your home server is not configured.
</p>
) : null} ) : null}
<ResearchConversation <ResearchConversation
key={`conversation:${run?.id ?? "new"}`} key={`conversation:${run?.id ?? "new"}`}
@@ -358,14 +391,7 @@ export function ResearchPanel({
return navigated; return navigated;
}} }}
/> />
{run && ( {run && <ResearchRunStatus run={run} onOpenDeck={(deck) => void openDeck(deck)} />}
<ResearchRunStatus
run={run}
busy={busy}
onCancel={() => void stop()}
onOpenDeck={(deck) => void openDeck(deck)}
/>
)}
<ResearchComposer <ResearchComposer
key={run?.id ?? "new"} key={run?.id ?? "new"}
topic={draft.topic} topic={draft.topic}
@@ -378,6 +404,9 @@ export function ResearchPanel({
!ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured !ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured
} }
onSend={begin} onSend={begin}
active={isResearchActive(run)}
busy={busy}
onStop={() => void stop()}
/> />
</aside> </aside>
); );
@@ -398,15 +427,20 @@ function ResearchConversation({
const names = { user: "You", assistant: "Codex", tool: "Tool activity" }; const names = { user: "You", assistant: "Codex", tool: "Tool activity" };
return ( return (
<MessageScrollerProvider autoScroll> <MessageScrollerProvider autoScroll>
<MessageScroller className="research-conversation"> <MessageScroller data-testid="research-conversation" className="min-h-[80px] flex-1">
<MessageScrollerViewport aria-label="Research messages"> <MessageScrollerViewport aria-label="Research messages">
<MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4"> <MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4">
{messages.length === 0 ? ( {messages.length === 0 ? (
<MessageScrollerItem messageId="empty"> <MessageScrollerItem messageId="empty">
<p className="research-empty"> <Empty data-testid="research-empty">
Send a topic or question. The assistant adds columns as it searches and saves a <EmptyHeader>
report to your home server. <EmptyTitle>What would you like to explore?</EmptyTitle>
</p> <EmptyDescription>
Send a topic or question. Codex adds columns as it searches and saves a report
to your home server.
</EmptyDescription>
</EmptyHeader>
</Empty>
</MessageScrollerItem> </MessageScrollerItem>
) : ( ) : (
messages.map((message, index) => ( messages.map((message, index) => (
@@ -416,7 +450,10 @@ function ResearchConversation({
scrollAnchor={message.role === "user"} scrollAnchor={message.role === "user"}
> >
{message.role === "tool" ? ( {message.role === "tool" ? (
<Marker className="research-tool-message"> <Marker
data-testid="research-tool-message"
className="text-research-label whitespace-pre-wrap text-muted-foreground wrap-anywhere"
>
<MarkerContent> <MarkerContent>
<span className="sr-only">{names.tool}: </span> <span className="sr-only">{names.tool}: </span>
{message.text} {message.text}
@@ -425,10 +462,11 @@ function ResearchConversation({
) : ( ) : (
<Message <Message
align={message.role === "user" ? "end" : "start"} align={message.role === "user" ? "end" : "start"}
className="research-message" data-testid="research-message"
className="wrap-anywhere"
> >
<MessageContent> <MessageContent>
<MessageHeader>{names[message.role]}</MessageHeader> <span className="sr-only">{names[message.role]}</span>
{message.role === "assistant" ? ( {message.role === "assistant" ? (
<ResearchMarkdown <ResearchMarkdown
text={message.text} text={message.text}
@@ -438,9 +476,9 @@ function ResearchConversation({
} }
/> />
) : ( ) : (
<Bubble align="end"> <Bubble align="end" variant="muted">
<BubbleContent> <BubbleContent>
<p>{message.text}</p> <p className="m-0 whitespace-pre-wrap">{message.text}</p>
</BubbleContent> </BubbleContent>
</Bubble> </Bubble>
)} )}
@@ -467,6 +505,9 @@ function ResearchComposer({
contextTitle, contextTitle,
disabled, disabled,
onSend, onSend,
active,
busy,
onStop,
}: { }: {
topic: string; topic: string;
selected: string[] | null; selected: string[] | null;
@@ -476,6 +517,9 @@ function ResearchComposer({
contextTitle: string; contextTitle: string;
disabled: boolean; disabled: boolean;
onSend: (input: ResearchInput) => Promise<boolean>; onSend: (input: ResearchInput) => Promise<boolean>;
active: boolean;
busy: boolean;
onStop: () => void;
}) { }) {
const available = connections.filter((connection) => connection.status === "connected"); const available = connections.filter((connection) => connection.status === "connected");
const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id); const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id);
@@ -485,7 +529,8 @@ function ResearchComposer({
); );
return ( return (
<form <form
className="research-start-form" data-testid="research-start-form"
className="shrink-0 p-3"
aria-label="Research messages" aria-label="Research messages"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
@@ -498,22 +543,72 @@ function ResearchComposer({
}); });
}} }}
> >
<p className="research-context"> <Collapsible data-testid="research-account-picker" disabled={disabled}>
Deck to send: <strong>{contextTitle}</strong> <label className="sr-only" htmlFor={id}>
</p> Message
<fieldset disabled={disabled}> </label>
<Collapsible className="research-account-picker" disabled={disabled}> <InputGroup>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}> <InputGroupTextarea
id={id}
value={topic}
onChange={(event) => onTopicChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
maxLength={1000}
rows={3}
placeholder="Ask about this deck…"
required
disabled={disabled}
/>
<InputGroupAddon align="block-end">
<CollapsibleTrigger render={<InputGroupButton size="sm" />}>
Connected accounts · {validSelection.length} Connected accounts · {validSelection.length}
</CollapsibleTrigger> </CollapsibleTrigger>
{active ? (
<InputGroupButton
type="button"
size="icon-sm"
variant="outline"
className="ml-auto"
aria-label="Stop research"
disabled={busy}
onClick={onStop}
>
<SquareIcon />
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
className="ml-auto"
aria-label="Send"
disabled={disabled || !topic.trim() || validSelection.length === 0}
>
<ArrowUpIcon />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
<CollapsibleContent> <CollapsibleContent>
<fieldset className="research-accounts"> <fieldset
data-testid="research-accounts"
className="grid max-h-[160px] min-w-0 gap-2 overflow-auto border p-3"
>
<legend>Accounts for research (up to 6)</legend> <legend>Accounts for research (up to 6)</legend>
{available.length === 0 ? ( {available.length === 0 ? (
<p>Add a connected account first.</p> <p>Add a connected account first.</p>
) : ( ) : (
available.map((connection) => ( available.map((connection) => (
<label key={connection.id} htmlFor={`${id}-${connection.id}`}> <label
className="flex items-center gap-2 text-research-field wrap-anywhere"
key={connection.id}
htmlFor={`${id}-${connection.id}`}
>
<Checkbox <Checkbox
id={`${id}-${connection.id}`} id={`${id}-${connection.id}`}
checked={selectedIds.includes(connection.id)} checked={selectedIds.includes(connection.id)}
@@ -537,69 +632,52 @@ function ResearchComposer({
</fieldset> </fieldset>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<label htmlFor={id}>Message</label> <p
<InputGroup> data-testid="research-context"
<InputGroupTextarea className="mt-2 mb-0 text-research-label text-muted-foreground wrap-anywhere"
id={id}
value={topic}
onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000}
rows={3}
placeholder="e.g. Compare reactions to WebMCP, then refine this deck."
required
disabled={disabled}
/>
<InputGroupAddon align="block-end" className="justify-end">
<InputGroupButton
type="submit"
variant="default"
size="sm"
disabled={disabled || !topic.trim() || validSelection.length === 0}
> >
Send Deck to send: <strong>{contextTitle}</strong>
</InputGroupButton> </p>
</InputGroupAddon>
</InputGroup>
</fieldset>
</form> </form>
); );
} }
function ResearchRunStatus({ function ResearchRunStatus({
run, run,
busy,
onCancel,
onOpenDeck, onOpenDeck,
}: { }: {
run: ResearchRun; run: ResearchRun;
busy: boolean;
onCancel: () => void;
onOpenDeck: (deck: Deck) => void; onOpenDeck: (deck: Deck) => void;
}) { }) {
const deck = run.deck ?? run.contextDeck; const deck = run.deck ?? run.contextDeck;
return ( return (
<section className="research-run" aria-label="Research status"> <section
data-testid="research-run"
className="max-h-[180px] shrink-0 overflow-y-auto border-t px-3 py-2 text-research-label"
aria-label="Research status"
>
<Marker role="status"> <Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent> <MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker> </Marker>
{run.error && ( {run.error && (
<p className="deck-error" role="alert"> <p
data-testid="deck-error"
className="mt-0 mb-2 text-sm leading-body text-destructive wrap-anywhere"
role="alert"
>
{run.error} {run.error}
</p> </p>
)} )}
{run.reportPath && ( {run.reportPath && (
<p> <p className="mt-0 mb-2 wrap-anywhere">
Markdown location Markdown location
<br /> <br />
<code className="research-report-path">{run.reportPath}</code> <code data-testid="research-report-path" className="text-research-label wrap-anywhere">
{run.reportPath}
</code>
</p> </p>
)} )}
<div className="deck-actions"> <div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
{isResearchActive(run) && (
<Button variant="outline" size="sm" type="button" disabled={busy} onClick={onCancel}>
Stop research
</Button>
)}
{deck && ( {deck && (
<Button <Button
variant="outline" variant="outline"
+148 -72
View File
@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react"; import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
import { Bubble, BubbleContent } from "#/components/ui/bubble";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { import {
Dialog, Dialog,
@@ -11,7 +13,22 @@ import {
DialogTitle, DialogTitle,
} from "#/components/ui/dialog"; } from "#/components/ui/dialog";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { Textarea } from "#/components/ui/textarea"; import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from "#/components/ui/input-group";
import { Message, MessageContent, MessageHeader } from "#/components/ui/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "#/components/ui/message-scroller";
import type { Conversation } from "./mock-conversations"; import type { Conversation } from "./mock-conversations";
import { reviewReply } from "./server-functions"; import { reviewReply } from "./server-functions";
@@ -39,41 +56,71 @@ export function ConversationDetail({
}); });
return ( return (
<article <article
className="conversation-workbench" className="grid min-h-[calc(100dvh-64px)] min-w-0 grid-cols-1 @min-[780px]:h-[calc(100dvh-64px)] @min-[780px]:min-h-0 @min-[780px]:grid-cols-[minmax(0,1fr)_250px]"
aria-label={`Conversation with ${conversation.name}`} aria-label={`Conversation with ${conversation.name}`}
> >
<div className="conversation-thread"> <div className="flex min-w-0 flex-col @min-[780px]:min-h-0">
<section className="conversation-messages" aria-label="Original messages"> <section
className="flex h-[45dvh] min-h-[220px] flex-col @min-[780px]:h-auto @min-[780px]:min-h-0 @min-[780px]:flex-1"
aria-label="Original messages"
>
<MessageScrollerProvider>
<MessageScroller className="flex-1">
<MessageScrollerViewport>
<MessageScrollerContent className="mx-auto w-full max-w-2xl gap-5 p-4">
{conversation.messages.map((message) => ( {conversation.messages.map((message) => (
<div <MessageScrollerItem key={message.id} messageId={message.id}>
key={message.id} <Message
id={`${instanceId}-source-${message.id}`} id={`${instanceId}-source-${message.id}`}
className="conversation-message" align={message.direction === "out" ? "end" : "start"}
data-direction={message.direction}
tabIndex={-1} tabIndex={-1}
> >
<p className="conversation-message-meta"> <MessageContent>
<MessageHeader>
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"} {message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
<span> <span className="ml-2 font-normal">
{message.channel} · {message.time} {message.channel} · {message.time}
</span> </span>
</p> </MessageHeader>
<div className="conversation-bubble">{message.text}</div> <Bubble
</div> align={message.direction === "out" ? "end" : "start"}
variant={message.direction === "out" ? "muted" : "outline"}
>
<BubbleContent>
<p className="whitespace-pre-wrap">{message.text}</p>
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))} ))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</section> </section>
<section aria-label="Compose a reply" className="conversation-compose"> <section
<div className="conversation-compose-heading"> aria-label="Compose a reply"
<label htmlFor={`${instanceId}-reply`}>Reply</label> className="mx-auto grid w-full max-w-2xl auto-rows-max content-start gap-3 p-4 @min-[780px]:max-h-[65%] @min-[780px]:shrink-0 @min-[780px]:overflow-y-auto"
<span>To {conversation.name.split(" ")[0]}</span> >
<div className="flex items-center justify-between text-ui-12">
<label className="font-book" htmlFor={`${instanceId}-reply`}>
Reply
</label>
<span className="text-ui-11 text-muted-foreground">
To {conversation.name.split(" ")[0]}
</span>
</div> </div>
<details className="conversation-suggestions"> <details data-testid="conversation-suggestions">
<summary> <summary className="flex cursor-pointer list-none items-center gap-px-6 text-ui-11 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4">
Reply suggestions <ChevronDown aria-hidden="true" /> Reply suggestions <ChevronDown className="size-[12px]" aria-hidden="true" />
</summary> </summary>
<div className="conversation-draft-options" aria-label="Reply suggestions"> <div className="grid gap-px-6 pt-px-10" aria-label="Reply suggestions">
{conversation.drafts.map((option) => ( {conversation.drafts.map((option) => (
<button <Button
variant={tone === option.label ? "secondary" : "outline"}
className="h-auto w-full justify-start"
key={option.label} key={option.label}
type="button" type="button"
aria-pressed={tone === option.label} aria-pressed={tone === option.label}
@@ -82,14 +129,17 @@ export function ConversationDetail({
setTone(option.label); setTone(option.label);
}} }}
> >
<span>{option.label}</span> <span className="block text-left whitespace-normal">
<p>{option.text}</p> <span className="block text-xs text-muted-foreground">{option.label}</span>
</button> <span className="mt-1 block font-normal leading-relaxed">{option.text}</span>
</span>
</Button>
))} ))}
</div> </div>
</details> </details>
<div className="conversation-editor"> <InputGroup>
<Textarea <InputGroupTextarea
className="min-h-[100px] max-h-[240px]"
id={`${instanceId}-reply`} id={`${instanceId}-reply`}
value={draft} value={draft}
onChange={(event) => { onChange={(event) => {
@@ -97,22 +147,28 @@ export function ConversationDetail({
setTone(""); setTone("");
}} }}
/> />
<div className="conversation-compose-footer"> <InputGroupAddon align="block-end">
<span>Draft · {conversation.replyChannel}</span> <InputGroupText>Draft · {conversation.replyChannel}</InputGroupText>
<Button size="sm" disabled={!draft.trim()} onClick={() => setReview("send")}> <InputGroupButton
className="ml-auto"
size="sm"
variant="default"
disabled={!draft.trim()}
onClick={() => setReview("send")}
>
<Send /> <Send />
Review reply Review reply
</Button> </InputGroupButton>
</div> </InputGroupAddon>
</div> </InputGroup>
{conversation.scheduleOptions.length > 0 && ( {conversation.scheduleOptions.length > 0 && (
<details className="conversation-scheduling"> <details data-testid="conversation-scheduling">
<summary> <summary className="flex cursor-pointer list-none items-center gap-px-7 text-ui-11 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2">
<CalendarPlus aria-hidden="true" /> <CalendarPlus className="size-[13px]" aria-hidden="true" />
Suggest a time Suggest a time
<ChevronDown aria-hidden="true" /> <ChevronDown className="size-[13px]" aria-hidden="true" />
</summary> </summary>
<div> <div className="mt-px-10 flex flex-wrap gap-px-6">
<NativeSelect <NativeSelect
aria-label="Suggested times" aria-label="Suggested times"
value={slot} value={slot}
@@ -143,7 +199,7 @@ export function ConversationDetail({
aria-label="Preview calendar hold" aria-label="Preview calendar hold"
onClick={() => setReview("schedule")} onClick={() => setReview("schedule")}
> >
<CalendarPlus /> <CalendarPlus className="size-[13px]" />
</Button> </Button>
</div> </div>
</details> </details>
@@ -154,7 +210,7 @@ export function ConversationDetail({
{scheduled} held in this preview {scheduled} held in this preview
</p> </p>
)} )}
<div className="conversation-triage"> <div className="mt-px-2 flex flex-wrap gap-px-4">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -172,47 +228,59 @@ export function ConversationDetail({
</div> </div>
</section> </section>
</div> </div>
<aside className="conversation-context" aria-label="Person and conversation context"> <aside
<details open className="conversation-context-details"> className="min-w-0 border-t border-border bg-sidebar p-px-22 @min-[780px]:overflow-y-auto @min-[780px]:border-t-0 @min-[780px]:border-l"
<summary> aria-label="Person and conversation context"
>
<details open>
<summary className="flex cursor-pointer list-none items-center justify-between text-ui-11 font-medium focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2">
Context Context
<ChevronDown aria-hidden="true" /> <ChevronDown className="size-3.5" aria-hidden="true" />
</summary> </summary>
<div className="conversation-person"> <div className="grid justify-items-start py-px-20">
<span className="support-avatar" aria-hidden="true"> <Avatar size="lg" aria-hidden="true">
<AvatarFallback>
{conversation.name {conversation.name
.split(" ") .split(" ")
.map((part) => part[0]) .map((part) => part[0])
.join("")} .join("")}
</span> </AvatarFallback>
<h3>{conversation.name}</h3> </Avatar>
<p>{conversation.role}</p> <h3 className="mt-px-12 text-ui-13 font-book">{conversation.name}</h3>
<p className="mt-px-4 text-ui-11 text-muted-foreground">{conversation.role}</p>
</div> </div>
<header className="conversation-summary"> <header className="border-t border-border py-px-20">
<span className="conversation-eyebrow">Summary</span> <span className="text-ui-10 text-muted-foreground">Summary</span>
<h2>{conversation.subject}</h2> <h2 className="mt-px-7 text-ui-12 font-book leading-normal tracking-subtitle">
<p>{conversation.summary}</p> {conversation.subject}
</h2>
<p className="mt-px-8 max-w-[64ch] text-ui-12 leading-relaxed-copy text-muted-foreground">
{conversation.summary}
</p>
</header> </header>
<section className="conversation-next"> <section className="border-y border-border py-px-20">
<h3> <h3 className="flex items-center gap-px-6 text-ui-11 font-book">
<CornerDownRight aria-hidden="true" /> <CornerDownRight className="size-[13px]" aria-hidden="true" />
Next step Next step
</h3> </h3>
<p>{conversation.nextStep}</p> <p className="mt-px-12 text-ui-12 leading-relaxed-copy">{conversation.nextStep}</p>
{conversation.deadline && ( {conversation.deadline && (
<div className="conversation-deadline"> <div className="mt-px-18 grid gap-px-5 text-ui-11">
<span>Requested by</span> <span className="text-muted-foreground">Requested by</span>
<strong>{conversation.deadline}</strong> <strong className="font-medium">{conversation.deadline}</strong>
</div> </div>
)} )}
</section> </section>
<section className="conversation-facts"> <section className="pt-px-22">
<h3>From the conversation</h3> <h3 className="flex items-center gap-px-6 text-ui-11 font-book">
<ul> From the conversation
</h3>
<ul className="grid gap-px-18 pt-px-16">
{conversation.facts.map((fact) => ( {conversation.facts.map((fact) => (
<li key={fact.sourceId}> <li key={fact.sourceId}>
<p>{fact.text}</p> <p className="text-ui-11 leading-relaxed-copy">{fact.text}</p>
<a <a
className="mt-px-5 inline-block text-ui-10 text-muted-foreground underline underline-offset-3"
href={`#${instanceId}-source-${fact.sourceId}`} href={`#${instanceId}-source-${fact.sourceId}`}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@@ -262,27 +330,35 @@ export function ConversationDetail({
</p> </p>
</div> </div>
{review === "send" && ( {review === "send" && (
<section className="conversation-evaluation" aria-label="Jev review" aria-live="polite"> <section
<h3>Jev review</h3> className="border-t border-border pt-px-16 text-ui-12"
aria-label="Jev review"
aria-live="polite"
>
<h3 className="mb-px-12 font-semibold">Jev review</h3>
{evaluation.isPending ? ( {evaluation.isPending ? (
<p role="status">Checking this reply against the conversation…</p> <p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground" role="status">
Checking this reply against the conversation…
</p>
) : evaluation.data?.status === "reviewed" ? ( ) : evaluation.data?.status === "reviewed" ? (
<> <>
<ul> <ul className="grid gap-px-10">
{evaluation.data.checks.map((check) => ( {evaluation.data.checks.map((check) => (
<li key={check.id}> <li className="flex justify-between gap-px-16" key={check.id}>
<span>{check.label}</span> <span>{check.label}</span>
<strong>{Math.round(check.probability * 100)}%</strong> <strong className="font-medium tabular-nums">
{Math.round(check.probability * 100)}%
</strong>
</li> </li>
))} ))}
</ul> </ul>
<p> <p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground">
Estimated likelihood that each criterion is met. Review the wording yourself. Estimated likelihood that each criterion is met. Review the wording yourself.
</p> </p>
</> </>
) : ( ) : (
<div> <div>
<p> <p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground">
{evaluation.data?.status === "unavailable" {evaluation.data?.status === "unavailable"
? evaluation.data.message ? evaluation.data.message
: "Review unavailable. Please try again."} : "Review unavailable. Please try again."}
+61 -27
View File
@@ -1,5 +1,6 @@
import { ArrowLeft, MessageSquare, Search } from "lucide-react"; import { ArrowLeft, MessageSquare, Search } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input"; import { Input } from "#/components/ui/input";
import { import {
@@ -61,14 +62,21 @@ export function SupportPage() {
} }
return ( return (
<TooltipProvider> <TooltipProvider>
<SidebarProvider className="support-workspace" data-reading={!!selectedId}> <SidebarProvider
<Sidebar collapsible="none" className="support-sidebar"> data-testid="support-workspace"
className="group/support h-dvh min-h-0 overflow-hidden"
data-reading={!!selectedId}
>
<Sidebar
collapsible="none"
className="w-[370px] shrink-0 flex-row border-r border-border max-md:w-full max-md:group-data-[reading=true]/support:hidden"
>
<WorkspaceNavigation /> <WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list"> <Sidebar collapsible="none" className="w-auto min-w-0 flex-1">
<SidebarHeader className="p-5 gap-4"> <SidebarHeader className="p-5 gap-4">
<div className="support-list-title"> <div className="flex items-center gap-px-10">
<h1>Messages</h1> <h1 className="text-ui-19 font-semibold tracking-title">Messages</h1>
<span>{visible.length}</span> <span className="text-ui-12 text-muted-foreground">{visible.length}</span>
</div> </div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
@@ -82,9 +90,10 @@ export function SupportPage() {
onCompositionEnd={searchState.onCompositionEnd} onCompositionEnd={searchState.onCompositionEnd}
/> />
</div> </div>
<nav className="support-filters" aria-label="Conversation filters"> <nav className="flex flex-wrap gap-px-4" aria-label="Conversation filters">
{views.map((tab) => ( {views.map((tab) => (
<button <button
className="cursor-pointer rounded-ui-6 px-px-9 py-px-6 text-ui-11 whitespace-nowrap text-muted-foreground aria-pressed:bg-sidebar-accent aria-pressed:text-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2"
type="button" type="button"
key={tab.id} key={tab.id}
aria-pressed={view === tab.id} aria-pressed={view === tab.id}
@@ -105,24 +114,34 @@ export function SupportPage() {
{visible.map((item) => ( {visible.map((item) => (
<button <button
type="button" type="button"
className="support-row" data-testid="support-row"
aria-label={`${item.name}: ${item.subject}`}
className="mx-px-8 my-px-2 flex cursor-pointer gap-px-12 rounded-ui-10 px-px-14 py-px-12 text-left leading-normal hover:bg-sidebar-accent aria-current:bg-sidebar-accent active:bg-muted focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2"
aria-current={selected?.id === item.id ? "true" : undefined} aria-current={selected?.id === item.id ? "true" : undefined}
key={item.id} key={item.id}
onClick={() => updateSearch({ conversation: item.id })} onClick={() => updateSearch({ conversation: item.id })}
> >
<span className="support-avatar" aria-hidden="true"> <Avatar aria-hidden="true">
<AvatarFallback>
{item.name {item.name
.split(" ") .split(" ")
.map((part) => part[0]) .map((part) => part[0])
.join("")} .join("")}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col gap-px-3">
<span className="flex items-center justify-between gap-px-8">
<strong className="text-ui-12 font-book">{item.name}</strong>
<span className="text-ui-10 whitespace-nowrap text-muted-foreground">
{item.updatedAt}
</span> </span>
<span className="support-row-content">
<span className="support-row-heading">
<strong>{item.name}</strong>
<span>{item.updatedAt}</span>
</span> </span>
<span className="support-row-preview">{item.subject}</span> <span className="truncate text-ui-12 text-muted-foreground">
<span className="support-row-channel">{item.channels.join(" · ")}</span> {item.subject}
</span>
<span className="text-ui-10 text-muted-foreground">
{item.channels.join(" · ")}
</span>
</span> </span>
</button> </button>
))} ))}
@@ -132,17 +151,20 @@ export function SupportPage() {
</p> </p>
)} )}
</SidebarContent> </SidebarContent>
<div className="support-list-footer" role="status"> <div className="px-px-20 py-px-16 text-ui-11 text-muted-foreground" role="status">
{notice || "Sample conversations"} {notice || "Sample conversations"}
</div> </div>
</Sidebar> </Sidebar>
</Sidebar> </Sidebar>
<SidebarInset className="support-main"> <SidebarInset className="h-full min-w-0 overflow-hidden max-md:hidden max-md:group-data-[reading=true]/support:flex">
<header className="support-toolbar"> <header
data-testid="support-toolbar"
className="flex min-h-[64px] items-center gap-px-10 border-b border-border px-px-20 py-px-12 max-md:px-px-12"
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="support-back" className="hidden max-md:inline-flex"
aria-label="Back to messages" aria-label="Back to messages"
onClick={() => updateSearch({ conversation: undefined })} onClick={() => updateSearch({ conversation: undefined })}
> >
@@ -150,15 +172,19 @@ export function SupportPage() {
</Button> </Button>
{selected && ( {selected && (
<> <>
<span className="support-avatar support-avatar-small" aria-hidden="true"> <Avatar aria-hidden="true">
<AvatarFallback>
{selected.name {selected.name
.split(" ") .split(" ")
.map((part) => part[0]) .map((part) => part[0])
.join("")} .join("")}
</span> </AvatarFallback>
</Avatar>
<div> <div>
<strong>{selected.name}</strong> <strong className="text-ui-13 font-book">{selected.name}</strong>
<p>{selected.channels.join(" · ")}</p> <p className="mt-px-2 text-ui-10 text-muted-foreground">
{selected.channels.join(" · ")}
</p>
</div> </div>
</> </>
)} )}
@@ -177,7 +203,10 @@ export function SupportPage() {
</div> </div>
)} )}
</header> </header>
<section className="support-detail" aria-label="Message details"> <section
className="@container min-h-0 flex-1 overflow-y-auto wrap-anywhere"
aria-label="Message details"
>
{conversations.map((conversation) => ( {conversations.map((conversation) => (
<div key={conversation.id} hidden={selected?.id !== conversation.id}> <div key={conversation.id} hidden={selected?.id !== conversation.id}>
<ConversationDetail <ConversationDetail
@@ -185,15 +214,20 @@ export function SupportPage() {
onResolve={(status, note) => resolve(conversation.id, status, note)} onResolve={(status, note) => resolve(conversation.id, status, note)}
/> />
{outcomes[conversation.id] && ( {outcomes[conversation.id] && (
<div className="support-outcome"> <div
data-testid="support-outcome"
className="border-t border-border p-px-24 text-ui-12"
>
<h2>Reply preview</h2> <h2>Reply preview</h2>
<p>{outcomes[conversation.id]}</p> <p className="mt-px-8 whitespace-pre-wrap text-muted-foreground">
{outcomes[conversation.id]}
</p>
</div> </div>
)} )}
</div> </div>
))} ))}
{!selected && ( {!selected && (
<div className="support-empty"> <div className="grid justify-items-center gap-px-12 px-px-24 py-px-64 text-center text-ui-13 text-muted-foreground">
<MessageSquare aria-hidden="true" /> <MessageSquare aria-hidden="true" />
<p>Select a conversation</p> <p>Select a conversation</p>
</div> </div>
-573
View File
@@ -1,573 +0,0 @@
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 · existing neutral tokens */
.support-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.support-sidebar {
width: 370px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.support-list {
flex: 1;
min-width: 0;
width: auto;
}
.support-list-title {
display: flex;
align-items: center;
gap: 10px;
}
.support-list-title h1 {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.04em;
}
.support-list-title > span {
font-size: 12px;
color: var(--muted-foreground);
}
.support-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.support-filters button {
cursor: pointer;
padding: 6px 9px;
font-size: 11px;
color: var(--muted-foreground);
border-radius: 6px;
white-space: nowrap;
}
.support-filters button[aria-pressed="true"] {
background: var(--sidebar-accent);
color: var(--foreground);
}
.support-filters button:hover {
background: var(--muted);
color: var(--foreground);
}
.support-row {
display: flex;
gap: 12px;
padding: 12px 14px;
margin: 2px 8px;
border-radius: 10px;
text-align: left;
cursor: pointer;
line-height: 1.5;
}
.support-row:hover {
background: var(--sidebar-accent);
}
.support-row[aria-current="true"] {
background: var(--sidebar-accent);
}
.support-row:focus-visible,
.support-filters button:focus-visible,
.conversation-draft-options button:focus-visible,
.conversation-scheduling summary:focus-visible,
.conversation-context summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.support-row:active {
background: var(--muted);
}
.support-avatar {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--muted);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
}
.support-avatar-small {
width: 32px;
height: 32px;
font-size: 10px;
}
.support-row-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.support-row-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.support-row-heading strong {
font-size: 12px;
font-weight: 550;
}
.support-row-heading > span {
font-size: 10px;
color: var(--muted-foreground);
white-space: nowrap;
}
.support-row-preview {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.support-row-channel {
font-size: 10px;
color: var(--muted-foreground);
}
.support-list-footer {
padding: 16px 20px;
font-size: 11px;
color: var(--muted-foreground);
}
.support-main {
min-width: 0;
height: 100%;
overflow: hidden;
}
.support-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 64px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.support-toolbar strong {
font-size: 13px;
font-weight: 550;
}
.support-toolbar p {
font-size: 10px;
color: var(--muted-foreground);
margin-top: 2px;
}
.support-detail {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-wrap: anywhere;
container-type: inline-size;
}
.support-outcome {
padding: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
}
.support-outcome p {
white-space: pre-wrap;
margin-top: 8px;
color: var(--muted-foreground);
}
.support-empty {
padding: 64px 24px;
color: var(--muted-foreground);
text-align: center;
font-size: 13px;
display: grid;
justify-items: center;
gap: 12px;
}
.support-back {
display: none;
}
.conversation-workbench {
min-height: calc(100dvh - 64px);
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
}
.conversation-thread {
min-width: 0;
display: flex;
flex-direction: column;
}
.conversation-summary {
padding: 20px 0;
border-top: 1px solid var(--border);
}
.conversation-eyebrow {
color: var(--muted-foreground);
font-size: 10px;
}
.conversation-summary h2 {
margin-top: 7px;
font-size: 12px;
line-height: 1.5;
font-weight: 550;
letter-spacing: -0.015em;
}
.conversation-summary > p {
margin-top: 8px;
font-size: 12px;
line-height: 1.8;
color: var(--muted-foreground);
max-width: 64ch;
}
.conversation-messages {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.conversation-message {
max-width: 90%;
border-radius: 12px;
scroll-margin: 24px;
}
.conversation-message:focus {
outline: 2px solid var(--ring);
outline-offset: 5px;
}
.conversation-message[data-direction="out"] {
align-self: flex-end;
}
.conversation-message-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 10px;
margin-bottom: 7px;
color: var(--muted-foreground);
}
.conversation-message-meta span {
opacity: 0.8;
}
.conversation-bubble {
padding: 10px 14px;
border-radius: 3px 14px 14px;
background: var(--muted);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.conversation-message[data-direction="out"] .conversation-bubble {
background: var(--accent);
border-radius: 14px 3px 14px 14px;
}
.conversation-compose {
padding: 12px 24px 16px;
background: var(--background);
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
}
.conversation-compose-heading {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.conversation-compose-heading label {
font-weight: 550;
}
.conversation-compose-heading span {
font-size: 11px;
color: var(--muted-foreground);
}
.conversation-suggestions > summary {
display: flex;
align-items: center;
gap: 6px;
list-style: none;
cursor: pointer;
color: var(--muted-foreground);
font-size: 11px;
}
.conversation-suggestions > summary svg {
width: 12px;
height: 12px;
}
.conversation-suggestions > summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
}
.conversation-draft-options {
display: grid;
gap: 6px;
padding-top: 10px;
}
.conversation-draft-options button {
text-align: left;
cursor: pointer;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
}
.conversation-draft-options button > span {
font-size: 10px;
color: var(--muted-foreground);
}
.conversation-draft-options button p {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
}
.conversation-draft-options button:hover {
background: var(--muted);
}
.conversation-draft-options button[aria-pressed="true"] {
border-color: var(--ring);
background: var(--accent);
}
.conversation-editor {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--sidebar);
overflow: hidden;
}
.conversation-editor:focus-within {
border-color: var(--ring);
}
.conversation-editor textarea {
min-height: 100px;
max-height: 240px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 14px;
font-size: 13px;
line-height: 1.7;
resize: vertical;
}
.conversation-editor textarea:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -3px;
box-shadow: none;
}
.conversation-editor .conversation-compose-footer {
padding: 4px 10px 10px 14px;
}
.conversation-compose-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.conversation-compose-footer > span {
color: var(--muted-foreground);
font-size: 10px;
}
.conversation-triage {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.conversation-scheduling summary {
display: flex;
align-items: center;
gap: 7px;
list-style: none;
font-size: 11px;
cursor: pointer;
color: var(--muted-foreground);
}
.conversation-scheduling summary svg {
width: 13px;
height: 13px;
}
.conversation-scheduling > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.conversation-context {
border-top: 1px solid var(--border);
min-width: 0;
padding: 22px;
background: var(--sidebar);
}
.conversation-context-details > summary {
list-style: none;
cursor: pointer;
font-size: 11px;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
.conversation-context-details > summary svg {
width: 14px;
height: 14px;
}
.conversation-person {
display: grid;
justify-items: start;
padding: 20px 0;
}
.conversation-person h3 {
margin-top: 12px;
font-size: 13px;
font-weight: 550;
}
.conversation-person p {
margin-top: 4px;
font-size: 11px;
color: var(--muted-foreground);
}
.conversation-next {
padding: 20px 0;
border-block: 1px solid var(--border);
}
.conversation-next h3,
.conversation-facts h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 550;
}
.conversation-next h3 svg {
width: 13px;
height: 13px;
}
.conversation-next > p {
font-size: 12px;
line-height: 1.8;
margin-top: 12px;
}
.conversation-deadline {
display: grid;
gap: 5px;
margin-top: 18px;
font-size: 11px;
}
.conversation-deadline span {
color: var(--muted-foreground);
}
.conversation-deadline strong {
font-weight: 500;
}
.conversation-next > .conversation-next-note {
font-size: 10px;
color: var(--muted-foreground);
margin-top: 16px;
}
.conversation-facts {
padding-top: 22px;
}
.conversation-facts ul {
display: grid;
gap: 18px;
padding-top: 16px;
}
.conversation-facts li p {
font-size: 11px;
line-height: 1.8;
}
.conversation-facts a {
display: inline-block;
margin-top: 5px;
color: var(--muted-foreground);
font-size: 10px;
text-decoration: underline;
text-underline-offset: 3px;
}
@container (min-width: 780px) {
.conversation-workbench {
grid-template-columns: minmax(0, 1fr) 250px;
height: calc(100dvh - 64px);
min-height: 0;
}
.conversation-thread {
min-height: 0;
}
.conversation-messages {
overflow-y: auto;
min-height: 100px;
}
.conversation-compose {
flex-shrink: 0;
max-height: 65%;
overflow-y: auto;
}
.conversation-context {
overflow-y: auto;
border-top: 0;
border-left: 1px solid var(--border);
}
}
@media (max-width: 767px) {
.support-sidebar {
width: 100%;
}
.support-main {
display: none;
}
.support-back {
display: inline-flex;
}
.support-workspace[data-reading="true"] .support-sidebar {
display: none;
}
.support-workspace[data-reading="true"] .support-main {
display: flex;
}
.support-toolbar {
padding: 12px;
}
.conversation-compose {
padding: 12px;
}
.conversation-messages {
padding: 20px 12px;
}
.conversation-message {
max-width: 100%;
}
.conversation-compose-footer > span {
max-width: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.conversation-message {
scroll-behavior: auto;
}
}
.conversation-evaluation {
border-top: 1px solid var(--border);
padding-top: 16px;
font-size: 12px;
}
.conversation-evaluation h3 {
font-weight: 600;
margin-bottom: 12px;
}
.conversation-evaluation ul {
display: grid;
gap: 10px;
}
.conversation-evaluation li {
display: flex;
justify-content: space-between;
gap: 16px;
}
.conversation-evaluation strong {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.conversation-evaluation p {
margin-top: 12px;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.6;
}
+249 -113
View File
@@ -190,14 +190,23 @@ function points(values: readonly number[], width = 640, height = 190) {
.join(" "); .join(" ");
} }
function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) { function Sparkline({
index,
tone = "green",
variant = "metric",
}: {
index: number;
tone?: Tone;
variant?: "metric" | "score";
}) {
const values = const values =
index === 7 index === 7
? Array<number>(30).fill(20) ? Array<number>(30).fill(20)
: (series[index % series.length]?.values ?? series[0].values); : (series[index % series.length]?.values ?? series[0].values);
return ( return (
<svg <svg
className="vital-sparkline" className="block w-full min-w-0 h-[24px] data-[variant=score]:mt-px-28 data-[variant=score]:h-[36px] min-[1700px]:data-[variant=score]:h-[40px] min-[1700px]:data-[variant=score]:mt-px-34 max-[1401px]:data-[variant=score]:mt-px-18"
data-variant={variant}
viewBox="0 0 300 40" viewBox="0 0 300 40"
preserveAspectRatio="none" preserveAspectRatio="none"
aria-hidden="true" aria-hidden="true"
@@ -205,7 +214,8 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
<polyline <polyline
points={points(values, 300, 36)} points={points(values, 300, 36)}
fill="none" fill="none"
stroke={`var(--vital-${tone})`} data-tone={tone}
className="data-[tone=green]:stroke-vital-green data-[tone=blue]:stroke-vital-blue data-[tone=purple]:stroke-vital-purple data-[tone=yellow]:stroke-vital-yellow"
strokeWidth="1.8" strokeWidth="1.8"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
@@ -215,18 +225,18 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
function Gauge({ value }: { value: number }) { function Gauge({ value }: { value: number }) {
return ( return (
<svg className="vital-gauge" viewBox="0 0 110 65" aria-hidden="true"> <svg className="w-[43%] shrink-0" viewBox="0 0 110 65" aria-hidden="true">
<path <path
d="M 5 58 A 50 50 0 0 1 105 58" d="M 5 58 A 50 50 0 0 1 105 58"
fill="none" fill="none"
stroke="var(--vital-border)" className="stroke-vital-border"
strokeWidth="5" strokeWidth="5"
strokeLinecap="round" strokeLinecap="round"
/> />
<path <path
d="M 5 58 A 50 50 0 0 1 105 58" d="M 5 58 A 50 50 0 0 1 105 58"
fill="none" fill="none"
stroke="var(--vital-green)" className="stroke-vital-green"
strokeWidth="5" strokeWidth="5"
strokeLinecap="round" strokeLinecap="round"
pathLength="100" pathLength="100"
@@ -254,12 +264,19 @@ function Trends() {
? ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"] ? ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"]
: ["Mon 7 PM", "Tue 3 AM", "Tue 11 AM", "Tue 7 PM", "Wed 3 AM", "Wed 11 AM"]; : ["Mon 7 PM", "Tue 3 AM", "Tue 11 AM", "Tue 7 PM", "Wed 3 AM", "Wed 11 AM"];
return ( return (
<section className="vital-trends vital-card" aria-label="Environmental trends"> <section
<div className="vital-card-header"> className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-20 max-[768px]:p-px-12"
<h2>Trends</h2> aria-label="Environmental trends"
<fieldset className="vital-segmented" aria-label="Trend period"> >
<div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
<h2 className="text-ui-15 font-medium">Trends</h2>
<fieldset
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
aria-label="Trend period"
>
{(["24H", "48H", "7D"] as const).map((item) => ( {(["24H", "48H", "7D"] as const).map((item) => (
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
type="button" type="button"
key={item} key={item}
aria-pressed={range === item} aria-pressed={range === item}
@@ -270,9 +287,13 @@ function Trends() {
))} ))}
</fieldset> </fieldset>
</div> </div>
<fieldset className="vital-chart-tabs vital-segmented" aria-label="Trend metric"> <fieldset
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
aria-label="Trend metric"
>
{["Overview", ...series.map((item) => item.name)].map((item) => ( {["Overview", ...series.map((item) => item.name)].map((item) => (
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border text-ui-12 max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
type="button" type="button"
key={item} key={item}
aria-pressed={metric === item} aria-pressed={metric === item}
@@ -282,16 +303,20 @@ function Trends() {
</button> </button>
))} ))}
</fieldset> </fieldset>
<div className="vital-chart-legend"> <div className="flex flex-wrap gap-px-15 mt-px-20 mb-px-20 mx-0 text-ui-10">
{visible.map((item) => ( {visible.map((item) => (
<span key={item.name} data-tone={item.tone}> <span
<i /> className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow flex items-center gap-px-7"
key={item.name}
data-tone={item.tone}
>
<i className="w-[8px] h-[8px] rounded-ui-2 bg-current" />
{item.name} ({item.unit}) {item.name} ({item.unit})
</span> </span>
))} ))}
</div> </div>
<svg <svg
className="vital-trend-chart" className="w-full h-auto min-h-[220px] max-[768px]:min-h-[140px]"
viewBox="0 0 740 260" viewBox="0 0 740 260"
role="img" role="img"
aria-label={`${range} ${metric} simulated environmental readings`} aria-label={`${range} ${metric} simulated environmental readings`}
@@ -303,23 +328,23 @@ function Trends() {
x2="696" x2="696"
y1={30 + index * 62} y1={30 + index * 62}
y2={30 + index * 62} y2={30 + index * 62}
stroke="var(--vital-border)" className="stroke-vital-border"
/> />
<text <text
x="46" x="46"
y={35 + index * 62} y={35 + index * 62}
textAnchor="end" textAnchor="end"
fill="var(--vital-muted)" className="fill-vital-muted"
fontSize="12" fontSize="12"
> >
{["1,500", "1,000", "500", "0"][index]} {["1,500", "1,000", "500", "0"][index]}
</text> </text>
</g> </g>
))} ))}
<text x="6" y="14" fill="var(--vital-muted)" fontSize="12"> <text x="6" y="14" className="fill-vital-muted" fontSize="12">
ppm / % ppm / %
</text> </text>
<text x="712" y="14" fill="var(--vital-purple)" fontSize="12"> <text x="712" y="14" className="fill-vital-purple" fontSize="12">
°F °F
</text> </text>
<g transform="translate(56,26)"> <g transform="translate(56,26)">
@@ -335,7 +360,8 @@ function Trends() {
key={item.name} key={item.name}
points={points(values)} points={points(values)}
fill="none" fill="none"
stroke={`var(--vital-${item.tone})`} data-tone={item.tone}
className="data-[tone=green]:stroke-vital-green data-[tone=blue]:stroke-vital-blue data-[tone=purple]:stroke-vital-purple data-[tone=yellow]:stroke-vital-yellow"
strokeWidth="2.3" strokeWidth="2.3"
strokeLinejoin="round" strokeLinejoin="round"
/> />
@@ -347,7 +373,7 @@ function Trends() {
key={label} key={label}
x={75 + (index * 610) / (labels.length - 1)} x={75 + (index * 610) / (labels.length - 1)}
y="242" y="242"
fill="var(--vital-muted)" className="fill-vital-muted"
fontSize="12" fontSize="12"
textAnchor="middle" textAnchor="middle"
> >
@@ -362,31 +388,51 @@ function Trends() {
function AirMonitor() { function AirMonitor() {
const [synced, setSynced] = useState(false); const [synced, setSynced] = useState(false);
return ( return (
<section className="vital-monitor vital-card"> <section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-20 max-[768px]:p-px-12">
<div className="vital-card-header"> <div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
<h2>Air monitor</h2> <h2 className="text-ui-15 font-medium">Air monitor</h2>
<button className="vital-outline-button" type="button" onClick={() => setSynced(true)}> <button
className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
type="button"
onClick={() => setSynced(true)}
>
<RefreshCw size={15} /> <RefreshCw size={15} />
{synced ? "Synced" : "Sync now"} {synced ? "Synced" : "Sync now"}
</button> </button>
</div> </div>
<div className="vital-device"> <div className="flex items-center gap-px-12 pt-px-16 pb-px-16 px-px-12 border border-vital-border rounded-ui-12">
<Radio className="vital-device-icon" size={66} strokeWidth={1} /> <Radio
<div> className="text-vital-muted shrink-0 max-[1101px]:w-[50px]"
<div className="vital-device-heading"> size={66}
<strong>Room</strong> strokeWidth={1}
<span data-tone="green">● Connected</span> />
<div className="flex-1 min-w-0">
<div className="flex gap-px-8 flex-wrap justify-between">
<strong className="text-ui-14">Room</strong>
<span
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow text-ui-10"
data-tone="green"
>
● Connected
</span>
</div> </div>
<p>Air monitor</p> <p className="text-ui-12 text-vital-muted mt-px-8">Air monitor</p>
<p role="status">{synced ? "Sample readings refreshed" : "Updated just now"}</p> <p className="text-ui-12 text-vital-muted mt-px-8" role="status">
<div className="vital-device-badges"> {synced ? "Sample readings refreshed" : "Updated just now"}
</p>
<div className="flex flex-wrap gap-px-5 mt-px-10">
{["CO₂", "PM2.5", "Temp", "Humidity", "VOC", "NOx"].map((item) => ( {["CO₂", "PM2.5", "Temp", "Humidity", "VOC", "NOx"].map((item) => (
<span key={item}>{item}</span> <span
className="text-ui-10 text-vital-muted bg-muted pt-px-3 pb-px-3 px-px-6 rounded-ui-12"
key={item}
>
{item}
</span>
))} ))}
</div> </div>
</div> </div>
</div> </div>
<p className="vital-monitor-footnote"> <p className="mt-px-48 text-ui-11 text-vital-muted">
{synced {synced
? "71 sample readings synced. No device connected." ? "71 sample readings synced. No device connected."
: "71 sample readings · simulated device"} : "71 sample readings · simulated device"}
@@ -397,60 +443,98 @@ function AirMonitor() {
function Insights() { function Insights() {
return ( return (
<aside className="vital-env-insights"> <aside className="grid gap-px-16 max-[1101px]:grid-cols-[1fr_1fr] max-[768px]:grid-cols-[1fr]">
<section className="vital-insights vital-card"> <section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
<div className="vital-card-header"> <div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
<h2>Insights</h2> <h2 className="text-ui-15 font-medium m-0">Insights</h2>
<span>today</span> <span className="text-ui-12 text-vital-muted">today</span>
</div> </div>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<TriangleAlert data-tone="yellow" size={20} /> <TriangleAlert
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image"> className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
<i /> data-tone="yellow"
<i /> size={20}
<i /> />
<i /> <div
className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[105px]"
role="img"
aria-label="Text obscured in reference image"
>
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
</div> </div>
</div> </div>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<Check data-tone="green" size={20} /> <Check
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
data-tone="green"
size={20}
/>
<div> <div>
<h3>Particulates stayed low</h3> <h3 className="text-ui-14 m-0 text-vital-text leading-normal">
<p>The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in Trends.</p> Particulates stayed low
</h3>
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in Trends.
</p>
</div> </div>
</div> </div>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<Check data-tone="green" size={20} /> <Check
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
data-tone="green"
size={20}
/>
<div> <div>
<h3>Humidity through the day</h3> <h3 className="text-ui-14 m-0 text-vital-text leading-normal">
<p>The room’s current sample reading is 64%. Select Humidity to explore the chart.</p> Humidity through the day
</h3>
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
The room’s current sample reading is 64%. Select Humidity to explore the chart.
</p>
</div> </div>
</div> </div>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<Check data-tone="green" size={20} /> <Check
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
data-tone="green"
size={20}
/>
<div> <div>
<h3>Room temperature</h3> <h3 className="text-ui-14 m-0 text-vital-text leading-normal">Room temperature</h3>
<p> <p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
The current sample temperature is 64.0°F. Recent readings are shown alongside the The current sample temperature is 64.0°F. Recent readings are shown alongside the
other room metrics. other room metrics.
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section className="vital-insights vital-card"> <section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
<h2>Recommended actions</h2> <h2 className="text-ui-15 font-medium mb-px-20">Recommended actions</h2>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<Grid2X2 size={20} /> <Grid2X2 className="shrink-0" size={20} />
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image"> <div
<i /> className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[105px]"
<i /> role="img"
aria-label="Text obscured in reference image"
>
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
</div> </div>
</div> </div>
<div className="vital-insight"> <div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
<Droplets data-tone="yellow" size={20} /> <Droplets
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
data-tone="yellow"
size={20}
/>
<div> <div>
<h3>Review room humidity</h3> <h3 className="text-ui-14 m-0 text-vital-text leading-normal">Review room humidity</h3>
<p>Explore the climate controls with simulated settings.</p> <p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
Explore the climate controls with simulated settings.
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -463,13 +547,16 @@ function ClimateControl() {
const [mode, setMode] = useState("Auto"); const [mode, setMode] = useState("Auto");
const [purifier, setPurifier] = useState(true); const [purifier, setPurifier] = useState(true);
return ( return (
<section className="vital-climate vital-card"> <section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden max-w-[650px] mt-px-20 p-px-30 grid gap-px-24 max-[768px]:p-px-18">
<h2>Room climate</h2> <h2 className="text-ui-15 font-medium">Room climate</h2>
<p>Simulated controls. Changes stay on this screen.</p> <p className="text-vital-muted text-ui-13">
<label htmlFor="vital-temperature"> Simulated controls. Changes stay on this screen.
</p>
<label className="flex justify-between text-ui-14" htmlFor="vital-temperature">
Target temperature <strong>{temperature}°F</strong> Target temperature <strong>{temperature}°F</strong>
</label> </label>
<input <input
className="accent-vital-green w-full"
id="vital-temperature" id="vital-temperature"
type="range" type="range"
min="60" min="60"
@@ -477,9 +564,13 @@ function ClimateControl() {
value={temperature} value={temperature}
onChange={(event) => setTemperature(Number(event.target.value))} onChange={(event) => setTemperature(Number(event.target.value))}
/> />
<fieldset className="vital-segmented" aria-label="Climate mode"> <fieldset
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
aria-label="Climate mode"
>
{["Auto", "Cool", "Heat", "Off"].map((item) => ( {["Auto", "Cool", "Heat", "Off"].map((item) => (
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
type="button" type="button"
key={item} key={item}
aria-pressed={item === mode} aria-pressed={item === mode}
@@ -490,7 +581,7 @@ function ClimateControl() {
))} ))}
</fieldset> </fieldset>
<button <button
className="vital-outline-button" className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
type="button" type="button"
aria-pressed={purifier} aria-pressed={purifier}
onClick={() => setPurifier(!purifier)} onClick={() => setPurifier(!purifier)}
@@ -512,9 +603,18 @@ export function EnvironmentPanel() {
setSearch({ environmentDetail: value?.name }); setSearch({ environmentDetail: value?.name });
return ( return (
<> <>
<fieldset className="vital-segmented" aria-label="Environment view"> <fieldset
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
aria-label="Environment view"
>
{(["Overview", "Climate control"] as const).map((item) => ( {(["Overview", "Climate control"] as const).map((item) => (
<button type="button" key={item} aria-pressed={tab === item} onClick={() => setTab(item)}> <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
type="button"
key={item}
aria-pressed={tab === item}
onClick={() => setTab(item)}
>
{item} {item}
</button> </button>
))} ))}
@@ -522,37 +622,49 @@ export function EnvironmentPanel() {
{tab === "Climate control" ? ( {tab === "Climate control" ? (
<ClimateControl /> <ClimateControl />
) : ( ) : (
<div className="vital-env-layout"> <div className="grid grid-cols-[minmax(0,_1fr)_290px] gap-px-20 mt-px-20 items-start min-[1700px]:grid-cols-[minmax(0,_1fr)_350px] max-[1401px]:grid-cols-[minmax(0,_1fr)_230px] max-[1101px]:grid-cols-[1fr]">
<div className="vital-env-main"> <div className="min-w-0">
<div className="vital-env-scores"> <div className="grid grid-cols-[repeat(4,_minmax(0,_1fr))] gap-px-14 max-[1401px]:grid-cols-[repeat(2,_minmax(0,_1fr))] max-[768px]:gap-px-10">
{scores.map((score, index) => ( {scores.map((score, index) => (
<button <button
type="button" type="button"
className="vital-env-score vital-card" className="bg-vital-card border border-vital-border min-w-0 overflow-hidden text-left pt-px-20 pb-0 px-px-18 cursor-pointer rounded-ui-15 min-[1700px]:pt-px-24 max-[768px]:pt-px-14 max-[768px]:pb-0 max-[768px]:px-px-10"
key={score.name} key={score.name}
onClick={() => setDetail(score)} onClick={() => setDetail(score)}
> >
<span className="vital-score-heading"> <span className="flex items-center justify-between gap-px-8 text-ui-14 text-vital-muted max-[768px]:text-ui-11">
{score.name} {score.name}
<ChevronRight size={16} /> <ChevronRight size={16} />
</span> </span>
<div className="vital-score-reading"> <div className="flex items-center gap-px-15 mt-px-24 max-[1401px]:mt-px-16 max-[768px]:gap-px-6 max-[768px]:flex-wrap">
<Gauge value={score.progress} /> <Gauge value={score.progress} />
<div> <div>
<strong>{score.value}</strong> <strong className="text-ui-36 font-light tracking-score min-[1700px]:text-ui-42 max-[768px]:text-ui-30">
<small>{score.unit}</small> {score.value}
<p data-tone="green">{score.status}</p> </strong>
<small className="text-ui-10 text-vital-muted">{score.unit}</small>
<p
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow text-ui-13"
data-tone="green"
>
{score.status}
</p>
</div> </div>
</div> </div>
<p className="vital-delta"> <p className="text-vital-muted text-ui-12 mt-px-16 max-[768px]:text-ui-10">
<span data-tone={index === 3 ? "green" : "yellow"}>{score.change}</span>{" "} <span
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
data-tone={index === 3 ? "green" : "yellow"}
>
{score.change}
</span>{" "}
{score.suffix} vs yesterday {score.suffix} vs yesterday
</p> </p>
<Sparkline index={index} /> <Sparkline index={index} variant="score" />
</button> </button>
))} ))}
</div> </div>
<div className="vital-env-metrics"> <div className="grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-px-14 mt-px-14 max-[1401px]:grid-cols-[repeat(3,_minmax(0,_1fr))] max-[1101px]:grid-cols-[repeat(3,_minmax(0,_1fr))] max-[768px]:grid-cols-[repeat(2,_minmax(0,_1fr))] max-[768px]:gap-px-10">
{metrics.map((metric, index) => { {metrics.map((metric, index) => {
const MetricIcon = const MetricIcon =
index === 0 index === 0
@@ -565,43 +677,63 @@ export function EnvironmentPanel() {
return ( return (
<button <button
type="button" type="button"
className="vital-metric vital-card" className="bg-vital-card border border-vital-border min-w-0 overflow-hidden pt-px-16 pb-px-8 px-px-12 flex gap-px-8 flex-wrap text-left rounded-ui-12 cursor-pointer max-[768px]:pt-px-12 max-[768px]:pb-px-12 max-[768px]:px-px-10"
key={metric.name} key={metric.name}
data-testid="vital-metric"
onClick={() => setDetail(metric)} onClick={() => setDetail(metric)}
> >
<MetricIcon size={17} data-tone={metric.tone} /> <MetricIcon
<div className="vital-metric-content"> className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow max-[768px]:w-[14px]"
<span>{metric.name}</span> size={17}
<div className="vital-metric-reading"> data-tone={metric.tone}
<strong>{metric.value}</strong> />
<small>{metric.unit}</small> <div className="flex-1 min-w-0">
<span className="text-vital-muted text-ui-12">{metric.name}</span>
<div className="flex items-baseline flex-wrap gap-px-3">
<strong className="text-ui-23 font-normal">{metric.value}</strong>
<small className="text-ui-10 text-vital-muted">{metric.unit}</small>
{metric.status && ( {metric.status && (
<em data-tone={metric.status === "Watch" ? "yellow" : "green"}> <em
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow not-italic text-ui-11 ml-auto"
data-tone={metric.status === "Watch" ? "yellow" : "green"}
>
{metric.status} {metric.status}
</em> </em>
)} )}
</div> </div>
</div> </div>
<div className="vital-metric-spark"> <div className="flex items-end w-full gap-px-4">
<small>{metric.min}</small> <small className="text-ui-9 text-vital-muted">{metric.min}</small>
<Sparkline index={index} tone={metric.tone} /> <Sparkline index={index} tone={metric.tone} />
<small>{metric.max}</small> <small className="text-ui-9 text-vital-muted">{metric.max}</small>
</div> </div>
</button> </button>
); );
})} })}
<div className="vital-metric vital-card vital-room"> <div className="bg-vital-card border border-vital-border min-w-0 overflow-hidden pt-px-16 pb-px-8 px-px-12 flex gap-px-8 flex-wrap text-left rounded-ui-12 cursor-pointer max-[768px]:pt-px-12 max-[768px]:pb-px-12 max-[768px]:px-px-10">
<Wifi size={17} data-tone="green" /> <Wifi
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow max-[768px]:w-[14px]"
size={17}
data-tone="green"
/>
<div> <div>
<span>Room</span> <span className="text-vital-muted text-ui-12">Room</span>
<strong>Connected</strong> <strong className="block text-ui-14 font-medium mt-px-3 mb-px-3 mx-0">
<p> Connected
<span data-tone="green">●</span> Updated just now </strong>
<p className="text-ui-10 text-vital-muted">
<span
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
data-tone="green"
>
●
</span>{" "}
Updated just now
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="vital-env-bottom"> <div className="grid grid-cols-[1.65fr_1fr] gap-px-14 mt-px-14 max-[1401px]:grid-cols-[1fr]">
<Trends /> <Trends />
<AirMonitor /> <AirMonitor />
</div> </div>
@@ -611,14 +743,18 @@ export function EnvironmentPanel() {
)} )}
{detail && ( {detail && (
<Dialog title={detail.name} onClose={() => setDetail(null)}> <Dialog title={detail.name} onClose={() => setDetail(null)}>
<p className="vital-detail-value"> <p className="text-ui-32">
{detail.value} <small>{detail.unit}</small> {detail.value} <small className="text-ui-14">{detail.unit}</small>
</p> </p>
<p> <p>
This is a sample reading for the dashboard preview. No health or environmental device is This is a sample reading for the dashboard preview. No health or environmental device is
connected. connected.
</p> </p>
<button className="vital-outline-button" type="button" onClick={() => setDetail(null)}> <button
className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
type="button"
onClick={() => setDetail(null)}
>
Back to overview Back to overview
</button> </button>
</Dialog> </Dialog>
+207 -110
View File
@@ -78,33 +78,35 @@ export function VitalsPage() {
const [mobileMenu, setMobileMenu] = useState(false); const [mobileMenu, setMobileMenu] = useState(false);
return ( return (
<TooltipProvider> <TooltipProvider>
<SidebarProvider className="vital-workspace"> <SidebarProvider className="h-[100dvh] min-h-0 overflow-hidden bg-vital-bg text-vital-text font-arial">
<WorkspaceNavigation /> <WorkspaceNavigation />
<div className="vital-app"> <div className="flex flex-1 min-w-0 h-full">
<aside <aside
className={`vital-navigation ${mobileMenu ? "is-open" : ""}`} className="w-[208px] shrink-0 flex flex-col bg-vital-bg border-r border-vital-border pt-0 pb-0 px-px-10 max-[1401px]:w-[170px] max-[1101px]:w-[150px] max-[768px]:hidden max-[768px]:data-[open=true]:flex max-[768px]:data-[open=true]:fixed max-[768px]:data-[open=true]:left-[56px] max-[768px]:data-[open=true]:top-0 max-[768px]:data-[open=true]:bottom-0 max-[768px]:data-[open=true]:z-[40] max-[768px]:data-[open=true]:w-[220px] max-[768px]:data-[open=true]:shadow-vital-menu"
data-open={mobileMenu}
aria-label="Health navigation" aria-label="Health navigation"
> >
<div className="vital-brand"> <div className="h-[64px] flex items-center gap-px-12 pt-0 pb-0 px-px-12 text-ui-18 font-semibold whitespace-nowrap max-[1101px]:text-ui-14 max-[1101px]:px-px-4">
<Activity /> <Activity className="w-[24px] text-vital-muted" />
<span> <span>
1F2T <span>Health</span> 1F2T <span className="text-vital-muted font-normal">Health</span>
</span> </span>
<button <button
className="vital-mobile-close" className="items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text hidden max-[768px]:flex max-[768px]:ml-auto"
type="button" type="button"
aria-label="Close menu" aria-label="Close menu"
onClick={() => setMobileMenu(false)} onClick={() => setMobileMenu(false)}
> >
<X /> <X className="w-[17px] h-[17px] shrink-0" />
</button> </button>
</div> </div>
<nav> <nav className="grid gap-px-3 mt-px-14">
{nav.map((item) => ( {nav.map((item) => (
<button <button
key={item.name} key={item.name}
type="button" type="button"
className={section === item.name ? "is-active" : ""} className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted data-[active=true]:bg-muted data-[active=true]:text-vital-text hover:text-vital-text"
data-active={section === item.name}
onClick={() => { onClick={() => {
if (item.name === "Body" || item.name === "Environment") { if (item.name === "Body" || item.name === "Environment") {
setSection(item.name); setSection(item.name);
@@ -112,63 +114,80 @@ export function VitalsPage() {
} else setDetail(item.name); } else setDetail(item.name);
}} }}
> >
<item.icon /> <item.icon className="w-[17px] h-[17px] shrink-0" />
<span>{item.name}</span> <span>{item.name}</span>
</button> </button>
))} ))}
</nav> </nav>
<div className="vital-navigation-bottom"> <div className="mt-auto pb-px-20">
<button type="button" onClick={() => setDetail("Log with Claude")}> <button
<Plus /> className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text"
type="button"
onClick={() => setDetail("Log with Claude")}
>
<Plus className="w-[17px] h-[17px] shrink-0" />
Log with Claude Log with Claude
</button> </button>
<button type="button" onClick={() => setDetail("Help")}> <button
<CircleHelp /> className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text"
type="button"
onClick={() => setDetail("Help")}
>
<CircleHelp className="w-[17px] h-[17px] shrink-0" />
Help Help
</button> </button>
<p>Reference preview</p> <p className="text-ui-11 pt-px-8 pb-px-8 px-px-12 text-vital-muted">
Reference preview
</p>
</div> </div>
</aside> </aside>
<div className="vital-surface"> <div className="flex flex-col flex-1 min-w-0">
<header className="vital-topbar"> <header className="h-[64px] shrink-0 flex items-center pt-0 pb-0 px-px-28 border-b border-vital-border gap-px-12 max-[768px]:pt-0 max-[768px]:pb-0 max-[768px]:px-px-12 max-[768px]:h-[56px]">
<button <button
className="vital-mobile-toggle" className="hidden max-[768px]:flex"
type="button" type="button"
aria-label="Open Health menu" aria-label="Open Health menu"
onClick={() => setMobileMenu(true)} onClick={() => setMobileMenu(true)}
> >
<Menu /> <Menu className="w-[17px] h-[17px] text-vital-muted" />
</button> </button>
<h1>{section}</h1> <h1 className="text-ui-16 font-medium">{section}</h1>
<div className="vital-topbar-right"> <div className="ml-auto flex items-center gap-px-28 text-ui-14 max-[768px]:gap-px-14">
<span className="vital-pulse"> <span className="flex items-center gap-px-10 text-vital-muted max-[768px]:hidden">
<Heart /> <Heart className="w-[17px] h-[17px] text-vital-red" />
86 bpm 86 bpm
</span> </span>
<button <button
className="flex items-center gap-px-10 cursor-pointer max-[768px]:hidden"
type="button" type="button"
aria-label="Open notifications" aria-label="Open notifications"
onClick={() => setDetail("Notifications")} onClick={() => setDetail("Notifications")}
> >
<Bell /> <Bell className="w-[17px] h-[17px] text-vital-muted" />
</button> </button>
<button <button
type="button" type="button"
className="vital-profile" className="flex items-center gap-px-10 cursor-pointer"
onClick={() => setDetail("Profile")} onClick={() => setDetail("Profile")}
> >
<span className="vital-avatar">{auth.owner?.name.slice(0, 1).toUpperCase()}</span> <span className="grid place-items-center w-[30px] h-[30px] rounded-full bg-muted text-ui-12">
{auth.owner?.name.slice(0, 1).toUpperCase()}
</span>
{auth.owner?.name} {auth.owner?.name}
<ChevronDown /> <ChevronDown className="w-[17px] h-[17px] text-vital-muted max-[768px]:hidden" />
</button> </button>
</div> </div>
</header> </header>
<main className="vital-scroll"> <main className="overflow-auto flex-1 min-h-0 p-px-32 max-[1401px]:p-px-24 max-[768px]:pt-px-16 max-[768px]:pb-px-16 max-[768px]:px-px-12">
{section === "Environment" ? ( {section === "Environment" ? (
<EnvironmentPanel /> <EnvironmentPanel />
) : ( ) : (
<div> <div>
<div className="vital-tabs" role="group" aria-label="Body systems"> <div
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
role="group"
aria-label="Body systems"
>
{[ {[
"Overview", "Overview",
"Cardiovascular", "Cardiovascular",
@@ -181,28 +200,46 @@ export function VitalsPage() {
"Nutrients", "Nutrients",
].map((name) => ( ].map((name) => (
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
type="button" type="button"
key={name} key={name}
className={tab === name ? "is-active" : ""} data-active={tab === name}
onClick={() => setTab(name)} onClick={() => setTab(name)}
> >
{name} {name}
</button> </button>
))} ))}
</div> </div>
<div className="vital-body-columns"> <div className="grid grid-cols-[minmax(0,_1fr)_290px] gap-px-20 mt-px-20 items-start min-[1700px]:grid-cols-[minmax(0,_1fr)_350px] max-[1401px]:grid-cols-[minmax(0,_1fr)_250px] max-[1101px]:grid-cols-[1fr] max-[768px]:gap-px-16">
<div className="vital-card"> <div className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden">
<div className="vital-lab-row"> <div className="grid grid-cols-[0.95fr_1.15fr] border-b border-vital-border max-[1401px]:grid-cols-[1fr]">
<section className="vital-lab-score"> <section className="p-px-26 min-w-0 max-[768px]:p-px-16">
<header> <header className="flex items-center justify-between pb-px-16 border-b border-vital-border gap-px-12">
<h2>Lab score</h2> <h2 className="text-ui-15 font-medium">Lab score</h2>
<span>Latest draw · Jul 28</span> <span className="text-ui-10 text-vital-muted">
Latest draw · Jul 28
</span>
</header> </header>
<div className="vital-lab-value"> <div className="flex items-center gap-px-20 mt-px-24 max-[768px]:gap-px-12">
<div className="vital-ring"> <div className="w-[142px] h-[160px] relative shrink-0 max-[768px]:w-[90px] max-[768px]:h-[120px]">
<svg viewBox="0 0 160 160" aria-hidden="true"> <svg
<circle cx="80" cy="80" r="67" /> className="w-full h-full -rotate-90"
viewBox="0 0 160 160"
aria-hidden="true"
>
<circle <circle
fill="none"
className="stroke-vital-border"
strokeWidth={9}
cx="80"
cy="80"
r="67"
/>
<circle
fill="none"
className="stroke-vital-green drop-shadow-vital-glow"
strokeWidth={9}
strokeLinecap="round"
cx="80" cx="80"
cy="80" cy="80"
r="67" r="67"
@@ -210,24 +247,36 @@ export function VitalsPage() {
strokeDasharray="92 100" strokeDasharray="92 100"
/> />
</svg> </svg>
<span> <span className="absolute inset-0 flex flex-col justify-center items-center text-ui-40 font-light max-[768px]:text-ui-28">
92<small>Lab score</small> 92
<small className="text-ui-12 text-vital-muted mt-px-4">
Lab score
</small>
</span> </span>
</div> </div>
<div className="vital-lab-description"> <div className="border-l border-vital-border pl-px-20 min-w-0 max-[768px]:pl-px-12">
<h3> <h3 className="text-ui-23 whitespace-nowrap max-[768px]:text-ui-19">
Optimal <span>▾ 1</span> Optimal <span className="text-ui-13 text-vital-yellow">▾ 1</span>
</h3> </h3>
<p>7 of 8 systems optimal · 21 of 161 biomarkers out of range</p> <p className="text-ui-11 text-vital-muted leading-normal">
<div className="vital-lab-change"> 7 of 8 systems optimal · 21 of 161 biomarkers out of range
</p>
<div className="flex items-center gap-px-16 mt-px-20 border-t border-vital-border pt-px-16">
<div> <div>
<span>Change ⓘ</span> <span className="text-ui-13 text-vital-muted">Change ⓘ</span>
<strong> <strong className="block text-ui-20 font-medium text-vital-yellow">
−1 <small>pt</small> −1 <small className="text-ui-12">pt</small>
</strong> </strong>
<p>Since last draw</p> <p className="text-ui-11 text-vital-muted leading-normal">
Since last draw
</p>
</div> </div>
<svg viewBox="0 0 190 65" aria-label="Lab score trend" role="img"> <svg
className="w-[120px] min-w-[40px] text-vital-green max-[768px]:hidden"
viewBox="0 0 190 65"
aria-label="Lab score trend"
role="img"
>
<path <path
d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51" d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51"
fill="none" fill="none"
@@ -239,42 +288,53 @@ export function VitalsPage() {
</div> </div>
</div> </div>
</section> </section>
<section className="vital-recent"> <section className="border-l border-vital-border p-px-24 max-[1401px]:hidden">
<h2>Recent results</h2> <h2 className="text-ui-15 font-medium mb-px-10">Recent results</h2>
{[1, 2, 3].map((index) => ( {[1, 2, 3].map((index) => (
<button <button
className="flex items-center gap-px-16 w-full pt-px-16 pb-px-16 px-0 border-b border-vital-border cursor-pointer last:border-0"
key={index} key={index}
type="button" type="button"
onClick={() => setDetail("Recent result")} onClick={() => setDetail("Recent result")}
> >
<span className="vital-warning">△</span> <span className="grid place-items-center w-[34px] h-[34px] rounded-ui-9 bg-muted text-vital-yellow text-ui-23">
△
</span>
<span <span
className="vital-obscured" className="flex flex-col gap-px-10 flex-1 min-w-0"
role="img" role="img"
aria-label="Reference text obscured" aria-label="Reference text obscured"
> >
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
</span> </span>
<ChevronRight /> <ChevronRight className="w-[16px] text-vital-muted" />
</button> </button>
))} ))}
</section> </section>
</div> </div>
<section className="vital-systems"> <section>
<header> <header className="flex items-center justify-between p-px-20 gap-px-12 max-[768px]:p-px-16 max-[768px]:flex-wrap">
<h2>{tab === "Overview" ? "Body systems" : tab}</h2> <h2 className="text-ui-15 font-medium">
<div className="vital-tabs" role="group" aria-label="Body systems"> {tab === "Overview" ? "Body systems" : tab}
</h2>
<div
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
role="group"
aria-label="Body systems"
>
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
type="button" type="button"
className={!history ? "is-active" : ""} data-active={!history}
onClick={() => setHistory(false)} onClick={() => setHistory(false)}
> >
Systems Systems
</button> </button>
<button <button
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
type="button" type="button"
className={history ? "is-active" : ""} data-active={history}
onClick={() => setHistory(true)} onClick={() => setHistory(true)}
> >
Lab history Lab history
@@ -282,25 +342,28 @@ export function VitalsPage() {
</div> </div>
</header> </header>
{history ? ( {history ? (
<div className="vital-history"> <div data-testid="vital-history" className="min-h-[440px] p-px-30">
<h3>Lab history</h3> <h3 className="mb-px-28">Lab history</h3>
{[ {[
["Jul 28", "92"], ["Jul 28", "92"],
["May 16", "93"], ["May 16", "93"],
["Mar 04", "89"], ["Mar 04", "89"],
].map(([date, score]) => ( ].map(([date, score]) => (
<div key={date}> <div
className="flex justify-between pt-px-24 pb-px-24 px-0 border-b border-vital-border"
key={date}
>
<span>{date}</span> <span>{date}</span>
<span> <span>
{score} {score}
<small> Lab score</small> <small className="text-vital-muted"> Lab score</small>
</span> </span>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="vital-anatomy-grid"> <div className="grid grid-cols-[minmax(0,_1fr)_230px_minmax(0,_1fr)] items-center min-h-[505px] pt-px-22 pb-0 px-px-20 min-[1700px]:grid-cols-[minmax(0,_1fr)_280px_minmax(0,_1fr)] max-[1401px]:grid-cols-[minmax(0,_1fr)_155px_minmax(0,_1fr)] max-[1401px]:px-px-12 max-[1101px]:grid-cols-[minmax(0,_1fr)_210px_minmax(0,_1fr)] max-[768px]:grid-cols-[1fr_1fr] max-[768px]:gap-px-12 max-[768px]:p-px-12">
<div className="vital-system-list"> <div className="grid gap-px-12 relative z-[1]">
{systems {systems
.slice(0, 4) .slice(0, 4)
.filter((item) => tab === "Overview" || tab === item.name) .filter((item) => tab === "Overview" || tab === item.name)
@@ -313,13 +376,17 @@ export function VitalsPage() {
))} ))}
</div> </div>
<div <div
className="vital-anatomy" className="h-[500px] w-[230px] relative overflow-hidden self-end min-[1700px]:w-[280px] max-[1401px]:w-[155px] max-[1101px]:w-[210px] max-[768px]:col-span-full max-[768px]:row-start-1 max-[768px]:justify-self-center max-[768px]:h-[420px] max-[768px]:w-[230px]"
role="img" role="img"
aria-label="Reference illustration of the body and its systems" aria-label="Reference illustration of the body and its systems"
> >
<img src="/vitals/body-reference.jpg" alt="" /> <img
className="absolute max-w-none w-[2008px] h-auto left-[-831px] top-[-563px] mix-blend-lighten min-[1700px]:left-[-806px] max-[1401px]:left-[-868px] max-[1101px]:left-[-841px] max-[768px]:w-[1687px] max-[768px]:left-[-695px] max-[768px]:top-[-473px]"
src="/vitals/body-reference.jpg"
alt=""
/>
</div> </div>
<div className="vital-system-list"> <div className="grid gap-px-12 relative z-[1]">
{systems {systems
.slice(4) .slice(4)
.filter((item) => tab === "Overview" || tab === item.name) .filter((item) => tab === "Overview" || tab === item.name)
@@ -335,60 +402,77 @@ export function VitalsPage() {
)} )}
</section> </section>
</div> </div>
<aside className="vital-card vital-insights"> <aside className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
<h2>Insights</h2> <h2 className="text-ui-15 font-medium mb-px-20">Insights</h2>
<div className="vital-insight-feature"> <div className="border border-vital-border rounded-ui-12 p-px-16 flex items-start gap-px-12 flex-wrap">
<span className="vital-red">△</span> <span className="text-vital-red">△</span>
<div <div
className="vital-obscured" className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[80px]"
role="img" role="img"
aria-label="Reference text obscured" aria-label="Reference text obscured"
> >
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
</div> </div>
<button type="button" onClick={() => setHistory(true)}> <button
className="border border-vital-border rounded-ui-7 pt-px-6 pb-px-6 px-px-10 text-ui-12 cursor-pointer"
type="button"
onClick={() => setHistory(true)}
>
View lab history View lab history
</button> </button>
</div> </div>
<h3>Summary</h3> <h3 className="text-ui-14 mt-px-24 mb-px-10 mx-0 text-vital-muted">
<div className="vital-summary"> Summary
<span> </h3>
<b className="vital-dot vital-red" />4 need attention <div className="flex flex-wrap gap-px-8">
<span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
<b className="text-vital-red inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
4 need attention
</span> </span>
<span> <span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
<b className="vital-dot vital-yellow" />3 to watch <b className="text-vital-yellow inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
3 to watch
</span> </span>
<span> <span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
<b className="vital-dot vital-green" />4 going well <b className="text-vital-green inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
4 going well
</span> </span>
</div> </div>
<h3>More insights</h3> <h3 className="text-ui-14 mt-px-24 mb-px-10 mx-0 text-vital-muted">
More insights
</h3>
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
<div className="vital-insight-placeholder" key={index}> <div
className="flex items-start gap-px-10 mb-px-20 min-h-[85px]"
key={index}
>
<b <b
className={`vital-dot ${index === 2 ? "vital-yellow" : "vital-red"}`} className="inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0 data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
data-tone={index === 2 ? "yellow" : "red"}
/> />
<div <div
className="vital-obscured" className="flex flex-col gap-px-10 flex-1 min-w-0"
role="img" role="img"
aria-label="Reference text obscured" aria-label="Reference text obscured"
> >
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
<i /> <i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
</div> </div>
</div> </div>
))} ))}
<button <button
className="vital-text-button" className="text-ui-13 text-vital-muted cursor-pointer"
type="button" type="button"
onClick={() => setDetail("Insights")} onClick={() => setDetail("Insights")}
> >
Show 6 more Show 6 more
</button> </button>
<p className="vital-updated">Reference data · Sep 25</p> <p className="text-ui-12 text-vital-muted mt-px-28">
Reference data · Sep 25
</p>
</aside> </aside>
</div> </div>
</div> </div>
@@ -419,17 +503,30 @@ export function VitalsPage() {
} }
function SystemCard({ item, onSelect }: { item: (typeof systems)[number]; onSelect: () => void }) { function SystemCard({ item, onSelect }: { item: (typeof systems)[number]; onSelect: () => void }) {
return ( return (
<button type="button" className="vital-system-card" onClick={onSelect}> <button
<span className={`vital-system-icon vital-${item.tone}`}> data-testid="vital-system-card"
<item.icon /> type="button"
className="flex items-center gap-px-12 border border-vital-border rounded-ui-10 p-px-16 cursor-pointer text-left min-w-0 relative hover:bg-muted min-[1700px]:p-px-18 max-[1401px]:pt-px-12 max-[1401px]:pb-px-12 max-[1401px]:px-px-8 max-[1401px]:gap-px-8 max-[1401px]:flex-wrap max-[768px]:p-px-12"
onClick={onSelect}
>
<span
className="grid place-items-center w-[34px] h-[34px] border border-vital-border rounded-ui-7 shrink-0 max-[768px]:w-[28px] max-[768px]:h-[28px] data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
data-tone={item.tone}
>
<item.icon className="w-[17px] h-[17px]" />
</span> </span>
<span className="vital-system-label"> <span className="flex-1 text-ui-14 max-[1401px]:text-ui-12 max-[768px]:text-ui-11">
{item.name} {item.name}
<small>{item.count} biomarkers</small> <small className="block text-ui-11 mt-px-4 whitespace-nowrap text-vital-muted max-[768px]:text-ui-9">
{item.count} biomarkers
</small>
</span> </span>
<span className="vital-system-score"> <span className="text-right text-ui-23 max-[1401px]:ml-auto max-[768px]:text-ui-20">
{item.score} {item.score}
<small className={item.score < 70 ? "vital-yellow" : "vital-green"}> <small
className="block text-ui-11 mt-px-4 whitespace-nowrap max-[768px]:text-ui-9 data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
data-tone={item.score < 70 ? "yellow" : "green"}
>
● {item.score < 70 ? "Watch" : "Optimal"} ● {item.score < 70 ? "Watch" : "Optimal"}
</small> </small>
</span> </span>
File diff suppressed because it is too large Load Diff
+250 -15
View File
@@ -1,15 +1,250 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */ @import "tailwindcss";
@import "./ui.css"; @import "tw-animate-css";
@import "../tokens.css"; @import "shadcn/tailwind.css";
@import "./base.css";
@import "./features/connections/connection-manager.css"; @custom-variant dark (&:is(.dark *));
@import "./features/research/citation-navigation.css";
@import "./features/research/research-panel.css"; :root {
@import "./features/decks/decks.css"; --radius: 0.625rem;
@import "./features/auth/auth.css"; --background: oklch(0.145 0 0);
@import "./features/inbox/inbox.css"; --foreground: oklch(0.985 0 0);
@import "./features/journal/journal.css"; --card: oklch(0.205 0 0);
@import "./features/support/support.css"; --card-foreground: oklch(0.985 0 0);
@import "./features/home/home.css"; --popover: oklch(0.205 0 0);
@import "./features/home/execution-workbench.css"; --popover-foreground: oklch(0.985 0 0);
@import "./features/vitals/vitals.css"; --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--overlay: oklch(0 0 0 / 45%);
}
@theme inline {
--inset-shadow-selection: 2px 0 var(--primary);
--radius-ui-2: 2px;
--radius-ui-7: 7px;
--radius-ui-9: 9px;
--radius-ui-15: 15px;
--tracking-score: -2px;
--drop-shadow-vital-glow: 0 0 10px var(--color-vital-green);
--shadow-vital-menu: 10px 0 50px var(--color-vital-bg);
--spacing-px-10: 10px;
--spacing-px-12: 12px;
--spacing-px-4: 4px;
--spacing-px-14: 14px;
--spacing-px-3: 3px;
--spacing-px-20: 20px;
--spacing-px-8: 8px;
--spacing-px-28: 28px;
--spacing-px-32: 32px;
--spacing-px-24: 24px;
--spacing-px-16: 16px;
--spacing-px-2: 2px;
--spacing-px-7: 7px;
--spacing-px-26: 26px;
--spacing-px-30: 30px;
--spacing-px-22: 22px;
--spacing-px-6: 6px;
--spacing-px-5: 5px;
--spacing-px-18: 18px;
--spacing-px-34: 34px;
--spacing-px-9: 9px;
--spacing-px-15: 15px;
--spacing-px-48: 48px;
--spacing-px-40: 40px;
--spacing-px-80: 80px;
--spacing-px-36: 36px;
--spacing-px-13: 13px;
--spacing-px-55: 55px;
--spacing-px-25: 25px;
--spacing-px-44: 44px;
--spacing-px-64: 64px;
--spacing-px-11: 11px;
--spacing-px-52: 52px;
--spacing-px-54: 54px;
--font-arial: Arial, sans-serif;
--text-ui-36: 36px;
--text-ui-40: 40px;
--text-ui-42: 42px;
--text-research-label: 0.75rem;
--text-research-field: 0.8rem;
--text-research-empty: 0.85rem;
--text-research-body: 0.875rem;
--leading-compact: 1.3;
--leading-heading: 1.4;
--leading-post: 1.55;
--leading-body: 1.6;
--leading-chat: 1.65;
--leading-relaxedcopy: 1.8;
--text-home-greeting: clamp(24px, 3vw, 34px);
--tracking-home: -0.035em;
--tracking-title: -0.04em;
--tracking-subtitle: -0.015em;
--leading-relaxed-copy: 1.8;
--leading-brief: 1.95;
--leading-loose-copy: 1.75;
--leading-message: 1.7;
--color-activity-expanded: color-mix(in oklch, var(--foreground) 28%, var(--border));
--radius-ui-3: 3px;
--radius-ui-6: 6px;
--radius-ui-8: 8px;
--radius-ui-10: 10px;
--radius-ui-12: 12px;
--radius-ui-14: 14px;
--radius-ui-16: 16px;
--radius-ui-20: 20px;
--color-activity-focus: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
--color-activity-routine: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
--color-activity-reply: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
--color-activity-weight: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
--color-activity-optional: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
--font-body:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
--font-display: var(--font-body);
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
--font-weight-book: 550;
--font-weight-demibold: 650;
--text-xs: 0.8571rem;
--text-sm: 1rem;
--text-base: 1rem;
--text-md: 1.125rem;
--text-display-s: 1.125rem;
--text-display-long: 1.125rem;
--text-reader-title: clamp(24px, 2.4vw, 34px);
--leading-cozy: 1.6;
--leading-reader: 2.15;
--color-canvas: var(--background);
--color-paper: var(--background);
--color-paper-2: var(--card);
--color-paper-3: var(--muted);
--color-ink: var(--foreground);
--color-ink-2: var(--foreground);
--color-accent-hover: var(--foreground);
--color-accent-ink: var(--primary-foreground);
--color-rule: var(--border);
--color-control-rule: var(--input);
--color-focus: var(--ring);
--color-error: var(--destructive);
--color-activity-lilac: oklch(78% 0.09 295);
--color-activity-sage: oklch(80% 0.07 155);
--color-activity-peach: oklch(82% 0.08 60);
--color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%);
--color-vital-bg: #0c0d10;
--color-vital-card: #101115;
--color-vital-border: #282a30;
--color-vital-text: #e5e7eb;
--color-vital-muted: #90959f;
--color-vital-green: #55c9a1;
--color-vital-blue: #7e9fe5;
--color-vital-purple: #a892e5;
--color-vital-yellow: #d9ac58;
--color-vital-red: #df6e87;
--color-vital-teal: #59b9b2;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--text-ui-9: 9px;
--text-ui-10: 10px;
--text-ui-11: 11px;
--text-ui-12: 12px;
--text-ui-13: 13px;
--text-ui-14: 14px;
--text-ui-15: 15px;
--text-ui-16: 16px;
--text-ui-17: 17px;
--text-ui-18: 18px;
--text-ui-19: 19px;
--text-ui-20: 20px;
--text-ui-21: 21px;
--text-ui-22: 22px;
--text-ui-23: 23px;
--text-ui-24: 24px;
--text-ui-25: 25px;
--text-ui-26: 26px;
--text-ui-27: 27px;
--text-ui-28: 28px;
--text-ui-29: 29px;
--text-ui-30: 30px;
--text-ui-31: 31px;
--text-ui-32: 32px;
--text-ui-33: 33px;
--text-ui-34: 34px;
--font-sans: var(--font-body);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-overlay: var(--overlay);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply min-w-[320px] overflow-x-clip bg-background text-foreground font-sans text-ui-14 scheme-dark;
font-synthesis: none;
}
body {
@apply m-0 min-w-[320px] overflow-x-clip bg-background text-foreground;
}
a {
@apply text-primary underline-offset-2 active:text-foreground;
}
:focus-visible {
@apply outline-2 outline-offset-2 outline-ring;
}
h1 {
@apply m-0 min-w-0 font-display text-display-s font-bold leading-tight wrap-anywhere;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
@apply animate-none! transition-none!;
}
}
}
-82
View File
@@ -1,82 +0,0 @@
/* Hallmark · macrostructure: sidebar-09 Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · mobile: pass 320/375/414/768 */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--overlay: oklch(0 0 0 / 45%);
}
@theme inline {
--font-sans: var(--font-body);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-overlay: var(--overlay);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
-56
View File
@@ -1,56 +0,0 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
:root {
--color-canvas: var(--background);
--color-paper: var(--background);
--color-paper-2: var(--card);
--color-paper-3: var(--muted);
--color-ink: var(--foreground);
--color-ink-2: var(--foreground);
--color-muted: var(--muted-foreground);
--color-accent: var(--primary);
--color-accent-hover: var(--foreground);
--color-accent-ink: var(--primary-foreground);
--color-rule: var(--border);
--color-control-rule: var(--input);
--color-focus: var(--ring);
--color-error: var(--destructive);
--color-activity-lilac: oklch(78% 0.09 295);
--color-activity-sage: oklch(80% 0.07 155);
--color-activity-peach: oklch(82% 0.08 60);
--color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%);
--color-overlay: oklch(10% 0.015 250 / 70%);
--font-body:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
--font-display: var(--font-body);
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
--space-3xs: 0.125rem;
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.5rem;
--space-2xl: 4rem;
--space-3xl: 6rem;
--text-xs: 0.8571rem;
--text-sm: 1rem;
--text-base: 1rem;
--text-md: 1.125rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-display-s: 1.125rem;
--text-display-long: 1.125rem;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-instant: 100ms;
--dur-short: 150ms;
--dur-medium: 300ms;
--rule-hairline: 1px;
--rule-active: 2px;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}