refactor: migrate UI to Tailwind and unify chat with shadcn

This commit is contained in:
2026-10-07 20:51:05 +09:00
parent f41d0c1964
commit 1e03e8f4ff
56 changed files with 2014 additions and 4399 deletions
+85 -51
View File
@@ -19,7 +19,9 @@
"react-perf",
"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"
}
+12 -9
View File
@@ -262,22 +262,25 @@ shadcn dialogs with Escape handling and focus restoration. Column menus contain
editing, ordering, and deletion; refresh stays available directly in each header.
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
+2 -1
View File
@@ -15,7 +15,8 @@ remains a visual placeholder, not inferred medical content.
as the anatomy illustration source and clipped in CSS. Source media:
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
+13 -7
View File
@@ -74,18 +74,18 @@ test("creates, switches, renames and deletes persisted deck profiles", async ({
await page.getByRole("button", { name: "Create deck", exact: true }).click();
await page.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("");
});
+1 -1
View File
@@ -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 -3
View File
@@ -3,7 +3,7 @@ import { expect, test } from "../fixtures";
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
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,
+9 -6
View File
@@ -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([]);
});
+8 -7
View File
@@ -9,16 +9,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
}) => {
await page.goto("/support");
await page.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([]);
+20 -12
View File
@@ -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 }) => {
+8 -5
View File
@@ -4,7 +4,7 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
await page.goto("/vitals");
await page.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();
+1 -1
View File
@@ -234,7 +234,7 @@ test("rejects invalid input without changing the active view", async ({ page })
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
true,
);
await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]);
}
expect(savedCount()).toEqual({ count: 0 });
});
-75
View File
@@ -1,75 +0,0 @@
:root {
--canvas: var(--color-canvas);
--surface: var(--color-paper-2);
--paper: var(--color-paper);
--ink: var(--color-ink);
--accent-hover: var(--color-accent-hover);
--control-border: var(--color-control-rule);
--error: var(--color-error);
color: var(--ink);
background: var(--canvas);
color-scheme: dark;
font-family: var(--font-body);
font-size: 14px;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
overflow-x: clip;
background: var(--canvas);
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
}
a {
color: var(--primary);
text-underline-offset: 0.18em;
}
a:active {
color: var(--accent-hover);
}
:focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 2px;
}
h1 {
margin: 0;
min-width: 0;
font-family: var(--font-display);
font-size: var(--text-display-s);
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
}
.handle {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.workspace .reader {
overflow: hidden;
}
.sidebar-note {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* Respect reduced motion over registry component animation utilities. */
animation-duration: 0.01ms !important;
/* Respect reduced motion over registry component transition utilities. */
transition-duration: 0.01ms !important;
}
}
+6 -11
View File
@@ -1,5 +1,5 @@
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { 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>
+1 -1
View File
@@ -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}
+8 -1
View File
@@ -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}
+3 -32
View File
@@ -18,9 +18,6 @@ import { Skeleton } from "#/components/ui/skeleton.tsx";
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
import { 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>
);
}
+23 -5
View File
@@ -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}
/>
);
+29 -17
View File
@@ -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."}
-136
View File
@@ -1,136 +0,0 @@
.auth-page {
min-height: 100svh;
display: grid;
place-items: center;
padding: 48px 24px;
background: var(--background);
color: var(--foreground);
}
.auth-panel {
width: 100%;
max-width: 390px;
min-width: 0;
}
.auth-brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--muted-foreground);
font-size: 13px;
margin-bottom: 44px;
}
.auth-heading {
margin-bottom: 28px;
}
.auth-heading h1 {
font-size: 27px;
font-weight: 550;
letter-spacing: -0.8px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.auth-heading p {
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.6;
margin-top: 12px;
}
.auth-form {
display: grid;
gap: 20px;
}
.auth-field {
display: grid;
gap: 9px;
}
.auth-field input {
height: 40px;
}
.auth-hint,
.auth-footnote,
.auth-prototype {
font-size: 12px;
line-height: 1.6;
color: var(--muted-foreground);
}
.auth-submit {
width: 100%;
margin-top: 4px;
justify-content: space-between;
padding-inline: 14px;
}
.auth-footnote {
margin-top: 24px;
}
.auth-error {
color: var(--destructive);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-step {
color: var(--muted-foreground);
font-size: 12px;
margin-bottom: 16px;
}
.auth-overview {
list-style: none;
padding: 0;
margin: 0 0 28px;
display: grid;
gap: 24px;
}
.auth-overview li {
display: flex;
gap: 14px;
align-items: flex-start;
}
.auth-overview svg {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: var(--muted-foreground);
}
.auth-overview strong {
font-size: 14px;
font-weight: 500;
}
.auth-overview p {
font-size: 13px;
line-height: 1.6;
color: var(--muted-foreground);
margin-top: 4px;
}
.auth-prototype {
padding-top: 20px;
border-top: 1px solid var(--border);
}
.auth-actions {
margin-top: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.auth-footer {
margin-top: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.auth-footer span {
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.auth-page {
padding: 32px 24px;
}
.auth-brand {
margin-bottom: 36px;
}
}
+52 -28
View File
@@ -46,17 +46,22 @@ export function OnboardingPage() {
return (
<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;
}
}
+30 -10
View File
@@ -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>
+14 -7
View File
@@ -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>
+51 -14
View File
@@ -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>
+57 -16
View File
@@ -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}
+17 -6
View File
@@ -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"
-313
View File
@@ -1,313 +0,0 @@
.deck-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: var(--space-sm);
min-height: 58px;
padding: var(--space-xs) var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--muted-foreground);
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
overflow-y: hidden;
padding: 0 var(--space-xs) var(--space-xs);
scroll-snap-type: x proximity;
scrollbar-color: var(--control-border) var(--canvas);
}
.deck-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
position: relative;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--surface);
}
.deck-column-title {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.deck-column h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-base);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-source-label {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.deck-source-label svg {
width: 14px;
height: 14px;
}
.deck-query {
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-status {
margin: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
flex: 1;
overscroll-behavior-y: contain;
scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper);
}
.deck-message {
padding: var(--space-md);
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
padding: var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post strong {
font-size: var(--text-sm);
}
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
margin: var(--space-sm) 0;
padding: var(--space-sm);
border: var(--rule-hairline) solid var(--border);
border-radius: var(--radius-sm);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post a {
color: var(--primary);
text-underline-offset: 0.2em;
}
.research-media {
display: block;
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
.deck-editor {
margin: 0;
padding: 0;
}
.deck-editor-fields,
.deck-source-fields {
display: grid;
gap: var(--space-md);
}
.deck-source-fields {
margin-block: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin: 0;
}
.deck-rename label {
flex: 1 1 100%;
}
.deck-error {
color: var(--destructive);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.reader > .deck-error {
margin: 0;
padding: var(--space-xs) var(--space-md);
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.deck-undo p {
margin: 0;
}
.deck-empty {
margin: auto;
max-width: 32rem;
padding: var(--space-lg);
}
.deck-empty p {
color: var(--muted-foreground);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
.deck-workspaces {
min-width: 0;
}
.deck-workspaces > .deck-actions {
align-items: stretch;
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
flex: 1;
}
.deck-list-choices {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.app-deck .sidebar-add {
width: 100%;
}
@media (max-width: 767px) {
.deck-heading {
min-height: 52px;
padding-inline: var(--space-sm);
}
.deck-heading .deck-actions {
flex-wrap: nowrap;
}
.deck-board {
grid-auto-columns: min(328px, 100%);
}
}
.deck-count {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
.research-post header > div:not([data-slot="avatar"]) {
flex: 1;
}
.research-post header time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.research-post footer a {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.research-post footer svg {
width: 14px;
height: 14px;
}
+15 -11
View File
@@ -34,7 +34,7 @@ export function MastodonSourceEditor({
const id = useId();
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>
) : (
+20 -4
View File
@@ -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", () => {
+35 -15
View File
@@ -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">
+20 -11
View File
@@ -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>
) : (
+13 -4
View File
@@ -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>
);
-333
View File
@@ -1,333 +0,0 @@
/* Flexible day flow: quiet activity blocks, with detail where the action happens. */
.execution-day {
min-width: 0;
}
.execution-flow-group {
margin-bottom: 30px;
}
.execution-flow-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.execution-flow-heading h2 {
font-size: 14px;
font-weight: 600;
}
.execution-flow-heading > span {
color: var(--muted-foreground);
font-size: 11px;
}
.execution-flow-items {
display: grid;
gap: 9px;
}
.execution-flow-card {
border: 1px solid var(--border);
border-radius: 16px;
background: var(--muted);
overflow: hidden;
}
.execution-flow-card[data-kind="focus"] {
background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
}
.execution-flow-card[data-kind="routine"] {
background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
}
.execution-flow-card[data-kind="reply"] {
background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
}
.execution-flow-card[data-kind="weight"] {
background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
}
.execution-flow-card[data-kind="optional"] {
background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
}
.execution-flow-card[data-expanded="true"] {
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
}
.execution-flow-card[data-status="done"] {
background: var(--background);
}
.execution-flow-card[data-status="done"] .execution-flow-title {
color: var(--muted-foreground);
}
.execution-flow-card[data-status="done"] .execution-complete,
.execution-flow-card[data-status="done"] .execution-flow-icon {
color: var(--color-success);
}
.execution-flow-row {
display: flex;
align-items: center;
gap: 13px;
padding: 16px 18px;
}
.execution-complete,
.execution-flow-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
height: 28px;
color: var(--muted-foreground);
}
.execution-complete {
cursor: pointer;
border-radius: 50%;
}
.execution-complete:disabled {
cursor: default;
opacity: 0.45;
}
.execution-complete:hover:not(:disabled) {
color: var(--foreground);
}
.execution-complete svg,
.execution-flow-icon svg {
width: 21px;
height: 21px;
stroke-width: 1.5;
}
.execution-flow-open {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
text-align: left;
gap: 5px;
cursor: pointer;
border-radius: 3px;
}
.execution-flow-title {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
}
.execution-flow-description {
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.5;
}
.execution-expand-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
color: var(--muted-foreground);
}
.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon {
transform: rotate(180deg);
}
.execution-inline-detail {
padding: 6px 22px 24px 55px;
}
.execution-inline-detail h2 {
display: none;
}
.execution-detail-symbol,
.execution-eyebrow {
display: none;
}
.execution-description {
font-size: 13px;
line-height: 1.8;
color: var(--muted-foreground);
max-width: 480px;
}
.execution-routine-progress {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 11px;
color: var(--muted-foreground);
margin: 20px 0 8px;
}
.execution-step-list {
display: grid;
}
.execution-step-list label {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
cursor: pointer;
border-top: 1px solid var(--border);
font-size: 13px;
}
.execution-step-list label > .execution-step-text {
flex: 1;
}
.execution-step-list label[data-checked="true"] > .execution-step-text {
text-decoration: line-through;
color: var(--muted-foreground);
}
.execution-step-number {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.execution-next-step {
padding: 14px 0;
margin-top: 14px;
border-top: 1px solid var(--border);
}
.execution-next-step > span {
font-size: 11px;
color: var(--muted-foreground);
}
.execution-next-step p {
font-size: 13px;
line-height: 1.75;
margin-top: 5px;
}
.execution-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.execution-resume-note {
border-top: 1px solid var(--border);
margin-top: 25px;
padding-top: 20px;
}
.execution-resume-note > label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 10px;
}
.execution-resume-note textarea {
min-height: 74px;
background: var(--background);
font-size: 12px;
}
.execution-note-footer {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
margin-top: 8px;
}
.execution-note-footer > span {
color: var(--muted-foreground);
font-size: 11px;
}
.execution-order {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 20px;
color: var(--muted-foreground);
}
.execution-order > span {
font-size: 11px;
margin-right: auto;
}
.execution-weight-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-top: 18px;
}
.execution-weight-form > label {
width: 100%;
font-size: 12px;
}
.execution-weight-input {
display: flex;
align-items: center;
gap: 10px;
max-width: 180px;
}
.execution-weight-input input {
background: var(--background);
font-size: 20px;
height: 42px;
font-variant-numeric: tabular-nums;
}
.execution-weight-input > span {
color: var(--muted-foreground);
font-size: 13px;
}
.execution-weight-form > p {
width: 100%;
color: var(--destructive);
font-size: 12px;
}
.execution-source-note {
font-size: 11px;
color: var(--muted-foreground);
margin-top: 20px;
}
.execution-flow-empty {
font-size: 12px;
color: var(--muted-foreground);
padding: 16px 0;
}
.execution-rest {
border-top: 1px solid var(--border);
padding-top: 20px;
}
.execution-rest summary {
cursor: pointer;
font-size: 12px;
color: var(--muted-foreground);
}
.execution-rest summary > span {
margin-left: 8px;
font-variant-numeric: tabular-nums;
}
.execution-rest-items {
margin-top: 12px;
}
.execution-rest-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
font-size: 13px;
}
.execution-rest-row > svg {
width: 17px;
height: 17px;
color: var(--muted-foreground);
}
.execution-rest-row > span {
flex: 1;
}
.execution-rest-row small {
display: block;
color: var(--muted-foreground);
font-size: 11px;
margin-top: 3px;
}
.execution-flow-open:focus-visible,
.execution-complete:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
}
@media (max-width: 767px) {
.execution-flow-row {
padding: 15px 14px;
gap: 10px;
}
.execution-flow-heading > span {
font-size: 10px;
}
.execution-inline-detail {
padding: 4px 18px 20px;
}
.execution-flow-title {
font-size: 13px;
}
.execution-flow-description {
font-size: 11px;
}
.execution-note-footer {
align-items: flex-start;
}
}
+128 -65
View File
@@ -49,7 +49,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
);
}
return (
<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>
</>
);
}
+49 -26
View File
@@ -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>
-254
View File
@@ -1,254 +0,0 @@
/* Hallmark · daily workbench · neutral / quiet · P5 H5 E4 S5 R5 V4 */
.home-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.home-main {
min-width: 0;
overflow: hidden;
}
.home-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.home-content {
max-width: 1200px;
margin: auto;
padding: 40px 48px 80px;
}
.home-greeting {
margin-bottom: 32px;
}
.home-greeting h1 {
font-size: clamp(24px, 3vw, 34px);
font-weight: 550;
letter-spacing: -0.035em;
line-height: 1.5;
margin-top: 12px;
}
.home-composer {
border: 1px solid var(--border);
border-radius: 20px;
background: var(--muted);
padding: 16px;
}
.home-composer textarea {
min-height: 130px;
resize: vertical;
border: 0;
box-shadow: none;
background: transparent;
padding: 8px;
font-size: 16px;
line-height: 1.8;
}
.home-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
}
.home-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 16px;
}
.home-suggestions button {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.home-proposal {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
}
.home-proposal p {
flex: 1;
font-size: 14px;
min-width: 140px;
}
.home-status {
min-height: 36px;
padding-top: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.home-columns {
display: grid;
grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
gap: 40px;
margin-top: 24px;
}
.home-section {
margin-bottom: 36px;
}
.home-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-section-heading h2 {
font-size: 14px;
font-weight: 600;
}
.home-section-heading a {
color: var(--muted-foreground);
padding: 6px;
}
.home-task {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.home-check {
padding-top: 2px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-task-body {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 1.8;
}
.home-task-body summary {
margin-top: 6px;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-steps {
display: grid;
gap: 12px;
padding-top: 14px;
font-size: 13px;
}
.home-task-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 115px;
}
.home-rest summary {
font-size: 13px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-rest summary span {
margin-left: 8px;
}
.home-empty {
font-size: 12px;
color: var(--muted-foreground);
padding: 16px 0;
}
.home-context-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.home-record,
.home-reading {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
text-align: left;
font-size: 13px;
line-height: 1.8;
overflow-wrap: anywhere;
}
.home-reading {
width: 100%;
cursor: pointer;
}
.home-deck-link {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted-foreground);
}
@media (max-width: 1000px) {
.home-content {
padding: 40px 28px;
}
.home-columns {
gap: 32px;
}
.home-task-actions {
max-width: 70px;
}
}
@media (max-width: 767px) {
.home-content {
padding: 32px 20px;
}
.home-columns {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.home-greeting h1 {
font-size: 25px;
}
.home-composer {
padding: 10px;
}
.home-composer textarea {
min-height: 150px;
}
.home-task {
gap: 10px;
}
.home-task-actions {
max-width: 60px;
}
.home-suggestions {
gap: 12px;
}
}
.home-brief {
margin-top: 20px;
max-width: 760px;
font-size: 15px;
line-height: 1.95;
overflow-wrap: anywhere;
}
.home-brief button {
text-decoration: underline;
text-decoration-color: var(--border);
text-underline-offset: 4px;
cursor: pointer;
}
.home-brief button:hover {
text-decoration-color: var(--foreground);
}
.home-brief-secondary {
margin-top: 8px;
color: var(--muted-foreground);
font-size: 13px;
}
+65 -16
View File
@@ -29,10 +29,22 @@ export function InboxPage() {
return (
<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>
))}
-148
View File
@@ -1,148 +0,0 @@
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.inbox-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.inbox-sidebar {
width: 420px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.inbox-list-panel {
flex: 1;
min-width: 0;
width: auto;
}
.inbox-article-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
width: 100%;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.inbox-article-row:hover {
background: var(--sidebar-accent);
}
.inbox-article-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.inbox-row-meta {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted-foreground);
font-size: 11px;
}
.inbox-row-title {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.65;
text-wrap: pretty;
}
.inbox-row-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.8;
}
.inbox-reader {
min-width: 0;
height: 100%;
overflow: hidden;
}
.inbox-reader-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 10px 26px;
border-bottom: 1px solid var(--border);
}
.inbox-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.inbox-article {
max-width: 800px;
margin: 0 auto;
padding: 54px 52px 40px;
}
.inbox-article-title {
margin: 0;
font-size: clamp(24px, 2.4vw, 34px);
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.inbox-byline {
margin-block: 30px;
}
.inbox-article-body {
font-size: 15px;
line-height: 2.15;
letter-spacing: 0.015em;
}
.inbox-article-body p + p {
margin-top: 24px;
}
.inbox-no-results {
display: grid;
justify-items: start;
gap: 12px;
padding: 32px 20px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.8;
}
.inbox-back {
display: none;
}
@media (min-width: 768px) and (max-width: 1100px) {
.inbox-sidebar {
width: 340px;
}
.inbox-article {
padding: 36px 28px;
}
}
@media (max-width: 767px) {
.inbox-sidebar {
width: 100%;
border-right: 0;
}
.inbox-reader {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-sidebar {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-reader {
display: flex;
}
.inbox-back {
display: inline-flex;
}
.inbox-reader-toolbar {
padding-inline: 12px;
}
.inbox-article {
padding: 30px 24px;
}
}
+34 -18
View File
@@ -58,10 +58,10 @@ export function JournalPage() {
return (
<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>
-129
View File
@@ -1,129 +0,0 @@
.journal-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.journal-main {
min-width: 0;
overflow: hidden;
}
.journal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
}
.journal-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.journal-column {
width: min(100%, 760px);
margin: 0 auto;
padding: 40px 28px;
}
.journal-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.journal-kind-switch {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.journal-editor textarea {
min-height: 112px;
}
.journal-editor-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.journal-date-label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: var(--muted-foreground);
min-width: 0;
}
.journal-date-label input {
max-width: 100%;
}
.journal-entries {
margin-top: 36px;
}
.journal-empty {
padding: 40px 0;
color: var(--muted-foreground);
font-size: 14px;
text-align: center;
}
.journal-entry {
border-top: 1px solid var(--border);
padding: 20px 0;
}
.journal-entry-heading {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
}
.journal-entry-text {
margin: 12px 0;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.journal-review-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 220px;
overflow: auto;
padding: 4px;
}
.journal-review-row {
display: flex;
align-items: flex-start;
gap: 12px;
overflow-wrap: anywhere;
}
.journal-review-row [data-slot="checkbox"] {
margin-top: 4px;
}
.journal-summary {
min-height: 180px;
max-height: 280px;
overflow-y: auto;
font-size: 13px;
}
@media (max-width: 600px) {
.journal-header {
padding: 14px;
flex-wrap: wrap;
}
.journal-column {
padding: 24px 14px;
}
.journal-editor-footer {
align-items: stretch;
flex-direction: column;
}
.journal-editor-footer button {
align-self: flex-end;
}
.journal-kind-switch {
gap: 0;
}
.journal-kind-switch button {
padding-inline: 8px;
}
}
@@ -1,10 +0,0 @@
.research-post.citation-highlight {
outline: 2px solid var(--primary);
outline-offset: -2px;
background-color: color-mix(in srgb, var(--primary) 14%, transparent);
}
.research-post:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
+51 -12
View File
@@ -21,7 +21,10 @@ export function ResearchMarkdown({
onNavigateCitation?: (url: string) => boolean;
}) {
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>
),
}}
-197
View File
@@ -1,197 +0,0 @@
.research-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--background);
overflow: hidden;
}
.research-heading {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.research-heading h2 {
margin: 0;
font-size: 1rem;
}
.research-context {
margin: 0 0 0.75rem;
color: var(--muted-foreground);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.research-history {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.research-history span {
flex-shrink: 0;
}
.research-history > [data-slot="native-select-wrapper"] {
flex: 1;
min-width: 0;
}
.research-panel > p {
padding: 0.5rem 1rem;
margin: 0;
font-size: 0.8rem;
}
.research-conversation {
flex: 1;
min-height: 80px;
}
.research-empty {
color: var(--muted-foreground);
font-size: 0.85rem;
line-height: 1.8;
}
.research-message {
font-size: 0.875rem;
line-height: 1.65;
overflow-wrap: anywhere;
}
.research-message p {
margin: 0.35rem 0 0;
white-space: pre-wrap;
}
.research-tool-message {
color: var(--muted-foreground);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.research-start-form {
flex-shrink: 0;
padding: 0.75rem;
border-top: 1px solid var(--border);
}
.research-start-form > fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
min-width: 0;
}
.research-start-form label {
font-size: 0.8rem;
}
.research-account-picker {
font-size: 0.75rem;
}
.research-accounts {
display: grid;
gap: 0.5rem;
border: 1px solid var(--border);
padding: 0.75rem;
min-width: 0;
max-height: 160px;
overflow: auto;
}
.research-accounts label {
display: flex;
gap: 0.5rem;
align-items: center;
overflow-wrap: anywhere;
}
.research-run {
flex-shrink: 0;
border-top: 1px solid var(--border);
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
max-height: 180px;
overflow-y: auto;
}
.research-run p {
margin: 0 0 0.5rem;
overflow-wrap: anywhere;
}
.research-report-path {
overflow-wrap: anywhere;
font-size: 0.75rem;
}
.research-markdown {
min-width: 0;
overflow-wrap: anywhere;
}
.research-message .research-markdown p {
white-space: normal;
}
.research-markdown h3,
.research-markdown h4,
.research-markdown h5,
.research-markdown h6 {
font-size: 1em;
font-weight: 600;
margin: 1em 0 0.4em;
}
.research-markdown ul,
.research-markdown ol {
padding-left: 1.4rem;
}
.research-markdown ul {
list-style: disc;
}
.research-markdown ol {
list-style: decimal;
}
.research-markdown li + li {
margin-top: 0.25rem;
}
.research-markdown blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 2px solid var(--border);
color: var(--muted-foreground);
}
.research-markdown pre {
max-width: 100%;
overflow-x: auto;
padding: 0.75rem;
background: var(--muted);
white-space: pre;
font-size: 0.75rem;
}
.research-markdown code {
overflow-wrap: anywhere;
}
.research-markdown-table {
overflow-x: auto;
max-width: 100%;
margin: 0.75rem 0;
}
.research-markdown table {
border-collapse: collapse;
width: 100%;
font-size: 0.75rem;
}
.research-markdown th,
.research-markdown td {
border: 1px solid var(--border);
padding: 0.4rem;
text-align: left;
}
.research-markdown a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 0.15em;
}
@media (prefers-reduced-motion: reduce) {
.research-markdown [data-sd-animate],
.research-markdown [data-sd-animate-marker]::marker {
animation: none;
}
}
+16 -1
View File
@@ -801,7 +801,7 @@ it("animates only the latest live assistant response and leaves prior replies st
};
const view = show();
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" }),
});
});
+174 -96
View File
@@ -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,108 +543,141 @@ 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" />}>
Connected accounts · {validSelection.length}
</CollapsibleTrigger>
<CollapsibleContent>
<fieldset className="research-accounts">
<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}`}>
<Checkbox
id={`${id}-${connection.id}`}
checked={selectedIds.includes(connection.id)}
disabled={
disabled ||
(!selectedIds.includes(connection.id) && validSelection.length >= 6)
}
onCheckedChange={(checked) =>
onSelectionChange(
checked
? [...selectedIds, connection.id]
: selectedIds.filter((value) => value !== connection.id),
)
}
/>
{connection.displayName} ·{" "}
{connection.platform === "twitter" ? "Twitter" : "Mastodon"}
</label>
))
)}
</fieldset>
</CollapsibleContent>
</Collapsible>
<label htmlFor={id}>Message</label>
<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="e.g. Compare reactions to WebMCP, then refine this deck."
placeholder="Ask about this deck…"
required
disabled={disabled}
/>
<InputGroupAddon align="block-end" className="justify-end">
<InputGroupButton
type="submit"
variant="default"
size="sm"
disabled={disabled || !topic.trim() || validSelection.length === 0}
>
Send
</InputGroupButton>
<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>
</fieldset>
<CollapsibleContent>
<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
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)}
disabled={
disabled ||
(!selectedIds.includes(connection.id) && validSelection.length >= 6)
}
onCheckedChange={(checked) =>
onSelectionChange(
checked
? [...selectedIds, connection.id]
: selectedIds.filter((value) => value !== connection.id),
)
}
/>
{connection.displayName} ·{" "}
{connection.platform === "twitter" ? "Twitter" : "Mastodon"}
</label>
))
)}
</fieldset>
</CollapsibleContent>
</Collapsible>
<p
data-testid="research-context"
className="mt-2 mb-0 text-research-label text-muted-foreground wrap-anywhere"
>
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"
+160 -84
View File
@@ -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">
{conversation.messages.map((message) => (
<div
key={message.id}
id={`${instanceId}-source-${message.id}`}
className="conversation-message"
data-direction={message.direction}
tabIndex={-1}
>
<p className="conversation-message-meta">
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
<span>
{message.channel} · {message.time}
</span>
</p>
<div className="conversation-bubble">{message.text}</div>
</div>
))}
<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) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message
id={`${instanceId}-source-${message.id}`}
align={message.direction === "out" ? "end" : "start"}
tabIndex={-1}
>
<MessageContent>
<MessageHeader>
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
<span className="ml-2 font-normal">
{message.channel} · {message.time}
</span>
</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">
{conversation.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<h3>{conversation.name}</h3>
<p>{conversation.role}</p>
<div className="grid justify-items-start py-px-20">
<Avatar size="lg" aria-hidden="true">
<AvatarFallback>
{conversation.name
.split(" ")
.map((part) => part[0])
.join("")}
</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."}
+70 -36
View File
@@ -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">
{item.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<span className="support-row-content">
<span className="support-row-heading">
<strong>{item.name}</strong>
<span>{item.updatedAt}</span>
<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>
<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 className="support-row-preview">{item.subject}</span>
<span className="support-row-channel">{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">
{selected.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<Avatar aria-hidden="true">
<AvatarFallback>
{selected.name
.split(" ")
.map((part) => part[0])
.join("")}
</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>
-573
View File
@@ -1,573 +0,0 @@
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 · existing neutral tokens */
.support-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.support-sidebar {
width: 370px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.support-list {
flex: 1;
min-width: 0;
width: auto;
}
.support-list-title {
display: flex;
align-items: center;
gap: 10px;
}
.support-list-title h1 {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.04em;
}
.support-list-title > span {
font-size: 12px;
color: var(--muted-foreground);
}
.support-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.support-filters button {
cursor: pointer;
padding: 6px 9px;
font-size: 11px;
color: var(--muted-foreground);
border-radius: 6px;
white-space: nowrap;
}
.support-filters button[aria-pressed="true"] {
background: var(--sidebar-accent);
color: var(--foreground);
}
.support-filters button:hover {
background: var(--muted);
color: var(--foreground);
}
.support-row {
display: flex;
gap: 12px;
padding: 12px 14px;
margin: 2px 8px;
border-radius: 10px;
text-align: left;
cursor: pointer;
line-height: 1.5;
}
.support-row:hover {
background: var(--sidebar-accent);
}
.support-row[aria-current="true"] {
background: var(--sidebar-accent);
}
.support-row:focus-visible,
.support-filters button:focus-visible,
.conversation-draft-options button:focus-visible,
.conversation-scheduling summary:focus-visible,
.conversation-context summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.support-row:active {
background: var(--muted);
}
.support-avatar {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--muted);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
}
.support-avatar-small {
width: 32px;
height: 32px;
font-size: 10px;
}
.support-row-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.support-row-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.support-row-heading strong {
font-size: 12px;
font-weight: 550;
}
.support-row-heading > span {
font-size: 10px;
color: var(--muted-foreground);
white-space: nowrap;
}
.support-row-preview {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.support-row-channel {
font-size: 10px;
color: var(--muted-foreground);
}
.support-list-footer {
padding: 16px 20px;
font-size: 11px;
color: var(--muted-foreground);
}
.support-main {
min-width: 0;
height: 100%;
overflow: hidden;
}
.support-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 64px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.support-toolbar strong {
font-size: 13px;
font-weight: 550;
}
.support-toolbar p {
font-size: 10px;
color: var(--muted-foreground);
margin-top: 2px;
}
.support-detail {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-wrap: anywhere;
container-type: inline-size;
}
.support-outcome {
padding: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
}
.support-outcome p {
white-space: pre-wrap;
margin-top: 8px;
color: var(--muted-foreground);
}
.support-empty {
padding: 64px 24px;
color: var(--muted-foreground);
text-align: center;
font-size: 13px;
display: grid;
justify-items: center;
gap: 12px;
}
.support-back {
display: none;
}
.conversation-workbench {
min-height: calc(100dvh - 64px);
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
}
.conversation-thread {
min-width: 0;
display: flex;
flex-direction: column;
}
.conversation-summary {
padding: 20px 0;
border-top: 1px solid var(--border);
}
.conversation-eyebrow {
color: var(--muted-foreground);
font-size: 10px;
}
.conversation-summary h2 {
margin-top: 7px;
font-size: 12px;
line-height: 1.5;
font-weight: 550;
letter-spacing: -0.015em;
}
.conversation-summary > p {
margin-top: 8px;
font-size: 12px;
line-height: 1.8;
color: var(--muted-foreground);
max-width: 64ch;
}
.conversation-messages {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.conversation-message {
max-width: 90%;
border-radius: 12px;
scroll-margin: 24px;
}
.conversation-message:focus {
outline: 2px solid var(--ring);
outline-offset: 5px;
}
.conversation-message[data-direction="out"] {
align-self: flex-end;
}
.conversation-message-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 10px;
margin-bottom: 7px;
color: var(--muted-foreground);
}
.conversation-message-meta span {
opacity: 0.8;
}
.conversation-bubble {
padding: 10px 14px;
border-radius: 3px 14px 14px;
background: var(--muted);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.conversation-message[data-direction="out"] .conversation-bubble {
background: var(--accent);
border-radius: 14px 3px 14px 14px;
}
.conversation-compose {
padding: 12px 24px 16px;
background: var(--background);
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
}
.conversation-compose-heading {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.conversation-compose-heading label {
font-weight: 550;
}
.conversation-compose-heading span {
font-size: 11px;
color: var(--muted-foreground);
}
.conversation-suggestions > summary {
display: flex;
align-items: center;
gap: 6px;
list-style: none;
cursor: pointer;
color: var(--muted-foreground);
font-size: 11px;
}
.conversation-suggestions > summary svg {
width: 12px;
height: 12px;
}
.conversation-suggestions > summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
}
.conversation-draft-options {
display: grid;
gap: 6px;
padding-top: 10px;
}
.conversation-draft-options button {
text-align: left;
cursor: pointer;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
}
.conversation-draft-options button > span {
font-size: 10px;
color: var(--muted-foreground);
}
.conversation-draft-options button p {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
}
.conversation-draft-options button:hover {
background: var(--muted);
}
.conversation-draft-options button[aria-pressed="true"] {
border-color: var(--ring);
background: var(--accent);
}
.conversation-editor {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--sidebar);
overflow: hidden;
}
.conversation-editor:focus-within {
border-color: var(--ring);
}
.conversation-editor textarea {
min-height: 100px;
max-height: 240px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 14px;
font-size: 13px;
line-height: 1.7;
resize: vertical;
}
.conversation-editor textarea:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -3px;
box-shadow: none;
}
.conversation-editor .conversation-compose-footer {
padding: 4px 10px 10px 14px;
}
.conversation-compose-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.conversation-compose-footer > span {
color: var(--muted-foreground);
font-size: 10px;
}
.conversation-triage {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.conversation-scheduling summary {
display: flex;
align-items: center;
gap: 7px;
list-style: none;
font-size: 11px;
cursor: pointer;
color: var(--muted-foreground);
}
.conversation-scheduling summary svg {
width: 13px;
height: 13px;
}
.conversation-scheduling > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.conversation-context {
border-top: 1px solid var(--border);
min-width: 0;
padding: 22px;
background: var(--sidebar);
}
.conversation-context-details > summary {
list-style: none;
cursor: pointer;
font-size: 11px;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
.conversation-context-details > summary svg {
width: 14px;
height: 14px;
}
.conversation-person {
display: grid;
justify-items: start;
padding: 20px 0;
}
.conversation-person h3 {
margin-top: 12px;
font-size: 13px;
font-weight: 550;
}
.conversation-person p {
margin-top: 4px;
font-size: 11px;
color: var(--muted-foreground);
}
.conversation-next {
padding: 20px 0;
border-block: 1px solid var(--border);
}
.conversation-next h3,
.conversation-facts h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 550;
}
.conversation-next h3 svg {
width: 13px;
height: 13px;
}
.conversation-next > p {
font-size: 12px;
line-height: 1.8;
margin-top: 12px;
}
.conversation-deadline {
display: grid;
gap: 5px;
margin-top: 18px;
font-size: 11px;
}
.conversation-deadline span {
color: var(--muted-foreground);
}
.conversation-deadline strong {
font-weight: 500;
}
.conversation-next > .conversation-next-note {
font-size: 10px;
color: var(--muted-foreground);
margin-top: 16px;
}
.conversation-facts {
padding-top: 22px;
}
.conversation-facts ul {
display: grid;
gap: 18px;
padding-top: 16px;
}
.conversation-facts li p {
font-size: 11px;
line-height: 1.8;
}
.conversation-facts a {
display: inline-block;
margin-top: 5px;
color: var(--muted-foreground);
font-size: 10px;
text-decoration: underline;
text-underline-offset: 3px;
}
@container (min-width: 780px) {
.conversation-workbench {
grid-template-columns: minmax(0, 1fr) 250px;
height: calc(100dvh - 64px);
min-height: 0;
}
.conversation-thread {
min-height: 0;
}
.conversation-messages {
overflow-y: auto;
min-height: 100px;
}
.conversation-compose {
flex-shrink: 0;
max-height: 65%;
overflow-y: auto;
}
.conversation-context {
overflow-y: auto;
border-top: 0;
border-left: 1px solid var(--border);
}
}
@media (max-width: 767px) {
.support-sidebar {
width: 100%;
}
.support-main {
display: none;
}
.support-back {
display: inline-flex;
}
.support-workspace[data-reading="true"] .support-sidebar {
display: none;
}
.support-workspace[data-reading="true"] .support-main {
display: flex;
}
.support-toolbar {
padding: 12px;
}
.conversation-compose {
padding: 12px;
}
.conversation-messages {
padding: 20px 12px;
}
.conversation-message {
max-width: 100%;
}
.conversation-compose-footer > span {
max-width: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.conversation-message {
scroll-behavior: auto;
}
}
.conversation-evaluation {
border-top: 1px solid var(--border);
padding-top: 16px;
font-size: 12px;
}
.conversation-evaluation h3 {
font-weight: 600;
margin-bottom: 12px;
}
.conversation-evaluation ul {
display: grid;
gap: 10px;
}
.conversation-evaluation li {
display: flex;
justify-content: space-between;
gap: 16px;
}
.conversation-evaluation strong {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.conversation-evaluation p {
margin-top: 12px;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.6;
}
+249 -113
View File
@@ -190,14 +190,23 @@ function points(values: readonly number[], width = 640, height = 190) {
.join(" ");
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -1,82 +0,0 @@
/* Hallmark · macrostructure: sidebar-09 Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · mobile: pass 320/375/414/768 */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--overlay: oklch(0 0 0 / 45%);
}
@theme inline {
--font-sans: var(--font-body);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-overlay: var(--overlay);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
-56
View File
@@ -1,56 +0,0 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
:root {
--color-canvas: var(--background);
--color-paper: var(--background);
--color-paper-2: var(--card);
--color-paper-3: var(--muted);
--color-ink: var(--foreground);
--color-ink-2: var(--foreground);
--color-muted: var(--muted-foreground);
--color-accent: var(--primary);
--color-accent-hover: var(--foreground);
--color-accent-ink: var(--primary-foreground);
--color-rule: var(--border);
--color-control-rule: var(--input);
--color-focus: var(--ring);
--color-error: var(--destructive);
--color-activity-lilac: oklch(78% 0.09 295);
--color-activity-sage: oklch(80% 0.07 155);
--color-activity-peach: oklch(82% 0.08 60);
--color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%);
--color-overlay: oklch(10% 0.015 250 / 70%);
--font-body:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
--font-display: var(--font-body);
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
--space-3xs: 0.125rem;
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.5rem;
--space-2xl: 4rem;
--space-3xl: 6rem;
--text-xs: 0.8571rem;
--text-sm: 1rem;
--text-base: 1rem;
--text-md: 1.125rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-display-s: 1.125rem;
--text-display-long: 1.125rem;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-instant: 100ms;
--dur-short: 150ms;
--dur-medium: 300ms;
--rule-hairline: 1px;
--rule-active: 2px;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}