refactor: migrate UI to Tailwind and unify chat with shadcn
This commit is contained in:
+85
-51
@@ -19,7 +19,9 @@
|
||||
"react-perf",
|
||||
"jsx-a11y"
|
||||
],
|
||||
"jsPlugins": ["@shadcn/lint"],
|
||||
"jsPlugins": [
|
||||
"@shadcn/lint"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "error",
|
||||
"no-unused-vars": [
|
||||
@@ -48,7 +50,9 @@
|
||||
"shadcn/no-arbitrary-values": [
|
||||
"error",
|
||||
{
|
||||
"allow": ["layout"]
|
||||
"allow": [
|
||||
"layout"
|
||||
]
|
||||
}
|
||||
],
|
||||
"shadcn/no-inline-styles": "error",
|
||||
@@ -56,95 +60,114 @@
|
||||
"shadcn/no-restyle": [
|
||||
"error",
|
||||
{
|
||||
"allow": ["layout"],
|
||||
"allow": [
|
||||
"layout"
|
||||
],
|
||||
"contracts": [
|
||||
{
|
||||
"pattern": "^Sidebar$",
|
||||
"allow": [
|
||||
"layout",
|
||||
"border-r",
|
||||
"transition-none",
|
||||
"inbox-sidebar",
|
||||
"inbox-list-panel",
|
||||
"support-sidebar",
|
||||
"support-list"
|
||||
"border-r-0",
|
||||
"border-border",
|
||||
"transition-none"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarHeader$",
|
||||
"allow": ["layout", "py-4", "gap-4", "border-b", "p-5"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"py-4",
|
||||
"gap-4",
|
||||
"border-b",
|
||||
"p-5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarGroup$",
|
||||
"allow": ["layout", "px-3"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"px-3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarFooter$",
|
||||
"allow": ["layout", "px-3", "pb-4"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"px-3",
|
||||
"pb-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarMenuButton$",
|
||||
"allow": ["layout", "p-0"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"p-0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^Label$",
|
||||
"allow": ["layout", "deck-checkbox"]
|
||||
},
|
||||
{
|
||||
"pattern": "^Textarea$",
|
||||
"allow": ["layout", "text-sm", "leading-7", "journal-summary"]
|
||||
},
|
||||
{
|
||||
"pattern": "^MessageScroller$",
|
||||
"allow": ["layout", "research-conversation"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"gap-2",
|
||||
"text-sm",
|
||||
"text-xs",
|
||||
"text-muted-foreground"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^MessageScrollerContent$",
|
||||
"allow": ["layout", "gap-5", "p-4"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"gap-5",
|
||||
"p-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^Marker$",
|
||||
"allow": ["layout", "research-tool-message"]
|
||||
},
|
||||
{
|
||||
"pattern": "^Message$",
|
||||
"allow": ["layout", "research-message"]
|
||||
},
|
||||
{
|
||||
"pattern": "^Collapsible$",
|
||||
"allow": ["layout", "research-account-picker"]
|
||||
"pattern": "^(Marker|Collapsible)$",
|
||||
"allow": [
|
||||
"layout",
|
||||
"text-research-label",
|
||||
"text-muted-foreground"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarProvider$",
|
||||
"allow": [
|
||||
"layout",
|
||||
"workspace",
|
||||
"home-workspace",
|
||||
"inbox-workspace",
|
||||
"vital-workspace",
|
||||
"support-workspace",
|
||||
"journal-workspace"
|
||||
"bg-vital-bg",
|
||||
"text-vital-text",
|
||||
"font-arial"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^Button$",
|
||||
"allow": ["layout", "auth-submit", "inbox-back", "support-back"]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarInset$",
|
||||
"allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"px-2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^Input$",
|
||||
"allow": ["layout", "pl-9"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"pl-9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^SidebarContent$",
|
||||
"allow": ["layout", "gap-0"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"gap-0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "^Badge$",
|
||||
"allow": ["layout", "deck-source-label"]
|
||||
"allow": [
|
||||
"layout",
|
||||
"gap-1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -154,13 +177,17 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["scripts/**"],
|
||||
"files": [
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/ui/**"],
|
||||
"files": [
|
||||
"src/components/ui/**"
|
||||
],
|
||||
"rules": {
|
||||
"shadcn/no-arbitrary-values": "off",
|
||||
"shadcn/no-restyle": "off",
|
||||
@@ -168,8 +195,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||
"plugins": ["vitest"],
|
||||
"files": [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"tests/**/*.{ts,tsx}"
|
||||
],
|
||||
"plugins": [
|
||||
"vitest"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/unbound-method": "error",
|
||||
"vitest/expect-expect": "error",
|
||||
@@ -179,7 +211,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["e2e/**/*.{ts,tsx}"],
|
||||
"files": [
|
||||
"e2e/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "off"
|
||||
}
|
||||
|
||||
@@ -262,22 +262,25 @@ shadcn dialogs with Escape handling and focus restoration. Column menus contain
|
||||
editing, ordering, and deletion; refresh stays available directly in each header.
|
||||
|
||||
Shared UI components live in `src/components/ui`; `components.json` configures
|
||||
the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble,
|
||||
Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved
|
||||
conversations. The icon rail switches between chat and deck management;
|
||||
the registry and theme entrypoint. Research and Messages follow the
|
||||
[shadcn chatbot template](https://github.com/shadcn-ui/chatbot-template), composing
|
||||
MessageScroller, Message, Bubble and InputGroup without separate chat primitives.
|
||||
Research uses Marker and Streamdown for tool progress and streamed Markdown,
|
||||
with reconnectable snapshot SSE and saved conversations. Enter sends a research
|
||||
message; Shift+Enter inserts a line break and IME composition does not submit. The icon rail switches between chat and deck management;
|
||||
closing the mobile sheet or crossing the desktop breakpoint preserves chat
|
||||
drafts, selected accounts and live deck updates. Theme colors are declared in
|
||||
`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens,
|
||||
base styles and feature styles in that order, so the UI lint rules can resolve
|
||||
application classes through the same stylesheet used by the app.
|
||||
drafts, selected accounts and live deck updates. All application styling uses Tailwind utilities in the components.
|
||||
`src/styles.css` contains the Tailwind imports, shared theme tokens, and base
|
||||
utilities; there are no feature stylesheets. Named pixel spacing tokens preserve
|
||||
the compact layouts alongside the existing rem scale. Tests use accessible
|
||||
roles and explicit data attributes rather than styling classes.
|
||||
|
||||
`pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through
|
||||
`oxlint-tsgolint`, including the
|
||||
[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The
|
||||
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
|
||||
class helpers, including shared UI components. Layout values remain available
|
||||
for the sidebar and deck. Component contracts allow only the existing workspace
|
||||
styles; Next.js-specific rules are omitted because this app uses TanStack Start.
|
||||
for the sidebar and deck. Component contracts allow only the required shared-component treatments; Next.js-specific rules are omitted because this app uses TanStack Start.
|
||||
`pnpm format` formats files with Oxfmt defaults, and
|
||||
`pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports
|
||||
of registry components for composition. Additional design rules can be configured
|
||||
|
||||
@@ -15,7 +15,8 @@ remains a visual placeholder, not inferred medical content.
|
||||
as the anatomy illustration source and clipped in CSS. Source media:
|
||||
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
|
||||
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
|
||||
colors are scoped to `--vital-*` tokens in `vitals.css`.
|
||||
colors use `--color-vital-*` Tailwind theme tokens in `src/styles.css`.
|
||||
Layout, responsive states, and chart styling use utilities in the components.
|
||||
|
||||
Sections, tabs, history, chart filters, and detail dialogs are URL state and
|
||||
restore on reload or browser Back/Forward. Simulated climate control values
|
||||
|
||||
@@ -74,18 +74,18 @@ test("creates, switches, renames and deletes persisted deck profiles", async ({
|
||||
await page.getByRole("button", { name: "Create deck", exact: true }).click();
|
||||
await page.getByLabel("New deck name").fill("別の調査");
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await expect(page.locator(".deck-column")).toHaveCount(0);
|
||||
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0);
|
||||
await addColumn(page, "別の観点", "second-deck", "alternate");
|
||||
await showDeckManagement(page);
|
||||
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
|
||||
await closeMobileSidebar(page);
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
|
||||
await showDeckManagement(page);
|
||||
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
|
||||
await closeMobileSidebar(page);
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["別の観点"]);
|
||||
await showDeckManagement(page);
|
||||
await page.getByRole("button", { name: "Delete deck", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
@@ -108,11 +108,17 @@ test("reads user and list timelines within columns and reorders, deletes and res
|
||||
await expect(list.getByText("list page 2")).toBeVisible();
|
||||
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
||||
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||
"リストの投稿",
|
||||
"アカウント",
|
||||
]);
|
||||
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||
"リストの投稿",
|
||||
"アカウント",
|
||||
]);
|
||||
await page.reload();
|
||||
await expect(user.getByText("alternate profile page 1")).toBeVisible();
|
||||
await expect(list.getByText("list page 1")).toBeVisible();
|
||||
@@ -149,7 +155,7 @@ test("keeps the deck and editor accessible within narrow viewports", async ({
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
|
||||
true,
|
||||
);
|
||||
const board = await page.locator(".deck-board").boundingBox();
|
||||
const board = await page.locator("[data-testid='deck-board']").boundingBox();
|
||||
expect(board).not.toBeNull();
|
||||
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
|
||||
}
|
||||
@@ -184,7 +190,7 @@ test("closes the column editor with Escape and restores focus without applying e
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
|
||||
await expect(opener).toBeFocused();
|
||||
await expect(page.locator(".deck-column")).toHaveCount(0);
|
||||
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0);
|
||||
await opener.click();
|
||||
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ test("keeps completed actions in their original positions with visible outcomes"
|
||||
}, testInfo) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const titles = page.locator(".execution-flow-title");
|
||||
const titles = page.locator("[data-testid='execution-flow-title']");
|
||||
const before = await titles.allTextContents();
|
||||
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
||||
await expect(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "../fixtures";
|
||||
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const rows = page.locator(".inbox-article-row");
|
||||
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||
await expect(rows).toHaveCount(9);
|
||||
const articleTitle = "Context for an agent can be brief";
|
||||
await rows.filter({ hasText: articleTitle }).click();
|
||||
@@ -33,7 +33,7 @@ test("selects articles and keeps the reader focused on their content", async ({
|
||||
test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const rows = page.locator(".inbox-article-row");
|
||||
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||
await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
|
||||
await expect(rows).toHaveCount(0);
|
||||
await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
|
||||
@@ -58,7 +58,7 @@ test("keeps article navigation readable without horizontal overflow", async ({
|
||||
await page.setViewportSize({ width, height: 850 });
|
||||
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
await page.locator(".inbox-article-row").first().click();
|
||||
await page.locator("[data-testid='inbox-article-row']").first().click();
|
||||
const reader = page.getByRole("article", {
|
||||
name: "Thinking in the browser: a new workflow with AI agents",
|
||||
exact: true,
|
||||
|
||||
@@ -100,7 +100,7 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]);
|
||||
run.deckVersion = 2;
|
||||
run.status = "complete";
|
||||
run.messages.push({
|
||||
@@ -124,7 +124,10 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
|
||||
await showResearch(page);
|
||||
await expect(conversation).toContainText("既存の観点を使って調査して");
|
||||
await expect(conversation).toContainText("比較する観点を追加しました。");
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||
"最初の観点",
|
||||
"比較の観点",
|
||||
]);
|
||||
await closeMobileSidebar(page);
|
||||
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
|
||||
await showResearch(page);
|
||||
@@ -260,15 +263,15 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col
|
||||
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
|
||||
await chat.getByRole("link", { name: "根拠の投稿" }).click();
|
||||
const card = page.locator(
|
||||
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
|
||||
'[data-testid="research-post"][data-post-url="https://twitter.com/author/status/123456789"]',
|
||||
);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveClass(/citation-highlight/);
|
||||
await expect(card).toHaveAttribute("data-citation-highlight", "true");
|
||||
await expect(card).toBeFocused();
|
||||
await expect(card).toContainText("Post saved during research");
|
||||
await expect(card).toContainText("AIが調査時に取得した引用本文");
|
||||
expect(
|
||||
await page.locator(".deck-board").evaluate((element) => element.scrollLeft),
|
||||
await page.locator("[data-testid='deck-board']").evaluate((element) => element.scrollLeft),
|
||||
).toBeGreaterThan(0);
|
||||
expect(page.context().pages()).toHaveLength(1);
|
||||
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
||||
@@ -283,7 +286,7 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveClass(/citation-highlight/);
|
||||
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
||||
await expect(page.locator(".deck-column")).toHaveCount(3);
|
||||
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(3);
|
||||
expect(submitted).toEqual([]);
|
||||
expect((await a11y().analyze()).violations).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -9,16 +9,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
||||
}) => {
|
||||
await page.goto("/support");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const rows = page.locator(".support-row");
|
||||
const rows = page.locator("[data-testid='support-row']");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await rows.filter({ hasText: reviewSubject }).click();
|
||||
await page.locator(".conversation-suggestions summary:visible").click();
|
||||
await page.locator("[data-testid='conversation-suggestions'] summary:visible").click();
|
||||
await page.getByRole("button", { name: /^Polite/ }).click();
|
||||
const reply = page.getByRole("textbox", { name: "Reply", exact: true });
|
||||
await expect(reply).toHaveValue(/Thank you for sharing/);
|
||||
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
|
||||
await reply.fill(edited);
|
||||
await page.locator(".conversation-scheduling summary:visible").click();
|
||||
await page.locator("[data-testid='conversation-scheduling'] summary:visible").click();
|
||||
const slot = "Mon, Oct 5, 14:00–14:30";
|
||||
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
|
||||
await page.getByRole("button", { name: "Add to reply" }).click();
|
||||
@@ -37,15 +37,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
||||
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Archive", exact: true }).click();
|
||||
await rows.filter({ hasText: reviewSubject }).click();
|
||||
await expect(page.locator(".support-outcome")).toContainText(edited);
|
||||
await expect(page.locator(".support-outcome")).toContainText(slot);
|
||||
await expect(page.locator("[data-testid='support-outcome']")).toContainText(edited);
|
||||
await expect(page.locator("[data-testid='support-outcome']")).toContainText(slot);
|
||||
});
|
||||
|
||||
test("defers a conversation and restores it to the current queue", async ({ page }) => {
|
||||
await page.goto("/support");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const rows = page.locator(".support-row");
|
||||
const rows = page.locator("[data-testid='support-row']");
|
||||
await rows.filter({ hasText: coffeeSubject }).click();
|
||||
await expect(rows.filter({ hasText: coffeeSubject })).toHaveAttribute("aria-current", "true");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Reply", exact: true })
|
||||
.fill("土曜の予定を確認してから返事するね。");
|
||||
@@ -117,7 +118,7 @@ test("fits communication and journal interactions in narrow screens", async ({
|
||||
await page.goto("/support");
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
await page.locator(".support-row").first().click();
|
||||
await page.locator("[data-testid='support-row']").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
|
||||
|
||||
@@ -2,19 +2,22 @@ import { expect, test } from "../fixtures";
|
||||
|
||||
test("restores article selection with browser history and reload", async ({ page }) => {
|
||||
await page.goto("/inbox", { waitUntil: "networkidle" });
|
||||
const rows = page.locator(".inbox-article-row");
|
||||
const heading = await rows.nth(1).locator(".inbox-row-title").innerText();
|
||||
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||
const heading = await rows.nth(1).locator("[data-testid='inbox-row-title']").innerText();
|
||||
await rows.nth(1).click();
|
||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading);
|
||||
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading);
|
||||
const selectedUrl = page.url();
|
||||
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
|
||||
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? "");
|
||||
await page.goBack();
|
||||
await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false");
|
||||
await expect(page.locator("[data-testid='inbox-workspace']")).toHaveAttribute(
|
||||
"data-reading",
|
||||
"false",
|
||||
);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(selectedUrl);
|
||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
|
||||
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? "");
|
||||
});
|
||||
|
||||
test("restores search and replaces typing history", async ({ page }) => {
|
||||
@@ -24,10 +27,10 @@ test("restores search and replaces typing history", async ({ page }) => {
|
||||
await search.pressSequentially("browser");
|
||||
await expect(search).toHaveValue("browser");
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
|
||||
const count = await page.locator(".inbox-article-row").count();
|
||||
const count = await page.locator("[data-testid='inbox-article-row']").count();
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await expect(search).toHaveValue("browser");
|
||||
await expect(page.locator(".inbox-article-row")).toHaveCount(count);
|
||||
await expect(page.locator("[data-testid='inbox-article-row']")).toHaveCount(count);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL("http://127.0.0.1:4173/");
|
||||
});
|
||||
@@ -47,16 +50,21 @@ test("restores account dialog without losing underlying article", async ({ page
|
||||
test("restores a message view and conversation from its URL", async ({ page }) => {
|
||||
await page.goto("/support", { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "All", exact: true }).click();
|
||||
const row = page.locator(".support-row").nth(1);
|
||||
const row = page.locator("[data-testid='support-row']").nth(1);
|
||||
const heading = await row.locator("span").first().innerText();
|
||||
await row.click();
|
||||
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading);
|
||||
await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(heading);
|
||||
const url = page.url();
|
||||
expect(new URL(url).searchParams.get("view")).toBe("all");
|
||||
expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? "");
|
||||
await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true");
|
||||
await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(
|
||||
heading ?? "",
|
||||
);
|
||||
await expect(page.locator("[data-testid='support-workspace']")).toHaveAttribute(
|
||||
"data-reading",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a deep link through login", async ({ page, context }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
|
||||
await page.goto("/vitals");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
|
||||
const systems = page.locator(".vital-system-card");
|
||||
const systems = page.locator("[data-testid='vital-system-card']");
|
||||
await expect(systems).toHaveCount(8);
|
||||
await page
|
||||
.getByRole("group", { name: "Body systems", exact: true })
|
||||
@@ -24,11 +24,11 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await page.getByRole("button", { name: "Lab history", exact: true }).click();
|
||||
await expect(page.locator(".vital-history")).toContainText("Jul 28");
|
||||
await expect(page.locator(".vital-history")).toContainText("93");
|
||||
await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28");
|
||||
await expect(page.locator("[data-testid='vital-history']")).toContainText("93");
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator(".vital-history")).toContainText("Jul 28");
|
||||
await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28");
|
||||
await page.getByRole("button", { name: "Systems", exact: true }).click();
|
||||
await page
|
||||
.getByRole("group", { name: "Body systems", exact: true })
|
||||
@@ -61,7 +61,10 @@ test("explores environment charts and simulated climate controls", async ({ page
|
||||
await expect(
|
||||
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
|
||||
).toBeVisible();
|
||||
await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click();
|
||||
await page
|
||||
.locator("[data-testid='vital-metric']")
|
||||
.filter({ hasText: "Room temperature" })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Room temperature" });
|
||||
await expect(dialog).toContainText("64.0");
|
||||
await page.reload();
|
||||
|
||||
@@ -234,7 +234,7 @@ test("rejects invalid input without changing the active view", async ({ page })
|
||||
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
|
||||
true,
|
||||
);
|
||||
await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
|
||||
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]);
|
||||
}
|
||||
expect(savedCount()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarInset,
|
||||
@@ -22,15 +22,7 @@ type ShellProps = {
|
||||
export function AppShell(props: ShellProps) {
|
||||
return (
|
||||
<TooltipProvider delay={800}>
|
||||
<SidebarProvider
|
||||
className="workspace h-dvh min-h-0 overflow-hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "25rem",
|
||||
"--sidebar-width-icon": "4rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<SidebarProvider className="h-dvh min-h-0 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
|
||||
<Workspace {...props} />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
@@ -106,7 +98,10 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell
|
||||
<span className="text-sm font-medium">Research</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
|
||||
</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}
|
||||
</section>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
<fieldset
|
||||
data-slot="input-group"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,13 +2,20 @@ import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
import { cn } from "cn";
|
||||
import type * as React from "react";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"input"> & { variant?: "default" | "measurement" }) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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",
|
||||
variant === "measurement" &&
|
||||
"bg-background text-ui-20 tabular-nums md:text-ui-20 dark:bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -18,9 +18,6 @@ import { Skeleton } from "#/components/ui/skeleton.tsx";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
|
||||
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";
|
||||
|
||||
type SidebarContextProps = {
|
||||
@@ -49,7 +46,6 @@ function SidebarProvider({
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
@@ -115,15 +111,8 @@ function SidebarProvider({
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -173,12 +162,7 @@ function Sidebar({
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="[--sidebar-width:min(25rem,100vw)] w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
@@ -574,11 +558,6 @@ function SidebarMenuSkeleton({
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
@@ -587,15 +566,7 @@ function SidebarMenuSkeleton({
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<Skeleton className="h-4 max-w-3/4 flex-1" data-sidebar="menu-skeleton-text" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "cn";
|
||||
import type * as React from "react";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
const textareaVariants = cva(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
composer:
|
||||
"resize-y border-0 bg-transparent p-px-8 text-ui-16 leading-relaxed-copy shadow-none md:text-ui-16 dark:bg-transparent",
|
||||
note: "bg-background text-ui-12 md:text-ui-12 dark:bg-background",
|
||||
summary: "text-ui-13 md:text-ui-13",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
function Textarea({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea"> & VariantProps<typeof textareaVariants>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
className={cn(textareaVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,9 @@ import { safeReturnTo } from "./return-to";
|
||||
|
||||
export function AuthFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<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="w-full max-w-[390px] min-w-0">
|
||||
<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" />
|
||||
Personal Workspace
|
||||
</div>
|
||||
@@ -75,19 +75,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
|
||||
return (
|
||||
<AuthFrame>
|
||||
<header className="auth-heading">
|
||||
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
|
||||
<p>
|
||||
<header className="mb-px-28">
|
||||
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||
{setup ? "Make yourself at home." : "Welcome back."}
|
||||
</h1>
|
||||
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
|
||||
{setup
|
||||
? "Create the owner account for your personal workspace."
|
||||
: "Sign in to your personal workspace."}
|
||||
</p>
|
||||
</header>
|
||||
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
|
||||
<form onSubmit={submit} className="grid gap-px-20" aria-busy={pending}>
|
||||
{setup && (
|
||||
<div className="auth-field">
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="name">Your name</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="given-name"
|
||||
@@ -97,9 +100,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-field">
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
@@ -109,9 +113,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
@@ -123,16 +128,17 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
disabled={pending}
|
||||
/>
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{setup && (
|
||||
<>
|
||||
<div className="auth-field">
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
@@ -143,9 +149,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="setupToken">Setup code</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="setupToken"
|
||||
name="setupToken"
|
||||
type="password"
|
||||
@@ -154,18 +161,23 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
aria-describedby="setup-hint"
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="auth-error" role="alert">
|
||||
<p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
|
||||
{error}
|
||||
</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
|
||||
? setup
|
||||
? "Creating account…"
|
||||
@@ -176,7 +188,7 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
<p className="auth-footnote">
|
||||
<p className="mt-px-24 text-ui-12 leading-cozy text-muted-foreground">
|
||||
{setup
|
||||
? "This workspace has one owner. Connections can be added later."
|
||||
: "A private workspace. Access is limited to its owner."}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -46,17 +46,22 @@ export function OnboardingPage() {
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<>
|
||||
<header className="auth-heading">
|
||||
<h1>A little context, to start.</h1>
|
||||
<p>Your day, conversations, and reading, together in one place.</p>
|
||||
<header className="mb-px-28">
|
||||
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||
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>
|
||||
<form className="auth-form" onSubmit={next}>
|
||||
<div className="auth-field">
|
||||
<form className="grid gap-px-20" onSubmit={next}>
|
||||
<div className="grid gap-px-9">
|
||||
<Label htmlFor="displayName">What should we call you?</Label>
|
||||
<Input
|
||||
className="h-[40px]"
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
value={name}
|
||||
@@ -69,7 +74,7 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="auth-submit"
|
||||
className="mt-[4px] w-full justify-between"
|
||||
size="lg"
|
||||
disabled={pending || !name.trim()}
|
||||
>
|
||||
@@ -80,38 +85,57 @@ export function OnboardingPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<header className="auth-heading">
|
||||
<h1>Start with today.</h1>
|
||||
<p>You can take it one thing at a time.</p>
|
||||
<header className="mb-px-28">
|
||||
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||
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>
|
||||
<ul className="auth-overview">
|
||||
<li>
|
||||
<House aria-hidden="true" />
|
||||
<ul className="m-0 mb-px-28 grid list-none gap-px-24 p-0">
|
||||
<li className="flex items-start gap-px-14">
|
||||
<House
|
||||
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<strong>A place to begin</strong>
|
||||
<p>A brief and flexible tasks help you find your next step.</p>
|
||||
<strong className="text-ui-14 font-medium">A place to begin</strong>
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<MessagesSquare aria-hidden="true" />
|
||||
<li className="flex items-start gap-px-14">
|
||||
<MessagesSquare
|
||||
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<strong>Room for the everyday</strong>
|
||||
<p>Keep conversations and quick notes close at hand.</p>
|
||||
<strong className="text-ui-14 font-medium">Room for the everyday</strong>
|
||||
<p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
|
||||
Keep conversations and quick notes close at hand.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<BookOpen aria-hidden="true" />
|
||||
<li className="flex items-start gap-px-14">
|
||||
<BookOpen
|
||||
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<strong>Follow your curiosity</strong>
|
||||
<p>Explore reading and research alongside your day.</p>
|
||||
<strong className="text-ui-14 font-medium">Follow your curiosity</strong>
|
||||
<p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
|
||||
Explore reading and research alongside your day.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</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
|
||||
connections from your profile menu whenever you’re ready.
|
||||
</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}>
|
||||
Back
|
||||
</Button>
|
||||
@@ -123,12 +147,12 @@ export function OnboardingPage() {
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="auth-error" role="alert">
|
||||
<p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<footer className="auth-footer">
|
||||
<span>{auth.owner?.email}</span>
|
||||
<footer className="mt-px-32 flex items-center justify-between gap-px-12 text-ui-12 text-muted-foreground">
|
||||
<span className="min-w-0 wrap-anywhere">{auth.owner?.email}</span>
|
||||
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
|
||||
Sign out
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -74,20 +74,33 @@ export function ConnectionManager({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="connection-manager" aria-label="Connected account management">
|
||||
<p className="connection-manager-description">
|
||||
<section
|
||||
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.
|
||||
</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{connections.length === 0 ? (
|
||||
<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) => (
|
||||
<li key={connection.id}>
|
||||
<div className="connection-manager-account">
|
||||
<li
|
||||
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>
|
||||
<span>
|
||||
<span className="text-research-field text-muted-foreground">
|
||||
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
|
||||
{new URL(connection.origin).hostname}
|
||||
</span>
|
||||
@@ -96,7 +109,10 @@ export function ConnectionManager({
|
||||
</Badge>
|
||||
</div>
|
||||
{connection.platform === "mastodon" && (
|
||||
<div className="connection-manager-actions">
|
||||
<div
|
||||
data-testid="connection-manager-actions"
|
||||
className="flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
@@ -124,13 +140,14 @@ export function ConnectionManager({
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="connection-manager-add"
|
||||
data-testid="connection-manager-add"
|
||||
className="grid gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (origin) void connect(origin);
|
||||
}}
|
||||
>
|
||||
<h3>Add a Mastodon account</h3>
|
||||
<h3 className="m-0 text-base">Add a Mastodon account</h3>
|
||||
{instances.isPending ? (
|
||||
<p role="status">Checking servers…</p>
|
||||
) : instances.isError ? (
|
||||
@@ -164,7 +181,10 @@ export function ConnectionManager({
|
||||
</>
|
||||
)}
|
||||
{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
|
||||
keep first.
|
||||
</p>
|
||||
|
||||
@@ -33,7 +33,8 @@ export function ColumnEditor({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="deck-editor"
|
||||
data-testid="deck-editor"
|
||||
className="m-0 p-0"
|
||||
aria-label={column ? "Edit column" : "Add column"}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -54,8 +55,8 @@ export function ColumnEditor({
|
||||
await onSave(parsed.data);
|
||||
}}
|
||||
>
|
||||
<div className="deck-editor-fields">
|
||||
<Label htmlFor={`${formId}-title`}>
|
||||
<div data-testid="deck-editor-fields" className="grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-title`}>
|
||||
Column name
|
||||
<Input
|
||||
id={`${formId}-title`}
|
||||
@@ -66,7 +67,7 @@ export function ColumnEditor({
|
||||
required
|
||||
/>
|
||||
</Label>
|
||||
<Label htmlFor={`${formId}-profile`}>
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-profile`}>
|
||||
Connection profile
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
@@ -102,13 +103,19 @@ export function ColumnEditor({
|
||||
connectionId={validConnection ? connectionId : ""}
|
||||
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 && (
|
||||
<p role="alert" className="deck-error">
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="deck-error"
|
||||
className="text-sm leading-body text-destructive wrap-anywhere"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="deck-actions">
|
||||
<div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" disabled={!validConnection}>
|
||||
{column ? "Save changes" : "Add and search"}
|
||||
</Button>
|
||||
|
||||
@@ -58,11 +58,20 @@ export function ResearchColumn({
|
||||
const headingId = `column-${column.id}`;
|
||||
const sourceDescription = describeSource(column.source);
|
||||
return (
|
||||
<section className="deck-column" aria-labelledby={headingId}>
|
||||
<header className="deck-column-header">
|
||||
<div className="deck-column-title">
|
||||
<section
|
||||
data-testid="deck-column"
|
||||
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} />
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -114,26 +123,43 @@ export function ResearchColumn({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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 ? (
|
||||
<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"}
|
||||
</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} />
|
||||
{sourceDescription.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="deck-query">{sourceDescription.query}</p>
|
||||
<p className="deck-column-status" role="status">
|
||||
<p data-testid="deck-query" className="my-1 truncate text-xs text-muted-foreground">
|
||||
{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`}
|
||||
</p>
|
||||
</header>
|
||||
<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.
|
||||
tabIndex={0}
|
||||
aria-label={`Posts in ${column.title}`}
|
||||
@@ -157,7 +183,11 @@ export function ResearchColumn({
|
||||
</>
|
||||
) : null}
|
||||
{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>
|
||||
{error.retryable ? (
|
||||
<Button
|
||||
@@ -176,10 +206,15 @@ export function ResearchColumn({
|
||||
</div>
|
||||
) : null}
|
||||
{!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}
|
||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||
<div className="deck-message">
|
||||
<div
|
||||
data-testid="deck-message"
|
||||
className="p-4 text-sm leading-body text-muted-foreground"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
@@ -191,7 +226,9 @@ export function ResearchColumn({
|
||||
</div>
|
||||
) : null}
|
||||
{!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}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -113,7 +113,11 @@ export function DeckPage() {
|
||||
}
|
||||
|
||||
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}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -194,7 +198,7 @@ export function DeckPage() {
|
||||
<Icon name="plus" />
|
||||
<span>Add column</span>
|
||||
</Button>
|
||||
<p className="sidebar-note">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{deck.persisted
|
||||
? "Saved to server · Shared across devices"
|
||||
: "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>
|
||||
{ready ? (
|
||||
<>
|
||||
<h1>{deck.title}</h1>
|
||||
<span className="deck-count">
|
||||
<h1 className="m-0 min-w-0 truncate text-md leading-compact font-bold">
|
||||
{deck.title}
|
||||
</h1>
|
||||
<span data-testid="deck-count" className="text-xs text-muted-foreground">
|
||||
{deck.columns.length} / {MAX_COLUMNS} columns
|
||||
</span>
|
||||
</>
|
||||
@@ -218,7 +227,10 @@ export function DeckPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
<div
|
||||
data-testid="deck-actions"
|
||||
className="flex flex-nowrap items-center gap-2 md:flex-wrap"
|
||||
>
|
||||
{deck.persisted ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -254,12 +266,19 @@ export function DeckPage() {
|
||||
</div>
|
||||
</div>
|
||||
{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…
|
||||
</p>
|
||||
) : null}
|
||||
{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.
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -272,7 +291,11 @@ export function DeckPage() {
|
||||
</p>
|
||||
) : null}
|
||||
{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.
|
||||
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
|
||||
Retry
|
||||
@@ -280,17 +303,29 @@ export function DeckPage() {
|
||||
</p>
|
||||
) : null}
|
||||
{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.
|
||||
</p>
|
||||
) : null}
|
||||
{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.
|
||||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -298,7 +333,8 @@ export function DeckPage() {
|
||||
{renaming ? (
|
||||
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
data-testid="deck-rename"
|
||||
className="m-0 flex flex-wrap items-end gap-3"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
const rawTitle = new FormData(event.currentTarget).get("title");
|
||||
@@ -307,7 +343,7 @@ export function DeckPage() {
|
||||
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
|
||||
<Input
|
||||
id="deck-title"
|
||||
@@ -344,7 +380,11 @@ export function DeckPage() {
|
||||
</Dialog>
|
||||
) : null}
|
||||
{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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -384,7 +424,8 @@ export function DeckPage() {
|
||||
) : null}
|
||||
{ready && deck.columns.length > 0 ? (
|
||||
<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"
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
|
||||
tabIndex={0}
|
||||
|
||||
@@ -33,9 +33,14 @@ export function DeckSwitcher({
|
||||
errorNotice?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="deck-workspaces">
|
||||
<div className="deck-actions">
|
||||
<Label htmlFor="deck-selection">Deck profile</Label>
|
||||
<div data-testid="deck-workspaces" className="min-w-0">
|
||||
<div
|
||||
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
|
||||
className="w-full"
|
||||
id="deck-selection"
|
||||
@@ -55,6 +60,7 @@ export function DeckSwitcher({
|
||||
</NativeSelect>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
aria-label="Create deck"
|
||||
title="Create deck"
|
||||
type="button"
|
||||
@@ -68,6 +74,7 @@ export function DeckSwitcher({
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
aria-label="Delete deck"
|
||||
title="Delete deck"
|
||||
type="button"
|
||||
@@ -83,7 +90,8 @@ export function DeckSwitcher({
|
||||
{dialog === "create" ? (
|
||||
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
data-testid="deck-rename"
|
||||
className="m-0 flex flex-wrap items-end gap-3"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
const title = newTitle.trim();
|
||||
@@ -91,7 +99,7 @@ export function DeckSwitcher({
|
||||
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
|
||||
<Input
|
||||
id="new-deck-title"
|
||||
@@ -112,7 +120,10 @@ export function DeckSwitcher({
|
||||
) : null}
|
||||
{dialog === "delete" ? (
|
||||
<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>
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function MastodonSourceEditor({
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={id}>
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
Column type
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
@@ -78,8 +78,8 @@ function MastodonSearchEditor({
|
||||
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
Mastodon search query
|
||||
<Input
|
||||
id={id}
|
||||
@@ -90,7 +90,7 @@ function MastodonSearchEditor({
|
||||
required
|
||||
/>
|
||||
</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
|
||||
supported.
|
||||
</p>
|
||||
@@ -104,8 +104,8 @@ function MastodonUserEditor({
|
||||
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
User
|
||||
<Input
|
||||
id={id}
|
||||
@@ -126,8 +126,8 @@ function MastodonHashtagEditor({
|
||||
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
Hashtag
|
||||
<Input
|
||||
id={id}
|
||||
@@ -151,8 +151,8 @@ function MastodonListEditor({
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
List
|
||||
<Input
|
||||
id={id}
|
||||
@@ -197,7 +197,11 @@ function MastodonListChoices({
|
||||
</p>
|
||||
);
|
||||
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 ? (
|
||||
<p>This account has no lists.</p>
|
||||
) : (
|
||||
|
||||
@@ -3,10 +3,23 @@ import { ResearchPostSkeleton } from "./research-post-skeleton";
|
||||
|
||||
export function ResearchDeckSkeleton() {
|
||||
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) => (
|
||||
<div key={column} className="deck-column" aria-hidden="true">
|
||||
<div className="deck-column-header space-y-3">
|
||||
<div
|
||||
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">
|
||||
<Skeleton className="size-4 shrink-0" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
@@ -15,7 +28,10 @@ export function ResearchDeckSkeleton() {
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</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 />
|
||||
|
||||
@@ -27,7 +27,7 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
|
||||
article.scrollIntoView = scroll;
|
||||
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
|
||||
expect(article).toHaveFocus();
|
||||
expect(article).toHaveClass("citation-highlight");
|
||||
expect(article).toHaveAttribute("data-citation-highlight", "true");
|
||||
expect(scroll).toHaveBeenCalledWith({
|
||||
block: "center",
|
||||
inline: "center",
|
||||
@@ -46,11 +46,11 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2500);
|
||||
});
|
||||
expect(article).not.toHaveClass("citation-highlight");
|
||||
expect(article).not.toHaveAttribute("data-citation-highlight", "true");
|
||||
rerender(<ResearchPostCard post={post} citationRequest={2} />);
|
||||
expect(article).toHaveClass("citation-highlight");
|
||||
expect(article).toHaveAttribute("data-citation-highlight", "true");
|
||||
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", () => {
|
||||
|
||||
@@ -46,22 +46,32 @@ export function ResearchPostCard({
|
||||
<article
|
||||
ref={article}
|
||||
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}
|
||||
>
|
||||
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
|
||||
{post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null}
|
||||
<header>
|
||||
{post.boostedBy ? (
|
||||
<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">
|
||||
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
|
||||
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">@{post.author.handle.replace(/^@/, "")}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<strong className="text-sm">{post.author.name}</strong>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
@{post.author.handle.replace(/^@/, "")}
|
||||
</span>
|
||||
</div>
|
||||
{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))}
|
||||
</time>
|
||||
) : null}
|
||||
@@ -84,19 +94,24 @@ export function ResearchPostCard({
|
||||
<>
|
||||
{post.html !== undefined ? (
|
||||
<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.
|
||||
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} />
|
||||
</p>
|
||||
)}
|
||||
{post.media?.map((media) =>
|
||||
media.type === "photo" ? (
|
||||
<img
|
||||
className="research-media"
|
||||
data-testid="research-media"
|
||||
className="my-2 block max-h-80 w-full rounded-sm object-contain"
|
||||
key={media.url}
|
||||
src={media.url}
|
||||
alt={media.alt ?? ""}
|
||||
@@ -109,9 +124,9 @@ export function ResearchPostCard({
|
||||
),
|
||||
)}
|
||||
{post.quotedPost ? (
|
||||
<blockquote>
|
||||
<strong>{post.quotedPost.author.name}</strong>
|
||||
<p>
|
||||
<blockquote className="my-3 rounded-sm border p-3">
|
||||
<strong className="text-sm">{post.quotedPost.author.name}</strong>
|
||||
<p className="my-2 text-sm leading-post whitespace-pre-wrap">
|
||||
<PostText text={post.quotedPost.text} />
|
||||
</p>
|
||||
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
|
||||
@@ -121,8 +136,13 @@ export function ResearchPostCard({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<footer>
|
||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
||||
<footer className="mt-3 flex flex-wrap justify-between gap-2 text-xs text-muted-foreground">
|
||||
<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" />
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "#/components/ui/skeleton";
|
||||
|
||||
export function ResearchPostSkeleton() {
|
||||
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">
|
||||
<Skeleton className="size-9 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
|
||||
@@ -38,7 +38,7 @@ export function TwitterSourceEditor({
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={id}>
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
Column type
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
@@ -77,8 +77,8 @@ function TwitterSearchEditor({
|
||||
}: EditorProps<Extract<Source, { kind: "search" }>>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={`${id}-query`}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-query`}>
|
||||
Twitter search query
|
||||
<Input
|
||||
id={`${id}-query`}
|
||||
@@ -89,7 +89,7 @@ function TwitterSearchEditor({
|
||||
required
|
||||
/>
|
||||
</Label>
|
||||
<Label htmlFor={`${id}-product`}>
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-product`}>
|
||||
Sort by
|
||||
<NativeSelect
|
||||
className="w-full"
|
||||
@@ -106,8 +106,13 @@ function TwitterSearchEditor({
|
||||
<NativeSelectOption value="Top">Top</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Label>
|
||||
<p className="deck-help">Use Twitter search operators such as from:, lang:, and since:.</p>
|
||||
<Label className="deck-checkbox">
|
||||
<p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
|
||||
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
|
||||
checked={source.following}
|
||||
onCheckedChange={(checked) => onChange({ ...source, following: checked })}
|
||||
@@ -121,8 +126,8 @@ function TwitterSearchEditor({
|
||||
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
User
|
||||
<Input
|
||||
id={id}
|
||||
@@ -144,8 +149,8 @@ function TwitterListEditor({
|
||||
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="deck-source-fields">
|
||||
<Label htmlFor={id}>
|
||||
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||
List
|
||||
<Input
|
||||
id={id}
|
||||
@@ -185,7 +190,11 @@ function TwitterListChoices({
|
||||
</p>
|
||||
);
|
||||
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 ? (
|
||||
<p>No lists found. You can enter a URL or ID instead.</p>
|
||||
) : (
|
||||
|
||||
@@ -17,13 +17,20 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
||||
);
|
||||
|
||||
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>
|
||||
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
|
||||
{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}
|
||||
</button>
|
||||
{contacts.length > 1
|
||||
@@ -35,12 +42,14 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
||||
)}
|
||||
</p>
|
||||
{resume ? (
|
||||
<p className="home-brief-secondary">
|
||||
<p className="mt-px-8 text-ui-13 text-muted-foreground">
|
||||
Your resume note: “
|
||||
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
|
||||
</p>
|
||||
) : 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}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
);
|
||||
}
|
||||
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" },
|
||||
@@ -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" },
|
||||
] as const
|
||||
).map((section) => (
|
||||
<section className="execution-flow-group" key={section.id}>
|
||||
<header className="execution-flow-heading">
|
||||
<h2>{section.label}</h2>
|
||||
<section className="mb-px-30" key={section.id}>
|
||||
<header className="mb-px-12 flex items-baseline justify-between gap-px-12">
|
||||
<h2 className="text-ui-14 font-semibold">{section.label}</h2>
|
||||
</header>
|
||||
<div className="execution-flow-items">
|
||||
<div className="grid gap-px-9">
|
||||
{tasks
|
||||
.filter((task) => task.section === section.id)
|
||||
.map((task) => {
|
||||
@@ -71,16 +71,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||
return (
|
||||
<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}
|
||||
data-expanded={expanded}
|
||||
data-kind={task.section}
|
||||
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
|
||||
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-pressed={task.status === "done"}
|
||||
disabled={
|
||||
@@ -89,22 +89,27 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
onClick={() => finish(task.id)}
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<Check />
|
||||
<Check className="size-[21px]" strokeWidth={1.5} />
|
||||
) : task.status === "later" ? (
|
||||
<Pause />
|
||||
<Pause className="size-[21px]" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Circle />
|
||||
<Circle className="size-[21px]" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
<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-controls={`detail-${task.id}`}
|
||||
onClick={() => toggle(id)}
|
||||
>
|
||||
<span className="execution-flow-title">{task.title}</span>
|
||||
<span className="execution-flow-description">
|
||||
<span
|
||||
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"
|
||||
? "Completed"
|
||||
: task.status === "later"
|
||||
@@ -116,10 +121,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
: "At your own pace"}
|
||||
</span>
|
||||
</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>
|
||||
{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("")} />
|
||||
</div>
|
||||
)}
|
||||
@@ -129,7 +140,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
{section.id === "focus" &&
|
||||
replies.slice(0, 2).map((conversation) => (
|
||||
<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}
|
||||
data-kind="reply"
|
||||
data-status={
|
||||
@@ -139,23 +150,28 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
: statuses[conversation.id]
|
||||
}
|
||||
>
|
||||
<div className="execution-flow-row">
|
||||
<span className="execution-flow-icon">
|
||||
<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="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] === "dismissed" ? (
|
||||
<Check />
|
||||
<Check className="size-[21px]" strokeWidth={1.5} />
|
||||
) : (
|
||||
<MessageCircle />
|
||||
<MessageCircle className="size-[21px]" strokeWidth={1.5} />
|
||||
)}
|
||||
</span>
|
||||
<Link
|
||||
to="/support"
|
||||
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}`}
|
||||
>
|
||||
<span className="execution-flow-title">Reply to {conversation.name}</span>
|
||||
<span className="execution-flow-description">
|
||||
<span
|
||||
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"
|
||||
? "Reply recorded · Preview only"
|
||||
: statuses[conversation.id] === "dismissed"
|
||||
@@ -167,35 +183,55 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
: conversation.subject}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
{section.id === "routine" && (
|
||||
<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-kind="weight"
|
||||
data-status={weightEntry ? "done" : "todo"}
|
||||
>
|
||||
<div className="execution-flow-row">
|
||||
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span>
|
||||
<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="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
|
||||
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-controls="detail-weight"
|
||||
onClick={() => toggle("weight")}
|
||||
>
|
||||
<span className="execution-flow-title">Record weight</span>
|
||||
<span className="execution-flow-description">
|
||||
<span
|
||||
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"}
|
||||
</span>
|
||||
</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>
|
||||
{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
|
||||
key={weightEntry?.id ?? "new"}
|
||||
entry={weightEntry}
|
||||
@@ -208,7 +244,9 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
||||
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
|
||||
(section.id !== "focus" || !replies.length) &&
|
||||
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>
|
||||
</section>
|
||||
@@ -248,20 +286,20 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="execution-detail-symbol">
|
||||
<div className="hidden">
|
||||
<ListChecks />
|
||||
</div>
|
||||
<p className="execution-eyebrow">
|
||||
<p className="hidden">
|
||||
{task.section === "routine"
|
||||
? "Your routine"
|
||||
: task.section === "optional"
|
||||
? "If there’s room"
|
||||
: "One next step"}
|
||||
</p>
|
||||
<h2>{task.title}</h2>
|
||||
<h2 className="hidden">{task.title}</h2>
|
||||
{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>
|
||||
{checked} of {steps.length} steps
|
||||
</span>
|
||||
@@ -273,9 +311,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
: "At your pace"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="execution-step-list">
|
||||
<div className="grid">
|
||||
{steps.map((step, index) => (
|
||||
<label
|
||||
className="group/step flex cursor-pointer items-center gap-px-12 border-t border-border py-px-14 text-ui-13"
|
||||
key={step}
|
||||
htmlFor={`execution-step-${task.id}-${index}`}
|
||||
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="execution-step-number">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="flex-1 group-data-[checked=true]/step:text-muted-foreground group-data-[checked=true]/step:line-through">
|
||||
{step}
|
||||
</span>
|
||||
<span className="text-ui-11 text-muted-foreground tabular-nums">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="execution-description">
|
||||
<p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
|
||||
{task.id === "review"
|
||||
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
|
||||
: 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."}
|
||||
</p>
|
||||
{task.id === "review" && (
|
||||
<div className="execution-next-step">
|
||||
<span>Start here</span>
|
||||
<p>Open the materials and look at the first screen.</p>
|
||||
<div className="mt-px-14 border-t border-border py-px-14">
|
||||
<span className="text-ui-11 text-muted-foreground">Start here</span>
|
||||
<p className="mt-px-5 text-ui-13 leading-loose-copy">
|
||||
Open the materials and look at the first screen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="execution-detail-actions">
|
||||
<div className="mt-px-20 flex flex-wrap gap-px-8">
|
||||
{task.status !== "done" && (
|
||||
<Button
|
||||
onClick={() => setStatus("done")}
|
||||
@@ -344,9 +389,13 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<section className="execution-resume-note" aria-label="Resume note">
|
||||
<label htmlFor={`resume-${task.id}`}>A place to pick up</label>
|
||||
<section className="mt-px-25 border-t border-border pt-px-20" aria-label="Resume note">
|
||||
<label className="mb-px-10 block text-ui-12 font-medium" htmlFor={`resume-${task.id}`}>
|
||||
A place to pick up
|
||||
</label>
|
||||
<Textarea
|
||||
variant="note"
|
||||
className="min-h-[74px]"
|
||||
id={`resume-${task.id}`}
|
||||
placeholder="Where did you leave off?"
|
||||
value={note}
|
||||
@@ -355,8 +404,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
setNotice("");
|
||||
}}
|
||||
/>
|
||||
<div className="execution-note-footer">
|
||||
<span role="status">{notice || "Leave a sentence for your future self."}</span>
|
||||
<div className="mt-px-8 flex items-center justify-between gap-px-10 max-md:items-start">
|
||||
<span className="text-ui-11 text-muted-foreground" role="status">
|
||||
{notice || "Leave a sentence for your future self."}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -381,8 +432,8 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="execution-order">
|
||||
<span>Order in your list</span>
|
||||
<div className="mt-px-20 flex items-center justify-end gap-px-6 text-muted-foreground">
|
||||
<span className="mr-auto text-ui-11">Order in your list</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -410,14 +461,16 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
||||
const [error, setError] = useState("");
|
||||
return (
|
||||
<>
|
||||
<div className="execution-detail-symbol">
|
||||
<div className="hidden">
|
||||
<Scale />
|
||||
</div>
|
||||
<p className="execution-eyebrow">A quick check-in</p>
|
||||
<h2>Record your weight</h2>
|
||||
<p className="execution-description">Just a measurement. No target to hit.</p>
|
||||
<p className="hidden">A quick check-in</p>
|
||||
<h2 className="hidden">Record your weight</h2>
|
||||
<p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
|
||||
Just a measurement. No target to hit.
|
||||
</p>
|
||||
<form
|
||||
className="execution-weight-form"
|
||||
className="mt-px-18 flex flex-wrap items-end gap-px-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const value = Number(weight);
|
||||
@@ -441,9 +494,13 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
||||
onAction();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="execution-weight">Weight</label>
|
||||
<div className="execution-weight-input">
|
||||
<label className="w-full text-ui-12" htmlFor="execution-weight">
|
||||
Weight
|
||||
</label>
|
||||
<div className="flex max-w-[180px] items-center gap-px-10">
|
||||
<Input
|
||||
variant="measurement"
|
||||
className="h-[42px]"
|
||||
id="execution-weight"
|
||||
inputMode="decimal"
|
||||
type="number"
|
||||
@@ -459,10 +516,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
<span>kg</span>
|
||||
<span className="text-ui-13 text-muted-foreground">kg</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p id="execution-weight-error" role="alert">
|
||||
<p
|
||||
className="w-full text-ui-12 text-destructive"
|
||||
id="execution-weight-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -471,12 +532,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
||||
</Button>
|
||||
</form>
|
||||
{entry && (
|
||||
<div className="execution-next-step">
|
||||
<span>Recorded</span>
|
||||
<p>{entry.text} kg</p>
|
||||
<div className="mt-px-14 border-t border-border py-px-14">
|
||||
<span className="text-ui-11 text-muted-foreground">Recorded</span>
|
||||
<p className="mt-px-5 text-ui-13 leading-loose-copy">{entry.text} kg</p>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,24 +136,28 @@ export function HomePage() {
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delay={500}>
|
||||
<SidebarProvider className="home-workspace">
|
||||
<SidebarProvider className="h-dvh min-h-0 overflow-hidden">
|
||||
<WorkspaceNavigation />
|
||||
<SidebarInset className="home-main">
|
||||
<div className="home-scroll">
|
||||
<div className="home-content">
|
||||
<header className="home-greeting">
|
||||
<SidebarInset className="min-w-0 overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<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="mb-px-32">
|
||||
<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} />
|
||||
</header>
|
||||
<form
|
||||
className="home-composer"
|
||||
className="rounded-ui-20 border border-border bg-muted p-px-16 max-md:p-px-10"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
interpret();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
variant="composer"
|
||||
className="min-h-[130px] max-md:min-h-[150px]"
|
||||
ref={input}
|
||||
aria-label="Home input"
|
||||
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>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -186,17 +190,22 @@ export function HomePage() {
|
||||
</Button>
|
||||
</div>
|
||||
</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) => (
|
||||
<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}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{proposal && (
|
||||
<div className="home-proposal">
|
||||
<p>{proposal.label}</p>
|
||||
<div className="mt-px-20 flex flex-wrap items-center gap-px-12 rounded-ui-12 border border-border p-px-16">
|
||||
<p className="min-w-[140px] flex-1 text-ui-14">{proposal.label}</p>
|
||||
<Button onClick={() => apply(proposal.action)}>Apply</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -208,24 +217,31 @@ export function HomePage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="home-status" role="status">
|
||||
<p className="min-h-[36px] pt-px-12 text-ui-12 text-muted-foreground" role="status">
|
||||
{notice}
|
||||
</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
|
||||
selectedId={search.activity}
|
||||
onSelect={(id) => updateSearch({ activity: id || undefined })}
|
||||
/>
|
||||
<aside aria-label="Today’s context">
|
||||
<section className="home-section">
|
||||
<header className="home-section-heading">
|
||||
<h2>Journal</h2>
|
||||
<Link to="/journal" aria-label="Open journal">
|
||||
<section className="mb-px-36">
|
||||
<header className="mb-px-12 flex items-center justify-between">
|
||||
<h2 className="text-ui-14 font-semibold">Journal</h2>
|
||||
<Link
|
||||
className="p-px-6 text-muted-foreground"
|
||||
to="/journal"
|
||||
aria-label="Open journal"
|
||||
>
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
{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">
|
||||
{entryKindLabels[entry.kind]}
|
||||
</span>
|
||||
@@ -238,7 +254,7 @@ export function HomePage() {
|
||||
{!currentEntries.length && (
|
||||
<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:")}
|
||||
>
|
||||
<span>Leave a note</span>
|
||||
@@ -246,17 +262,21 @@ export function HomePage() {
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
<section className="home-section">
|
||||
<header className="home-section-heading">
|
||||
<h2>Reading</h2>
|
||||
<Link to="/inbox" aria-label="Open reader">
|
||||
<section className="mb-px-36">
|
||||
<header className="mb-px-12 flex items-center justify-between">
|
||||
<h2 className="text-ui-14 font-semibold">Reading</h2>
|
||||
<Link
|
||||
className="p-px-6 text-muted-foreground"
|
||||
to="/inbox"
|
||||
aria-label="Open reader"
|
||||
>
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
{articles.slice(0, 2).map((item) => (
|
||||
<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}
|
||||
onClick={() => setArticleId(item.id)}
|
||||
>
|
||||
@@ -265,7 +285,10 @@ export function HomePage() {
|
||||
</button>
|
||||
))}
|
||||
</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
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,10 +29,22 @@ export function InboxPage() {
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={500}>
|
||||
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
|
||||
<Sidebar collapsible="none" className="inbox-sidebar">
|
||||
<SidebarProvider
|
||||
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 />
|
||||
<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">
|
||||
<h1 className="text-base font-semibold">Reader</h1>
|
||||
<div className="relative">
|
||||
@@ -60,7 +72,10 @@ export function InboxPage() {
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
<Button variant="outline" onClick={clearSearch}>
|
||||
Clear search
|
||||
@@ -70,12 +85,19 @@ export function InboxPage() {
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</Sidebar>
|
||||
<SidebarInset className="inbox-reader">
|
||||
<header className="inbox-reader-toolbar">
|
||||
<SidebarInset
|
||||
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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="inbox-back"
|
||||
data-testid="inbox-back"
|
||||
className="hidden max-md:inline-flex"
|
||||
aria-label="Back to articles"
|
||||
onClick={() => updateSearch({ article: undefined })}
|
||||
>
|
||||
@@ -84,7 +106,8 @@ export function InboxPage() {
|
||||
<span className="text-xs text-muted-foreground">{selected?.source}</span>
|
||||
</header>
|
||||
<section
|
||||
className="inbox-reader-scroll"
|
||||
data-testid="inbox-reader-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
key={selected?.id}
|
||||
aria-label="Article content"
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable.
|
||||
@@ -110,28 +133,54 @@ function ArticleRow({
|
||||
return (
|
||||
<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}
|
||||
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>
|
||||
<time className="ml-auto shrink-0">{article.date}</time>
|
||||
</span>
|
||||
<span className="inbox-row-title">{article.title}</span>
|
||||
<span className="inbox-row-summary">{article.summary}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleReader({ article }: { article: Article }) {
|
||||
return (
|
||||
<article className="inbox-article" aria-label={article.title}>
|
||||
<h2 className="inbox-article-title">{article.title}</h2>
|
||||
<p className="inbox-byline text-xs text-muted-foreground">
|
||||
<article
|
||||
data-testid="inbox-article"
|
||||
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}
|
||||
</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) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,10 @@ export function JournalPage() {
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={500}>
|
||||
<SidebarProvider className="journal-workspace">
|
||||
<SidebarProvider className="h-dvh min-h-0 overflow-hidden">
|
||||
<WorkspaceNavigation />
|
||||
<SidebarInset className="journal-main">
|
||||
<header className="journal-header">
|
||||
<SidebarInset className="min-w-0 overflow-hidden">
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -74,8 +74,8 @@ export function JournalPage() {
|
||||
Summarize entries
|
||||
</Button>
|
||||
</header>
|
||||
<div className="journal-scroll">
|
||||
<div className="journal-column">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<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
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
@@ -85,15 +85,19 @@ export function JournalPage() {
|
||||
}}
|
||||
submitLabel="Save entry"
|
||||
/>
|
||||
<section className="journal-entries" aria-label="Journal entries">
|
||||
{entries.length === 0 && <p className="journal-empty">No entries yet</p>}
|
||||
<section className="mt-px-36" aria-label="Journal entries">
|
||||
{entries.length === 0 && (
|
||||
<p className="py-px-40 text-center text-ui-14 text-muted-foreground">
|
||||
No entries yet
|
||||
</p>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
className="journal-entry"
|
||||
className="border-t border-border py-px-20"
|
||||
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>
|
||||
{superseded.has(entry.id) && (
|
||||
<span className="text-xs text-muted-foreground">Original</span>
|
||||
@@ -112,7 +116,7 @@ export function JournalPage() {
|
||||
</Button>
|
||||
)}
|
||||
</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.kind === "体重" ? " kg" : ""}
|
||||
</p>
|
||||
@@ -151,10 +155,15 @@ export function JournalPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the entries to include. Nothing is sent externally.
|
||||
</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) => (
|
||||
<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
|
||||
className="mt-[4px]"
|
||||
id={`review-${entry.id}`}
|
||||
checked={selected.has(entry.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -180,7 +189,8 @@ export function JournalPage() {
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="Entry summary"
|
||||
className="journal-summary"
|
||||
variant="summary"
|
||||
className="min-h-[180px] max-h-[280px] overflow-y-auto"
|
||||
readOnly
|
||||
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));
|
||||
return (
|
||||
<form
|
||||
className="journal-editor"
|
||||
className="flex flex-col gap-px-16"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
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) => (
|
||||
<Button
|
||||
key={kind}
|
||||
className="max-[600px]:px-2"
|
||||
type="button"
|
||||
variant={draft.kind === kind ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
@@ -242,6 +253,7 @@ function EntryEditor({
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-[112px]"
|
||||
aria-label="Entry text"
|
||||
placeholder={
|
||||
draft.kind === "服薬"
|
||||
@@ -262,10 +274,14 @@ function EntryEditor({
|
||||
onChange={(event) => onChange({ ...draft, source: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
<div className="journal-editor-footer">
|
||||
<label className="journal-date-label" htmlFor={dateId}>
|
||||
<div className="flex items-end justify-between gap-px-12 max-[600px]:flex-col max-[600px]:items-stretch">
|
||||
<label
|
||||
className="flex min-w-0 flex-col gap-px-8 text-ui-12 text-muted-foreground"
|
||||
htmlFor={dateId}
|
||||
>
|
||||
When it happened (optional)
|
||||
<Input
|
||||
className="max-w-full"
|
||||
id={dateId}
|
||||
type="datetime-local"
|
||||
aria-label="When it happened"
|
||||
@@ -273,7 +289,7 @@ function EntryEditor({
|
||||
onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" disabled={!valid}>
|
||||
<Button className="max-[600px]:self-end" type="submit" disabled={!valid}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,7 +21,10 @@ export function ResearchMarkdown({
|
||||
onNavigateCitation?: (url: string) => boolean;
|
||||
}) {
|
||||
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
|
||||
mode={isAnimating ? "streaming" : "static"}
|
||||
isAnimating={isAnimating}
|
||||
@@ -32,17 +35,47 @@ export function ResearchMarkdown({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
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>,
|
||||
pre: ({ children }) => <pre>{children}</pre>,
|
||||
code: ({ children, className }) => <code className={className}>{children}</code>,
|
||||
h1: ({ children }) => <h3>{children}</h3>,
|
||||
h2: ({ children }) => <h4>{children}</h4>,
|
||||
h3: ({ children }) => <h5>{children}</h5>,
|
||||
h4: ({ children }) => <h6>{children}</h6>,
|
||||
h5: ({ children }) => <h6>{children}</h6>,
|
||||
h6: ({ children }) => <h6>{children}</h6>,
|
||||
pre: ({ children }) => (
|
||||
<pre className="max-w-full overflow-x-auto bg-muted p-3 text-research-label whitespace-pre">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }) => (
|
||||
<code className={`wrap-anywhere ${className ?? ""}`}>{children}</code>
|
||||
),
|
||||
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
|
||||
className="text-primary underline underline-offset-2"
|
||||
href={href}
|
||||
title={title}
|
||||
target="_blank"
|
||||
@@ -65,7 +98,12 @@ export function ResearchMarkdown({
|
||||
),
|
||||
img: ({ src, alt }) =>
|
||||
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"}
|
||||
</a>
|
||||
) : (
|
||||
@@ -73,12 +111,13 @@ export function ResearchMarkdown({
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<section
|
||||
className="research-markdown-table"
|
||||
data-testid="research-markdown-table"
|
||||
className="my-3 max-w-full overflow-x-auto"
|
||||
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.
|
||||
tabIndex={0}
|
||||
>
|
||||
<table>{children}</table>
|
||||
<table className="w-full border-collapse text-research-label">{children}</table>
|
||||
</section>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -801,7 +801,7 @@ it("animates only the latest live assistant response and leaves prior replies st
|
||||
};
|
||||
const view = show();
|
||||
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[0]?.querySelector("[data-sd-animate]")).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());
|
||||
});
|
||||
|
||||
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" }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerFn } from "@tanstack/react-start";
|
||||
import { ArrowUpIcon, SquareIcon } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { Bubble, BubbleContent } from "#/components/ui/bubble";
|
||||
import { Button } from "#/components/ui/button";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty";
|
||||
import { Checkbox } from "#/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
|
||||
import {
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
InputGroupTextarea,
|
||||
} from "#/components/ui/input-group";
|
||||
import { Marker, MarkerContent } from "#/components/ui/marker";
|
||||
import { Message, MessageContent, MessageHeader } from "#/components/ui/message";
|
||||
import { Message, MessageContent } from "#/components/ui/message";
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
@@ -291,15 +293,26 @@ export function ResearchPanel({
|
||||
}
|
||||
|
||||
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
||||
<aside className="research-panel" aria-label="Research chat">
|
||||
<header className="research-heading">
|
||||
<h2>Research chat</h2>
|
||||
<aside
|
||||
data-testid="research-panel"
|
||||
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}>
|
||||
New chat
|
||||
</Button>
|
||||
{(status.data?.history?.length ?? 0) > 0 && (
|
||||
<label className="research-history" htmlFor={historyId}>
|
||||
<span>Chat history</span>
|
||||
<label
|
||||
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
|
||||
id={historyId}
|
||||
size="sm"
|
||||
@@ -320,12 +333,20 @@ export function ResearchPanel({
|
||||
)}
|
||||
</header>
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
{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.
|
||||
<Button type="button" onClick={() => void status.refetch()}>
|
||||
Retry
|
||||
@@ -333,19 +354,31 @@ export function ResearchPanel({
|
||||
</p>
|
||||
)}
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{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 ? (
|
||||
<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}
|
||||
<ResearchConversation
|
||||
key={`conversation:${run?.id ?? "new"}`}
|
||||
@@ -358,14 +391,7 @@ export function ResearchPanel({
|
||||
return navigated;
|
||||
}}
|
||||
/>
|
||||
{run && (
|
||||
<ResearchRunStatus
|
||||
run={run}
|
||||
busy={busy}
|
||||
onCancel={() => void stop()}
|
||||
onOpenDeck={(deck) => void openDeck(deck)}
|
||||
/>
|
||||
)}
|
||||
{run && <ResearchRunStatus run={run} onOpenDeck={(deck) => void openDeck(deck)} />}
|
||||
<ResearchComposer
|
||||
key={run?.id ?? "new"}
|
||||
topic={draft.topic}
|
||||
@@ -378,6 +404,9 @@ export function ResearchPanel({
|
||||
!ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured
|
||||
}
|
||||
onSend={begin}
|
||||
active={isResearchActive(run)}
|
||||
busy={busy}
|
||||
onStop={() => void stop()}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
@@ -398,15 +427,20 @@ function ResearchConversation({
|
||||
const names = { user: "You", assistant: "Codex", tool: "Tool activity" };
|
||||
return (
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller className="research-conversation">
|
||||
<MessageScroller data-testid="research-conversation" className="min-h-[80px] flex-1">
|
||||
<MessageScrollerViewport aria-label="Research messages">
|
||||
<MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<MessageScrollerItem messageId="empty">
|
||||
<p className="research-empty">
|
||||
Send a topic or question. The assistant adds columns as it searches and saves a
|
||||
report to your home server.
|
||||
</p>
|
||||
<Empty data-testid="research-empty">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>What would you like to explore?</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Send a topic or question. Codex adds columns as it searches and saves a report
|
||||
to your home server.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</MessageScrollerItem>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
@@ -416,7 +450,10 @@ function ResearchConversation({
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
{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>
|
||||
<span className="sr-only">{names.tool}: </span>
|
||||
{message.text}
|
||||
@@ -425,10 +462,11 @@ function ResearchConversation({
|
||||
) : (
|
||||
<Message
|
||||
align={message.role === "user" ? "end" : "start"}
|
||||
className="research-message"
|
||||
data-testid="research-message"
|
||||
className="wrap-anywhere"
|
||||
>
|
||||
<MessageContent>
|
||||
<MessageHeader>{names[message.role]}</MessageHeader>
|
||||
<span className="sr-only">{names[message.role]}</span>
|
||||
{message.role === "assistant" ? (
|
||||
<ResearchMarkdown
|
||||
text={message.text}
|
||||
@@ -438,9 +476,9 @@ function ResearchConversation({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Bubble align="end">
|
||||
<Bubble align="end" variant="muted">
|
||||
<BubbleContent>
|
||||
<p>{message.text}</p>
|
||||
<p className="m-0 whitespace-pre-wrap">{message.text}</p>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)}
|
||||
@@ -467,6 +505,9 @@ function ResearchComposer({
|
||||
contextTitle,
|
||||
disabled,
|
||||
onSend,
|
||||
active,
|
||||
busy,
|
||||
onStop,
|
||||
}: {
|
||||
topic: string;
|
||||
selected: string[] | null;
|
||||
@@ -476,6 +517,9 @@ function ResearchComposer({
|
||||
contextTitle: string;
|
||||
disabled: boolean;
|
||||
onSend: (input: ResearchInput) => Promise<boolean>;
|
||||
active: boolean;
|
||||
busy: boolean;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const available = connections.filter((connection) => connection.status === "connected");
|
||||
const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id);
|
||||
@@ -485,7 +529,8 @@ function ResearchComposer({
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="research-start-form"
|
||||
data-testid="research-start-form"
|
||||
className="shrink-0 p-3"
|
||||
aria-label="Research messages"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -498,22 +543,72 @@ function ResearchComposer({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="research-context">
|
||||
Deck to send: <strong>{contextTitle}</strong>
|
||||
</p>
|
||||
<fieldset disabled={disabled}>
|
||||
<Collapsible className="research-account-picker" disabled={disabled}>
|
||||
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
|
||||
<Collapsible data-testid="research-account-picker" disabled={disabled}>
|
||||
<label className="sr-only" htmlFor={id}>
|
||||
Message
|
||||
</label>
|
||||
<InputGroup>
|
||||
<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}
|
||||
</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>
|
||||
<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>
|
||||
{available.length === 0 ? (
|
||||
<p>Add a connected account first.</p>
|
||||
) : (
|
||||
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
|
||||
id={`${id}-${connection.id}`}
|
||||
checked={selectedIds.includes(connection.id)}
|
||||
@@ -537,69 +632,52 @@ function ResearchComposer({
|
||||
</fieldset>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<label htmlFor={id}>Message</label>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
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}
|
||||
<p
|
||||
data-testid="research-context"
|
||||
className="mt-2 mb-0 text-research-label text-muted-foreground wrap-anywhere"
|
||||
>
|
||||
Send
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</fieldset>
|
||||
Deck to send: <strong>{contextTitle}</strong>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ResearchRunStatus({
|
||||
run,
|
||||
busy,
|
||||
onCancel,
|
||||
onOpenDeck,
|
||||
}: {
|
||||
run: ResearchRun;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onOpenDeck: (deck: Deck) => void;
|
||||
}) {
|
||||
const deck = run.deck ?? run.contextDeck;
|
||||
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">
|
||||
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
||||
</Marker>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{run.reportPath && (
|
||||
<p>
|
||||
<p className="mt-0 mb-2 wrap-anywhere">
|
||||
Markdown location
|
||||
<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>
|
||||
)}
|
||||
<div className="deck-actions">
|
||||
{isResearchActive(run) && (
|
||||
<Button variant="outline" size="sm" type="button" disabled={busy} onClick={onCancel}>
|
||||
Stop research
|
||||
</Button>
|
||||
)}
|
||||
<div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
|
||||
{deck && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-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 {
|
||||
Dialog,
|
||||
@@ -11,7 +13,22 @@ import {
|
||||
DialogTitle,
|
||||
} from "#/components/ui/dialog";
|
||||
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 { reviewReply } from "./server-functions";
|
||||
|
||||
@@ -39,41 +56,71 @@ export function ConversationDetail({
|
||||
});
|
||||
return (
|
||||
<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}`}
|
||||
>
|
||||
<div className="conversation-thread">
|
||||
<section className="conversation-messages" aria-label="Original messages">
|
||||
<div className="flex min-w-0 flex-col @min-[780px]:min-h-0">
|
||||
<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) => (
|
||||
<div
|
||||
key={message.id}
|
||||
<MessageScrollerItem key={message.id} messageId={message.id}>
|
||||
<Message
|
||||
id={`${instanceId}-source-${message.id}`}
|
||||
className="conversation-message"
|
||||
data-direction={message.direction}
|
||||
align={message.direction === "out" ? "end" : "start"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p className="conversation-message-meta">
|
||||
<MessageContent>
|
||||
<MessageHeader>
|
||||
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
|
||||
<span>
|
||||
<span className="ml-2 font-normal">
|
||||
{message.channel} · {message.time}
|
||||
</span>
|
||||
</p>
|
||||
<div className="conversation-bubble">{message.text}</div>
|
||||
</div>
|
||||
</MessageHeader>
|
||||
<Bubble
|
||||
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 aria-label="Compose a reply" className="conversation-compose">
|
||||
<div className="conversation-compose-heading">
|
||||
<label htmlFor={`${instanceId}-reply`}>Reply</label>
|
||||
<span>To {conversation.name.split(" ")[0]}</span>
|
||||
<section
|
||||
aria-label="Compose a reply"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<details className="conversation-suggestions">
|
||||
<summary>
|
||||
Reply suggestions <ChevronDown aria-hidden="true" />
|
||||
<details data-testid="conversation-suggestions">
|
||||
<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 className="size-[12px]" aria-hidden="true" />
|
||||
</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) => (
|
||||
<button
|
||||
<Button
|
||||
variant={tone === option.label ? "secondary" : "outline"}
|
||||
className="h-auto w-full justify-start"
|
||||
key={option.label}
|
||||
type="button"
|
||||
aria-pressed={tone === option.label}
|
||||
@@ -82,14 +129,17 @@ export function ConversationDetail({
|
||||
setTone(option.label);
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<p>{option.text}</p>
|
||||
</button>
|
||||
<span className="block text-left whitespace-normal">
|
||||
<span className="block text-xs text-muted-foreground">{option.label}</span>
|
||||
<span className="mt-1 block font-normal leading-relaxed">{option.text}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<div className="conversation-editor">
|
||||
<Textarea
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
className="min-h-[100px] max-h-[240px]"
|
||||
id={`${instanceId}-reply`}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
@@ -97,22 +147,28 @@ export function ConversationDetail({
|
||||
setTone("");
|
||||
}}
|
||||
/>
|
||||
<div className="conversation-compose-footer">
|
||||
<span>Draft · {conversation.replyChannel}</span>
|
||||
<Button size="sm" disabled={!draft.trim()} onClick={() => setReview("send")}>
|
||||
<InputGroupAddon align="block-end">
|
||||
<InputGroupText>Draft · {conversation.replyChannel}</InputGroupText>
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => setReview("send")}
|
||||
>
|
||||
<Send />
|
||||
Review reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{conversation.scheduleOptions.length > 0 && (
|
||||
<details className="conversation-scheduling">
|
||||
<summary>
|
||||
<CalendarPlus aria-hidden="true" />
|
||||
<details data-testid="conversation-scheduling">
|
||||
<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 className="size-[13px]" aria-hidden="true" />
|
||||
Suggest a time
|
||||
<ChevronDown aria-hidden="true" />
|
||||
<ChevronDown className="size-[13px]" aria-hidden="true" />
|
||||
</summary>
|
||||
<div>
|
||||
<div className="mt-px-10 flex flex-wrap gap-px-6">
|
||||
<NativeSelect
|
||||
aria-label="Suggested times"
|
||||
value={slot}
|
||||
@@ -143,7 +199,7 @@ export function ConversationDetail({
|
||||
aria-label="Preview calendar hold"
|
||||
onClick={() => setReview("schedule")}
|
||||
>
|
||||
<CalendarPlus />
|
||||
<CalendarPlus className="size-[13px]" />
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -154,7 +210,7 @@ export function ConversationDetail({
|
||||
{scheduled} held in this preview
|
||||
</p>
|
||||
)}
|
||||
<div className="conversation-triage">
|
||||
<div className="mt-px-2 flex flex-wrap gap-px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -172,47 +228,59 @@ export function ConversationDetail({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside className="conversation-context" aria-label="Person and conversation context">
|
||||
<details open className="conversation-context-details">
|
||||
<summary>
|
||||
<aside
|
||||
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"
|
||||
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
|
||||
<ChevronDown aria-hidden="true" />
|
||||
<ChevronDown className="size-3.5" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="conversation-person">
|
||||
<span className="support-avatar" aria-hidden="true">
|
||||
<div className="grid justify-items-start py-px-20">
|
||||
<Avatar size="lg" aria-hidden="true">
|
||||
<AvatarFallback>
|
||||
{conversation.name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")}
|
||||
</span>
|
||||
<h3>{conversation.name}</h3>
|
||||
<p>{conversation.role}</p>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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>
|
||||
<header className="conversation-summary">
|
||||
<span className="conversation-eyebrow">Summary</span>
|
||||
<h2>{conversation.subject}</h2>
|
||||
<p>{conversation.summary}</p>
|
||||
<header className="border-t border-border py-px-20">
|
||||
<span className="text-ui-10 text-muted-foreground">Summary</span>
|
||||
<h2 className="mt-px-7 text-ui-12 font-book leading-normal tracking-subtitle">
|
||||
{conversation.subject}
|
||||
</h2>
|
||||
<p className="mt-px-8 max-w-[64ch] text-ui-12 leading-relaxed-copy text-muted-foreground">
|
||||
{conversation.summary}
|
||||
</p>
|
||||
</header>
|
||||
<section className="conversation-next">
|
||||
<h3>
|
||||
<CornerDownRight aria-hidden="true" />
|
||||
<section className="border-y border-border py-px-20">
|
||||
<h3 className="flex items-center gap-px-6 text-ui-11 font-book">
|
||||
<CornerDownRight className="size-[13px]" aria-hidden="true" />
|
||||
Next step
|
||||
</h3>
|
||||
<p>{conversation.nextStep}</p>
|
||||
<p className="mt-px-12 text-ui-12 leading-relaxed-copy">{conversation.nextStep}</p>
|
||||
{conversation.deadline && (
|
||||
<div className="conversation-deadline">
|
||||
<span>Requested by</span>
|
||||
<strong>{conversation.deadline}</strong>
|
||||
<div className="mt-px-18 grid gap-px-5 text-ui-11">
|
||||
<span className="text-muted-foreground">Requested by</span>
|
||||
<strong className="font-medium">{conversation.deadline}</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="conversation-facts">
|
||||
<h3>From the conversation</h3>
|
||||
<ul>
|
||||
<section className="pt-px-22">
|
||||
<h3 className="flex items-center gap-px-6 text-ui-11 font-book">
|
||||
From the conversation
|
||||
</h3>
|
||||
<ul className="grid gap-px-18 pt-px-16">
|
||||
{conversation.facts.map((fact) => (
|
||||
<li key={fact.sourceId}>
|
||||
<p>{fact.text}</p>
|
||||
<p className="text-ui-11 leading-relaxed-copy">{fact.text}</p>
|
||||
<a
|
||||
className="mt-px-5 inline-block text-ui-10 text-muted-foreground underline underline-offset-3"
|
||||
href={`#${instanceId}-source-${fact.sourceId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -262,27 +330,35 @@ export function ConversationDetail({
|
||||
</p>
|
||||
</div>
|
||||
{review === "send" && (
|
||||
<section className="conversation-evaluation" aria-label="Jev review" aria-live="polite">
|
||||
<h3>Jev review</h3>
|
||||
<section
|
||||
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 ? (
|
||||
<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" ? (
|
||||
<>
|
||||
<ul>
|
||||
<ul className="grid gap-px-10">
|
||||
{evaluation.data.checks.map((check) => (
|
||||
<li key={check.id}>
|
||||
<li className="flex justify-between gap-px-16" key={check.id}>
|
||||
<span>{check.label}</span>
|
||||
<strong>{Math.round(check.probability * 100)}%</strong>
|
||||
<strong className="font-medium tabular-nums">
|
||||
{Math.round(check.probability * 100)}%
|
||||
</strong>
|
||||
</li>
|
||||
))}
|
||||
</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.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p>
|
||||
<p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground">
|
||||
{evaluation.data?.status === "unavailable"
|
||||
? evaluation.data.message
|
||||
: "Review unavailable. Please try again."}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowLeft, MessageSquare, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
|
||||
import { Button } from "#/components/ui/button";
|
||||
import { Input } from "#/components/ui/input";
|
||||
import {
|
||||
@@ -61,14 +62,21 @@ export function SupportPage() {
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="support-workspace" data-reading={!!selectedId}>
|
||||
<Sidebar collapsible="none" className="support-sidebar">
|
||||
<SidebarProvider
|
||||
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 />
|
||||
<Sidebar collapsible="none" className="support-list">
|
||||
<Sidebar collapsible="none" className="w-auto min-w-0 flex-1">
|
||||
<SidebarHeader className="p-5 gap-4">
|
||||
<div className="support-list-title">
|
||||
<h1>Messages</h1>
|
||||
<span>{visible.length}</span>
|
||||
<div className="flex items-center gap-px-10">
|
||||
<h1 className="text-ui-19 font-semibold tracking-title">Messages</h1>
|
||||
<span className="text-ui-12 text-muted-foreground">{visible.length}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
||||
@@ -82,9 +90,10 @@ export function SupportPage() {
|
||||
onCompositionEnd={searchState.onCompositionEnd}
|
||||
/>
|
||||
</div>
|
||||
<nav className="support-filters" aria-label="Conversation filters">
|
||||
<nav className="flex flex-wrap gap-px-4" aria-label="Conversation filters">
|
||||
{views.map((tab) => (
|
||||
<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"
|
||||
key={tab.id}
|
||||
aria-pressed={view === tab.id}
|
||||
@@ -105,24 +114,34 @@ export function SupportPage() {
|
||||
{visible.map((item) => (
|
||||
<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}
|
||||
key={item.id}
|
||||
onClick={() => updateSearch({ conversation: item.id })}
|
||||
>
|
||||
<span className="support-avatar" aria-hidden="true">
|
||||
<Avatar aria-hidden="true">
|
||||
<AvatarFallback>
|
||||
{item.name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.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 className="support-row-content">
|
||||
<span className="support-row-heading">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.updatedAt}</span>
|
||||
</span>
|
||||
<span className="support-row-preview">{item.subject}</span>
|
||||
<span className="support-row-channel">{item.channels.join(" · ")}</span>
|
||||
<span className="truncate text-ui-12 text-muted-foreground">
|
||||
{item.subject}
|
||||
</span>
|
||||
<span className="text-ui-10 text-muted-foreground">
|
||||
{item.channels.join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -132,17 +151,20 @@ export function SupportPage() {
|
||||
</p>
|
||||
)}
|
||||
</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"}
|
||||
</div>
|
||||
</Sidebar>
|
||||
</Sidebar>
|
||||
<SidebarInset className="support-main">
|
||||
<header className="support-toolbar">
|
||||
<SidebarInset className="h-full min-w-0 overflow-hidden max-md:hidden max-md:group-data-[reading=true]/support:flex">
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="support-back"
|
||||
className="hidden max-md:inline-flex"
|
||||
aria-label="Back to messages"
|
||||
onClick={() => updateSearch({ conversation: undefined })}
|
||||
>
|
||||
@@ -150,15 +172,19 @@ export function SupportPage() {
|
||||
</Button>
|
||||
{selected && (
|
||||
<>
|
||||
<span className="support-avatar support-avatar-small" aria-hidden="true">
|
||||
<Avatar aria-hidden="true">
|
||||
<AvatarFallback>
|
||||
{selected.name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<strong>{selected.name}</strong>
|
||||
<p>{selected.channels.join(" · ")}</p>
|
||||
<strong className="text-ui-13 font-book">{selected.name}</strong>
|
||||
<p className="mt-px-2 text-ui-10 text-muted-foreground">
|
||||
{selected.channels.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -177,7 +203,10 @@ export function SupportPage() {
|
||||
</div>
|
||||
)}
|
||||
</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) => (
|
||||
<div key={conversation.id} hidden={selected?.id !== conversation.id}>
|
||||
<ConversationDetail
|
||||
@@ -185,15 +214,20 @@ export function SupportPage() {
|
||||
onResolve={(status, note) => resolve(conversation.id, status, note)}
|
||||
/>
|
||||
{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>
|
||||
<p>{outcomes[conversation.id]}</p>
|
||||
<p className="mt-px-8 whitespace-pre-wrap text-muted-foreground">
|
||||
{outcomes[conversation.id]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!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" />
|
||||
<p>Select a conversation</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -190,14 +190,23 @@ function points(values: readonly number[], width = 640, height = 190) {
|
||||
.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 =
|
||||
index === 7
|
||||
? Array<number>(30).fill(20)
|
||||
: (series[index % series.length]?.values ?? series[0].values);
|
||||
return (
|
||||
<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"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
@@ -205,7 +214,8 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
|
||||
<polyline
|
||||
points={points(values, 300, 36)}
|
||||
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"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -215,18 +225,18 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
|
||||
|
||||
function Gauge({ value }: { value: number }) {
|
||||
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
|
||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||
fill="none"
|
||||
stroke="var(--vital-border)"
|
||||
className="stroke-vital-border"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||
fill="none"
|
||||
stroke="var(--vital-green)"
|
||||
className="stroke-vital-green"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
pathLength="100"
|
||||
@@ -254,12 +264,19 @@ function Trends() {
|
||||
? ["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"];
|
||||
return (
|
||||
<section className="vital-trends vital-card" aria-label="Environmental trends">
|
||||
<div className="vital-card-header">
|
||||
<h2>Trends</h2>
|
||||
<fieldset className="vital-segmented" aria-label="Trend period">
|
||||
<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"
|
||||
aria-label="Environmental trends"
|
||||
>
|
||||
<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) => (
|
||||
<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={range === item}
|
||||
@@ -270,9 +287,13 @@ function Trends() {
|
||||
))}
|
||||
</fieldset>
|
||||
</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) => (
|
||||
<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"
|
||||
key={item}
|
||||
aria-pressed={metric === item}
|
||||
@@ -282,16 +303,20 @@ function Trends() {
|
||||
</button>
|
||||
))}
|
||||
</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) => (
|
||||
<span key={item.name} data-tone={item.tone}>
|
||||
<i />
|
||||
<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 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})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg
|
||||
className="vital-trend-chart"
|
||||
className="w-full h-auto min-h-[220px] max-[768px]:min-h-[140px]"
|
||||
viewBox="0 0 740 260"
|
||||
role="img"
|
||||
aria-label={`${range} ${metric} simulated environmental readings`}
|
||||
@@ -303,23 +328,23 @@ function Trends() {
|
||||
x2="696"
|
||||
y1={30 + index * 62}
|
||||
y2={30 + index * 62}
|
||||
stroke="var(--vital-border)"
|
||||
className="stroke-vital-border"
|
||||
/>
|
||||
<text
|
||||
x="46"
|
||||
y={35 + index * 62}
|
||||
textAnchor="end"
|
||||
fill="var(--vital-muted)"
|
||||
className="fill-vital-muted"
|
||||
fontSize="12"
|
||||
>
|
||||
{["1,500", "1,000", "500", "0"][index]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<text x="6" y="14" fill="var(--vital-muted)" fontSize="12">
|
||||
<text x="6" y="14" className="fill-vital-muted" fontSize="12">
|
||||
ppm / %
|
||||
</text>
|
||||
<text x="712" y="14" fill="var(--vital-purple)" fontSize="12">
|
||||
<text x="712" y="14" className="fill-vital-purple" fontSize="12">
|
||||
°F
|
||||
</text>
|
||||
<g transform="translate(56,26)">
|
||||
@@ -335,7 +360,8 @@ function Trends() {
|
||||
key={item.name}
|
||||
points={points(values)}
|
||||
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"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
@@ -347,7 +373,7 @@ function Trends() {
|
||||
key={label}
|
||||
x={75 + (index * 610) / (labels.length - 1)}
|
||||
y="242"
|
||||
fill="var(--vital-muted)"
|
||||
className="fill-vital-muted"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
@@ -362,31 +388,51 @@ function Trends() {
|
||||
function AirMonitor() {
|
||||
const [synced, setSynced] = useState(false);
|
||||
return (
|
||||
<section className="vital-monitor vital-card">
|
||||
<div className="vital-card-header">
|
||||
<h2>Air monitor</h2>
|
||||
<button className="vital-outline-button" type="button" onClick={() => setSynced(true)}>
|
||||
<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="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
|
||||
<h2 className="text-ui-15 font-medium">Air monitor</h2>
|
||||
<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} />
|
||||
{synced ? "Synced" : "Sync now"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="vital-device">
|
||||
<Radio className="vital-device-icon" size={66} strokeWidth={1} />
|
||||
<div>
|
||||
<div className="vital-device-heading">
|
||||
<strong>Room</strong>
|
||||
<span data-tone="green">● Connected</span>
|
||||
<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="text-vital-muted shrink-0 max-[1101px]:w-[50px]"
|
||||
size={66}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<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>
|
||||
<p>Air monitor</p>
|
||||
<p role="status">{synced ? "Sample readings refreshed" : "Updated just now"}</p>
|
||||
<div className="vital-device-badges">
|
||||
<p className="text-ui-12 text-vital-muted mt-px-8">Air monitor</p>
|
||||
<p className="text-ui-12 text-vital-muted mt-px-8" role="status">
|
||||
{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) => (
|
||||
<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>
|
||||
<p className="vital-monitor-footnote">
|
||||
<p className="mt-px-48 text-ui-11 text-vital-muted">
|
||||
{synced
|
||||
? "71 sample readings synced. No device connected."
|
||||
: "71 sample readings · simulated device"}
|
||||
@@ -397,60 +443,98 @@ function AirMonitor() {
|
||||
|
||||
function Insights() {
|
||||
return (
|
||||
<aside className="vital-env-insights">
|
||||
<section className="vital-insights vital-card">
|
||||
<div className="vital-card-header">
|
||||
<h2>Insights</h2>
|
||||
<span>today</span>
|
||||
<aside className="grid gap-px-16 max-[1101px]:grid-cols-[1fr_1fr] max-[768px]:grid-cols-[1fr]">
|
||||
<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="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
|
||||
<h2 className="text-ui-15 font-medium m-0">Insights</h2>
|
||||
<span className="text-ui-12 text-vital-muted">today</span>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<TriangleAlert data-tone="yellow" size={20} />
|
||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<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
|
||||
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
|
||||
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 className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<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
|
||||
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>
|
||||
<h3>Particulates stayed low</h3>
|
||||
<p>The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in Trends.</p>
|
||||
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">
|
||||
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 className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<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
|
||||
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>
|
||||
<h3>Humidity through the day</h3>
|
||||
<p>The room’s current sample reading is 64%. Select Humidity to explore the chart.</p>
|
||||
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">
|
||||
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 className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<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
|
||||
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>
|
||||
<h3>Room temperature</h3>
|
||||
<p>
|
||||
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">Room temperature</h3>
|
||||
<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
|
||||
other room metrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="vital-insights vital-card">
|
||||
<h2>Recommended actions</h2>
|
||||
<div className="vital-insight">
|
||||
<Grid2X2 size={20} />
|
||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
||||
<i />
|
||||
<i />
|
||||
<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 className="text-ui-15 font-medium mb-px-20">Recommended actions</h2>
|
||||
<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 className="shrink-0" size={20} />
|
||||
<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%]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<Droplets data-tone="yellow" size={20} />
|
||||
<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
|
||||
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>
|
||||
<h3>Review room humidity</h3>
|
||||
<p>Explore the climate controls with simulated settings.</p>
|
||||
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">Review room humidity</h3>
|
||||
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
|
||||
Explore the climate controls with simulated settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -463,13 +547,16 @@ function ClimateControl() {
|
||||
const [mode, setMode] = useState("Auto");
|
||||
const [purifier, setPurifier] = useState(true);
|
||||
return (
|
||||
<section className="vital-climate vital-card">
|
||||
<h2>Room climate</h2>
|
||||
<p>Simulated controls. Changes stay on this screen.</p>
|
||||
<label htmlFor="vital-temperature">
|
||||
<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 className="text-ui-15 font-medium">Room climate</h2>
|
||||
<p className="text-vital-muted text-ui-13">
|
||||
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>
|
||||
</label>
|
||||
<input
|
||||
className="accent-vital-green w-full"
|
||||
id="vital-temperature"
|
||||
type="range"
|
||||
min="60"
|
||||
@@ -477,9 +564,13 @@ function ClimateControl() {
|
||||
value={temperature}
|
||||
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) => (
|
||||
<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={item === mode}
|
||||
@@ -490,7 +581,7 @@ function ClimateControl() {
|
||||
))}
|
||||
</fieldset>
|
||||
<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"
|
||||
aria-pressed={purifier}
|
||||
onClick={() => setPurifier(!purifier)}
|
||||
@@ -512,9 +603,18 @@ export function EnvironmentPanel() {
|
||||
setSearch({ environmentDetail: value?.name });
|
||||
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) => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
@@ -522,37 +622,49 @@ export function EnvironmentPanel() {
|
||||
{tab === "Climate control" ? (
|
||||
<ClimateControl />
|
||||
) : (
|
||||
<div className="vital-env-layout">
|
||||
<div className="vital-env-main">
|
||||
<div className="vital-env-scores">
|
||||
<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="min-w-0">
|
||||
<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) => (
|
||||
<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}
|
||||
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}
|
||||
<ChevronRight size={16} />
|
||||
</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} />
|
||||
<div>
|
||||
<strong>{score.value}</strong>
|
||||
<small>{score.unit}</small>
|
||||
<p data-tone="green">{score.status}</p>
|
||||
<strong className="text-ui-36 font-light tracking-score min-[1700px]:text-ui-42 max-[768px]:text-ui-30">
|
||||
{score.value}
|
||||
</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>
|
||||
<p className="vital-delta">
|
||||
<span data-tone={index === 3 ? "green" : "yellow"}>{score.change}</span>{" "}
|
||||
<p className="text-vital-muted text-ui-12 mt-px-16 max-[768px]:text-ui-10">
|
||||
<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
|
||||
</p>
|
||||
<Sparkline index={index} />
|
||||
<Sparkline index={index} variant="score" />
|
||||
</button>
|
||||
))}
|
||||
</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) => {
|
||||
const MetricIcon =
|
||||
index === 0
|
||||
@@ -565,43 +677,63 @@ export function EnvironmentPanel() {
|
||||
return (
|
||||
<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}
|
||||
data-testid="vital-metric"
|
||||
onClick={() => setDetail(metric)}
|
||||
>
|
||||
<MetricIcon size={17} data-tone={metric.tone} />
|
||||
<div className="vital-metric-content">
|
||||
<span>{metric.name}</span>
|
||||
<div className="vital-metric-reading">
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.unit}</small>
|
||||
<MetricIcon
|
||||
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={metric.tone}
|
||||
/>
|
||||
<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 && (
|
||||
<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}
|
||||
</em>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-metric-spark">
|
||||
<small>{metric.min}</small>
|
||||
<div className="flex items-end w-full gap-px-4">
|
||||
<small className="text-ui-9 text-vital-muted">{metric.min}</small>
|
||||
<Sparkline index={index} tone={metric.tone} />
|
||||
<small>{metric.max}</small>
|
||||
<small className="text-ui-9 text-vital-muted">{metric.max}</small>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="vital-metric vital-card vital-room">
|
||||
<Wifi size={17} data-tone="green" />
|
||||
<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
|
||||
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>
|
||||
<span>Room</span>
|
||||
<strong>Connected</strong>
|
||||
<p>
|
||||
<span data-tone="green">●</span> Updated just now
|
||||
<span className="text-vital-muted text-ui-12">Room</span>
|
||||
<strong className="block text-ui-14 font-medium mt-px-3 mb-px-3 mx-0">
|
||||
Connected
|
||||
</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>
|
||||
</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 />
|
||||
<AirMonitor />
|
||||
</div>
|
||||
@@ -611,14 +743,18 @@ export function EnvironmentPanel() {
|
||||
)}
|
||||
{detail && (
|
||||
<Dialog title={detail.name} onClose={() => setDetail(null)}>
|
||||
<p className="vital-detail-value">
|
||||
{detail.value} <small>{detail.unit}</small>
|
||||
<p className="text-ui-32">
|
||||
{detail.value} <small className="text-ui-14">{detail.unit}</small>
|
||||
</p>
|
||||
<p>
|
||||
This is a sample reading for the dashboard preview. No health or environmental device is
|
||||
connected.
|
||||
</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
|
||||
</button>
|
||||
</Dialog>
|
||||
|
||||
+207
-110
@@ -78,33 +78,35 @@ export function VitalsPage() {
|
||||
const [mobileMenu, setMobileMenu] = useState(false);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="vital-workspace">
|
||||
<SidebarProvider className="h-[100dvh] min-h-0 overflow-hidden bg-vital-bg text-vital-text font-arial">
|
||||
<WorkspaceNavigation />
|
||||
<div className="vital-app">
|
||||
<div className="flex flex-1 min-w-0 h-full">
|
||||
<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"
|
||||
>
|
||||
<div className="vital-brand">
|
||||
<Activity />
|
||||
<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 className="w-[24px] text-vital-muted" />
|
||||
<span>
|
||||
1F2T <span>Health</span>
|
||||
1F2T <span className="text-vital-muted font-normal">Health</span>
|
||||
</span>
|
||||
<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"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setMobileMenu(false)}
|
||||
>
|
||||
<X />
|
||||
<X className="w-[17px] h-[17px] shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
<nav>
|
||||
<nav className="grid gap-px-3 mt-px-14">
|
||||
{nav.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
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={() => {
|
||||
if (item.name === "Body" || item.name === "Environment") {
|
||||
setSection(item.name);
|
||||
@@ -112,63 +114,80 @@ export function VitalsPage() {
|
||||
} else setDetail(item.name);
|
||||
}}
|
||||
>
|
||||
<item.icon />
|
||||
<item.icon className="w-[17px] h-[17px] shrink-0" />
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="vital-navigation-bottom">
|
||||
<button type="button" onClick={() => setDetail("Log with Claude")}>
|
||||
<Plus />
|
||||
<div className="mt-auto pb-px-20">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button type="button" onClick={() => setDetail("Help")}>
|
||||
<CircleHelp />
|
||||
<button
|
||||
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
|
||||
</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>
|
||||
</aside>
|
||||
<div className="vital-surface">
|
||||
<header className="vital-topbar">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<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
|
||||
className="vital-mobile-toggle"
|
||||
className="hidden max-[768px]:flex"
|
||||
type="button"
|
||||
aria-label="Open Health menu"
|
||||
onClick={() => setMobileMenu(true)}
|
||||
>
|
||||
<Menu />
|
||||
<Menu className="w-[17px] h-[17px] text-vital-muted" />
|
||||
</button>
|
||||
<h1>{section}</h1>
|
||||
<div className="vital-topbar-right">
|
||||
<span className="vital-pulse">
|
||||
<Heart />
|
||||
<h1 className="text-ui-16 font-medium">{section}</h1>
|
||||
<div className="ml-auto flex items-center gap-px-28 text-ui-14 max-[768px]:gap-px-14">
|
||||
<span className="flex items-center gap-px-10 text-vital-muted max-[768px]:hidden">
|
||||
<Heart className="w-[17px] h-[17px] text-vital-red" />
|
||||
86 bpm
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-px-10 cursor-pointer max-[768px]:hidden"
|
||||
type="button"
|
||||
aria-label="Open notifications"
|
||||
onClick={() => setDetail("Notifications")}
|
||||
>
|
||||
<Bell />
|
||||
<Bell className="w-[17px] h-[17px] text-vital-muted" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vital-profile"
|
||||
className="flex items-center gap-px-10 cursor-pointer"
|
||||
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}
|
||||
<ChevronDown />
|
||||
<ChevronDown className="w-[17px] h-[17px] text-vital-muted max-[768px]:hidden" />
|
||||
</button>
|
||||
</div>
|
||||
</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" ? (
|
||||
<EnvironmentPanel />
|
||||
) : (
|
||||
<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",
|
||||
"Cardiovascular",
|
||||
@@ -181,28 +200,46 @@ export function VitalsPage() {
|
||||
"Nutrients",
|
||||
].map((name) => (
|
||||
<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"
|
||||
key={name}
|
||||
className={tab === name ? "is-active" : ""}
|
||||
data-active={tab === name}
|
||||
onClick={() => setTab(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vital-body-columns">
|
||||
<div className="vital-card">
|
||||
<div className="vital-lab-row">
|
||||
<section className="vital-lab-score">
|
||||
<header>
|
||||
<h2>Lab score</h2>
|
||||
<span>Latest draw · Jul 28</span>
|
||||
<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="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden">
|
||||
<div className="grid grid-cols-[0.95fr_1.15fr] border-b border-vital-border max-[1401px]:grid-cols-[1fr]">
|
||||
<section className="p-px-26 min-w-0 max-[768px]:p-px-16">
|
||||
<header className="flex items-center justify-between pb-px-16 border-b border-vital-border gap-px-12">
|
||||
<h2 className="text-ui-15 font-medium">Lab score</h2>
|
||||
<span className="text-ui-10 text-vital-muted">
|
||||
Latest draw · Jul 28
|
||||
</span>
|
||||
</header>
|
||||
<div className="vital-lab-value">
|
||||
<div className="vital-ring">
|
||||
<svg viewBox="0 0 160 160" aria-hidden="true">
|
||||
<circle cx="80" cy="80" r="67" />
|
||||
<div className="flex items-center gap-px-20 mt-px-24 max-[768px]:gap-px-12">
|
||||
<div className="w-[142px] h-[160px] relative shrink-0 max-[768px]:w-[90px] max-[768px]:h-[120px]">
|
||||
<svg
|
||||
className="w-full h-full -rotate-90"
|
||||
viewBox="0 0 160 160"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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"
|
||||
cy="80"
|
||||
r="67"
|
||||
@@ -210,24 +247,36 @@ export function VitalsPage() {
|
||||
strokeDasharray="92 100"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
92<small>Lab score</small>
|
||||
<span className="absolute inset-0 flex flex-col justify-center items-center text-ui-40 font-light max-[768px]:text-ui-28">
|
||||
92
|
||||
<small className="text-ui-12 text-vital-muted mt-px-4">
|
||||
Lab score
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="vital-lab-description">
|
||||
<h3>
|
||||
Optimal <span>▾ 1</span>
|
||||
<div className="border-l border-vital-border pl-px-20 min-w-0 max-[768px]:pl-px-12">
|
||||
<h3 className="text-ui-23 whitespace-nowrap max-[768px]:text-ui-19">
|
||||
Optimal <span className="text-ui-13 text-vital-yellow">▾ 1</span>
|
||||
</h3>
|
||||
<p>7 of 8 systems optimal · 21 of 161 biomarkers out of range</p>
|
||||
<div className="vital-lab-change">
|
||||
<p className="text-ui-11 text-vital-muted leading-normal">
|
||||
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>
|
||||
<span>Change ⓘ</span>
|
||||
<strong>
|
||||
−1 <small>pt</small>
|
||||
<span className="text-ui-13 text-vital-muted">Change ⓘ</span>
|
||||
<strong className="block text-ui-20 font-medium text-vital-yellow">
|
||||
−1 <small className="text-ui-12">pt</small>
|
||||
</strong>
|
||||
<p>Since last draw</p>
|
||||
<p className="text-ui-11 text-vital-muted leading-normal">
|
||||
Since last draw
|
||||
</p>
|
||||
</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
|
||||
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"
|
||||
@@ -239,42 +288,53 @@ export function VitalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="vital-recent">
|
||||
<h2>Recent results</h2>
|
||||
<section className="border-l border-vital-border p-px-24 max-[1401px]:hidden">
|
||||
<h2 className="text-ui-15 font-medium mb-px-10">Recent results</h2>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<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}
|
||||
type="button"
|
||||
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
|
||||
className="vital-obscured"
|
||||
className="flex flex-col gap-px-10 flex-1 min-w-0"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<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%]" />
|
||||
</span>
|
||||
<ChevronRight />
|
||||
<ChevronRight className="w-[16px] text-vital-muted" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
<section className="vital-systems">
|
||||
<header>
|
||||
<h2>{tab === "Overview" ? "Body systems" : tab}</h2>
|
||||
<div className="vital-tabs" role="group" aria-label="Body systems">
|
||||
<section>
|
||||
<header className="flex items-center justify-between p-px-20 gap-px-12 max-[768px]:p-px-16 max-[768px]:flex-wrap">
|
||||
<h2 className="text-ui-15 font-medium">
|
||||
{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
|
||||
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"
|
||||
className={!history ? "is-active" : ""}
|
||||
data-active={!history}
|
||||
onClick={() => setHistory(false)}
|
||||
>
|
||||
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"
|
||||
className={history ? "is-active" : ""}
|
||||
data-active={history}
|
||||
onClick={() => setHistory(true)}
|
||||
>
|
||||
Lab history
|
||||
@@ -282,25 +342,28 @@ export function VitalsPage() {
|
||||
</div>
|
||||
</header>
|
||||
{history ? (
|
||||
<div className="vital-history">
|
||||
<h3>Lab history</h3>
|
||||
<div data-testid="vital-history" className="min-h-[440px] p-px-30">
|
||||
<h3 className="mb-px-28">Lab history</h3>
|
||||
{[
|
||||
["Jul 28", "92"],
|
||||
["May 16", "93"],
|
||||
["Mar 04", "89"],
|
||||
].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>
|
||||
{score}
|
||||
<small> Lab score</small>
|
||||
<small className="text-vital-muted"> Lab score</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vital-anatomy-grid">
|
||||
<div className="vital-system-list">
|
||||
<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="grid gap-px-12 relative z-[1]">
|
||||
{systems
|
||||
.slice(0, 4)
|
||||
.filter((item) => tab === "Overview" || tab === item.name)
|
||||
@@ -313,13 +376,17 @@ export function VitalsPage() {
|
||||
))}
|
||||
</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"
|
||||
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 className="vital-system-list">
|
||||
<div className="grid gap-px-12 relative z-[1]">
|
||||
{systems
|
||||
.slice(4)
|
||||
.filter((item) => tab === "Overview" || tab === item.name)
|
||||
@@ -335,60 +402,77 @@ export function VitalsPage() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<aside className="vital-card vital-insights">
|
||||
<h2>Insights</h2>
|
||||
<div className="vital-insight-feature">
|
||||
<span className="vital-red">△</span>
|
||||
<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 className="text-ui-15 font-medium mb-px-20">Insights</h2>
|
||||
<div className="border border-vital-border rounded-ui-12 p-px-16 flex items-start gap-px-12 flex-wrap">
|
||||
<span className="text-vital-red">△</span>
|
||||
<div
|
||||
className="vital-obscured"
|
||||
className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[80px]"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<h3>Summary</h3>
|
||||
<div className="vital-summary">
|
||||
<span>
|
||||
<b className="vital-dot vital-red" />4 need attention
|
||||
<h3 className="text-ui-14 mt-px-24 mb-px-10 mx-0 text-vital-muted">
|
||||
Summary
|
||||
</h3>
|
||||
<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>
|
||||
<b className="vital-dot vital-yellow" />3 to watch
|
||||
<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-yellow inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
|
||||
3 to watch
|
||||
</span>
|
||||
<span>
|
||||
<b className="vital-dot vital-green" />4 going well
|
||||
<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-green inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
|
||||
4 going well
|
||||
</span>
|
||||
</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) => (
|
||||
<div className="vital-insight-placeholder" key={index}>
|
||||
<div
|
||||
className="flex items-start gap-px-10 mb-px-20 min-h-[85px]"
|
||||
key={index}
|
||||
>
|
||||
<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
|
||||
className="vital-obscured"
|
||||
className="flex flex-col gap-px-10 flex-1 min-w-0"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<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>
|
||||
))}
|
||||
<button
|
||||
className="vital-text-button"
|
||||
className="text-ui-13 text-vital-muted cursor-pointer"
|
||||
type="button"
|
||||
onClick={() => setDetail("Insights")}
|
||||
>
|
||||
Show 6 more
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,17 +503,30 @@ export function VitalsPage() {
|
||||
}
|
||||
function SystemCard({ item, onSelect }: { item: (typeof systems)[number]; onSelect: () => void }) {
|
||||
return (
|
||||
<button type="button" className="vital-system-card" onClick={onSelect}>
|
||||
<span className={`vital-system-icon vital-${item.tone}`}>
|
||||
<item.icon />
|
||||
<button
|
||||
data-testid="vital-system-card"
|
||||
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 className="vital-system-label">
|
||||
<span className="flex-1 text-ui-14 max-[1401px]:text-ui-12 max-[768px]:text-ui-11">
|
||||
{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 className="vital-system-score">
|
||||
<span className="text-right text-ui-23 max-[1401px]:ml-auto max-[768px]:text-ui-20">
|
||||
{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"}
|
||||
</small>
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+250
-15
@@ -1,15 +1,250 @@
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
|
||||
@import "./ui.css";
|
||||
@import "../tokens.css";
|
||||
@import "./base.css";
|
||||
@import "./features/connections/connection-manager.css";
|
||||
@import "./features/research/citation-navigation.css";
|
||||
@import "./features/research/research-panel.css";
|
||||
@import "./features/decks/decks.css";
|
||||
@import "./features/auth/auth.css";
|
||||
@import "./features/inbox/inbox.css";
|
||||
@import "./features/journal/journal.css";
|
||||
@import "./features/support/support.css";
|
||||
@import "./features/home/home.css";
|
||||
@import "./features/home/execution-workbench.css";
|
||||
@import "./features/vitals/vitals.css";
|
||||
@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 {
|
||||
--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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user