refactor: migrate UI to Tailwind and unify chat with shadcn
This commit is contained in:
+85
-51
@@ -19,7 +19,9 @@
|
|||||||
"react-perf",
|
"react-perf",
|
||||||
"jsx-a11y"
|
"jsx-a11y"
|
||||||
],
|
],
|
||||||
"jsPlugins": ["@shadcn/lint"],
|
"jsPlugins": [
|
||||||
|
"@shadcn/lint"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-console": "error",
|
"no-console": "error",
|
||||||
"no-unused-vars": [
|
"no-unused-vars": [
|
||||||
@@ -48,7 +50,9 @@
|
|||||||
"shadcn/no-arbitrary-values": [
|
"shadcn/no-arbitrary-values": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"allow": ["layout"]
|
"allow": [
|
||||||
|
"layout"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shadcn/no-inline-styles": "error",
|
"shadcn/no-inline-styles": "error",
|
||||||
@@ -56,95 +60,114 @@
|
|||||||
"shadcn/no-restyle": [
|
"shadcn/no-restyle": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"allow": ["layout"],
|
"allow": [
|
||||||
|
"layout"
|
||||||
|
],
|
||||||
"contracts": [
|
"contracts": [
|
||||||
{
|
{
|
||||||
"pattern": "^Sidebar$",
|
"pattern": "^Sidebar$",
|
||||||
"allow": [
|
"allow": [
|
||||||
"layout",
|
"layout",
|
||||||
"border-r",
|
"border-r",
|
||||||
"transition-none",
|
"border-r-0",
|
||||||
"inbox-sidebar",
|
"border-border",
|
||||||
"inbox-list-panel",
|
"transition-none"
|
||||||
"support-sidebar",
|
|
||||||
"support-list"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarHeader$",
|
"pattern": "^SidebarHeader$",
|
||||||
"allow": ["layout", "py-4", "gap-4", "border-b", "p-5"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"py-4",
|
||||||
|
"gap-4",
|
||||||
|
"border-b",
|
||||||
|
"p-5"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarGroup$",
|
"pattern": "^SidebarGroup$",
|
||||||
"allow": ["layout", "px-3"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"px-3"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarFooter$",
|
"pattern": "^SidebarFooter$",
|
||||||
"allow": ["layout", "px-3", "pb-4"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"px-3",
|
||||||
|
"pb-4"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarMenuButton$",
|
"pattern": "^SidebarMenuButton$",
|
||||||
"allow": ["layout", "p-0"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"p-0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^Label$",
|
"pattern": "^Label$",
|
||||||
"allow": ["layout", "deck-checkbox"]
|
"allow": [
|
||||||
},
|
"layout",
|
||||||
{
|
"gap-2",
|
||||||
"pattern": "^Textarea$",
|
"text-sm",
|
||||||
"allow": ["layout", "text-sm", "leading-7", "journal-summary"]
|
"text-xs",
|
||||||
},
|
"text-muted-foreground"
|
||||||
{
|
]
|
||||||
"pattern": "^MessageScroller$",
|
|
||||||
"allow": ["layout", "research-conversation"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^MessageScrollerContent$",
|
"pattern": "^MessageScrollerContent$",
|
||||||
"allow": ["layout", "gap-5", "p-4"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"gap-5",
|
||||||
|
"p-4"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^Marker$",
|
"pattern": "^(Marker|Collapsible)$",
|
||||||
"allow": ["layout", "research-tool-message"]
|
"allow": [
|
||||||
},
|
"layout",
|
||||||
{
|
"text-research-label",
|
||||||
"pattern": "^Message$",
|
"text-muted-foreground"
|
||||||
"allow": ["layout", "research-message"]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"pattern": "^Collapsible$",
|
|
||||||
"allow": ["layout", "research-account-picker"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarProvider$",
|
"pattern": "^SidebarProvider$",
|
||||||
"allow": [
|
"allow": [
|
||||||
"layout",
|
"layout",
|
||||||
"workspace",
|
"bg-vital-bg",
|
||||||
"home-workspace",
|
"text-vital-text",
|
||||||
"inbox-workspace",
|
"font-arial"
|
||||||
"vital-workspace",
|
|
||||||
"support-workspace",
|
|
||||||
"journal-workspace"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^Button$",
|
"pattern": "^Button$",
|
||||||
"allow": ["layout", "auth-submit", "inbox-back", "support-back"]
|
"allow": [
|
||||||
},
|
"layout",
|
||||||
{
|
"px-2"
|
||||||
"pattern": "^SidebarInset$",
|
]
|
||||||
"allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^Input$",
|
"pattern": "^Input$",
|
||||||
"allow": ["layout", "pl-9"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"pl-9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^SidebarContent$",
|
"pattern": "^SidebarContent$",
|
||||||
"allow": ["layout", "gap-0"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"gap-0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "^Badge$",
|
"pattern": "^Badge$",
|
||||||
"allow": ["layout", "deck-source-label"]
|
"allow": [
|
||||||
|
"layout",
|
||||||
|
"gap-1"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -154,13 +177,17 @@
|
|||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["scripts/**"],
|
"files": [
|
||||||
|
"scripts/**"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-console": "off"
|
"no-console": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["src/components/ui/**"],
|
"files": [
|
||||||
|
"src/components/ui/**"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"shadcn/no-arbitrary-values": "off",
|
"shadcn/no-arbitrary-values": "off",
|
||||||
"shadcn/no-restyle": "off",
|
"shadcn/no-restyle": "off",
|
||||||
@@ -168,8 +195,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
"files": [
|
||||||
"plugins": ["vitest"],
|
"src/**/*.{test,spec}.{ts,tsx}",
|
||||||
|
"tests/**/*.{ts,tsx}"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"vitest"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"typescript/unbound-method": "error",
|
"typescript/unbound-method": "error",
|
||||||
"vitest/expect-expect": "error",
|
"vitest/expect-expect": "error",
|
||||||
@@ -179,7 +211,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["e2e/**/*.{ts,tsx}"],
|
"files": [
|
||||||
|
"e2e/**/*.{ts,tsx}"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/rules-of-hooks": "off"
|
"react/rules-of-hooks": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,22 +262,25 @@ shadcn dialogs with Escape handling and focus restoration. Column menus contain
|
|||||||
editing, ordering, and deletion; refresh stays available directly in each header.
|
editing, ordering, and deletion; refresh stays available directly in each header.
|
||||||
|
|
||||||
Shared UI components live in `src/components/ui`; `components.json` configures
|
Shared UI components live in `src/components/ui`; `components.json` configures
|
||||||
the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble,
|
the registry and theme entrypoint. Research and Messages follow the
|
||||||
Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved
|
[shadcn chatbot template](https://github.com/shadcn-ui/chatbot-template), composing
|
||||||
conversations. The icon rail switches between chat and deck management;
|
MessageScroller, Message, Bubble and InputGroup without separate chat primitives.
|
||||||
|
Research uses Marker and Streamdown for tool progress and streamed Markdown,
|
||||||
|
with reconnectable snapshot SSE and saved conversations. Enter sends a research
|
||||||
|
message; Shift+Enter inserts a line break and IME composition does not submit. The icon rail switches between chat and deck management;
|
||||||
closing the mobile sheet or crossing the desktop breakpoint preserves chat
|
closing the mobile sheet or crossing the desktop breakpoint preserves chat
|
||||||
drafts, selected accounts and live deck updates. Theme colors are declared in
|
drafts, selected accounts and live deck updates. All application styling uses Tailwind utilities in the components.
|
||||||
`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens,
|
`src/styles.css` contains the Tailwind imports, shared theme tokens, and base
|
||||||
base styles and feature styles in that order, so the UI lint rules can resolve
|
utilities; there are no feature stylesheets. Named pixel spacing tokens preserve
|
||||||
application classes through the same stylesheet used by the app.
|
the compact layouts alongside the existing rem scale. Tests use accessible
|
||||||
|
roles and explicit data attributes rather than styling classes.
|
||||||
|
|
||||||
`pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through
|
`pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through
|
||||||
`oxlint-tsgolint`, including the
|
`oxlint-tsgolint`, including the
|
||||||
[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The
|
[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The
|
||||||
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
|
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
|
||||||
class helpers, including shared UI components. Layout values remain available
|
class helpers, including shared UI components. Layout values remain available
|
||||||
for the sidebar and deck. Component contracts allow only the existing workspace
|
for the sidebar and deck. Component contracts allow only the required shared-component treatments; Next.js-specific rules are omitted because this app uses TanStack Start.
|
||||||
styles; Next.js-specific rules are omitted because this app uses TanStack Start.
|
|
||||||
`pnpm format` formats files with Oxfmt defaults, and
|
`pnpm format` formats files with Oxfmt defaults, and
|
||||||
`pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports
|
`pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports
|
||||||
of registry components for composition. Additional design rules can be configured
|
of registry components for composition. Additional design rules can be configured
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ remains a visual placeholder, not inferred medical content.
|
|||||||
as the anatomy illustration source and clipped in CSS. Source media:
|
as the anatomy illustration source and clipped in CSS. Source media:
|
||||||
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
|
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
|
||||||
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
|
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
|
||||||
colors are scoped to `--vital-*` tokens in `vitals.css`.
|
colors use `--color-vital-*` Tailwind theme tokens in `src/styles.css`.
|
||||||
|
Layout, responsive states, and chart styling use utilities in the components.
|
||||||
|
|
||||||
Sections, tabs, history, chart filters, and detail dialogs are URL state and
|
Sections, tabs, history, chart filters, and detail dialogs are URL state and
|
||||||
restore on reload or browser Back/Forward. Simulated climate control values
|
restore on reload or browser Back/Forward. Simulated climate control values
|
||||||
|
|||||||
@@ -74,18 +74,18 @@ test("creates, switches, renames and deletes persisted deck profiles", async ({
|
|||||||
await page.getByRole("button", { name: "Create deck", exact: true }).click();
|
await page.getByRole("button", { name: "Create deck", exact: true }).click();
|
||||||
await page.getByLabel("New deck name").fill("別の調査");
|
await page.getByLabel("New deck name").fill("別の調査");
|
||||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||||
await expect(page.locator(".deck-column")).toHaveCount(0);
|
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0);
|
||||||
await addColumn(page, "別の観点", "second-deck", "alternate");
|
await addColumn(page, "別の観点", "second-deck", "alternate");
|
||||||
await showDeckManagement(page);
|
await showDeckManagement(page);
|
||||||
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
|
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
|
||||||
await closeMobileSidebar(page);
|
await closeMobileSidebar(page);
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
|
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
|
||||||
await showDeckManagement(page);
|
await showDeckManagement(page);
|
||||||
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
|
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
|
||||||
await closeMobileSidebar(page);
|
await closeMobileSidebar(page);
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["別の観点"]);
|
||||||
await showDeckManagement(page);
|
await showDeckManagement(page);
|
||||||
await page.getByRole("button", { name: "Delete deck", exact: true }).click();
|
await page.getByRole("button", { name: "Delete deck", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||||
@@ -108,11 +108,17 @@ test("reads user and list timelines within columns and reorders, deletes and res
|
|||||||
await expect(list.getByText("list page 2")).toBeVisible();
|
await expect(list.getByText("list page 2")).toBeVisible();
|
||||||
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
||||||
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
|
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||||
|
"リストの投稿",
|
||||||
|
"アカウント",
|
||||||
|
]);
|
||||||
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
|
||||||
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
|
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
|
||||||
await page.getByRole("button", { name: "Undo" }).click();
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||||
|
"リストの投稿",
|
||||||
|
"アカウント",
|
||||||
|
]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(user.getByText("alternate profile page 1")).toBeVisible();
|
await expect(user.getByText("alternate profile page 1")).toBeVisible();
|
||||||
await expect(list.getByText("list page 1")).toBeVisible();
|
await expect(list.getByText("list page 1")).toBeVisible();
|
||||||
@@ -149,7 +155,7 @@ test("keeps the deck and editor accessible within narrow viewports", async ({
|
|||||||
expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
|
expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const board = await page.locator(".deck-board").boundingBox();
|
const board = await page.locator("[data-testid='deck-board']").boundingBox();
|
||||||
expect(board).not.toBeNull();
|
expect(board).not.toBeNull();
|
||||||
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
|
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
|
||||||
}
|
}
|
||||||
@@ -184,7 +190,7 @@ test("closes the column editor with Escape and restores focus without applying e
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
|
||||||
await expect(opener).toBeFocused();
|
await expect(opener).toBeFocused();
|
||||||
await expect(page.locator(".deck-column")).toHaveCount(0);
|
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0);
|
||||||
await opener.click();
|
await opener.click();
|
||||||
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
|
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ test("keeps completed actions in their original positions with visible outcomes"
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const titles = page.locator(".execution-flow-title");
|
const titles = page.locator("[data-testid='execution-flow-title']");
|
||||||
const before = await titles.allTextContents();
|
const before = await titles.allTextContents();
|
||||||
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "../fixtures";
|
|||||||
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
|
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
|
||||||
await page.goto("/inbox");
|
await page.goto("/inbox");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const rows = page.locator(".inbox-article-row");
|
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||||
await expect(rows).toHaveCount(9);
|
await expect(rows).toHaveCount(9);
|
||||||
const articleTitle = "Context for an agent can be brief";
|
const articleTitle = "Context for an agent can be brief";
|
||||||
await rows.filter({ hasText: articleTitle }).click();
|
await rows.filter({ hasText: articleTitle }).click();
|
||||||
@@ -33,7 +33,7 @@ test("selects articles and keeps the reader focused on their content", async ({
|
|||||||
test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
|
test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
|
||||||
await page.goto("/inbox");
|
await page.goto("/inbox");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const rows = page.locator(".inbox-article-row");
|
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||||
await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
|
await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
|
||||||
await expect(rows).toHaveCount(0);
|
await expect(rows).toHaveCount(0);
|
||||||
await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
|
await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
|
||||||
@@ -58,7 +58,7 @@ test("keeps article navigation readable without horizontal overflow", async ({
|
|||||||
await page.setViewportSize({ width, height: 850 });
|
await page.setViewportSize({ width, height: 850 });
|
||||||
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||||
await page.locator(".inbox-article-row").first().click();
|
await page.locator("[data-testid='inbox-article-row']").first().click();
|
||||||
const reader = page.getByRole("article", {
|
const reader = page.getByRole("article", {
|
||||||
name: "Thinking in the browser: a new workflow with AI agents",
|
name: "Thinking in the browser: a new workflow with AI agents",
|
||||||
exact: true,
|
exact: true,
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
"background-color",
|
"background-color",
|
||||||
"rgba(0, 0, 0, 0)",
|
"rgba(0, 0, 0, 0)",
|
||||||
);
|
);
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]);
|
||||||
run.deckVersion = 2;
|
run.deckVersion = 2;
|
||||||
run.status = "complete";
|
run.status = "complete";
|
||||||
run.messages.push({
|
run.messages.push({
|
||||||
@@ -124,7 +124,10 @@ test("restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
await showResearch(page);
|
await showResearch(page);
|
||||||
await expect(conversation).toContainText("既存の観点を使って調査して");
|
await expect(conversation).toContainText("既存の観点を使って調査して");
|
||||||
await expect(conversation).toContainText("比較する観点を追加しました。");
|
await expect(conversation).toContainText("比較する観点を追加しました。");
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([
|
||||||
|
"最初の観点",
|
||||||
|
"比較の観点",
|
||||||
|
]);
|
||||||
await closeMobileSidebar(page);
|
await closeMobileSidebar(page);
|
||||||
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
|
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
|
||||||
await showResearch(page);
|
await showResearch(page);
|
||||||
@@ -260,15 +263,15 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col
|
|||||||
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
|
||||||
await chat.getByRole("link", { name: "根拠の投稿" }).click();
|
await chat.getByRole("link", { name: "根拠の投稿" }).click();
|
||||||
const card = page.locator(
|
const card = page.locator(
|
||||||
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
|
'[data-testid="research-post"][data-post-url="https://twitter.com/author/status/123456789"]',
|
||||||
);
|
);
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
await expect(card).toHaveClass(/citation-highlight/);
|
await expect(card).toHaveAttribute("data-citation-highlight", "true");
|
||||||
await expect(card).toBeFocused();
|
await expect(card).toBeFocused();
|
||||||
await expect(card).toContainText("Post saved during research");
|
await expect(card).toContainText("Post saved during research");
|
||||||
await expect(card).toContainText("AIが調査時に取得した引用本文");
|
await expect(card).toContainText("AIが調査時に取得した引用本文");
|
||||||
expect(
|
expect(
|
||||||
await page.locator(".deck-board").evaluate((element) => element.scrollLeft),
|
await page.locator("[data-testid='deck-board']").evaluate((element) => element.scrollLeft),
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
expect(page.context().pages()).toHaveLength(1);
|
expect(page.context().pages()).toHaveLength(1);
|
||||||
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
||||||
@@ -283,7 +286,7 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col
|
|||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
await expect(card).toHaveClass(/citation-highlight/);
|
await expect(card).toHaveClass(/citation-highlight/);
|
||||||
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
||||||
await expect(page.locator(".deck-column")).toHaveCount(3);
|
await expect(page.locator("[data-testid='deck-column']")).toHaveCount(3);
|
||||||
expect(submitted).toEqual([]);
|
expect(submitted).toEqual([]);
|
||||||
expect((await a11y().analyze()).violations).toEqual([]);
|
expect((await a11y().analyze()).violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
|||||||
}) => {
|
}) => {
|
||||||
await page.goto("/support");
|
await page.goto("/support");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const rows = page.locator(".support-row");
|
const rows = page.locator("[data-testid='support-row']");
|
||||||
await expect(rows).toHaveCount(3);
|
await expect(rows).toHaveCount(3);
|
||||||
await rows.filter({ hasText: reviewSubject }).click();
|
await rows.filter({ hasText: reviewSubject }).click();
|
||||||
await page.locator(".conversation-suggestions summary:visible").click();
|
await page.locator("[data-testid='conversation-suggestions'] summary:visible").click();
|
||||||
await page.getByRole("button", { name: /^Polite/ }).click();
|
await page.getByRole("button", { name: /^Polite/ }).click();
|
||||||
const reply = page.getByRole("textbox", { name: "Reply", exact: true });
|
const reply = page.getByRole("textbox", { name: "Reply", exact: true });
|
||||||
await expect(reply).toHaveValue(/Thank you for sharing/);
|
await expect(reply).toHaveValue(/Thank you for sharing/);
|
||||||
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
|
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
|
||||||
await reply.fill(edited);
|
await reply.fill(edited);
|
||||||
await page.locator(".conversation-scheduling summary:visible").click();
|
await page.locator("[data-testid='conversation-scheduling'] summary:visible").click();
|
||||||
const slot = "Mon, Oct 5, 14:00–14:30";
|
const slot = "Mon, Oct 5, 14:00–14:30";
|
||||||
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
|
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
|
||||||
await page.getByRole("button", { name: "Add to reply" }).click();
|
await page.getByRole("button", { name: "Add to reply" }).click();
|
||||||
@@ -37,15 +37,16 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
|||||||
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
|
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Archive", exact: true }).click();
|
await page.getByRole("button", { name: "Archive", exact: true }).click();
|
||||||
await rows.filter({ hasText: reviewSubject }).click();
|
await rows.filter({ hasText: reviewSubject }).click();
|
||||||
await expect(page.locator(".support-outcome")).toContainText(edited);
|
await expect(page.locator("[data-testid='support-outcome']")).toContainText(edited);
|
||||||
await expect(page.locator(".support-outcome")).toContainText(slot);
|
await expect(page.locator("[data-testid='support-outcome']")).toContainText(slot);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defers a conversation and restores it to the current queue", async ({ page }) => {
|
test("defers a conversation and restores it to the current queue", async ({ page }) => {
|
||||||
await page.goto("/support");
|
await page.goto("/support");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const rows = page.locator(".support-row");
|
const rows = page.locator("[data-testid='support-row']");
|
||||||
await rows.filter({ hasText: coffeeSubject }).click();
|
await rows.filter({ hasText: coffeeSubject }).click();
|
||||||
|
await expect(rows.filter({ hasText: coffeeSubject })).toHaveAttribute("aria-current", "true");
|
||||||
await page
|
await page
|
||||||
.getByRole("textbox", { name: "Reply", exact: true })
|
.getByRole("textbox", { name: "Reply", exact: true })
|
||||||
.fill("土曜の予定を確認してから返事するね。");
|
.fill("土曜の予定を確認してから返事するね。");
|
||||||
@@ -117,7 +118,7 @@ test("fits communication and journal interactions in narrow screens", async ({
|
|||||||
await page.goto("/support");
|
await page.goto("/support");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||||
await page.locator(".support-row").first().click();
|
await page.locator("[data-testid='support-row']").first().click();
|
||||||
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||||
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
|
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ import { expect, test } from "../fixtures";
|
|||||||
|
|
||||||
test("restores article selection with browser history and reload", async ({ page }) => {
|
test("restores article selection with browser history and reload", async ({ page }) => {
|
||||||
await page.goto("/inbox", { waitUntil: "networkidle" });
|
await page.goto("/inbox", { waitUntil: "networkidle" });
|
||||||
const rows = page.locator(".inbox-article-row");
|
const rows = page.locator("[data-testid='inbox-article-row']");
|
||||||
const heading = await rows.nth(1).locator(".inbox-row-title").innerText();
|
const heading = await rows.nth(1).locator("[data-testid='inbox-row-title']").innerText();
|
||||||
await rows.nth(1).click();
|
await rows.nth(1).click();
|
||||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading);
|
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading);
|
||||||
const selectedUrl = page.url();
|
const selectedUrl = page.url();
|
||||||
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
|
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
|
||||||
await page.reload({ waitUntil: "networkidle" });
|
await page.reload({ waitUntil: "networkidle" });
|
||||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
|
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? "");
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false");
|
await expect(page.locator("[data-testid='inbox-workspace']")).toHaveAttribute(
|
||||||
|
"data-reading",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
await page.goForward();
|
await page.goForward();
|
||||||
await expect(page).toHaveURL(selectedUrl);
|
await expect(page).toHaveURL(selectedUrl);
|
||||||
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
|
await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("restores search and replaces typing history", async ({ page }) => {
|
test("restores search and replaces typing history", async ({ page }) => {
|
||||||
@@ -24,10 +27,10 @@ test("restores search and replaces typing history", async ({ page }) => {
|
|||||||
await search.pressSequentially("browser");
|
await search.pressSequentially("browser");
|
||||||
await expect(search).toHaveValue("browser");
|
await expect(search).toHaveValue("browser");
|
||||||
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
|
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
|
||||||
const count = await page.locator(".inbox-article-row").count();
|
const count = await page.locator("[data-testid='inbox-article-row']").count();
|
||||||
await page.reload({ waitUntil: "networkidle" });
|
await page.reload({ waitUntil: "networkidle" });
|
||||||
await expect(search).toHaveValue("browser");
|
await expect(search).toHaveValue("browser");
|
||||||
await expect(page.locator(".inbox-article-row")).toHaveCount(count);
|
await expect(page.locator("[data-testid='inbox-article-row']")).toHaveCount(count);
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).toHaveURL("http://127.0.0.1:4173/");
|
await expect(page).toHaveURL("http://127.0.0.1:4173/");
|
||||||
});
|
});
|
||||||
@@ -47,16 +50,21 @@ test("restores account dialog without losing underlying article", async ({ page
|
|||||||
test("restores a message view and conversation from its URL", async ({ page }) => {
|
test("restores a message view and conversation from its URL", async ({ page }) => {
|
||||||
await page.goto("/support", { waitUntil: "networkidle" });
|
await page.goto("/support", { waitUntil: "networkidle" });
|
||||||
await page.getByRole("button", { name: "All", exact: true }).click();
|
await page.getByRole("button", { name: "All", exact: true }).click();
|
||||||
const row = page.locator(".support-row").nth(1);
|
const row = page.locator("[data-testid='support-row']").nth(1);
|
||||||
const heading = await row.locator("span").first().innerText();
|
const heading = await row.locator("span").first().innerText();
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading);
|
await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(heading);
|
||||||
const url = page.url();
|
const url = page.url();
|
||||||
expect(new URL(url).searchParams.get("view")).toBe("all");
|
expect(new URL(url).searchParams.get("view")).toBe("all");
|
||||||
expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
|
expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
|
||||||
await page.reload({ waitUntil: "networkidle" });
|
await page.reload({ waitUntil: "networkidle" });
|
||||||
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? "");
|
await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(
|
||||||
await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true");
|
heading ?? "",
|
||||||
|
);
|
||||||
|
await expect(page.locator("[data-testid='support-workspace']")).toHaveAttribute(
|
||||||
|
"data-reading",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a deep link through login", async ({ page, context }) => {
|
test("keeps a deep link through login", async ({ page, context }) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
|
|||||||
await page.goto("/vitals");
|
await page.goto("/vitals");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
|
||||||
const systems = page.locator(".vital-system-card");
|
const systems = page.locator("[data-testid='vital-system-card']");
|
||||||
await expect(systems).toHaveCount(8);
|
await expect(systems).toHaveCount(8);
|
||||||
await page
|
await page
|
||||||
.getByRole("group", { name: "Body systems", exact: true })
|
.getByRole("group", { name: "Body systems", exact: true })
|
||||||
@@ -24,11 +24,11 @@ test("filters body systems and opens lab history", async ({ page, a11y }) => {
|
|||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
|
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||||
await page.getByRole("button", { name: "Lab history", exact: true }).click();
|
await page.getByRole("button", { name: "Lab history", exact: true }).click();
|
||||||
await expect(page.locator(".vital-history")).toContainText("Jul 28");
|
await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28");
|
||||||
await expect(page.locator(".vital-history")).toContainText("93");
|
await expect(page.locator("[data-testid='vital-history']")).toContainText("93");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator(".vital-history")).toContainText("Jul 28");
|
await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28");
|
||||||
await page.getByRole("button", { name: "Systems", exact: true }).click();
|
await page.getByRole("button", { name: "Systems", exact: true }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("group", { name: "Body systems", exact: true })
|
.getByRole("group", { name: "Body systems", exact: true })
|
||||||
@@ -61,7 +61,10 @@ test("explores environment charts and simulated climate controls", async ({ page
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
|
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click();
|
await page
|
||||||
|
.locator("[data-testid='vital-metric']")
|
||||||
|
.filter({ hasText: "Room temperature" })
|
||||||
|
.click();
|
||||||
const dialog = page.getByRole("dialog", { name: "Room temperature" });
|
const dialog = page.getByRole("dialog", { name: "Room temperature" });
|
||||||
await expect(dialog).toContainText("64.0");
|
await expect(dialog).toContainText("64.0");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ test("rejects invalid input without changing the active view", async ({ page })
|
|||||||
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
|
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
|
await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]);
|
||||||
}
|
}
|
||||||
expect(savedCount()).toEqual({ count: 0 });
|
expect(savedCount()).toEqual({ count: 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
:root {
|
|
||||||
--canvas: var(--color-canvas);
|
|
||||||
--surface: var(--color-paper-2);
|
|
||||||
--paper: var(--color-paper);
|
|
||||||
--ink: var(--color-ink);
|
|
||||||
--accent-hover: var(--color-accent-hover);
|
|
||||||
--control-border: var(--color-control-rule);
|
|
||||||
--error: var(--color-error);
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--canvas);
|
|
||||||
color-scheme: dark;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
font-synthesis: none;
|
|
||||||
}
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
overflow-x: clip;
|
|
||||||
background: var(--canvas);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-underline-offset: 0.18em;
|
|
||||||
}
|
|
||||||
a:active {
|
|
||||||
color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
:focus-visible {
|
|
||||||
outline: var(--rule-active) solid var(--color-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-display-s);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.handle {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace .reader {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.sidebar-note {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
/* Respect reduced motion over registry component animation utilities. */
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
/* Respect reduced motion over registry component transition utilities. */
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
|
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
|
||||||
import { type CSSProperties, type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@@ -22,15 +22,7 @@ type ShellProps = {
|
|||||||
export function AppShell(props: ShellProps) {
|
export function AppShell(props: ShellProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={800}>
|
<TooltipProvider delay={800}>
|
||||||
<SidebarProvider
|
<SidebarProvider className="h-dvh min-h-0 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
|
||||||
className="workspace h-dvh min-h-0 overflow-hidden"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": "25rem",
|
|
||||||
"--sidebar-width-icon": "4rem",
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Workspace {...props} />
|
<Workspace {...props} />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -106,7 +98,10 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell
|
|||||||
<span className="text-sm font-medium">Research</span>
|
<span className="text-sm font-medium">Research</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
|
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
|
||||||
</header>
|
</header>
|
||||||
<section className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks">
|
<section
|
||||||
|
className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
|
||||||
|
aria-label="Decks"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|||||||
<fieldset
|
<fieldset
|
||||||
data-slot="input-group"
|
data-slot="input-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import { Input as InputPrimitive } from "@base-ui/react/input";
|
|||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input"> & { variant?: "default" | "measurement" }) {
|
||||||
return (
|
return (
|
||||||
<InputPrimitive
|
<InputPrimitive
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
variant === "measurement" &&
|
||||||
|
"bg-background text-ui-20 tabular-nums md:text-ui-20 dark:bg-background",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ import { Skeleton } from "#/components/ui/skeleton.tsx";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
|
||||||
import { useIsMobile } from "#/hooks/use-mobile.ts";
|
import { useIsMobile } from "#/hooks/use-mobile.ts";
|
||||||
|
|
||||||
const SIDEBAR_WIDTH = "16rem";
|
|
||||||
const SIDEBAR_WIDTH_MOBILE = "min(25rem, 100vw)";
|
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
@@ -49,7 +46,6 @@ function SidebarProvider({
|
|||||||
open: openProp,
|
open: openProp,
|
||||||
onOpenChange: setOpenProp,
|
onOpenChange: setOpenProp,
|
||||||
className,
|
className,
|
||||||
style,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
@@ -115,15 +111,8 @@ function SidebarProvider({
|
|||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH,
|
|
||||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
||||||
...style,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
"[--sidebar-width:16rem] [--sidebar-width-icon:3rem] group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -173,12 +162,7 @@ function Sidebar({
|
|||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-mobile="true"
|
data-mobile="true"
|
||||||
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
className="[--sidebar-width:min(25rem,100vw)] w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
side={side}
|
side={side}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
@@ -574,11 +558,6 @@ function SidebarMenuSkeleton({
|
|||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Random width between 50 to 90%.
|
|
||||||
const [width] = React.useState(() => {
|
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-skeleton"
|
data-slot="sidebar-menu-skeleton"
|
||||||
@@ -587,15 +566,7 @@ function SidebarMenuSkeleton({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||||
<Skeleton
|
<Skeleton className="h-4 max-w-3/4 flex-1" data-sidebar="menu-skeleton-text" />
|
||||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
|
||||||
data-sidebar="menu-skeleton-text"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--skeleton-width": width,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
const textareaVariants = cva(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
composer:
|
||||||
|
"resize-y border-0 bg-transparent p-px-8 text-ui-16 leading-relaxed-copy shadow-none md:text-ui-16 dark:bg-transparent",
|
||||||
|
note: "bg-background text-ui-12 md:text-ui-12 dark:bg-background",
|
||||||
|
summary: "text-ui-13 md:text-ui-13",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Textarea({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea"> & VariantProps<typeof textareaVariants>) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(textareaVariants({ variant }), className)}
|
||||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { safeReturnTo } from "./return-to";
|
|||||||
|
|
||||||
export function AuthFrame({ children }: { children: ReactNode }) {
|
export function AuthFrame({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="grid min-h-svh place-items-center bg-background px-px-24 py-px-48 text-foreground max-[480px]:py-px-32">
|
||||||
<div className="auth-panel">
|
<div className="w-full max-w-[390px] min-w-0">
|
||||||
<div className="auth-brand">
|
<div className="mb-px-44 flex items-center gap-px-9 text-ui-13 text-muted-foreground max-[480px]:mb-px-36">
|
||||||
<LockKeyhole size={16} aria-hidden="true" />
|
<LockKeyhole size={16} aria-hidden="true" />
|
||||||
Personal Workspace
|
Personal Workspace
|
||||||
</div>
|
</div>
|
||||||
@@ -75,19 +75,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFrame>
|
<AuthFrame>
|
||||||
<header className="auth-heading">
|
<header className="mb-px-28">
|
||||||
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
|
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||||
<p>
|
{setup ? "Make yourself at home." : "Welcome back."}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
|
||||||
{setup
|
{setup
|
||||||
? "Create the owner account for your personal workspace."
|
? "Create the owner account for your personal workspace."
|
||||||
: "Sign in to your personal workspace."}
|
: "Sign in to your personal workspace."}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
|
<form onSubmit={submit} className="grid gap-px-20" aria-busy={pending}>
|
||||||
{setup && (
|
{setup && (
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="name">Your name</Label>
|
<Label htmlFor="name">Your name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
@@ -97,9 +100,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Label htmlFor="email">Email address</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -109,9 +113,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -123,16 +128,17 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
{setup && (
|
{setup && (
|
||||||
<p id="password-hint" className="auth-hint">
|
<p id="password-hint" className="text-ui-12 leading-cozy text-muted-foreground">
|
||||||
Use 15–128 characters. A few memorable words work well.
|
Use 15–128 characters. A few memorable words work well.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{setup && (
|
{setup && (
|
||||||
<>
|
<>
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -143,9 +149,10 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="setupToken">Setup code</Label>
|
<Label htmlFor="setupToken">Setup code</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="setupToken"
|
id="setupToken"
|
||||||
name="setupToken"
|
name="setupToken"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -154,18 +161,23 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
aria-describedby="setup-hint"
|
aria-describedby="setup-hint"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
<p id="setup-hint" className="auth-hint">
|
<p id="setup-hint" className="text-ui-12 leading-cozy text-muted-foreground">
|
||||||
Use the setup code provided by your server administrator.
|
Use the setup code provided by your server administrator.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="auth-error" role="alert">
|
<p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" size="lg" disabled={pending} className="auth-submit">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={pending}
|
||||||
|
className="mt-[4px] w-full justify-between"
|
||||||
|
>
|
||||||
{pending
|
{pending
|
||||||
? setup
|
? setup
|
||||||
? "Creating account…"
|
? "Creating account…"
|
||||||
@@ -176,7 +188,7 @@ function CredentialsForm({ setup }: { setup: boolean }) {
|
|||||||
<ArrowRight aria-hidden="true" />
|
<ArrowRight aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="auth-footnote">
|
<p className="mt-px-24 text-ui-12 leading-cozy text-muted-foreground">
|
||||||
{setup
|
{setup
|
||||||
? "This workspace has one owner. Connections can be added later."
|
? "This workspace has one owner. Connections can be added later."
|
||||||
: "A private workspace. Access is limited to its owner."}
|
: "A private workspace. Access is limited to its owner."}
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
.auth-page {
|
|
||||||
min-height: 100svh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.auth-panel {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 390px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 44px;
|
|
||||||
}
|
|
||||||
.auth-heading {
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
.auth-heading h1 {
|
|
||||||
font-size: 27px;
|
|
||||||
font-weight: 550;
|
|
||||||
letter-spacing: -0.8px;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.auth-heading p {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.auth-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.auth-field {
|
|
||||||
display: grid;
|
|
||||||
gap: 9px;
|
|
||||||
}
|
|
||||||
.auth-field input {
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
.auth-hint,
|
|
||||||
.auth-footnote,
|
|
||||||
.auth-prototype {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.auth-submit {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-inline: 14px;
|
|
||||||
}
|
|
||||||
.auth-footnote {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
.auth-error {
|
|
||||||
color: var(--destructive);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.auth-step {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.auth-overview {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 28px;
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
.auth-overview li {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.auth-overview svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.auth-overview strong {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.auth-overview p {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.auth-prototype {
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.auth-actions {
|
|
||||||
margin-top: 26px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.auth-footer {
|
|
||||||
margin-top: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.auth-footer span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.auth-page {
|
|
||||||
padding: 32px 24px;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
margin-bottom: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,17 +46,22 @@ export function OnboardingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFrame>
|
<AuthFrame>
|
||||||
<p className="auth-step">{step} of 2</p>
|
<p className="mb-px-16 text-ui-12 text-muted-foreground">{step} of 2</p>
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<>
|
<>
|
||||||
<header className="auth-heading">
|
<header className="mb-px-28">
|
||||||
<h1>A little context, to start.</h1>
|
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||||
<p>Your day, conversations, and reading, together in one place.</p>
|
A little context, to start.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
|
||||||
|
Your day, conversations, and reading, together in one place.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<form className="auth-form" onSubmit={next}>
|
<form className="grid gap-px-20" onSubmit={next}>
|
||||||
<div className="auth-field">
|
<div className="grid gap-px-9">
|
||||||
<Label htmlFor="displayName">What should we call you?</Label>
|
<Label htmlFor="displayName">What should we call you?</Label>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-[40px]"
|
||||||
id="displayName"
|
id="displayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
value={name}
|
value={name}
|
||||||
@@ -69,7 +74,7 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="auth-submit"
|
className="mt-[4px] w-full justify-between"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={pending || !name.trim()}
|
disabled={pending || !name.trim()}
|
||||||
>
|
>
|
||||||
@@ -80,38 +85,57 @@ export function OnboardingPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<header className="auth-heading">
|
<header className="mb-px-28">
|
||||||
<h1>Start with today.</h1>
|
<h1 className="text-ui-27 font-book tracking-tight leading-tight wrap-anywhere">
|
||||||
<p>You can take it one thing at a time.</p>
|
Start with today.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-px-12 text-ui-14 leading-cozy text-muted-foreground">
|
||||||
|
You can take it one thing at a time.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<ul className="auth-overview">
|
<ul className="m-0 mb-px-28 grid list-none gap-px-24 p-0">
|
||||||
<li>
|
<li className="flex items-start gap-px-14">
|
||||||
<House aria-hidden="true" />
|
<House
|
||||||
|
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<strong>A place to begin</strong>
|
<strong className="text-ui-14 font-medium">A place to begin</strong>
|
||||||
<p>A brief and flexible tasks help you find your next step.</p>
|
<p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
|
||||||
|
A brief and flexible tasks help you find your next step.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className="flex items-start gap-px-14">
|
||||||
<MessagesSquare aria-hidden="true" />
|
<MessagesSquare
|
||||||
|
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<strong>Room for the everyday</strong>
|
<strong className="text-ui-14 font-medium">Room for the everyday</strong>
|
||||||
<p>Keep conversations and quick notes close at hand.</p>
|
<p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
|
||||||
|
Keep conversations and quick notes close at hand.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className="flex items-start gap-px-14">
|
||||||
<BookOpen aria-hidden="true" />
|
<BookOpen
|
||||||
|
className="mt-px-2 size-[18px] shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<strong>Follow your curiosity</strong>
|
<strong className="text-ui-14 font-medium">Follow your curiosity</strong>
|
||||||
<p>Explore reading and research alongside your day.</p>
|
<p className="mt-[4px] text-ui-13 leading-cozy text-muted-foreground">
|
||||||
|
Explore reading and research alongside your day.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="auth-prototype">
|
<p className="border-t border-border pt-px-20 text-ui-12 leading-cozy text-muted-foreground">
|
||||||
Home, contacts, notes, reading, and vitals currently use sample data. You can add
|
Home, contacts, notes, reading, and vitals currently use sample data. You can add
|
||||||
connections from your profile menu whenever you’re ready.
|
connections from your profile menu whenever you’re ready.
|
||||||
</p>
|
</p>
|
||||||
<div className="auth-actions">
|
<div className="mt-px-26 flex items-center justify-between gap-px-12">
|
||||||
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
|
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,12 +147,12 @@ export function OnboardingPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="auth-error" role="alert">
|
<p className="text-ui-13 leading-cozy wrap-anywhere text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<footer className="auth-footer">
|
<footer className="mt-px-32 flex items-center justify-between gap-px-12 text-ui-12 text-muted-foreground">
|
||||||
<span>{auth.owner?.email}</span>
|
<span className="min-w-0 wrap-anywhere">{auth.owner?.email}</span>
|
||||||
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
|
<Button variant="ghost" size="sm" onClick={signOut} disabled={pending}>
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
.connection-manager {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.connection-manager-description,
|
|
||||||
.connection-manager-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
.connection-manager-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.connection-manager-list > li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding-block: 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.connection-manager-account {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.connection-manager-account > span:not([data-slot="badge"]) {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.connection-manager-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.connection-manager-add {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.connection-manager-add h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.connection-manager-list > li {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,20 +74,33 @@ export function ConnectionManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="connection-manager" aria-label="Connected account management">
|
<section
|
||||||
<p className="connection-manager-description">
|
data-testid="connection-manager"
|
||||||
|
className="grid gap-4"
|
||||||
|
aria-label="Connected account management"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
data-testid="connection-manager-description"
|
||||||
|
className="m-0 text-research-body text-muted-foreground"
|
||||||
|
>
|
||||||
Connected accounts are available in columns on all your devices.
|
Connected accounts are available in columns on all your devices.
|
||||||
</p>
|
</p>
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
{connections.length === 0 ? (
|
{connections.length === 0 ? (
|
||||||
<p>No connected accounts yet.</p>
|
<p>No connected accounts yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="connection-manager-list">
|
<ul data-testid="connection-manager-list" className="m-0 grid list-none gap-3 p-0">
|
||||||
{connections.map((connection) => (
|
{connections.map((connection) => (
|
||||||
<li key={connection.id}>
|
<li
|
||||||
<div className="connection-manager-account">
|
className="flex items-center justify-between gap-3 border-b py-3 max-[480px]:flex-col max-[480px]:items-start"
|
||||||
|
key={connection.id}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="connection-manager-account"
|
||||||
|
className="grid min-w-0 gap-1 wrap-anywhere"
|
||||||
|
>
|
||||||
<strong>{connection.displayName}</strong>
|
<strong>{connection.displayName}</strong>
|
||||||
<span>
|
<span className="text-research-field text-muted-foreground">
|
||||||
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
|
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
|
||||||
{new URL(connection.origin).hostname}
|
{new URL(connection.origin).hostname}
|
||||||
</span>
|
</span>
|
||||||
@@ -96,7 +109,10 @@ export function ConnectionManager({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{connection.platform === "mastodon" && (
|
{connection.platform === "mastodon" && (
|
||||||
<div className="connection-manager-actions">
|
<div
|
||||||
|
data-testid="connection-manager-actions"
|
||||||
|
className="flex flex-wrap justify-end gap-2"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -124,13 +140,14 @@ export function ConnectionManager({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
className="connection-manager-add"
|
data-testid="connection-manager-add"
|
||||||
|
className="grid gap-3"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (origin) void connect(origin);
|
if (origin) void connect(origin);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3>Add a Mastodon account</h3>
|
<h3 className="m-0 text-base">Add a Mastodon account</h3>
|
||||||
{instances.isPending ? (
|
{instances.isPending ? (
|
||||||
<p role="status">Checking servers…</p>
|
<p role="status">Checking servers…</p>
|
||||||
) : instances.isError ? (
|
) : instances.isError ? (
|
||||||
@@ -164,7 +181,10 @@ export function ConnectionManager({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasTemporaryDecks && (
|
{hasTemporaryDecks && (
|
||||||
<p className="connection-manager-note">
|
<p
|
||||||
|
data-testid="connection-manager-note"
|
||||||
|
className="m-0 text-research-body text-muted-foreground"
|
||||||
|
>
|
||||||
Temporary decks will be lost when you leave to authorize. Save any decks you want to
|
Temporary decks will be lost when you leave to authorize. Save any decks you want to
|
||||||
keep first.
|
keep first.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function ColumnEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="deck-editor"
|
data-testid="deck-editor"
|
||||||
|
className="m-0 p-0"
|
||||||
aria-label={column ? "Edit column" : "Add column"}
|
aria-label={column ? "Edit column" : "Add column"}
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -54,8 +55,8 @@ export function ColumnEditor({
|
|||||||
await onSave(parsed.data);
|
await onSave(parsed.data);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="deck-editor-fields">
|
<div data-testid="deck-editor-fields" className="grid gap-4">
|
||||||
<Label htmlFor={`${formId}-title`}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-title`}>
|
||||||
Column name
|
Column name
|
||||||
<Input
|
<Input
|
||||||
id={`${formId}-title`}
|
id={`${formId}-title`}
|
||||||
@@ -66,7 +67,7 @@ export function ColumnEditor({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Label htmlFor={`${formId}-profile`}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${formId}-profile`}>
|
||||||
Connection profile
|
Connection profile
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -102,13 +103,19 @@ export function ColumnEditor({
|
|||||||
connectionId={validConnection ? connectionId : ""}
|
connectionId={validConnection ? connectionId : ""}
|
||||||
onChange={setSource}
|
onChange={setSource}
|
||||||
/>
|
/>
|
||||||
<p className="deck-help">This column uses the selected connection profile.</p>
|
<p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
|
||||||
|
This column uses the selected connection profile.
|
||||||
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="deck-error">
|
<p
|
||||||
|
role="alert"
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="deck-actions">
|
<div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
|
||||||
<Button type="submit" disabled={!validConnection}>
|
<Button type="submit" disabled={!validConnection}>
|
||||||
{column ? "Save changes" : "Add and search"}
|
{column ? "Save changes" : "Add and search"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -58,11 +58,20 @@ export function ResearchColumn({
|
|||||||
const headingId = `column-${column.id}`;
|
const headingId = `column-${column.id}`;
|
||||||
const sourceDescription = describeSource(column.source);
|
const sourceDescription = describeSource(column.source);
|
||||||
return (
|
return (
|
||||||
<section className="deck-column" aria-labelledby={headingId}>
|
<section
|
||||||
<header className="deck-column-header">
|
data-testid="deck-column"
|
||||||
<div className="deck-column-title">
|
className="flex min-h-0 min-w-0 snap-start flex-col bg-background"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
data-testid="deck-column-header"
|
||||||
|
className="relative shrink-0 border-b bg-card px-3 py-2"
|
||||||
|
>
|
||||||
|
<div data-testid="deck-column-title" className="flex items-center gap-2">
|
||||||
<PlatformIcon platform={column.source.platform} />
|
<PlatformIcon platform={column.source.platform} />
|
||||||
<h2 id={headingId}>{column.title}</h2>
|
<h2 className="m-0 min-w-0 flex-1 truncate text-base leading-heading" id={headingId}>
|
||||||
|
{column.title}
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -114,26 +123,43 @@ export function ResearchColumn({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-column-label">
|
<div
|
||||||
|
data-testid="deck-column-label"
|
||||||
|
className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
{connectionLoading ? (
|
{connectionLoading ? (
|
||||||
<Skeleton className="h-3 w-24 shrink-0" aria-hidden="true" />
|
<Skeleton className="h-3 w-24 shrink-0" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
|
<span
|
||||||
|
className="min-w-0 wrap-anywhere"
|
||||||
|
title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}
|
||||||
|
>
|
||||||
{connectionLabel ?? "Connection not found"}
|
{connectionLabel ?? "Connection not found"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="deck-source-label">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
data-testid="deck-source-label"
|
||||||
|
className="inline-flex items-center gap-1 [&_svg]:size-[14px]"
|
||||||
|
>
|
||||||
<Icon name={column.source.kind} />
|
<Icon name={column.source.kind} />
|
||||||
{sourceDescription.label}
|
{sourceDescription.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="deck-query">{sourceDescription.query}</p>
|
<p data-testid="deck-query" className="my-1 truncate text-xs text-muted-foreground">
|
||||||
<p className="deck-column-status" role="status">
|
{sourceDescription.query}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
data-testid="deck-column-status"
|
||||||
|
className="mt-1 mb-0 text-right text-xs leading-normal text-muted-foreground tabular-nums"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
{query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
|
{query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<section
|
<section
|
||||||
className="deck-column-results"
|
data-testid="deck-column-results"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
|
||||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
|
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Posts in ${column.title}`}
|
aria-label={`Posts in ${column.title}`}
|
||||||
@@ -157,7 +183,11 @@ export function ResearchColumn({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{query.isError ? (
|
{query.isError ? (
|
||||||
<div className="deck-message" role="alert">
|
<div
|
||||||
|
data-testid="deck-message"
|
||||||
|
className="p-4 text-sm leading-body text-muted-foreground"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
{error.retryable ? (
|
{error.retryable ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -176,10 +206,15 @@ export function ResearchColumn({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
|
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
|
||||||
<p className="deck-message">No posts match your filters.</p>
|
<p data-testid="deck-message" className="p-4 text-sm leading-body text-muted-foreground">
|
||||||
|
No posts match your filters.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||||
<div className="deck-message">
|
<div
|
||||||
|
data-testid="deck-message"
|
||||||
|
className="p-4 text-sm leading-body text-muted-foreground"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -191,7 +226,9 @@ export function ResearchColumn({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
|
{!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
|
||||||
<p className="deck-message">No more posts.</p>
|
<p data-testid="deck-message" className="p-4 text-sm leading-body text-muted-foreground">
|
||||||
|
No more posts.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ export function DeckPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storageErrorNotice = storageError ? (
|
const storageErrorNotice = storageError ? (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{storageError}
|
{storageError}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -194,7 +198,7 @@ export function DeckPage() {
|
|||||||
<Icon name="plus" />
|
<Icon name="plus" />
|
||||||
<span>Add column</span>
|
<span>Add column</span>
|
||||||
</Button>
|
</Button>
|
||||||
<p className="sidebar-note">
|
<p className="text-xs text-muted-foreground">
|
||||||
{deck.persisted
|
{deck.persisted
|
||||||
? "Saved to server · Shared across devices"
|
? "Saved to server · Shared across devices"
|
||||||
: "Temporary view · This tab only"}
|
: "Temporary view · This tab only"}
|
||||||
@@ -202,12 +206,17 @@ export function DeckPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="deck-heading">
|
<div
|
||||||
|
data-testid="deck-heading"
|
||||||
|
className="[&>div]:min-w-0 flex shrink-0 items-center justify-between gap-3 min-h-[52px] px-3 py-2 md:min-h-[58px] md:px-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<>
|
<>
|
||||||
<h1>{deck.title}</h1>
|
<h1 className="m-0 min-w-0 truncate text-md leading-compact font-bold">
|
||||||
<span className="deck-count">
|
{deck.title}
|
||||||
|
</h1>
|
||||||
|
<span data-testid="deck-count" className="text-xs text-muted-foreground">
|
||||||
{deck.columns.length} / {MAX_COLUMNS} columns
|
{deck.columns.length} / {MAX_COLUMNS} columns
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -218,7 +227,10 @@ export function DeckPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-actions">
|
<div
|
||||||
|
data-testid="deck-actions"
|
||||||
|
className="flex flex-nowrap items-center gap-2 md:flex-wrap"
|
||||||
|
>
|
||||||
{deck.persisted ? (
|
{deck.persisted ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -254,12 +266,19 @@ export function DeckPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<p className="deck-save-status" role="status">
|
<p
|
||||||
|
data-testid="deck-save-status"
|
||||||
|
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Saving…
|
Saving…
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{legacyAvailable ? (
|
{legacyAvailable ? (
|
||||||
<p className="deck-save-status">
|
<p
|
||||||
|
data-testid="deck-save-status"
|
||||||
|
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
|
||||||
|
>
|
||||||
Legacy decks are available in this browser.
|
Legacy decks are available in this browser.
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -272,7 +291,11 @@ export function DeckPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{profiles.isError ? (
|
{profiles.isError ? (
|
||||||
<p role="alert" className="deck-error">
|
<p
|
||||||
|
role="alert"
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
>
|
||||||
Unable to load connection profiles.
|
Unable to load connection profiles.
|
||||||
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
|
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
|
||||||
Retry
|
Retry
|
||||||
@@ -280,17 +303,29 @@ export function DeckPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{oauthResult === "failed" ? (
|
{oauthResult === "failed" ? (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
Could not complete Mastodon authorization. Try again from Connected accounts.
|
Could not complete Mastodon authorization. Try again from Connected accounts.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{oauthResult === "connected" ? (
|
{oauthResult === "connected" ? (
|
||||||
<p className="deck-save-status" role="status">
|
<p
|
||||||
|
data-testid="deck-save-status"
|
||||||
|
className="m-0 px-4 pb-2 text-xs leading-normal text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Mastodon account connected. You can now use it in a column.
|
Mastodon account connected. You can now use it in a column.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{profiles.data?.relayError ? (
|
{profiles.data?.relayError ? (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{profiles.data.relayError}
|
{profiles.data.relayError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -298,7 +333,8 @@ export function DeckPage() {
|
|||||||
{renaming ? (
|
{renaming ? (
|
||||||
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
|
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
|
||||||
<form
|
<form
|
||||||
className="deck-rename"
|
data-testid="deck-rename"
|
||||||
|
className="m-0 flex flex-wrap items-end gap-3"
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rawTitle = new FormData(event.currentTarget).get("title");
|
const rawTitle = new FormData(event.currentTarget).get("title");
|
||||||
@@ -307,7 +343,7 @@ export function DeckPage() {
|
|||||||
if (await save({ ...deck, title })) setRenaming(false);
|
if (await save({ ...deck, title })) setRenaming(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="deck-title">
|
<label className="grid min-w-0 flex-[1_1_100%] gap-2 text-sm" htmlFor="deck-title">
|
||||||
Research topic / deck name
|
Research topic / deck name
|
||||||
<Input
|
<Input
|
||||||
id="deck-title"
|
id="deck-title"
|
||||||
@@ -344,7 +380,11 @@ export function DeckPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{removed ? (
|
{removed ? (
|
||||||
<div className="deck-undo" role="status">
|
<div
|
||||||
|
data-testid="deck-undo"
|
||||||
|
className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm wrap-anywhere"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span>Deleted “{removed.column.title}”.</span>
|
<span>Deleted “{removed.column.title}”.</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -384,7 +424,8 @@ export function DeckPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{ready && deck.columns.length > 0 ? (
|
{ready && deck.columns.length > 0 ? (
|
||||||
<section
|
<section
|
||||||
className="deck-board"
|
data-testid="deck-board"
|
||||||
|
className="grid min-h-0 flex-1 auto-cols-[min(328px,100%)] grid-flow-col justify-start gap-2 overflow-x-auto overflow-y-hidden px-2 pb-2 snap-x snap-proximity md:auto-cols-[328px]"
|
||||||
aria-label="Research columns"
|
aria-label="Research columns"
|
||||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
|
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ export function DeckSwitcher({
|
|||||||
errorNotice?: ReactNode;
|
errorNotice?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="deck-workspaces">
|
<div data-testid="deck-workspaces" className="min-w-0">
|
||||||
<div className="deck-actions">
|
<div
|
||||||
<Label htmlFor="deck-selection">Deck profile</Label>
|
data-testid="deck-actions"
|
||||||
|
className="flex flex-wrap items-stretch gap-2 [&>[data-slot=native-select-wrapper]]:flex-[1_1_100%]"
|
||||||
|
>
|
||||||
|
<Label className="w-full text-xs text-muted-foreground" htmlFor="deck-selection">
|
||||||
|
Deck profile
|
||||||
|
</Label>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
className="w-full"
|
className="w-full"
|
||||||
id="deck-selection"
|
id="deck-selection"
|
||||||
@@ -55,6 +60,7 @@ export function DeckSwitcher({
|
|||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
aria-label="Create deck"
|
aria-label="Create deck"
|
||||||
title="Create deck"
|
title="Create deck"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -68,6 +74,7 @@ export function DeckSwitcher({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
aria-label="Delete deck"
|
aria-label="Delete deck"
|
||||||
title="Delete deck"
|
title="Delete deck"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,7 +90,8 @@ export function DeckSwitcher({
|
|||||||
{dialog === "create" ? (
|
{dialog === "create" ? (
|
||||||
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
|
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
|
||||||
<form
|
<form
|
||||||
className="deck-rename"
|
data-testid="deck-rename"
|
||||||
|
className="m-0 flex flex-wrap items-end gap-3"
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const title = newTitle.trim();
|
const title = newTitle.trim();
|
||||||
@@ -91,7 +99,7 @@ export function DeckSwitcher({
|
|||||||
if (await onCreate(title)) onDialogChange(null);
|
if (await onCreate(title)) onDialogChange(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Label htmlFor="new-deck-title">
|
<Label className="grid min-w-0 flex-[1_1_100%] gap-2 text-sm" htmlFor="new-deck-title">
|
||||||
New deck name
|
New deck name
|
||||||
<Input
|
<Input
|
||||||
id="new-deck-title"
|
id="new-deck-title"
|
||||||
@@ -112,7 +120,10 @@ export function DeckSwitcher({
|
|||||||
) : null}
|
) : null}
|
||||||
{dialog === "delete" ? (
|
{dialog === "delete" ? (
|
||||||
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
|
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
|
||||||
<div className="deck-undo">
|
<div
|
||||||
|
data-testid="deck-undo"
|
||||||
|
className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm wrap-anywhere"
|
||||||
|
>
|
||||||
<p>Delete this deck and its column settings.</p>
|
<p>Delete this deck and its column settings.</p>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
.deck-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
min-height: 58px;
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
}
|
|
||||||
.deck-heading > div {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.deck-heading h1 {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.deck-heading p {
|
|
||||||
margin: var(--space-2xs) 0 0;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.deck-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
.deck-help,
|
|
||||||
.deck-save-status,
|
|
||||||
.deck-column-status {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.deck-save-status {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 var(--space-md) var(--space-xs);
|
|
||||||
}
|
|
||||||
.deck-board {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-auto-columns: 328px;
|
|
||||||
justify-content: start;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
padding: 0 var(--space-xs) var(--space-xs);
|
|
||||||
scroll-snap-type: x proximity;
|
|
||||||
scrollbar-color: var(--control-border) var(--canvas);
|
|
||||||
}
|
|
||||||
.deck-column {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--paper);
|
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
|
||||||
.deck-column-header {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
border-bottom: var(--rule-hairline) solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.deck-column-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
.deck-column h2 {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
line-height: 1.4;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.deck-column-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.deck-column-label span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.deck-source-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2xs);
|
|
||||||
}
|
|
||||||
.deck-source-label svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
.deck-query {
|
|
||||||
margin: var(--space-2xs) 0;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.deck-column-status {
|
|
||||||
margin: var(--space-2xs) 0 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.deck-column-results {
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
flex: 1;
|
|
||||||
overscroll-behavior-y: contain;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--control-border) var(--paper);
|
|
||||||
}
|
|
||||||
.deck-message {
|
|
||||||
padding: var(--space-md);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.research-post {
|
|
||||||
padding: var(--space-sm);
|
|
||||||
border-bottom: var(--rule-hairline) solid var(--border);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-post header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
.research-post header > div {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.research-post strong {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.research-post-text,
|
|
||||||
.research-post blockquote p {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.55;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
margin: var(--space-xs) 0;
|
|
||||||
}
|
|
||||||
.research-post blockquote {
|
|
||||||
margin: var(--space-sm) 0;
|
|
||||||
padding: var(--space-sm);
|
|
||||||
border: var(--rule-hairline) solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.research-post footer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.research-post a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-underline-offset: 0.2em;
|
|
||||||
}
|
|
||||||
.research-media {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 20rem;
|
|
||||||
object-fit: contain;
|
|
||||||
margin-block: var(--space-xs);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.deck-editor {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.deck-editor-fields,
|
|
||||||
.deck-source-fields {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
.deck-source-fields {
|
|
||||||
margin-block: var(--space-md);
|
|
||||||
}
|
|
||||||
.deck-editor label,
|
|
||||||
.deck-rename label {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.deck-editor .deck-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 40px;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
.deck-rename {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: end;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.deck-rename label {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
.deck-error {
|
|
||||||
color: var(--destructive);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
line-height: 1.6;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.reader > .deck-error {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
}
|
|
||||||
.deck-undo {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.deck-undo p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.deck-empty {
|
|
||||||
margin: auto;
|
|
||||||
max-width: 32rem;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
.deck-empty p {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
.deck-workspaces {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > label {
|
|
||||||
width: 100%;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.deck-list-choices {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
.app-deck .sidebar-add {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.deck-heading {
|
|
||||||
min-height: 52px;
|
|
||||||
padding-inline: var(--space-sm);
|
|
||||||
}
|
|
||||||
.deck-heading .deck-actions {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
.deck-board {
|
|
||||||
grid-auto-columns: min(328px, 100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.deck-count {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.research-post header time {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.deck-column-status {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.research-post header > div:not([data-slot="avatar"]) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.research-post header time {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.research-post footer a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2xs);
|
|
||||||
}
|
|
||||||
.research-post footer svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,7 @@ export function MastodonSourceEditor({
|
|||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
Column type
|
Column type
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -78,8 +78,8 @@ function MastodonSearchEditor({
|
|||||||
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
|
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
Mastodon search query
|
Mastodon search query
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -90,7 +90,7 @@ function MastodonSearchEditor({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<p className="deck-help">
|
<p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
|
||||||
Full-text search depends on server settings. Zero results do not necessarily mean search is
|
Full-text search depends on server settings. Zero results do not necessarily mean search is
|
||||||
supported.
|
supported.
|
||||||
</p>
|
</p>
|
||||||
@@ -104,8 +104,8 @@ function MastodonUserEditor({
|
|||||||
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
|
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
User
|
User
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -126,8 +126,8 @@ function MastodonHashtagEditor({
|
|||||||
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
|
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
Hashtag
|
Hashtag
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -151,8 +151,8 @@ function MastodonListEditor({
|
|||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
List
|
List
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -197,7 +197,11 @@ function MastodonListChoices({
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="deck-list-choices" aria-label="Lists for this account">
|
<section
|
||||||
|
data-testid="deck-list-choices"
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
aria-label="Lists for this account"
|
||||||
|
>
|
||||||
{lists.data.lists.length === 0 ? (
|
{lists.data.lists.length === 0 ? (
|
||||||
<p>This account has no lists.</p>
|
<p>This account has no lists.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,10 +3,23 @@ import { ResearchPostSkeleton } from "./research-post-skeleton";
|
|||||||
|
|
||||||
export function ResearchDeckSkeleton() {
|
export function ResearchDeckSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="deck-board" role="status" aria-label="Loading research">
|
<div
|
||||||
|
data-testid="deck-board"
|
||||||
|
className="grid min-h-0 flex-1 auto-cols-[min(328px,100%)] grid-flow-col justify-start gap-2 overflow-x-auto overflow-y-hidden px-2 pb-2 snap-x snap-proximity md:auto-cols-[328px]"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading research"
|
||||||
|
>
|
||||||
{["first", "second", "third"].map((column) => (
|
{["first", "second", "third"].map((column) => (
|
||||||
<div key={column} className="deck-column" aria-hidden="true">
|
<div
|
||||||
<div className="deck-column-header space-y-3">
|
key={column}
|
||||||
|
data-testid="deck-column"
|
||||||
|
className="flex min-h-0 min-w-0 snap-start flex-col bg-background"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="deck-column-header"
|
||||||
|
className="relative shrink-0 border-b bg-card px-3 py-2 space-y-3"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="size-4 shrink-0" />
|
<Skeleton className="size-4 shrink-0" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
@@ -15,7 +28,10 @@ export function ResearchDeckSkeleton() {
|
|||||||
<Skeleton className="h-3 w-40" />
|
<Skeleton className="h-3 w-40" />
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="h-3 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-column-results">
|
<div
|
||||||
|
data-testid="deck-column-results"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
|
||||||
|
>
|
||||||
<ResearchPostSkeleton />
|
<ResearchPostSkeleton />
|
||||||
<ResearchPostSkeleton />
|
<ResearchPostSkeleton />
|
||||||
<ResearchPostSkeleton />
|
<ResearchPostSkeleton />
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
|
|||||||
article.scrollIntoView = scroll;
|
article.scrollIntoView = scroll;
|
||||||
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
|
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
|
||||||
expect(article).toHaveFocus();
|
expect(article).toHaveFocus();
|
||||||
expect(article).toHaveClass("citation-highlight");
|
expect(article).toHaveAttribute("data-citation-highlight", "true");
|
||||||
expect(scroll).toHaveBeenCalledWith({
|
expect(scroll).toHaveBeenCalledWith({
|
||||||
block: "center",
|
block: "center",
|
||||||
inline: "center",
|
inline: "center",
|
||||||
@@ -46,11 +46,11 @@ it("focuses and briefly highlights a cited card without revealing sensitive cont
|
|||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(2500);
|
vi.advanceTimersByTime(2500);
|
||||||
});
|
});
|
||||||
expect(article).not.toHaveClass("citation-highlight");
|
expect(article).not.toHaveAttribute("data-citation-highlight", "true");
|
||||||
rerender(<ResearchPostCard post={post} citationRequest={2} />);
|
rerender(<ResearchPostCard post={post} citationRequest={2} />);
|
||||||
expect(article).toHaveClass("citation-highlight");
|
expect(article).toHaveAttribute("data-citation-highlight", "true");
|
||||||
rerender(<ResearchPostCard post={post} />);
|
rerender(<ResearchPostCard post={post} />);
|
||||||
expect(article).not.toHaveClass("citation-highlight");
|
expect(article).not.toHaveAttribute("data-citation-highlight", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits for reveal before mounting CW content or sensitive media", () => {
|
it("waits for reveal before mounting CW content or sensitive media", () => {
|
||||||
|
|||||||
@@ -46,22 +46,32 @@ export function ResearchPostCard({
|
|||||||
<article
|
<article
|
||||||
ref={article}
|
ref={article}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`research-post${highlighted ? " citation-highlight" : ""}`}
|
data-testid="research-post"
|
||||||
|
data-citation-highlight={highlighted || undefined}
|
||||||
|
className="border-b p-3 wrap-anywhere focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 data-[citation-highlight=true]:bg-primary/14 data-[citation-highlight=true]:outline-2 data-[citation-highlight=true]:outline-primary data-[citation-highlight=true]:-outline-offset-2 [&_a]:text-primary [&_a]:underline-offset-2"
|
||||||
data-post-url={post.url}
|
data-post-url={post.url}
|
||||||
>
|
>
|
||||||
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
|
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
|
||||||
{post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null}
|
{post.boostedBy ? (
|
||||||
<header>
|
<p className="block text-xs text-muted-foreground">Boosted by {post.boostedBy.name}</p>
|
||||||
|
) : null}
|
||||||
|
<header className="flex items-center gap-2">
|
||||||
<Avatar className="size-9 shrink-0">
|
<Avatar className="size-9 shrink-0">
|
||||||
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
|
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
|
||||||
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
|
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<strong>{post.author.name}</strong>
|
<strong className="text-sm">{post.author.name}</strong>
|
||||||
<span className="handle">@{post.author.handle.replace(/^@/, "")}</span>
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
@{post.author.handle.replace(/^@/, "")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{post.createdAt ? (
|
{post.createdAt ? (
|
||||||
<time dateTime={post.createdAt} title={post.createdAt}>
|
<time
|
||||||
|
className="block shrink-0 text-xs text-muted-foreground tabular-nums"
|
||||||
|
dateTime={post.createdAt}
|
||||||
|
title={post.createdAt}
|
||||||
|
>
|
||||||
{dateFormatter.format(new Date(post.createdAt))}
|
{dateFormatter.format(new Date(post.createdAt))}
|
||||||
</time>
|
</time>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -84,19 +94,24 @@ export function ResearchPostCard({
|
|||||||
<>
|
<>
|
||||||
{post.html !== undefined ? (
|
{post.html !== undefined ? (
|
||||||
<div
|
<div
|
||||||
className="research-post-text"
|
data-testid="research-post-text"
|
||||||
|
className="my-2 text-sm leading-post whitespace-pre-wrap"
|
||||||
// Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
|
// Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
|
||||||
dangerouslySetInnerHTML={{ __html: post.html }}
|
dangerouslySetInnerHTML={{ __html: post.html }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="research-post-text">
|
<p
|
||||||
|
data-testid="research-post-text"
|
||||||
|
className="my-2 text-sm leading-post whitespace-pre-wrap"
|
||||||
|
>
|
||||||
<PostText text={post.text} />
|
<PostText text={post.text} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{post.media?.map((media) =>
|
{post.media?.map((media) =>
|
||||||
media.type === "photo" ? (
|
media.type === "photo" ? (
|
||||||
<img
|
<img
|
||||||
className="research-media"
|
data-testid="research-media"
|
||||||
|
className="my-2 block max-h-80 w-full rounded-sm object-contain"
|
||||||
key={media.url}
|
key={media.url}
|
||||||
src={media.url}
|
src={media.url}
|
||||||
alt={media.alt ?? ""}
|
alt={media.alt ?? ""}
|
||||||
@@ -109,9 +124,9 @@ export function ResearchPostCard({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{post.quotedPost ? (
|
{post.quotedPost ? (
|
||||||
<blockquote>
|
<blockquote className="my-3 rounded-sm border p-3">
|
||||||
<strong>{post.quotedPost.author.name}</strong>
|
<strong className="text-sm">{post.quotedPost.author.name}</strong>
|
||||||
<p>
|
<p className="my-2 text-sm leading-post whitespace-pre-wrap">
|
||||||
<PostText text={post.quotedPost.text} />
|
<PostText text={post.quotedPost.text} />
|
||||||
</p>
|
</p>
|
||||||
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
|
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
|
||||||
@@ -121,8 +136,13 @@ export function ResearchPostCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<footer>
|
<footer className="mt-3 flex flex-wrap justify-between gap-2 text-xs text-muted-foreground">
|
||||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
<a
|
||||||
|
className="inline-flex items-center gap-1 [&_svg]:size-[14px]"
|
||||||
|
href={post.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
Open original post <Icon name="arrow" />
|
Open original post <Icon name="arrow" />
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Skeleton } from "#/components/ui/skeleton";
|
|||||||
|
|
||||||
export function ResearchPostSkeleton() {
|
export function ResearchPostSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="research-post" aria-hidden="true">
|
<div data-testid="research-post" className="border-b p-3 wrap-anywhere" aria-hidden="true">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="size-9 shrink-0" />
|
<Skeleton className="size-9 shrink-0" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function TwitterSourceEditor({
|
|||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
Column type
|
Column type
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -77,8 +77,8 @@ function TwitterSearchEditor({
|
|||||||
}: EditorProps<Extract<Source, { kind: "search" }>>) {
|
}: EditorProps<Extract<Source, { kind: "search" }>>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={`${id}-query`}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-query`}>
|
||||||
Twitter search query
|
Twitter search query
|
||||||
<Input
|
<Input
|
||||||
id={`${id}-query`}
|
id={`${id}-query`}
|
||||||
@@ -89,7 +89,7 @@ function TwitterSearchEditor({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Label htmlFor={`${id}-product`}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={`${id}-product`}>
|
||||||
Sort by
|
Sort by
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -106,8 +106,13 @@ function TwitterSearchEditor({
|
|||||||
<NativeSelectOption value="Top">Top</NativeSelectOption>
|
<NativeSelectOption value="Top">Top</NativeSelectOption>
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Label>
|
</Label>
|
||||||
<p className="deck-help">Use Twitter search operators such as from:, lang:, and since:.</p>
|
<p data-testid="deck-help" className="text-xs leading-normal text-muted-foreground">
|
||||||
<Label className="deck-checkbox">
|
Use Twitter search operators such as from:, lang:, and since:.
|
||||||
|
</p>
|
||||||
|
<Label
|
||||||
|
data-testid="deck-checkbox"
|
||||||
|
className="mb-3 flex min-h-[40px] items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={source.following}
|
checked={source.following}
|
||||||
onCheckedChange={(checked) => onChange({ ...source, following: checked })}
|
onCheckedChange={(checked) => onChange({ ...source, following: checked })}
|
||||||
@@ -121,8 +126,8 @@ function TwitterSearchEditor({
|
|||||||
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
|
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
User
|
User
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -144,8 +149,8 @@ function TwitterListEditor({
|
|||||||
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
|
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div data-testid="deck-source-fields" className="my-4 grid gap-4">
|
||||||
<Label htmlFor={id}>
|
<Label className="grid min-w-0 gap-2 text-sm" htmlFor={id}>
|
||||||
List
|
List
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -185,7 +190,11 @@ function TwitterListChoices({
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="deck-list-choices" aria-label="Lists for this profile">
|
<section
|
||||||
|
data-testid="deck-list-choices"
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
aria-label="Lists for this profile"
|
||||||
|
>
|
||||||
{lists.data.length === 0 ? (
|
{lists.data.length === 0 ? (
|
||||||
<p>No lists found. You can enter a URL or ID instead.</p>
|
<p>No lists found. You can enter a URL or ID instead.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="home-brief" aria-label="Daily brief">
|
<section
|
||||||
|
className="mt-px-20 max-w-[760px] text-ui-15 leading-brief wrap-anywhere"
|
||||||
|
aria-label="Daily brief"
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
|
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
|
||||||
{firstContact ? (
|
{firstContact ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<button type="button" onClick={() => onOpenContact(firstContact.id)}>
|
<button
|
||||||
|
className="cursor-pointer underline decoration-border underline-offset-4 hover:decoration-foreground"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenContact(firstContact.id)}
|
||||||
|
>
|
||||||
Reply to {firstContact.name}
|
Reply to {firstContact.name}
|
||||||
</button>
|
</button>
|
||||||
{contacts.length > 1
|
{contacts.length > 1
|
||||||
@@ -35,12 +42,14 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{resume ? (
|
{resume ? (
|
||||||
<p className="home-brief-secondary">
|
<p className="mt-px-8 text-ui-13 text-muted-foreground">
|
||||||
Your resume note: “
|
Your resume note: “
|
||||||
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
|
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
|
||||||
</p>
|
</p>
|
||||||
) : optional ? (
|
) : optional ? (
|
||||||
<p className="home-brief-secondary">“{optional.title}” can wait until you have room.</p>
|
<p className="mt-px-8 text-ui-13 text-muted-foreground">
|
||||||
|
“{optional.title}” can wait until you have room.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
/* Flexible day flow: quiet activity blocks, with detail where the action happens. */
|
|
||||||
.execution-day {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.execution-flow-group {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.execution-flow-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.execution-flow-heading h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.execution-flow-heading > span {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.execution-flow-items {
|
|
||||||
display: grid;
|
|
||||||
gap: 9px;
|
|
||||||
}
|
|
||||||
.execution-flow-card {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-kind="focus"] {
|
|
||||||
background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-kind="routine"] {
|
|
||||||
background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-kind="reply"] {
|
|
||||||
background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-kind="weight"] {
|
|
||||||
background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-kind="optional"] {
|
|
||||||
background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-expanded="true"] {
|
|
||||||
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-status="done"] {
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-status="done"] .execution-flow-title {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-status="done"] .execution-complete,
|
|
||||||
.execution-flow-card[data-status="done"] .execution-flow-icon {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.execution-flow-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 13px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
.execution-complete,
|
|
||||||
.execution-flow-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 0 0 24px;
|
|
||||||
height: 28px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-complete {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.execution-complete:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
.execution-complete:hover:not(:disabled) {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.execution-complete svg,
|
|
||||||
.execution-flow-icon svg {
|
|
||||||
width: 21px;
|
|
||||||
height: 21px;
|
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
|
||||||
.execution-flow-open {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: left;
|
|
||||||
gap: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.execution-flow-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.execution-flow-description {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.execution-expand-icon {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.execution-inline-detail {
|
|
||||||
padding: 6px 22px 24px 55px;
|
|
||||||
}
|
|
||||||
.execution-inline-detail h2 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.execution-detail-symbol,
|
|
||||||
.execution-eyebrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.execution-description {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
max-width: 480px;
|
|
||||||
}
|
|
||||||
.execution-routine-progress {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin: 20px 0 8px;
|
|
||||||
}
|
|
||||||
.execution-step-list {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
.execution-step-list label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.execution-step-list label > .execution-step-text {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.execution-step-list label[data-checked="true"] > .execution-step-text {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-step-number {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.execution-next-step {
|
|
||||||
padding: 14px 0;
|
|
||||||
margin-top: 14px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.execution-next-step > span {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-next-step p {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.75;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
.execution-detail-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.execution-resume-note {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin-top: 25px;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
.execution-resume-note > label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.execution-resume-note textarea {
|
|
||||||
min-height: 74px;
|
|
||||||
background: var(--background);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.execution-note-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.execution-note-footer > span {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.execution-order {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-order > span {
|
|
||||||
font-size: 11px;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.execution-weight-form {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
.execution-weight-form > label {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.execution-weight-input {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
max-width: 180px;
|
|
||||||
}
|
|
||||||
.execution-weight-input input {
|
|
||||||
background: var(--background);
|
|
||||||
font-size: 20px;
|
|
||||||
height: 42px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.execution-weight-input > span {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.execution-weight-form > p {
|
|
||||||
width: 100%;
|
|
||||||
color: var(--destructive);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.execution-source-note {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.execution-flow-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.execution-rest {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
.execution-rest summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-rest summary > span {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.execution-rest-items {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.execution-rest-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.execution-rest-row > svg {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.execution-rest-row > span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.execution-rest-row small {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
.execution-flow-open:focus-visible,
|
|
||||||
.execution-complete:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.execution-flow-row {
|
|
||||||
padding: 15px 14px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.execution-flow-heading > span {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.execution-inline-detail {
|
|
||||||
padding: 4px 18px 20px;
|
|
||||||
}
|
|
||||||
.execution-flow-title {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.execution-flow-description {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.execution-note-footer {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="execution-day" aria-label="Today’s plan">
|
<section className="min-w-0" aria-label="Today’s plan">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ id: "focus", label: "Start here", subtitle: "Just the next thing" },
|
{ id: "focus", label: "Start here", subtitle: "Just the next thing" },
|
||||||
@@ -57,11 +57,11 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
|
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
|
||||||
] as const
|
] as const
|
||||||
).map((section) => (
|
).map((section) => (
|
||||||
<section className="execution-flow-group" key={section.id}>
|
<section className="mb-px-30" key={section.id}>
|
||||||
<header className="execution-flow-heading">
|
<header className="mb-px-12 flex items-baseline justify-between gap-px-12">
|
||||||
<h2>{section.label}</h2>
|
<h2 className="text-ui-14 font-semibold">{section.label}</h2>
|
||||||
</header>
|
</header>
|
||||||
<div className="execution-flow-items">
|
<div className="grid gap-px-9">
|
||||||
{tasks
|
{tasks
|
||||||
.filter((task) => task.section === section.id)
|
.filter((task) => task.section === section.id)
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
@@ -71,16 +71,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="execution-flow-card"
|
className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
|
||||||
key={task.id}
|
key={task.id}
|
||||||
data-expanded={expanded}
|
data-expanded={expanded}
|
||||||
data-kind={task.section}
|
data-kind={task.section}
|
||||||
data-status={task.status}
|
data-status={task.status}
|
||||||
>
|
>
|
||||||
<div className="execution-flow-row">
|
<div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="execution-complete"
|
className="flex h-[28px] basis-[24px] shrink-0 grow-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground enabled:hover:text-foreground disabled:cursor-default disabled:opacity-45 group-data-[status=done]/flow:text-success focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
|
||||||
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
|
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
|
||||||
aria-pressed={task.status === "done"}
|
aria-pressed={task.status === "done"}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -89,22 +89,27 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
onClick={() => finish(task.id)}
|
onClick={() => finish(task.id)}
|
||||||
>
|
>
|
||||||
{task.status === "done" ? (
|
{task.status === "done" ? (
|
||||||
<Check />
|
<Check className="size-[21px]" strokeWidth={1.5} />
|
||||||
) : task.status === "later" ? (
|
) : task.status === "later" ? (
|
||||||
<Pause />
|
<Pause className="size-[21px]" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
<Circle />
|
<Circle className="size-[21px]" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="execution-flow-open"
|
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-controls={`detail-${task.id}`}
|
aria-controls={`detail-${task.id}`}
|
||||||
onClick={() => toggle(id)}
|
onClick={() => toggle(id)}
|
||||||
>
|
>
|
||||||
<span className="execution-flow-title">{task.title}</span>
|
<span
|
||||||
<span className="execution-flow-description">
|
data-testid="execution-flow-title"
|
||||||
|
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
|
||||||
{task.status === "done"
|
{task.status === "done"
|
||||||
? "Completed"
|
? "Completed"
|
||||||
: task.status === "later"
|
: task.status === "later"
|
||||||
@@ -116,10 +121,16 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
: "At your own pace"}
|
: "At your own pace"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
<ChevronDown
|
||||||
|
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="execution-inline-detail" id={`detail-${task.id}`}>
|
<div
|
||||||
|
className="pt-px-6 pr-px-22 pb-px-24 pl-px-55 max-md:px-px-18 max-md:pt-px-4 max-md:pb-px-20"
|
||||||
|
id={`detail-${task.id}`}
|
||||||
|
>
|
||||||
<TaskDetail task={task} onAction={() => onSelect("")} />
|
<TaskDetail task={task} onAction={() => onSelect("")} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -129,7 +140,7 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
{section.id === "focus" &&
|
{section.id === "focus" &&
|
||||||
replies.slice(0, 2).map((conversation) => (
|
replies.slice(0, 2).map((conversation) => (
|
||||||
<div
|
<div
|
||||||
className="execution-flow-card"
|
className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
|
||||||
key={conversation.id}
|
key={conversation.id}
|
||||||
data-kind="reply"
|
data-kind="reply"
|
||||||
data-status={
|
data-status={
|
||||||
@@ -139,23 +150,28 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
: statuses[conversation.id]
|
: statuses[conversation.id]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="execution-flow-row">
|
<div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
|
||||||
<span className="execution-flow-icon">
|
<span className="flex h-[28px] basis-[24px] shrink-0 grow-0 items-center justify-center text-muted-foreground group-data-[status=done]/flow:text-success">
|
||||||
{statuses[conversation.id] === "sent" ||
|
{statuses[conversation.id] === "sent" ||
|
||||||
statuses[conversation.id] === "dismissed" ? (
|
statuses[conversation.id] === "dismissed" ? (
|
||||||
<Check />
|
<Check className="size-[21px]" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
<MessageCircle />
|
<MessageCircle className="size-[21px]" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to="/support"
|
to="/support"
|
||||||
search={{ conversation: conversation.id, view: "all" }}
|
search={{ conversation: conversation.id, view: "all" }}
|
||||||
className="execution-flow-open"
|
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
|
||||||
aria-label={`Open conversation with ${conversation.name}`}
|
aria-label={`Open conversation with ${conversation.name}`}
|
||||||
>
|
>
|
||||||
<span className="execution-flow-title">Reply to {conversation.name}</span>
|
<span
|
||||||
<span className="execution-flow-description">
|
data-testid="execution-flow-title"
|
||||||
|
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
|
||||||
|
>
|
||||||
|
Reply to {conversation.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
|
||||||
{statuses[conversation.id] === "sent"
|
{statuses[conversation.id] === "sent"
|
||||||
? "Reply recorded · Preview only"
|
? "Reply recorded · Preview only"
|
||||||
: statuses[conversation.id] === "dismissed"
|
: statuses[conversation.id] === "dismissed"
|
||||||
@@ -167,35 +183,55 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
: conversation.subject}
|
: conversation.subject}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<ArrowUpRight className="execution-expand-icon" aria-hidden="true" />
|
<ArrowUpRight
|
||||||
|
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{section.id === "routine" && (
|
{section.id === "routine" && (
|
||||||
<div
|
<div
|
||||||
className="execution-flow-card"
|
className="group/flow overflow-hidden rounded-ui-16 border border-border bg-muted data-[kind=focus]:bg-activity-focus data-[kind=routine]:bg-activity-routine data-[kind=reply]:bg-activity-reply data-[kind=weight]:bg-activity-weight data-[kind=optional]:bg-activity-optional data-[expanded=true]:border-activity-expanded data-[status=done]:bg-background!"
|
||||||
data-expanded={selectedId === "weight"}
|
data-expanded={selectedId === "weight"}
|
||||||
data-kind="weight"
|
data-kind="weight"
|
||||||
data-status={weightEntry ? "done" : "todo"}
|
data-status={weightEntry ? "done" : "todo"}
|
||||||
>
|
>
|
||||||
<div className="execution-flow-row">
|
<div className="flex items-center gap-px-13 px-px-18 py-px-16 max-md:gap-px-10 max-md:px-px-14 max-md:py-px-15">
|
||||||
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span>
|
<span className="flex h-[28px] basis-[24px] shrink-0 grow-0 items-center justify-center text-muted-foreground group-data-[status=done]/flow:text-success">
|
||||||
|
{weightEntry ? (
|
||||||
|
<Check className="size-[21px]" strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<Scale className="size-[21px]" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="execution-flow-open"
|
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-px-5 rounded-ui-3 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4"
|
||||||
aria-expanded={selectedId === "weight"}
|
aria-expanded={selectedId === "weight"}
|
||||||
aria-controls="detail-weight"
|
aria-controls="detail-weight"
|
||||||
onClick={() => toggle("weight")}
|
onClick={() => toggle("weight")}
|
||||||
>
|
>
|
||||||
<span className="execution-flow-title">Record weight</span>
|
<span
|
||||||
<span className="execution-flow-description">
|
data-testid="execution-flow-title"
|
||||||
|
className="text-ui-14 font-medium leading-normal group-data-[status=done]/flow:text-muted-foreground max-md:text-ui-13"
|
||||||
|
>
|
||||||
|
Record weight
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-12 leading-normal text-muted-foreground max-md:text-ui-11">
|
||||||
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
|
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
<ChevronDown
|
||||||
|
className="size-[15px] shrink-0 text-muted-foreground group-data-[expanded=true]/flow:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedId === "weight" && (
|
{selectedId === "weight" && (
|
||||||
<div className="execution-inline-detail" id="detail-weight">
|
<div
|
||||||
|
className="pt-px-6 pr-px-22 pb-px-24 pl-px-55 max-md:px-px-18 max-md:pt-px-4 max-md:pb-px-20"
|
||||||
|
id="detail-weight"
|
||||||
|
>
|
||||||
<WeightDetail
|
<WeightDetail
|
||||||
key={weightEntry?.id ?? "new"}
|
key={weightEntry?.id ?? "new"}
|
||||||
entry={weightEntry}
|
entry={weightEntry}
|
||||||
@@ -208,7 +244,9 @@ export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchP
|
|||||||
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
|
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
|
||||||
(section.id !== "focus" || !replies.length) &&
|
(section.id !== "focus" || !replies.length) &&
|
||||||
section.id !== "routine" && (
|
section.id !== "routine" && (
|
||||||
<p className="execution-flow-empty">A little breathing room.</p>
|
<p className="py-px-16 text-ui-12 text-muted-foreground">
|
||||||
|
A little breathing room.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -248,20 +286,20 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="execution-detail-symbol">
|
<div className="hidden">
|
||||||
<ListChecks />
|
<ListChecks />
|
||||||
</div>
|
</div>
|
||||||
<p className="execution-eyebrow">
|
<p className="hidden">
|
||||||
{task.section === "routine"
|
{task.section === "routine"
|
||||||
? "Your routine"
|
? "Your routine"
|
||||||
: task.section === "optional"
|
: task.section === "optional"
|
||||||
? "If there’s room"
|
? "If there’s room"
|
||||||
: "One next step"}
|
: "One next step"}
|
||||||
</p>
|
</p>
|
||||||
<h2>{task.title}</h2>
|
<h2 className="hidden">{task.title}</h2>
|
||||||
{steps.length > 0 ? (
|
{steps.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="execution-routine-progress">
|
<div className="mt-px-20 mb-px-8 flex justify-between gap-px-12 text-ui-11 text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{checked} of {steps.length} steps
|
{checked} of {steps.length} steps
|
||||||
</span>
|
</span>
|
||||||
@@ -273,9 +311,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
: "At your pace"}
|
: "At your pace"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="execution-step-list">
|
<div className="grid">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<label
|
<label
|
||||||
|
className="group/step flex cursor-pointer items-center gap-px-12 border-t border-border py-px-14 text-ui-13"
|
||||||
key={step}
|
key={step}
|
||||||
htmlFor={`execution-step-${task.id}-${index}`}
|
htmlFor={`execution-step-${task.id}-${index}`}
|
||||||
data-checked={task.completedSteps?.includes(step) ?? false}
|
data-checked={task.completedSteps?.includes(step) ?? false}
|
||||||
@@ -301,15 +340,19 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="execution-step-text">{step}</span>
|
<span className="flex-1 group-data-[checked=true]/step:text-muted-foreground group-data-[checked=true]/step:line-through">
|
||||||
<span className="execution-step-number">{String(index + 1).padStart(2, "0")}</span>
|
{step}
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-11 text-muted-foreground tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="execution-description">
|
<p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
|
||||||
{task.id === "review"
|
{task.id === "review"
|
||||||
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
|
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
|
||||||
: task.section === "optional"
|
: task.section === "optional"
|
||||||
@@ -317,14 +360,16 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
: "Choose a small first step. You can leave a note if you need to pause."}
|
: "Choose a small first step. You can leave a note if you need to pause."}
|
||||||
</p>
|
</p>
|
||||||
{task.id === "review" && (
|
{task.id === "review" && (
|
||||||
<div className="execution-next-step">
|
<div className="mt-px-14 border-t border-border py-px-14">
|
||||||
<span>Start here</span>
|
<span className="text-ui-11 text-muted-foreground">Start here</span>
|
||||||
<p>Open the materials and look at the first screen.</p>
|
<p className="mt-px-5 text-ui-13 leading-loose-copy">
|
||||||
|
Open the materials and look at the first screen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="execution-detail-actions">
|
<div className="mt-px-20 flex flex-wrap gap-px-8">
|
||||||
{task.status !== "done" && (
|
{task.status !== "done" && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setStatus("done")}
|
onClick={() => setStatus("done")}
|
||||||
@@ -344,9 +389,13 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<section className="execution-resume-note" aria-label="Resume note">
|
<section className="mt-px-25 border-t border-border pt-px-20" aria-label="Resume note">
|
||||||
<label htmlFor={`resume-${task.id}`}>A place to pick up</label>
|
<label className="mb-px-10 block text-ui-12 font-medium" htmlFor={`resume-${task.id}`}>
|
||||||
|
A place to pick up
|
||||||
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
variant="note"
|
||||||
|
className="min-h-[74px]"
|
||||||
id={`resume-${task.id}`}
|
id={`resume-${task.id}`}
|
||||||
placeholder="Where did you leave off?"
|
placeholder="Where did you leave off?"
|
||||||
value={note}
|
value={note}
|
||||||
@@ -355,8 +404,10 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
setNotice("");
|
setNotice("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="execution-note-footer">
|
<div className="mt-px-8 flex items-center justify-between gap-px-10 max-md:items-start">
|
||||||
<span role="status">{notice || "Leave a sentence for your future self."}</span>
|
<span className="text-ui-11 text-muted-foreground" role="status">
|
||||||
|
{notice || "Leave a sentence for your future self."}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -381,8 +432,8 @@ function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="execution-order">
|
<div className="mt-px-20 flex items-center justify-end gap-px-6 text-muted-foreground">
|
||||||
<span>Order in your list</span>
|
<span className="mr-auto text-ui-11">Order in your list</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -410,14 +461,16 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="execution-detail-symbol">
|
<div className="hidden">
|
||||||
<Scale />
|
<Scale />
|
||||||
</div>
|
</div>
|
||||||
<p className="execution-eyebrow">A quick check-in</p>
|
<p className="hidden">A quick check-in</p>
|
||||||
<h2>Record your weight</h2>
|
<h2 className="hidden">Record your weight</h2>
|
||||||
<p className="execution-description">Just a measurement. No target to hit.</p>
|
<p className="max-w-[480px] text-ui-13 leading-relaxed-copy text-muted-foreground">
|
||||||
|
Just a measurement. No target to hit.
|
||||||
|
</p>
|
||||||
<form
|
<form
|
||||||
className="execution-weight-form"
|
className="mt-px-18 flex flex-wrap items-end gap-px-12"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const value = Number(weight);
|
const value = Number(weight);
|
||||||
@@ -441,9 +494,13 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
|||||||
onAction();
|
onAction();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="execution-weight">Weight</label>
|
<label className="w-full text-ui-12" htmlFor="execution-weight">
|
||||||
<div className="execution-weight-input">
|
Weight
|
||||||
|
</label>
|
||||||
|
<div className="flex max-w-[180px] items-center gap-px-10">
|
||||||
<Input
|
<Input
|
||||||
|
variant="measurement"
|
||||||
|
className="h-[42px]"
|
||||||
id="execution-weight"
|
id="execution-weight"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -459,10 +516,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>kg</span>
|
<span className="text-ui-13 text-muted-foreground">kg</span>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p id="execution-weight-error" role="alert">
|
<p
|
||||||
|
className="w-full text-ui-12 text-destructive"
|
||||||
|
id="execution-weight-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -471,12 +532,14 @@ function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: ()
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{entry && (
|
{entry && (
|
||||||
<div className="execution-next-step">
|
<div className="mt-px-14 border-t border-border py-px-14">
|
||||||
<span>Recorded</span>
|
<span className="text-ui-11 text-muted-foreground">Recorded</span>
|
||||||
<p>{entry.text} kg</p>
|
<p className="mt-px-5 text-ui-13 leading-loose-copy">{entry.text} kg</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="execution-source-note">Your measurement also appears in Journal.</p>
|
<p className="mt-px-20 text-ui-11 text-muted-foreground">
|
||||||
|
Your measurement also appears in Journal.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,24 +136,28 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={500}>
|
<TooltipProvider delay={500}>
|
||||||
<SidebarProvider className="home-workspace">
|
<SidebarProvider className="h-dvh min-h-0 overflow-hidden">
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<SidebarInset className="home-main">
|
<SidebarInset className="min-w-0 overflow-hidden">
|
||||||
<div className="home-scroll">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="home-content">
|
<div className="mx-auto max-w-[1200px] px-px-48 pt-px-40 pb-px-80 max-[1000px]:px-px-28 max-[1000px]:pb-px-40 max-md:px-px-20 max-md:py-px-32">
|
||||||
<header className="home-greeting">
|
<header className="mb-px-32">
|
||||||
<p className="text-sm text-muted-foreground">Today</p>
|
<p className="text-sm text-muted-foreground">Today</p>
|
||||||
<h1>Good morning, {auth.owner?.name}</h1>
|
<h1 className="mt-px-12 text-home-greeting font-book tracking-home leading-normal max-md:text-ui-25">
|
||||||
|
Good morning, {auth.owner?.name}
|
||||||
|
</h1>
|
||||||
<DailyBrief onOpenContact={openContact} />
|
<DailyBrief onOpenContact={openContact} />
|
||||||
</header>
|
</header>
|
||||||
<form
|
<form
|
||||||
className="home-composer"
|
className="rounded-ui-20 border border-border bg-muted p-px-16 max-md:p-px-10"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
interpret();
|
interpret();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
variant="composer"
|
||||||
|
className="min-h-[130px] max-md:min-h-[150px]"
|
||||||
ref={input}
|
ref={input}
|
||||||
aria-label="Home input"
|
aria-label="Home input"
|
||||||
placeholder="What’s on your mind?"
|
placeholder="What’s on your mind?"
|
||||||
@@ -174,7 +178,7 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="home-composer-footer">
|
<div className="flex items-center justify-between p-px-8">
|
||||||
<span className="text-xs text-muted-foreground">Tasks, replies, notes.</span>
|
<span className="text-xs text-muted-foreground">Tasks, replies, notes.</span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -186,17 +190,22 @@ export function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="home-suggestions">
|
<div className="mt-px-16 flex flex-wrap gap-x-px-20 gap-y-px-8 max-md:gap-px-12">
|
||||||
{["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => (
|
{["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => (
|
||||||
<button type="button" key={text} onClick={() => suggest(text)}>
|
<button
|
||||||
|
className="flex cursor-pointer items-center gap-px-6 text-left text-ui-12 text-muted-foreground"
|
||||||
|
type="button"
|
||||||
|
key={text}
|
||||||
|
onClick={() => suggest(text)}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
<ArrowUpRight className="size-3" />
|
<ArrowUpRight className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{proposal && (
|
{proposal && (
|
||||||
<div className="home-proposal">
|
<div className="mt-px-20 flex flex-wrap items-center gap-px-12 rounded-ui-12 border border-border p-px-16">
|
||||||
<p>{proposal.label}</p>
|
<p className="min-w-[140px] flex-1 text-ui-14">{proposal.label}</p>
|
||||||
<Button onClick={() => apply(proposal.action)}>Apply</Button>
|
<Button onClick={() => apply(proposal.action)}>Apply</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -208,24 +217,31 @@ export function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="home-status" role="status">
|
<p className="min-h-[36px] pt-px-12 text-ui-12 text-muted-foreground" role="status">
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
<div className="home-columns">
|
<div className="mt-px-24 grid grid-cols-[minmax(0,2.5fr)_minmax(190px,1fr)] gap-px-40 max-[1000px]:gap-px-32 max-md:grid-cols-1 max-md:gap-px-24">
|
||||||
<ExecutionWorkbench
|
<ExecutionWorkbench
|
||||||
selectedId={search.activity}
|
selectedId={search.activity}
|
||||||
onSelect={(id) => updateSearch({ activity: id || undefined })}
|
onSelect={(id) => updateSearch({ activity: id || undefined })}
|
||||||
/>
|
/>
|
||||||
<aside aria-label="Today’s context">
|
<aside aria-label="Today’s context">
|
||||||
<section className="home-section">
|
<section className="mb-px-36">
|
||||||
<header className="home-section-heading">
|
<header className="mb-px-12 flex items-center justify-between">
|
||||||
<h2>Journal</h2>
|
<h2 className="text-ui-14 font-semibold">Journal</h2>
|
||||||
<Link to="/journal" aria-label="Open journal">
|
<Link
|
||||||
|
className="p-px-6 text-muted-foreground"
|
||||||
|
to="/journal"
|
||||||
|
aria-label="Open journal"
|
||||||
|
>
|
||||||
<ArrowUpRight className="size-4" />
|
<ArrowUpRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
{currentEntries.slice(0, 2).map((entry) => (
|
{currentEntries.slice(0, 2).map((entry) => (
|
||||||
<p className="home-record" key={entry.id}>
|
<p
|
||||||
|
className="flex flex-col gap-px-6 py-px-12 text-left text-ui-13 leading-relaxed-copy wrap-anywhere"
|
||||||
|
key={entry.id}
|
||||||
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{entryKindLabels[entry.kind]}
|
{entryKindLabels[entry.kind]}
|
||||||
</span>
|
</span>
|
||||||
@@ -238,7 +254,7 @@ export function HomePage() {
|
|||||||
{!currentEntries.length && (
|
{!currentEntries.length && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="home-context-row"
|
className="flex w-full cursor-pointer items-center justify-between gap-px-10 py-px-12 text-left text-ui-13"
|
||||||
onClick={() => suggest("Note:")}
|
onClick={() => suggest("Note:")}
|
||||||
>
|
>
|
||||||
<span>Leave a note</span>
|
<span>Leave a note</span>
|
||||||
@@ -246,17 +262,21 @@ export function HomePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="home-section">
|
<section className="mb-px-36">
|
||||||
<header className="home-section-heading">
|
<header className="mb-px-12 flex items-center justify-between">
|
||||||
<h2>Reading</h2>
|
<h2 className="text-ui-14 font-semibold">Reading</h2>
|
||||||
<Link to="/inbox" aria-label="Open reader">
|
<Link
|
||||||
|
className="p-px-6 text-muted-foreground"
|
||||||
|
to="/inbox"
|
||||||
|
aria-label="Open reader"
|
||||||
|
>
|
||||||
<ArrowUpRight className="size-4" />
|
<ArrowUpRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
{articles.slice(0, 2).map((item) => (
|
{articles.slice(0, 2).map((item) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="home-reading"
|
className="flex w-full cursor-pointer flex-col gap-px-6 py-px-12 text-left text-ui-13 leading-relaxed-copy wrap-anywhere"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setArticleId(item.id)}
|
onClick={() => setArticleId(item.id)}
|
||||||
>
|
>
|
||||||
@@ -265,7 +285,10 @@ export function HomePage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<Link to="/deck" className="home-deck-link">
|
<Link
|
||||||
|
to="/deck"
|
||||||
|
className="flex items-center justify-between text-ui-12 text-muted-foreground"
|
||||||
|
>
|
||||||
Explore in Research
|
Explore in Research
|
||||||
<ArrowUpRight className="size-4" />
|
<ArrowUpRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
/* Hallmark · daily workbench · neutral / quiet · P5 H5 E4 S5 R5 V4 */
|
|
||||||
.home-workspace {
|
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.home-main {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.home-scroll {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.home-content {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: auto;
|
|
||||||
padding: 40px 48px 80px;
|
|
||||||
}
|
|
||||||
.home-greeting {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
.home-greeting h1 {
|
|
||||||
font-size: clamp(24px, 3vw, 34px);
|
|
||||||
font-weight: 550;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.home-composer {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: var(--muted);
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.home-composer textarea {
|
|
||||||
min-height: 130px;
|
|
||||||
resize: vertical;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.home-composer-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.home-suggestions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 20px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.home-suggestions button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-proposal {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.home-proposal p {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
.home-status {
|
|
||||||
min-height: 36px;
|
|
||||||
padding-top: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.home-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
|
|
||||||
gap: 40px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
.home-section {
|
|
||||||
margin-bottom: 36px;
|
|
||||||
}
|
|
||||||
.home-section-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.home-section-heading h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.home-section-heading a {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
.home-task {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 18px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.home-check {
|
|
||||||
padding-top: 2px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-task-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.home-task-body summary {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-steps {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.home-task-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
max-width: 115px;
|
|
||||||
}
|
|
||||||
.home-rest summary {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-rest summary span {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
.home-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.home-context-row {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-record,
|
|
||||||
.home-reading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 12px 0;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.8;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.home-reading {
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-deck-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.home-content {
|
|
||||||
padding: 40px 28px;
|
|
||||||
}
|
|
||||||
.home-columns {
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
.home-task-actions {
|
|
||||||
max-width: 70px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.home-content {
|
|
||||||
padding: 32px 20px;
|
|
||||||
}
|
|
||||||
.home-columns {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
.home-greeting h1 {
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
.home-composer {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
.home-composer textarea {
|
|
||||||
min-height: 150px;
|
|
||||||
}
|
|
||||||
.home-task {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.home-task-actions {
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
.home-suggestions {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-brief {
|
|
||||||
margin-top: 20px;
|
|
||||||
max-width: 760px;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.95;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.home-brief button {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: var(--border);
|
|
||||||
text-underline-offset: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.home-brief button:hover {
|
|
||||||
text-decoration-color: var(--foreground);
|
|
||||||
}
|
|
||||||
.home-brief-secondary {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
@@ -29,10 +29,22 @@ export function InboxPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={500}>
|
<TooltipProvider delay={500}>
|
||||||
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
|
<SidebarProvider
|
||||||
<Sidebar collapsible="none" className="inbox-sidebar">
|
data-testid="inbox-workspace"
|
||||||
|
className="group/inbox h-dvh min-h-0 overflow-hidden"
|
||||||
|
data-reading={readingMobile}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
data-testid="inbox-sidebar"
|
||||||
|
className="w-[420px] shrink-0 flex-row border-r border-border min-[768px]:max-[1100px]:w-[340px] max-md:w-full max-md:border-r-0 max-md:group-data-[reading=true]/inbox:hidden"
|
||||||
|
>
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<Sidebar collapsible="none" className="inbox-list-panel">
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
data-testid="inbox-list-panel"
|
||||||
|
className="w-auto min-w-0 flex-1"
|
||||||
|
>
|
||||||
<SidebarHeader className="gap-4 border-b p-5">
|
<SidebarHeader className="gap-4 border-b p-5">
|
||||||
<h1 className="text-base font-semibold">Reader</h1>
|
<h1 className="text-base font-semibold">Reader</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -60,7 +72,10 @@ export function InboxPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{visible.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<div className="inbox-no-results">
|
<div
|
||||||
|
data-testid="inbox-no-results"
|
||||||
|
className="grid justify-items-start gap-px-12 px-px-20 py-px-32 text-ui-13 leading-relaxed-copy text-muted-foreground"
|
||||||
|
>
|
||||||
<p>No articles found</p>
|
<p>No articles found</p>
|
||||||
<Button variant="outline" onClick={clearSearch}>
|
<Button variant="outline" onClick={clearSearch}>
|
||||||
Clear search
|
Clear search
|
||||||
@@ -70,12 +85,19 @@ export function InboxPage() {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset className="inbox-reader">
|
<SidebarInset
|
||||||
<header className="inbox-reader-toolbar">
|
data-testid="inbox-reader"
|
||||||
|
className="h-full min-w-0 overflow-hidden max-md:hidden max-md:group-data-[reading=true]/inbox:flex"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
data-testid="inbox-reader-toolbar"
|
||||||
|
className="flex min-h-[60px] items-center gap-px-12 border-b border-border px-px-26 py-px-10 max-md:px-px-12"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="inbox-back"
|
data-testid="inbox-back"
|
||||||
|
className="hidden max-md:inline-flex"
|
||||||
aria-label="Back to articles"
|
aria-label="Back to articles"
|
||||||
onClick={() => updateSearch({ article: undefined })}
|
onClick={() => updateSearch({ article: undefined })}
|
||||||
>
|
>
|
||||||
@@ -84,7 +106,8 @@ export function InboxPage() {
|
|||||||
<span className="text-xs text-muted-foreground">{selected?.source}</span>
|
<span className="text-xs text-muted-foreground">{selected?.source}</span>
|
||||||
</header>
|
</header>
|
||||||
<section
|
<section
|
||||||
className="inbox-reader-scroll"
|
data-testid="inbox-reader-scroll"
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||||
key={selected?.id}
|
key={selected?.id}
|
||||||
aria-label="Article content"
|
aria-label="Article content"
|
||||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable.
|
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- The independently scrolling reader must be keyboard-scrollable.
|
||||||
@@ -110,28 +133,54 @@ function ArticleRow({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inbox-article-row"
|
data-testid="inbox-article-row"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-stretch gap-px-11 border-b border-border px-px-20 py-px-22 text-left hover:bg-sidebar-accent aria-current:bg-muted aria-current:inset-shadow-selection"
|
||||||
aria-current={selected ? "true" : undefined}
|
aria-current={selected ? "true" : undefined}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<span className="inbox-row-meta">
|
<span
|
||||||
|
data-testid="inbox-row-meta"
|
||||||
|
className="flex min-w-0 items-center gap-px-7 text-ui-11 text-muted-foreground"
|
||||||
|
>
|
||||||
<span className="truncate">{article.source}</span>
|
<span className="truncate">{article.source}</span>
|
||||||
<time className="ml-auto shrink-0">{article.date}</time>
|
<time className="ml-auto shrink-0">{article.date}</time>
|
||||||
</span>
|
</span>
|
||||||
<span className="inbox-row-title">{article.title}</span>
|
<span
|
||||||
<span className="inbox-row-summary">{article.summary}</span>
|
data-testid="inbox-row-title"
|
||||||
|
className="block text-ui-14 leading-chat font-semibold text-pretty"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="inbox-row-summary"
|
||||||
|
className="line-clamp-2 text-ui-12 leading-relaxed-copy text-muted-foreground"
|
||||||
|
>
|
||||||
|
{article.summary}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArticleReader({ article }: { article: Article }) {
|
function ArticleReader({ article }: { article: Article }) {
|
||||||
return (
|
return (
|
||||||
<article className="inbox-article" aria-label={article.title}>
|
<article
|
||||||
<h2 className="inbox-article-title">{article.title}</h2>
|
data-testid="inbox-article"
|
||||||
<p className="inbox-byline text-xs text-muted-foreground">
|
className="mx-auto max-w-[800px] px-px-52 pt-px-54 pb-px-40 min-[768px]:max-[1100px]:px-px-28 min-[768px]:max-[1100px]:py-px-36 max-md:px-px-24 max-md:py-px-30"
|
||||||
|
aria-label={article.title}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
data-testid="inbox-article-title"
|
||||||
|
className="m-0 text-reader-title leading-post font-demibold tracking-tight wrap-anywhere text-pretty"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
<p data-testid="inbox-byline" className="my-px-30 text-xs text-muted-foreground">
|
||||||
{article.author} · {article.date}
|
{article.author} · {article.date}
|
||||||
</p>
|
</p>
|
||||||
<div className="inbox-article-body">
|
<div
|
||||||
|
data-testid="inbox-article-body"
|
||||||
|
className="text-ui-15 leading-reader tracking-wide [&_p+p]:mt-px-24"
|
||||||
|
>
|
||||||
{article.paragraphs.map((paragraph) => (
|
{article.paragraphs.map((paragraph) => (
|
||||||
<p key={paragraph}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
|
|
||||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
|
||||||
.inbox-workspace {
|
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.inbox-sidebar {
|
|
||||||
width: 420px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: row;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.inbox-list-panel {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.inbox-article-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 11px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 22px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.inbox-article-row:hover {
|
|
||||||
background: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
.inbox-article-row[aria-current="true"] {
|
|
||||||
background: var(--muted);
|
|
||||||
box-shadow: inset 2px 0 var(--primary);
|
|
||||||
}
|
|
||||||
.inbox-row-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.inbox-row-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.65;
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
.inbox-row-summary {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.inbox-reader {
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.inbox-reader-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 10px 26px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.inbox-reader-scroll {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
.inbox-article {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 54px 52px 40px;
|
|
||||||
}
|
|
||||||
.inbox-article-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(24px, 2.4vw, 34px);
|
|
||||||
line-height: 1.55;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
.inbox-byline {
|
|
||||||
margin-block: 30px;
|
|
||||||
}
|
|
||||||
.inbox-article-body {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 2.15;
|
|
||||||
letter-spacing: 0.015em;
|
|
||||||
}
|
|
||||||
.inbox-article-body p + p {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
.inbox-no-results {
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 32px 20px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.inbox-back {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (min-width: 768px) and (max-width: 1100px) {
|
|
||||||
.inbox-sidebar {
|
|
||||||
width: 340px;
|
|
||||||
}
|
|
||||||
.inbox-article {
|
|
||||||
padding: 36px 28px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.inbox-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
.inbox-reader {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.inbox-workspace[data-reading="true"] .inbox-sidebar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.inbox-workspace[data-reading="true"] .inbox-reader {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.inbox-back {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
.inbox-reader-toolbar {
|
|
||||||
padding-inline: 12px;
|
|
||||||
}
|
|
||||||
.inbox-article {
|
|
||||||
padding: 30px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,10 +58,10 @@ export function JournalPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={500}>
|
<TooltipProvider delay={500}>
|
||||||
<SidebarProvider className="journal-workspace">
|
<SidebarProvider className="h-dvh min-h-0 overflow-hidden">
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<SidebarInset className="journal-main">
|
<SidebarInset className="min-w-0 overflow-hidden">
|
||||||
<header className="journal-header">
|
<header className="flex min-h-[68px] items-center justify-between gap-px-12 border-b border-border px-px-28 py-px-16 max-[600px]:flex-wrap max-[600px]:p-px-14">
|
||||||
<h1 className="text-base font-semibold">Journal</h1>
|
<h1 className="text-base font-semibold">Journal</h1>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -74,8 +74,8 @@ export function JournalPage() {
|
|||||||
Summarize entries
|
Summarize entries
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="journal-scroll">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="journal-column">
|
<div className="mx-auto w-full max-w-[760px] px-px-28 py-px-40 max-[600px]:px-px-14 max-[600px]:py-px-24">
|
||||||
<EntryEditor
|
<EntryEditor
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
@@ -85,15 +85,19 @@ export function JournalPage() {
|
|||||||
}}
|
}}
|
||||||
submitLabel="Save entry"
|
submitLabel="Save entry"
|
||||||
/>
|
/>
|
||||||
<section className="journal-entries" aria-label="Journal entries">
|
<section className="mt-px-36" aria-label="Journal entries">
|
||||||
{entries.length === 0 && <p className="journal-empty">No entries yet</p>}
|
{entries.length === 0 && (
|
||||||
|
<p className="py-px-40 text-center text-ui-14 text-muted-foreground">
|
||||||
|
No entries yet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<article
|
<article
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="journal-entry"
|
className="border-t border-border py-px-20"
|
||||||
aria-label={`${entryKindLabels[entry.kind]} entry`}
|
aria-label={`${entryKindLabels[entry.kind]} entry`}
|
||||||
>
|
>
|
||||||
<div className="journal-entry-heading">
|
<div className="flex min-h-[28px] items-center gap-px-10">
|
||||||
<span className="text-sm font-medium">{entryKindLabels[entry.kind]}</span>
|
<span className="text-sm font-medium">{entryKindLabels[entry.kind]}</span>
|
||||||
{superseded.has(entry.id) && (
|
{superseded.has(entry.id) && (
|
||||||
<span className="text-xs text-muted-foreground">Original</span>
|
<span className="text-xs text-muted-foreground">Original</span>
|
||||||
@@ -112,7 +116,7 @@ export function JournalPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="journal-entry-text">
|
<p className="my-px-12 text-ui-14 leading-relaxed-copy whitespace-pre-wrap wrap-anywhere">
|
||||||
{entry.text}
|
{entry.text}
|
||||||
{entry.kind === "体重" ? " kg" : ""}
|
{entry.kind === "体重" ? " kg" : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -151,10 +155,15 @@ export function JournalPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Select the entries to include. Nothing is sent externally.
|
Select the entries to include. Nothing is sent externally.
|
||||||
</p>
|
</p>
|
||||||
<div className="journal-review-list">
|
<div className="flex max-h-[220px] flex-col gap-px-16 overflow-auto p-px-4">
|
||||||
{current.map((entry) => (
|
{current.map((entry) => (
|
||||||
<label key={entry.id} className="journal-review-row" htmlFor={`review-${entry.id}`}>
|
<label
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-start gap-px-12 wrap-anywhere"
|
||||||
|
htmlFor={`review-${entry.id}`}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mt-[4px]"
|
||||||
id={`review-${entry.id}`}
|
id={`review-${entry.id}`}
|
||||||
checked={selected.has(entry.id)}
|
checked={selected.has(entry.id)}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
@@ -180,7 +189,8 @@ export function JournalPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
aria-label="Entry summary"
|
aria-label="Entry summary"
|
||||||
className="journal-summary"
|
variant="summary"
|
||||||
|
className="min-h-[180px] max-h-[280px] overflow-y-auto"
|
||||||
readOnly
|
readOnly
|
||||||
value={buildSummary(current.filter((entry) => selected.has(entry.id)))}
|
value={buildSummary(current.filter((entry) => selected.has(entry.id)))}
|
||||||
/>
|
/>
|
||||||
@@ -207,16 +217,17 @@ function EntryEditor({
|
|||||||
(draft.kind !== "体重" || (Number.isFinite(Number(draft.text)) && Number(draft.text) > 0));
|
(draft.kind !== "体重" || (Number.isFinite(Number(draft.text)) && Number(draft.text) > 0));
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="journal-editor"
|
className="flex flex-col gap-px-16"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (valid) onSubmit();
|
if (valid) onSubmit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<fieldset className="journal-kind-switch" aria-label="Entry type">
|
<fieldset className="flex flex-wrap gap-px-4 max-[600px]:gap-0" aria-label="Entry type">
|
||||||
{kinds.map((kind) => (
|
{kinds.map((kind) => (
|
||||||
<Button
|
<Button
|
||||||
key={kind}
|
key={kind}
|
||||||
|
className="max-[600px]:px-2"
|
||||||
type="button"
|
type="button"
|
||||||
variant={draft.kind === kind ? "secondary" : "ghost"}
|
variant={draft.kind === kind ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -242,6 +253,7 @@ function EntryEditor({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
|
className="min-h-[112px]"
|
||||||
aria-label="Entry text"
|
aria-label="Entry text"
|
||||||
placeholder={
|
placeholder={
|
||||||
draft.kind === "服薬"
|
draft.kind === "服薬"
|
||||||
@@ -262,10 +274,14 @@ function EntryEditor({
|
|||||||
onChange={(event) => onChange({ ...draft, source: event.target.value })}
|
onChange={(event) => onChange({ ...draft, source: event.target.value })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="journal-editor-footer">
|
<div className="flex items-end justify-between gap-px-12 max-[600px]:flex-col max-[600px]:items-stretch">
|
||||||
<label className="journal-date-label" htmlFor={dateId}>
|
<label
|
||||||
|
className="flex min-w-0 flex-col gap-px-8 text-ui-12 text-muted-foreground"
|
||||||
|
htmlFor={dateId}
|
||||||
|
>
|
||||||
When it happened (optional)
|
When it happened (optional)
|
||||||
<Input
|
<Input
|
||||||
|
className="max-w-full"
|
||||||
id={dateId}
|
id={dateId}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
aria-label="When it happened"
|
aria-label="When it happened"
|
||||||
@@ -273,7 +289,7 @@ function EntryEditor({
|
|||||||
onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })}
|
onChange={(event) => onChange({ ...draft, occurredAt: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button type="submit" disabled={!valid}>
|
<Button className="max-[600px]:self-end" type="submit" disabled={!valid}>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
.journal-workspace {
|
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.journal-main {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.journal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 68px;
|
|
||||||
padding: 16px 28px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.journal-scroll {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.journal-column {
|
|
||||||
width: min(100%, 760px);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 40px 28px;
|
|
||||||
}
|
|
||||||
.journal-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.journal-kind-switch {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.journal-editor textarea {
|
|
||||||
min-height: 112px;
|
|
||||||
}
|
|
||||||
.journal-editor-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.journal-date-label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.journal-date-label input {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.journal-entries {
|
|
||||||
margin-top: 36px;
|
|
||||||
}
|
|
||||||
.journal-empty {
|
|
||||||
padding: 40px 0;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.journal-entry {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.journal-entry-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
.journal-entry-text {
|
|
||||||
margin: 12px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.8;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.journal-review-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
max-height: 220px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
.journal-review-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.journal-review-row [data-slot="checkbox"] {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.journal-summary {
|
|
||||||
min-height: 180px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.journal-header {
|
|
||||||
padding: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.journal-column {
|
|
||||||
padding: 24px 14px;
|
|
||||||
}
|
|
||||||
.journal-editor-footer {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.journal-editor-footer button {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
.journal-kind-switch {
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
.journal-kind-switch button {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.research-post.citation-highlight {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background-color: color-mix(in srgb, var(--primary) 14%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-post:focus-visible {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,10 @@ export function ResearchMarkdown({
|
|||||||
onNavigateCitation?: (url: string) => boolean;
|
onNavigateCitation?: (url: string) => boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="research-markdown">
|
<div
|
||||||
|
data-testid="research-markdown"
|
||||||
|
className="min-w-0 wrap-anywhere motion-reduce:[&_[data-sd-animate]]:animate-none motion-reduce:[&_[data-sd-animate-marker]::marker]:animate-none"
|
||||||
|
>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
mode={isAnimating ? "streaming" : "static"}
|
mode={isAnimating ? "streaming" : "static"}
|
||||||
isAnimating={isAnimating}
|
isAnimating={isAnimating}
|
||||||
@@ -32,17 +35,47 @@ export function ResearchMarkdown({
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
skipHtml
|
skipHtml
|
||||||
components={{
|
components={{
|
||||||
|
p: ({ children }) => <p className="mt-1.5 mb-0 whitespace-normal">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="not-first:mt-1">{children}</li>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-3 border-l-2 pl-3 text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
th: ({ children }) => <th className="border p-1.5 text-left">{children}</th>,
|
||||||
|
td: ({ children }) => <td className="border p-1.5 text-left">{children}</td>,
|
||||||
strong: ({ children }) => <strong>{children}</strong>,
|
strong: ({ children }) => <strong>{children}</strong>,
|
||||||
pre: ({ children }) => <pre>{children}</pre>,
|
pre: ({ children }) => (
|
||||||
code: ({ children, className }) => <code className={className}>{children}</code>,
|
<pre className="max-w-full overflow-x-auto bg-muted p-3 text-research-label whitespace-pre">
|
||||||
h1: ({ children }) => <h3>{children}</h3>,
|
{children}
|
||||||
h2: ({ children }) => <h4>{children}</h4>,
|
</pre>
|
||||||
h3: ({ children }) => <h5>{children}</h5>,
|
),
|
||||||
h4: ({ children }) => <h6>{children}</h6>,
|
code: ({ children, className }) => (
|
||||||
h5: ({ children }) => <h6>{children}</h6>,
|
<code className={`wrap-anywhere ${className ?? ""}`}>{children}</code>
|
||||||
h6: ({ children }) => <h6>{children}</h6>,
|
),
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h3 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h3>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h4 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h4>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h5 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h5>
|
||||||
|
),
|
||||||
|
h4: ({ children }) => (
|
||||||
|
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
|
||||||
|
),
|
||||||
|
h5: ({ children }) => (
|
||||||
|
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
|
||||||
|
),
|
||||||
|
h6: ({ children }) => (
|
||||||
|
<h6 className="mt-3.5 mb-1.5 text-research-body font-semibold">{children}</h6>
|
||||||
|
),
|
||||||
a: ({ href, children, title }) => (
|
a: ({ href, children, title }) => (
|
||||||
<a
|
<a
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
href={href}
|
href={href}
|
||||||
title={title}
|
title={title}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -65,7 +98,12 @@ export function ResearchMarkdown({
|
|||||||
),
|
),
|
||||||
img: ({ src, alt }) =>
|
img: ({ src, alt }) =>
|
||||||
typeof src === "string" && src ? (
|
typeof src === "string" && src ? (
|
||||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
href={src}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
{alt || "Open image"}
|
{alt || "Open image"}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,12 +111,13 @@ export function ResearchMarkdown({
|
|||||||
),
|
),
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
<section
|
<section
|
||||||
className="research-markdown-table"
|
data-testid="research-markdown-table"
|
||||||
|
className="my-3 max-w-full overflow-x-auto"
|
||||||
aria-label="Response table"
|
aria-label="Response table"
|
||||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need to scroll wide tables inside the narrow chat pane.
|
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need to scroll wide tables inside the narrow chat pane.
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<table>{children}</table>
|
<table className="w-full border-collapse text-research-label">{children}</table>
|
||||||
</section>
|
</section>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
.research-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
background: var(--background);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.research-heading {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.research-heading h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.research-context {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-history {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.research-history span {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.research-history > [data-slot="native-select-wrapper"] {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.research-panel > p {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.research-conversation {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
.research-empty {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.research-message {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-message p {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.research-tool-message {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-start-form {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.research-start-form > fieldset {
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.research-start-form label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.research-account-picker {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-accounts {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.75rem;
|
|
||||||
min-width: 0;
|
|
||||||
max-height: 160px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.research-accounts label {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-run {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
max-height: 180px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.research-run p {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-report-path {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-markdown {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-message .research-markdown p {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
.research-markdown h3,
|
|
||||||
.research-markdown h4,
|
|
||||||
.research-markdown h5,
|
|
||||||
.research-markdown h6 {
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 1em 0 0.4em;
|
|
||||||
}
|
|
||||||
.research-markdown ul,
|
|
||||||
.research-markdown ol {
|
|
||||||
padding-left: 1.4rem;
|
|
||||||
}
|
|
||||||
.research-markdown ul {
|
|
||||||
list-style: disc;
|
|
||||||
}
|
|
||||||
.research-markdown ol {
|
|
||||||
list-style: decimal;
|
|
||||||
}
|
|
||||||
.research-markdown li + li {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
.research-markdown blockquote {
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.research-markdown pre {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--muted);
|
|
||||||
white-space: pre;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-markdown code {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-markdown-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
}
|
|
||||||
.research-markdown table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-markdown th,
|
|
||||||
.research-markdown td {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.4rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.research-markdown a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 0.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.research-markdown [data-sd-animate],
|
|
||||||
.research-markdown [data-sd-animate-marker]::marker {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -801,7 +801,7 @@ it("animates only the latest live assistant response and leaves prior replies st
|
|||||||
};
|
};
|
||||||
const view = show();
|
const view = show();
|
||||||
expect(await screen.findByText("Earlier answer")).toBeVisible();
|
expect(await screen.findByText("Earlier answer")).toBeVisible();
|
||||||
const replies = view.container.querySelectorAll(".research-markdown");
|
const replies = view.container.querySelectorAll('[data-testid="research-markdown"]');
|
||||||
expect(replies).toHaveLength(2);
|
expect(replies).toHaveLength(2);
|
||||||
expect(replies[0]?.querySelector("[data-sd-animate]")).toBeNull();
|
expect(replies[0]?.querySelector("[data-sd-animate]")).toBeNull();
|
||||||
expect(replies[1]?.querySelector("[data-sd-animate]")).not.toBeNull();
|
expect(replies[1]?.querySelector("[data-sd-animate]")).not.toBeNull();
|
||||||
@@ -815,3 +815,18 @@ it("animates only the latest live assistant response and leaves prior replies st
|
|||||||
);
|
);
|
||||||
await waitFor(() => expect(view.container.querySelector("[data-sd-animate]")).toBeNull());
|
await waitFor(() => expect(view.container.querySelector("[data-sd-animate]")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits with Enter while preserving Shift+Enter and IME composition", async () => {
|
||||||
|
show();
|
||||||
|
const input = await screen.findByLabelText("Message");
|
||||||
|
await waitFor(() => expect(input).toBeEnabled());
|
||||||
|
fireEvent.change(input, { target: { value: "Compare browser automation tools" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
|
||||||
|
expect(actions.start).not.toHaveBeenCalled();
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(1));
|
||||||
|
expect(actions.start).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ topic: "Compare browser automation tools" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useServerFn } from "@tanstack/react-start";
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
|
import { ArrowUpIcon, SquareIcon } from "lucide-react";
|
||||||
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import { Bubble, BubbleContent } from "#/components/ui/bubble";
|
import { Bubble, BubbleContent } from "#/components/ui/bubble";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty";
|
||||||
import { Checkbox } from "#/components/ui/checkbox";
|
import { Checkbox } from "#/components/ui/checkbox";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +14,7 @@ import {
|
|||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from "#/components/ui/input-group";
|
} from "#/components/ui/input-group";
|
||||||
import { Marker, MarkerContent } from "#/components/ui/marker";
|
import { Marker, MarkerContent } from "#/components/ui/marker";
|
||||||
import { Message, MessageContent, MessageHeader } from "#/components/ui/message";
|
import { Message, MessageContent } from "#/components/ui/message";
|
||||||
import {
|
import {
|
||||||
MessageScroller,
|
MessageScroller,
|
||||||
MessageScrollerButton,
|
MessageScrollerButton,
|
||||||
@@ -291,15 +293,26 @@ export function ResearchPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
||||||
<aside className="research-panel" aria-label="Research chat">
|
<aside
|
||||||
<header className="research-heading">
|
data-testid="research-panel"
|
||||||
<h2>Research chat</h2>
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
||||||
|
aria-label="Research chat"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
data-testid="research-heading"
|
||||||
|
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b p-4"
|
||||||
|
>
|
||||||
|
<h2 className="m-0 text-base">Research chat</h2>
|
||||||
<Button variant="ghost" size="sm" type="button" disabled={busy || !run} onClick={newChat}>
|
<Button variant="ghost" size="sm" type="button" disabled={busy || !run} onClick={newChat}>
|
||||||
New chat
|
New chat
|
||||||
</Button>
|
</Button>
|
||||||
{(status.data?.history?.length ?? 0) > 0 && (
|
{(status.data?.history?.length ?? 0) > 0 && (
|
||||||
<label className="research-history" htmlFor={historyId}>
|
<label
|
||||||
<span>Chat history</span>
|
data-testid="research-history"
|
||||||
|
className="flex w-full min-w-0 items-center gap-2 text-research-label text-muted-foreground [&>[data-slot=native-select-wrapper]]:min-w-0 [&>[data-slot=native-select-wrapper]]:flex-1"
|
||||||
|
htmlFor={historyId}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">Chat history</span>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
id={historyId}
|
id={historyId}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -320,12 +333,20 @@ export function ResearchPanel({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
{reconnecting && (
|
{reconnecting && (
|
||||||
<p className="deck-save-status" role="status">
|
<p
|
||||||
|
data-testid="deck-save-status"
|
||||||
|
className="m-0 px-4 py-2 text-research-field leading-normal text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Reconnecting to research updates. Processing continues on your home server.
|
Reconnecting to research updates. Processing continues on your home server.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{status.isError && (
|
{status.isError && (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
Unable to load research status.
|
Unable to load research status.
|
||||||
<Button type="button" onClick={() => void status.refetch()}>
|
<Button type="button" onClick={() => void status.refetch()}>
|
||||||
Retry
|
Retry
|
||||||
@@ -333,19 +354,31 @@ export function ResearchPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selectedId && status.isSuccess && !run && (
|
{selectedId && status.isSuccess && !run && (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
This conversation could not be found.
|
This conversation could not be found.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{status.isPending ? (
|
{status.isPending ? (
|
||||||
<p role="status">Checking the research connection…</p>
|
<p className="m-0 px-4 py-2 text-research-field" role="status">
|
||||||
|
Checking the research connection…
|
||||||
|
</p>
|
||||||
) : !status.data?.configured ? (
|
) : !status.data?.configured ? (
|
||||||
<p role="status">The connection to Codex on your home server is not configured.</p>
|
<p className="m-0 px-4 py-2 text-research-field" role="status">
|
||||||
|
The connection to Codex on your home server is not configured.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<ResearchConversation
|
<ResearchConversation
|
||||||
key={`conversation:${run?.id ?? "new"}`}
|
key={`conversation:${run?.id ?? "new"}`}
|
||||||
@@ -358,14 +391,7 @@ export function ResearchPanel({
|
|||||||
return navigated;
|
return navigated;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{run && (
|
{run && <ResearchRunStatus run={run} onOpenDeck={(deck) => void openDeck(deck)} />}
|
||||||
<ResearchRunStatus
|
|
||||||
run={run}
|
|
||||||
busy={busy}
|
|
||||||
onCancel={() => void stop()}
|
|
||||||
onOpenDeck={(deck) => void openDeck(deck)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ResearchComposer
|
<ResearchComposer
|
||||||
key={run?.id ?? "new"}
|
key={run?.id ?? "new"}
|
||||||
topic={draft.topic}
|
topic={draft.topic}
|
||||||
@@ -378,6 +404,9 @@ export function ResearchPanel({
|
|||||||
!ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured
|
!ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured
|
||||||
}
|
}
|
||||||
onSend={begin}
|
onSend={begin}
|
||||||
|
active={isResearchActive(run)}
|
||||||
|
busy={busy}
|
||||||
|
onStop={() => void stop()}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
@@ -398,15 +427,20 @@ function ResearchConversation({
|
|||||||
const names = { user: "You", assistant: "Codex", tool: "Tool activity" };
|
const names = { user: "You", assistant: "Codex", tool: "Tool activity" };
|
||||||
return (
|
return (
|
||||||
<MessageScrollerProvider autoScroll>
|
<MessageScrollerProvider autoScroll>
|
||||||
<MessageScroller className="research-conversation">
|
<MessageScroller data-testid="research-conversation" className="min-h-[80px] flex-1">
|
||||||
<MessageScrollerViewport aria-label="Research messages">
|
<MessageScrollerViewport aria-label="Research messages">
|
||||||
<MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4">
|
<MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<MessageScrollerItem messageId="empty">
|
<MessageScrollerItem messageId="empty">
|
||||||
<p className="research-empty">
|
<Empty data-testid="research-empty">
|
||||||
Send a topic or question. The assistant adds columns as it searches and saves a
|
<EmptyHeader>
|
||||||
report to your home server.
|
<EmptyTitle>What would you like to explore?</EmptyTitle>
|
||||||
</p>
|
<EmptyDescription>
|
||||||
|
Send a topic or question. Codex adds columns as it searches and saves a report
|
||||||
|
to your home server.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
</MessageScrollerItem>
|
</MessageScrollerItem>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message, index) => (
|
messages.map((message, index) => (
|
||||||
@@ -416,7 +450,10 @@ function ResearchConversation({
|
|||||||
scrollAnchor={message.role === "user"}
|
scrollAnchor={message.role === "user"}
|
||||||
>
|
>
|
||||||
{message.role === "tool" ? (
|
{message.role === "tool" ? (
|
||||||
<Marker className="research-tool-message">
|
<Marker
|
||||||
|
data-testid="research-tool-message"
|
||||||
|
className="text-research-label whitespace-pre-wrap text-muted-foreground wrap-anywhere"
|
||||||
|
>
|
||||||
<MarkerContent>
|
<MarkerContent>
|
||||||
<span className="sr-only">{names.tool}: </span>
|
<span className="sr-only">{names.tool}: </span>
|
||||||
{message.text}
|
{message.text}
|
||||||
@@ -425,10 +462,11 @@ function ResearchConversation({
|
|||||||
) : (
|
) : (
|
||||||
<Message
|
<Message
|
||||||
align={message.role === "user" ? "end" : "start"}
|
align={message.role === "user" ? "end" : "start"}
|
||||||
className="research-message"
|
data-testid="research-message"
|
||||||
|
className="wrap-anywhere"
|
||||||
>
|
>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
<MessageHeader>{names[message.role]}</MessageHeader>
|
<span className="sr-only">{names[message.role]}</span>
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<ResearchMarkdown
|
<ResearchMarkdown
|
||||||
text={message.text}
|
text={message.text}
|
||||||
@@ -438,9 +476,9 @@ function ResearchConversation({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Bubble align="end">
|
<Bubble align="end" variant="muted">
|
||||||
<BubbleContent>
|
<BubbleContent>
|
||||||
<p>{message.text}</p>
|
<p className="m-0 whitespace-pre-wrap">{message.text}</p>
|
||||||
</BubbleContent>
|
</BubbleContent>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
)}
|
)}
|
||||||
@@ -467,6 +505,9 @@ function ResearchComposer({
|
|||||||
contextTitle,
|
contextTitle,
|
||||||
disabled,
|
disabled,
|
||||||
onSend,
|
onSend,
|
||||||
|
active,
|
||||||
|
busy,
|
||||||
|
onStop,
|
||||||
}: {
|
}: {
|
||||||
topic: string;
|
topic: string;
|
||||||
selected: string[] | null;
|
selected: string[] | null;
|
||||||
@@ -476,6 +517,9 @@ function ResearchComposer({
|
|||||||
contextTitle: string;
|
contextTitle: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onSend: (input: ResearchInput) => Promise<boolean>;
|
onSend: (input: ResearchInput) => Promise<boolean>;
|
||||||
|
active: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onStop: () => void;
|
||||||
}) {
|
}) {
|
||||||
const available = connections.filter((connection) => connection.status === "connected");
|
const available = connections.filter((connection) => connection.status === "connected");
|
||||||
const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id);
|
const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id);
|
||||||
@@ -485,7 +529,8 @@ function ResearchComposer({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="research-start-form"
|
data-testid="research-start-form"
|
||||||
|
className="shrink-0 p-3"
|
||||||
aria-label="Research messages"
|
aria-label="Research messages"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -498,22 +543,72 @@ function ResearchComposer({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="research-context">
|
<Collapsible data-testid="research-account-picker" disabled={disabled}>
|
||||||
Deck to send: <strong>{contextTitle}</strong>
|
<label className="sr-only" htmlFor={id}>
|
||||||
</p>
|
Message
|
||||||
<fieldset disabled={disabled}>
|
</label>
|
||||||
<Collapsible className="research-account-picker" disabled={disabled}>
|
<InputGroup>
|
||||||
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
|
<InputGroupTextarea
|
||||||
|
id={id}
|
||||||
|
value={topic}
|
||||||
|
onChange={(event) => onTopicChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ask about this deck…"
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="block-end">
|
||||||
|
<CollapsibleTrigger render={<InputGroupButton size="sm" />}>
|
||||||
Connected accounts · {validSelection.length}
|
Connected accounts · {validSelection.length}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
{active ? (
|
||||||
|
<InputGroupButton
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
aria-label="Stop research"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
<SquareIcon />
|
||||||
|
</InputGroupButton>
|
||||||
|
) : (
|
||||||
|
<InputGroupButton
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
size="icon-sm"
|
||||||
|
className="ml-auto"
|
||||||
|
aria-label="Send"
|
||||||
|
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon />
|
||||||
|
</InputGroupButton>
|
||||||
|
)}
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<fieldset className="research-accounts">
|
<fieldset
|
||||||
|
data-testid="research-accounts"
|
||||||
|
className="grid max-h-[160px] min-w-0 gap-2 overflow-auto border p-3"
|
||||||
|
>
|
||||||
<legend>Accounts for research (up to 6)</legend>
|
<legend>Accounts for research (up to 6)</legend>
|
||||||
{available.length === 0 ? (
|
{available.length === 0 ? (
|
||||||
<p>Add a connected account first.</p>
|
<p>Add a connected account first.</p>
|
||||||
) : (
|
) : (
|
||||||
available.map((connection) => (
|
available.map((connection) => (
|
||||||
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
|
<label
|
||||||
|
className="flex items-center gap-2 text-research-field wrap-anywhere"
|
||||||
|
key={connection.id}
|
||||||
|
htmlFor={`${id}-${connection.id}`}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`${id}-${connection.id}`}
|
id={`${id}-${connection.id}`}
|
||||||
checked={selectedIds.includes(connection.id)}
|
checked={selectedIds.includes(connection.id)}
|
||||||
@@ -537,69 +632,52 @@ function ResearchComposer({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<label htmlFor={id}>Message</label>
|
<p
|
||||||
<InputGroup>
|
data-testid="research-context"
|
||||||
<InputGroupTextarea
|
className="mt-2 mb-0 text-research-label text-muted-foreground wrap-anywhere"
|
||||||
id={id}
|
|
||||||
value={topic}
|
|
||||||
onChange={(event) => onTopicChange(event.target.value)}
|
|
||||||
maxLength={1000}
|
|
||||||
rows={3}
|
|
||||||
placeholder="e.g. Compare reactions to WebMCP, then refine this deck."
|
|
||||||
required
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<InputGroupAddon align="block-end" className="justify-end">
|
|
||||||
<InputGroupButton
|
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
|
||||||
>
|
>
|
||||||
Send
|
Deck to send: <strong>{contextTitle}</strong>
|
||||||
</InputGroupButton>
|
</p>
|
||||||
</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchRunStatus({
|
function ResearchRunStatus({
|
||||||
run,
|
run,
|
||||||
busy,
|
|
||||||
onCancel,
|
|
||||||
onOpenDeck,
|
onOpenDeck,
|
||||||
}: {
|
}: {
|
||||||
run: ResearchRun;
|
run: ResearchRun;
|
||||||
busy: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onOpenDeck: (deck: Deck) => void;
|
onOpenDeck: (deck: Deck) => void;
|
||||||
}) {
|
}) {
|
||||||
const deck = run.deck ?? run.contextDeck;
|
const deck = run.deck ?? run.contextDeck;
|
||||||
return (
|
return (
|
||||||
<section className="research-run" aria-label="Research status">
|
<section
|
||||||
|
data-testid="research-run"
|
||||||
|
className="max-h-[180px] shrink-0 overflow-y-auto border-t px-3 py-2 text-research-label"
|
||||||
|
aria-label="Research status"
|
||||||
|
>
|
||||||
<Marker role="status">
|
<Marker role="status">
|
||||||
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
||||||
</Marker>
|
</Marker>
|
||||||
{run.error && (
|
{run.error && (
|
||||||
<p className="deck-error" role="alert">
|
<p
|
||||||
|
data-testid="deck-error"
|
||||||
|
className="mt-0 mb-2 text-sm leading-body text-destructive wrap-anywhere"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{run.error}
|
{run.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{run.reportPath && (
|
{run.reportPath && (
|
||||||
<p>
|
<p className="mt-0 mb-2 wrap-anywhere">
|
||||||
Markdown location
|
Markdown location
|
||||||
<br />
|
<br />
|
||||||
<code className="research-report-path">{run.reportPath}</code>
|
<code data-testid="research-report-path" className="text-research-label wrap-anywhere">
|
||||||
|
{run.reportPath}
|
||||||
|
</code>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="deck-actions">
|
<div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
|
||||||
{isResearchActive(run) && (
|
|
||||||
<Button variant="outline" size="sm" type="button" disabled={busy} onClick={onCancel}>
|
|
||||||
Stop research
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{deck && (
|
{deck && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
|
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
|
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
|
||||||
|
import { Bubble, BubbleContent } from "#/components/ui/bubble";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -11,7 +13,22 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "#/components/ui/dialog";
|
} from "#/components/ui/dialog";
|
||||||
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
|
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
|
||||||
import { Textarea } from "#/components/ui/textarea";
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupTextarea,
|
||||||
|
} from "#/components/ui/input-group";
|
||||||
|
import { Message, MessageContent, MessageHeader } from "#/components/ui/message";
|
||||||
|
import {
|
||||||
|
MessageScroller,
|
||||||
|
MessageScrollerButton,
|
||||||
|
MessageScrollerContent,
|
||||||
|
MessageScrollerItem,
|
||||||
|
MessageScrollerProvider,
|
||||||
|
MessageScrollerViewport,
|
||||||
|
} from "#/components/ui/message-scroller";
|
||||||
import type { Conversation } from "./mock-conversations";
|
import type { Conversation } from "./mock-conversations";
|
||||||
import { reviewReply } from "./server-functions";
|
import { reviewReply } from "./server-functions";
|
||||||
|
|
||||||
@@ -39,41 +56,71 @@ export function ConversationDetail({
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className="conversation-workbench"
|
className="grid min-h-[calc(100dvh-64px)] min-w-0 grid-cols-1 @min-[780px]:h-[calc(100dvh-64px)] @min-[780px]:min-h-0 @min-[780px]:grid-cols-[minmax(0,1fr)_250px]"
|
||||||
aria-label={`Conversation with ${conversation.name}`}
|
aria-label={`Conversation with ${conversation.name}`}
|
||||||
>
|
>
|
||||||
<div className="conversation-thread">
|
<div className="flex min-w-0 flex-col @min-[780px]:min-h-0">
|
||||||
<section className="conversation-messages" aria-label="Original messages">
|
<section
|
||||||
|
className="flex h-[45dvh] min-h-[220px] flex-col @min-[780px]:h-auto @min-[780px]:min-h-0 @min-[780px]:flex-1"
|
||||||
|
aria-label="Original messages"
|
||||||
|
>
|
||||||
|
<MessageScrollerProvider>
|
||||||
|
<MessageScroller className="flex-1">
|
||||||
|
<MessageScrollerViewport>
|
||||||
|
<MessageScrollerContent className="mx-auto w-full max-w-2xl gap-5 p-4">
|
||||||
{conversation.messages.map((message) => (
|
{conversation.messages.map((message) => (
|
||||||
<div
|
<MessageScrollerItem key={message.id} messageId={message.id}>
|
||||||
key={message.id}
|
<Message
|
||||||
id={`${instanceId}-source-${message.id}`}
|
id={`${instanceId}-source-${message.id}`}
|
||||||
className="conversation-message"
|
align={message.direction === "out" ? "end" : "start"}
|
||||||
data-direction={message.direction}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<p className="conversation-message-meta">
|
<MessageContent>
|
||||||
|
<MessageHeader>
|
||||||
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
|
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
|
||||||
<span>
|
<span className="ml-2 font-normal">
|
||||||
{message.channel} · {message.time}
|
{message.channel} · {message.time}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</MessageHeader>
|
||||||
<div className="conversation-bubble">{message.text}</div>
|
<Bubble
|
||||||
</div>
|
align={message.direction === "out" ? "end" : "start"}
|
||||||
|
variant={message.direction === "out" ? "muted" : "outline"}
|
||||||
|
>
|
||||||
|
<BubbleContent>
|
||||||
|
<p className="whitespace-pre-wrap">{message.text}</p>
|
||||||
|
</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
</MessageScrollerItem>
|
||||||
))}
|
))}
|
||||||
|
</MessageScrollerContent>
|
||||||
|
</MessageScrollerViewport>
|
||||||
|
<MessageScrollerButton />
|
||||||
|
</MessageScroller>
|
||||||
|
</MessageScrollerProvider>
|
||||||
</section>
|
</section>
|
||||||
<section aria-label="Compose a reply" className="conversation-compose">
|
<section
|
||||||
<div className="conversation-compose-heading">
|
aria-label="Compose a reply"
|
||||||
<label htmlFor={`${instanceId}-reply`}>Reply</label>
|
className="mx-auto grid w-full max-w-2xl auto-rows-max content-start gap-3 p-4 @min-[780px]:max-h-[65%] @min-[780px]:shrink-0 @min-[780px]:overflow-y-auto"
|
||||||
<span>To {conversation.name.split(" ")[0]}</span>
|
>
|
||||||
|
<div className="flex items-center justify-between text-ui-12">
|
||||||
|
<label className="font-book" htmlFor={`${instanceId}-reply`}>
|
||||||
|
Reply
|
||||||
|
</label>
|
||||||
|
<span className="text-ui-11 text-muted-foreground">
|
||||||
|
To {conversation.name.split(" ")[0]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<details className="conversation-suggestions">
|
<details data-testid="conversation-suggestions">
|
||||||
<summary>
|
<summary className="flex cursor-pointer list-none items-center gap-px-6 text-ui-11 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-4">
|
||||||
Reply suggestions <ChevronDown aria-hidden="true" />
|
Reply suggestions <ChevronDown className="size-[12px]" aria-hidden="true" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="conversation-draft-options" aria-label="Reply suggestions">
|
<div className="grid gap-px-6 pt-px-10" aria-label="Reply suggestions">
|
||||||
{conversation.drafts.map((option) => (
|
{conversation.drafts.map((option) => (
|
||||||
<button
|
<Button
|
||||||
|
variant={tone === option.label ? "secondary" : "outline"}
|
||||||
|
className="h-auto w-full justify-start"
|
||||||
key={option.label}
|
key={option.label}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={tone === option.label}
|
aria-pressed={tone === option.label}
|
||||||
@@ -82,14 +129,17 @@ export function ConversationDetail({
|
|||||||
setTone(option.label);
|
setTone(option.label);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{option.label}</span>
|
<span className="block text-left whitespace-normal">
|
||||||
<p>{option.text}</p>
|
<span className="block text-xs text-muted-foreground">{option.label}</span>
|
||||||
</button>
|
<span className="mt-1 block font-normal leading-relaxed">{option.text}</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div className="conversation-editor">
|
<InputGroup>
|
||||||
<Textarea
|
<InputGroupTextarea
|
||||||
|
className="min-h-[100px] max-h-[240px]"
|
||||||
id={`${instanceId}-reply`}
|
id={`${instanceId}-reply`}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -97,22 +147,28 @@ export function ConversationDetail({
|
|||||||
setTone("");
|
setTone("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="conversation-compose-footer">
|
<InputGroupAddon align="block-end">
|
||||||
<span>Draft · {conversation.replyChannel}</span>
|
<InputGroupText>Draft · {conversation.replyChannel}</InputGroupText>
|
||||||
<Button size="sm" disabled={!draft.trim()} onClick={() => setReview("send")}>
|
<InputGroupButton
|
||||||
|
className="ml-auto"
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
onClick={() => setReview("send")}
|
||||||
|
>
|
||||||
<Send />
|
<Send />
|
||||||
Review reply
|
Review reply
|
||||||
</Button>
|
</InputGroupButton>
|
||||||
</div>
|
</InputGroupAddon>
|
||||||
</div>
|
</InputGroup>
|
||||||
{conversation.scheduleOptions.length > 0 && (
|
{conversation.scheduleOptions.length > 0 && (
|
||||||
<details className="conversation-scheduling">
|
<details data-testid="conversation-scheduling">
|
||||||
<summary>
|
<summary className="flex cursor-pointer list-none items-center gap-px-7 text-ui-11 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2">
|
||||||
<CalendarPlus aria-hidden="true" />
|
<CalendarPlus className="size-[13px]" aria-hidden="true" />
|
||||||
Suggest a time
|
Suggest a time
|
||||||
<ChevronDown aria-hidden="true" />
|
<ChevronDown className="size-[13px]" aria-hidden="true" />
|
||||||
</summary>
|
</summary>
|
||||||
<div>
|
<div className="mt-px-10 flex flex-wrap gap-px-6">
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
aria-label="Suggested times"
|
aria-label="Suggested times"
|
||||||
value={slot}
|
value={slot}
|
||||||
@@ -143,7 +199,7 @@ export function ConversationDetail({
|
|||||||
aria-label="Preview calendar hold"
|
aria-label="Preview calendar hold"
|
||||||
onClick={() => setReview("schedule")}
|
onClick={() => setReview("schedule")}
|
||||||
>
|
>
|
||||||
<CalendarPlus />
|
<CalendarPlus className="size-[13px]" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -154,7 +210,7 @@ export function ConversationDetail({
|
|||||||
{scheduled} held in this preview
|
{scheduled} held in this preview
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="conversation-triage">
|
<div className="mt-px-2 flex flex-wrap gap-px-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -172,47 +228,59 @@ export function ConversationDetail({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<aside className="conversation-context" aria-label="Person and conversation context">
|
<aside
|
||||||
<details open className="conversation-context-details">
|
className="min-w-0 border-t border-border bg-sidebar p-px-22 @min-[780px]:overflow-y-auto @min-[780px]:border-t-0 @min-[780px]:border-l"
|
||||||
<summary>
|
aria-label="Person and conversation context"
|
||||||
|
>
|
||||||
|
<details open>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between text-ui-11 font-medium focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2">
|
||||||
Context
|
Context
|
||||||
<ChevronDown aria-hidden="true" />
|
<ChevronDown className="size-3.5" aria-hidden="true" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="conversation-person">
|
<div className="grid justify-items-start py-px-20">
|
||||||
<span className="support-avatar" aria-hidden="true">
|
<Avatar size="lg" aria-hidden="true">
|
||||||
|
<AvatarFallback>
|
||||||
{conversation.name
|
{conversation.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((part) => part[0])
|
.map((part) => part[0])
|
||||||
.join("")}
|
.join("")}
|
||||||
</span>
|
</AvatarFallback>
|
||||||
<h3>{conversation.name}</h3>
|
</Avatar>
|
||||||
<p>{conversation.role}</p>
|
<h3 className="mt-px-12 text-ui-13 font-book">{conversation.name}</h3>
|
||||||
|
<p className="mt-px-4 text-ui-11 text-muted-foreground">{conversation.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<header className="conversation-summary">
|
<header className="border-t border-border py-px-20">
|
||||||
<span className="conversation-eyebrow">Summary</span>
|
<span className="text-ui-10 text-muted-foreground">Summary</span>
|
||||||
<h2>{conversation.subject}</h2>
|
<h2 className="mt-px-7 text-ui-12 font-book leading-normal tracking-subtitle">
|
||||||
<p>{conversation.summary}</p>
|
{conversation.subject}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-px-8 max-w-[64ch] text-ui-12 leading-relaxed-copy text-muted-foreground">
|
||||||
|
{conversation.summary}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<section className="conversation-next">
|
<section className="border-y border-border py-px-20">
|
||||||
<h3>
|
<h3 className="flex items-center gap-px-6 text-ui-11 font-book">
|
||||||
<CornerDownRight aria-hidden="true" />
|
<CornerDownRight className="size-[13px]" aria-hidden="true" />
|
||||||
Next step
|
Next step
|
||||||
</h3>
|
</h3>
|
||||||
<p>{conversation.nextStep}</p>
|
<p className="mt-px-12 text-ui-12 leading-relaxed-copy">{conversation.nextStep}</p>
|
||||||
{conversation.deadline && (
|
{conversation.deadline && (
|
||||||
<div className="conversation-deadline">
|
<div className="mt-px-18 grid gap-px-5 text-ui-11">
|
||||||
<span>Requested by</span>
|
<span className="text-muted-foreground">Requested by</span>
|
||||||
<strong>{conversation.deadline}</strong>
|
<strong className="font-medium">{conversation.deadline}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="conversation-facts">
|
<section className="pt-px-22">
|
||||||
<h3>From the conversation</h3>
|
<h3 className="flex items-center gap-px-6 text-ui-11 font-book">
|
||||||
<ul>
|
From the conversation
|
||||||
|
</h3>
|
||||||
|
<ul className="grid gap-px-18 pt-px-16">
|
||||||
{conversation.facts.map((fact) => (
|
{conversation.facts.map((fact) => (
|
||||||
<li key={fact.sourceId}>
|
<li key={fact.sourceId}>
|
||||||
<p>{fact.text}</p>
|
<p className="text-ui-11 leading-relaxed-copy">{fact.text}</p>
|
||||||
<a
|
<a
|
||||||
|
className="mt-px-5 inline-block text-ui-10 text-muted-foreground underline underline-offset-3"
|
||||||
href={`#${instanceId}-source-${fact.sourceId}`}
|
href={`#${instanceId}-source-${fact.sourceId}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -262,27 +330,35 @@ export function ConversationDetail({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{review === "send" && (
|
{review === "send" && (
|
||||||
<section className="conversation-evaluation" aria-label="Jev review" aria-live="polite">
|
<section
|
||||||
<h3>Jev review</h3>
|
className="border-t border-border pt-px-16 text-ui-12"
|
||||||
|
aria-label="Jev review"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<h3 className="mb-px-12 font-semibold">Jev review</h3>
|
||||||
{evaluation.isPending ? (
|
{evaluation.isPending ? (
|
||||||
<p role="status">Checking this reply against the conversation…</p>
|
<p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground" role="status">
|
||||||
|
Checking this reply against the conversation…
|
||||||
|
</p>
|
||||||
) : evaluation.data?.status === "reviewed" ? (
|
) : evaluation.data?.status === "reviewed" ? (
|
||||||
<>
|
<>
|
||||||
<ul>
|
<ul className="grid gap-px-10">
|
||||||
{evaluation.data.checks.map((check) => (
|
{evaluation.data.checks.map((check) => (
|
||||||
<li key={check.id}>
|
<li className="flex justify-between gap-px-16" key={check.id}>
|
||||||
<span>{check.label}</span>
|
<span>{check.label}</span>
|
||||||
<strong>{Math.round(check.probability * 100)}%</strong>
|
<strong className="font-medium tabular-nums">
|
||||||
|
{Math.round(check.probability * 100)}%
|
||||||
|
</strong>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground">
|
||||||
Estimated likelihood that each criterion is met. Review the wording yourself.
|
Estimated likelihood that each criterion is met. Review the wording yourself.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p className="mt-px-12 text-ui-11 leading-cozy text-muted-foreground">
|
||||||
{evaluation.data?.status === "unavailable"
|
{evaluation.data?.status === "unavailable"
|
||||||
? evaluation.data.message
|
? evaluation.data.message
|
||||||
: "Review unavailable. Please try again."}
|
: "Review unavailable. Please try again."}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ArrowLeft, MessageSquare, Search } from "lucide-react";
|
import { ArrowLeft, MessageSquare, Search } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from "#/components/ui/input";
|
import { Input } from "#/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -61,14 +62,21 @@ export function SupportPage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<SidebarProvider className="support-workspace" data-reading={!!selectedId}>
|
<SidebarProvider
|
||||||
<Sidebar collapsible="none" className="support-sidebar">
|
data-testid="support-workspace"
|
||||||
|
className="group/support h-dvh min-h-0 overflow-hidden"
|
||||||
|
data-reading={!!selectedId}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
className="w-[370px] shrink-0 flex-row border-r border-border max-md:w-full max-md:group-data-[reading=true]/support:hidden"
|
||||||
|
>
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<Sidebar collapsible="none" className="support-list">
|
<Sidebar collapsible="none" className="w-auto min-w-0 flex-1">
|
||||||
<SidebarHeader className="p-5 gap-4">
|
<SidebarHeader className="p-5 gap-4">
|
||||||
<div className="support-list-title">
|
<div className="flex items-center gap-px-10">
|
||||||
<h1>Messages</h1>
|
<h1 className="text-ui-19 font-semibold tracking-title">Messages</h1>
|
||||||
<span>{visible.length}</span>
|
<span className="text-ui-12 text-muted-foreground">{visible.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
||||||
@@ -82,9 +90,10 @@ export function SupportPage() {
|
|||||||
onCompositionEnd={searchState.onCompositionEnd}
|
onCompositionEnd={searchState.onCompositionEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav className="support-filters" aria-label="Conversation filters">
|
<nav className="flex flex-wrap gap-px-4" aria-label="Conversation filters">
|
||||||
{views.map((tab) => (
|
{views.map((tab) => (
|
||||||
<button
|
<button
|
||||||
|
className="cursor-pointer rounded-ui-6 px-px-9 py-px-6 text-ui-11 whitespace-nowrap text-muted-foreground aria-pressed:bg-sidebar-accent aria-pressed:text-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2"
|
||||||
type="button"
|
type="button"
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
aria-pressed={view === tab.id}
|
aria-pressed={view === tab.id}
|
||||||
@@ -105,24 +114,34 @@ export function SupportPage() {
|
|||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="support-row"
|
data-testid="support-row"
|
||||||
|
aria-label={`${item.name}: ${item.subject}`}
|
||||||
|
className="mx-px-8 my-px-2 flex cursor-pointer gap-px-12 rounded-ui-10 px-px-14 py-px-12 text-left leading-normal hover:bg-sidebar-accent aria-current:bg-sidebar-accent active:bg-muted focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2"
|
||||||
aria-current={selected?.id === item.id ? "true" : undefined}
|
aria-current={selected?.id === item.id ? "true" : undefined}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => updateSearch({ conversation: item.id })}
|
onClick={() => updateSearch({ conversation: item.id })}
|
||||||
>
|
>
|
||||||
<span className="support-avatar" aria-hidden="true">
|
<Avatar aria-hidden="true">
|
||||||
|
<AvatarFallback>
|
||||||
{item.name
|
{item.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((part) => part[0])
|
.map((part) => part[0])
|
||||||
.join("")}
|
.join("")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-px-3">
|
||||||
|
<span className="flex items-center justify-between gap-px-8">
|
||||||
|
<strong className="text-ui-12 font-book">{item.name}</strong>
|
||||||
|
<span className="text-ui-10 whitespace-nowrap text-muted-foreground">
|
||||||
|
{item.updatedAt}
|
||||||
</span>
|
</span>
|
||||||
<span className="support-row-content">
|
|
||||||
<span className="support-row-heading">
|
|
||||||
<strong>{item.name}</strong>
|
|
||||||
<span>{item.updatedAt}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="support-row-preview">{item.subject}</span>
|
<span className="truncate text-ui-12 text-muted-foreground">
|
||||||
<span className="support-row-channel">{item.channels.join(" · ")}</span>
|
{item.subject}
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-10 text-muted-foreground">
|
||||||
|
{item.channels.join(" · ")}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -132,17 +151,20 @@ export function SupportPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<div className="support-list-footer" role="status">
|
<div className="px-px-20 py-px-16 text-ui-11 text-muted-foreground" role="status">
|
||||||
{notice || "Sample conversations"}
|
{notice || "Sample conversations"}
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset className="support-main">
|
<SidebarInset className="h-full min-w-0 overflow-hidden max-md:hidden max-md:group-data-[reading=true]/support:flex">
|
||||||
<header className="support-toolbar">
|
<header
|
||||||
|
data-testid="support-toolbar"
|
||||||
|
className="flex min-h-[64px] items-center gap-px-10 border-b border-border px-px-20 py-px-12 max-md:px-px-12"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="support-back"
|
className="hidden max-md:inline-flex"
|
||||||
aria-label="Back to messages"
|
aria-label="Back to messages"
|
||||||
onClick={() => updateSearch({ conversation: undefined })}
|
onClick={() => updateSearch({ conversation: undefined })}
|
||||||
>
|
>
|
||||||
@@ -150,15 +172,19 @@ export function SupportPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
<span className="support-avatar support-avatar-small" aria-hidden="true">
|
<Avatar aria-hidden="true">
|
||||||
|
<AvatarFallback>
|
||||||
{selected.name
|
{selected.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((part) => part[0])
|
.map((part) => part[0])
|
||||||
.join("")}
|
.join("")}
|
||||||
</span>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selected.name}</strong>
|
<strong className="text-ui-13 font-book">{selected.name}</strong>
|
||||||
<p>{selected.channels.join(" · ")}</p>
|
<p className="mt-px-2 text-ui-10 text-muted-foreground">
|
||||||
|
{selected.channels.join(" · ")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -177,7 +203,10 @@ export function SupportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<section className="support-detail" aria-label="Message details">
|
<section
|
||||||
|
className="@container min-h-0 flex-1 overflow-y-auto wrap-anywhere"
|
||||||
|
aria-label="Message details"
|
||||||
|
>
|
||||||
{conversations.map((conversation) => (
|
{conversations.map((conversation) => (
|
||||||
<div key={conversation.id} hidden={selected?.id !== conversation.id}>
|
<div key={conversation.id} hidden={selected?.id !== conversation.id}>
|
||||||
<ConversationDetail
|
<ConversationDetail
|
||||||
@@ -185,15 +214,20 @@ export function SupportPage() {
|
|||||||
onResolve={(status, note) => resolve(conversation.id, status, note)}
|
onResolve={(status, note) => resolve(conversation.id, status, note)}
|
||||||
/>
|
/>
|
||||||
{outcomes[conversation.id] && (
|
{outcomes[conversation.id] && (
|
||||||
<div className="support-outcome">
|
<div
|
||||||
|
data-testid="support-outcome"
|
||||||
|
className="border-t border-border p-px-24 text-ui-12"
|
||||||
|
>
|
||||||
<h2>Reply preview</h2>
|
<h2>Reply preview</h2>
|
||||||
<p>{outcomes[conversation.id]}</p>
|
<p className="mt-px-8 whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{outcomes[conversation.id]}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!selected && (
|
{!selected && (
|
||||||
<div className="support-empty">
|
<div className="grid justify-items-center gap-px-12 px-px-24 py-px-64 text-center text-ui-13 text-muted-foreground">
|
||||||
<MessageSquare aria-hidden="true" />
|
<MessageSquare aria-hidden="true" />
|
||||||
<p>Select a conversation</p>
|
<p>Select a conversation</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,573 +0,0 @@
|
|||||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 · existing neutral tokens */
|
|
||||||
.support-workspace {
|
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.support-sidebar {
|
|
||||||
width: 370px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: row;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.support-list {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.support-list-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.support-list-title h1 {
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
.support-list-title > span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.support-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.support-filters button {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 9px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.support-filters button[aria-pressed="true"] {
|
|
||||||
background: var(--sidebar-accent);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.support-filters button:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.support-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.support-row:hover {
|
|
||||||
background: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
.support-row[aria-current="true"] {
|
|
||||||
background: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
.support-row:focus-visible,
|
|
||||||
.support-filters button:focus-visible,
|
|
||||||
.conversation-draft-options button:focus-visible,
|
|
||||||
.conversation-scheduling summary:focus-visible,
|
|
||||||
.conversation-context summary:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
.support-row:active {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
.support-avatar {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.support-avatar-small {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.support-row-content {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
.support-row-heading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.support-row-heading strong {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
.support-row-heading > span {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.support-row-preview {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.support-row-channel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.support-list-footer {
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.support-main {
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.support-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 64px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.support-toolbar strong {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
.support-toolbar p {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.support-detail {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
.support-outcome {
|
|
||||||
padding: 24px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.support-outcome p {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
margin-top: 8px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.support-empty {
|
|
||||||
padding: 64px 24px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.support-back {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.conversation-workbench {
|
|
||||||
min-height: calc(100dvh - 64px);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.conversation-thread {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.conversation-summary {
|
|
||||||
padding: 20px 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.conversation-eyebrow {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.conversation-summary h2 {
|
|
||||||
margin-top: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 550;
|
|
||||||
letter-spacing: -0.015em;
|
|
||||||
}
|
|
||||||
.conversation-summary > p {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
max-width: 64ch;
|
|
||||||
}
|
|
||||||
.conversation-messages {
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.conversation-message {
|
|
||||||
max-width: 90%;
|
|
||||||
border-radius: 12px;
|
|
||||||
scroll-margin: 24px;
|
|
||||||
}
|
|
||||||
.conversation-message:focus {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 5px;
|
|
||||||
}
|
|
||||||
.conversation-message[data-direction="out"] {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
.conversation-message-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 7px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-message-meta span {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.conversation-bubble {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 3px 14px 14px;
|
|
||||||
background: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.conversation-message[data-direction="out"] .conversation-bubble {
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 14px 3px 14px 14px;
|
|
||||||
}
|
|
||||||
.conversation-compose {
|
|
||||||
padding: 12px 24px 16px;
|
|
||||||
background: var(--background);
|
|
||||||
display: grid;
|
|
||||||
grid-auto-rows: max-content;
|
|
||||||
align-content: start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.conversation-compose-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.conversation-compose-heading label {
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
.conversation-compose-heading span {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-suggestions > summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.conversation-suggestions > summary svg {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
.conversation-suggestions > summary:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
.conversation-draft-options {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.conversation-draft-options button {
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
.conversation-draft-options button > span {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-draft-options button p {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.conversation-draft-options button:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
.conversation-draft-options button[aria-pressed="true"] {
|
|
||||||
border-color: var(--ring);
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
.conversation-editor {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--sidebar);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.conversation-editor:focus-within {
|
|
||||||
border-color: var(--ring);
|
|
||||||
}
|
|
||||||
.conversation-editor textarea {
|
|
||||||
min-height: 100px;
|
|
||||||
max-height: 240px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.conversation-editor textarea:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: -3px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.conversation-editor .conversation-compose-footer {
|
|
||||||
padding: 4px 10px 10px 14px;
|
|
||||||
}
|
|
||||||
.conversation-compose-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.conversation-compose-footer > span {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.conversation-triage {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.conversation-scheduling summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-scheduling summary svg {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
}
|
|
||||||
.conversation-scheduling > div {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.conversation-context {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
min-width: 0;
|
|
||||||
padding: 22px;
|
|
||||||
background: var(--sidebar);
|
|
||||||
}
|
|
||||||
.conversation-context-details > summary {
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.conversation-context-details > summary svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
.conversation-person {
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.conversation-person h3 {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
.conversation-person p {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-next {
|
|
||||||
padding: 20px 0;
|
|
||||||
border-block: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.conversation-next h3,
|
|
||||||
.conversation-facts h3 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
.conversation-next h3 svg {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
}
|
|
||||||
.conversation-next > p {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.conversation-deadline {
|
|
||||||
display: grid;
|
|
||||||
gap: 5px;
|
|
||||||
margin-top: 18px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.conversation-deadline span {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.conversation-deadline strong {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.conversation-next > .conversation-next-note {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.conversation-facts {
|
|
||||||
padding-top: 22px;
|
|
||||||
}
|
|
||||||
.conversation-facts ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
padding-top: 16px;
|
|
||||||
}
|
|
||||||
.conversation-facts li p {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.conversation-facts a {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 5px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 10px;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
}
|
|
||||||
@container (min-width: 780px) {
|
|
||||||
.conversation-workbench {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 250px;
|
|
||||||
height: calc(100dvh - 64px);
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.conversation-thread {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.conversation-messages {
|
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
.conversation-compose {
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-height: 65%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.conversation-context {
|
|
||||||
overflow-y: auto;
|
|
||||||
border-top: 0;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.support-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.support-main {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.support-back {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
.support-workspace[data-reading="true"] .support-sidebar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.support-workspace[data-reading="true"] .support-main {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.support-toolbar {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.conversation-compose {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.conversation-messages {
|
|
||||||
padding: 20px 12px;
|
|
||||||
}
|
|
||||||
.conversation-message {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.conversation-compose-footer > span {
|
|
||||||
max-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.conversation-message {
|
|
||||||
scroll-behavior: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.conversation-evaluation {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.conversation-evaluation h3 {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.conversation-evaluation ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.conversation-evaluation li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.conversation-evaluation strong {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.conversation-evaluation p {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
@@ -190,14 +190,23 @@ function points(values: readonly number[], width = 640, height = 190) {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
|
function Sparkline({
|
||||||
|
index,
|
||||||
|
tone = "green",
|
||||||
|
variant = "metric",
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
tone?: Tone;
|
||||||
|
variant?: "metric" | "score";
|
||||||
|
}) {
|
||||||
const values =
|
const values =
|
||||||
index === 7
|
index === 7
|
||||||
? Array<number>(30).fill(20)
|
? Array<number>(30).fill(20)
|
||||||
: (series[index % series.length]?.values ?? series[0].values);
|
: (series[index % series.length]?.values ?? series[0].values);
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className="vital-sparkline"
|
className="block w-full min-w-0 h-[24px] data-[variant=score]:mt-px-28 data-[variant=score]:h-[36px] min-[1700px]:data-[variant=score]:h-[40px] min-[1700px]:data-[variant=score]:mt-px-34 max-[1401px]:data-[variant=score]:mt-px-18"
|
||||||
|
data-variant={variant}
|
||||||
viewBox="0 0 300 40"
|
viewBox="0 0 300 40"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -205,7 +214,8 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
|
|||||||
<polyline
|
<polyline
|
||||||
points={points(values, 300, 36)}
|
points={points(values, 300, 36)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={`var(--vital-${tone})`}
|
data-tone={tone}
|
||||||
|
className="data-[tone=green]:stroke-vital-green data-[tone=blue]:stroke-vital-blue data-[tone=purple]:stroke-vital-purple data-[tone=yellow]:stroke-vital-yellow"
|
||||||
strokeWidth="1.8"
|
strokeWidth="1.8"
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
@@ -215,18 +225,18 @@ function Sparkline({ index, tone = "green" }: { index: number; tone?: Tone }) {
|
|||||||
|
|
||||||
function Gauge({ value }: { value: number }) {
|
function Gauge({ value }: { value: number }) {
|
||||||
return (
|
return (
|
||||||
<svg className="vital-gauge" viewBox="0 0 110 65" aria-hidden="true">
|
<svg className="w-[43%] shrink-0" viewBox="0 0 110 65" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--vital-border)"
|
className="stroke-vital-border"
|
||||||
strokeWidth="5"
|
strokeWidth="5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--vital-green)"
|
className="stroke-vital-green"
|
||||||
strokeWidth="5"
|
strokeWidth="5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
pathLength="100"
|
pathLength="100"
|
||||||
@@ -254,12 +264,19 @@ function Trends() {
|
|||||||
? ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"]
|
? ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"]
|
||||||
: ["Mon 7 PM", "Tue 3 AM", "Tue 11 AM", "Tue 7 PM", "Wed 3 AM", "Wed 11 AM"];
|
: ["Mon 7 PM", "Tue 3 AM", "Tue 11 AM", "Tue 7 PM", "Wed 3 AM", "Wed 11 AM"];
|
||||||
return (
|
return (
|
||||||
<section className="vital-trends vital-card" aria-label="Environmental trends">
|
<section
|
||||||
<div className="vital-card-header">
|
className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-20 max-[768px]:p-px-12"
|
||||||
<h2>Trends</h2>
|
aria-label="Environmental trends"
|
||||||
<fieldset className="vital-segmented" aria-label="Trend period">
|
>
|
||||||
|
<div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
|
||||||
|
<h2 className="text-ui-15 font-medium">Trends</h2>
|
||||||
|
<fieldset
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
aria-label="Trend period"
|
||||||
|
>
|
||||||
{(["24H", "48H", "7D"] as const).map((item) => (
|
{(["24H", "48H", "7D"] as const).map((item) => (
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
|
||||||
type="button"
|
type="button"
|
||||||
key={item}
|
key={item}
|
||||||
aria-pressed={range === item}
|
aria-pressed={range === item}
|
||||||
@@ -270,9 +287,13 @@ function Trends() {
|
|||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<fieldset className="vital-chart-tabs vital-segmented" aria-label="Trend metric">
|
<fieldset
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
aria-label="Trend metric"
|
||||||
|
>
|
||||||
{["Overview", ...series.map((item) => item.name)].map((item) => (
|
{["Overview", ...series.map((item) => item.name)].map((item) => (
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border text-ui-12 max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
|
||||||
type="button"
|
type="button"
|
||||||
key={item}
|
key={item}
|
||||||
aria-pressed={metric === item}
|
aria-pressed={metric === item}
|
||||||
@@ -282,16 +303,20 @@ function Trends() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="vital-chart-legend">
|
<div className="flex flex-wrap gap-px-15 mt-px-20 mb-px-20 mx-0 text-ui-10">
|
||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
<span key={item.name} data-tone={item.tone}>
|
<span
|
||||||
<i />
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow flex items-center gap-px-7"
|
||||||
|
key={item.name}
|
||||||
|
data-tone={item.tone}
|
||||||
|
>
|
||||||
|
<i className="w-[8px] h-[8px] rounded-ui-2 bg-current" />
|
||||||
{item.name} ({item.unit})
|
{item.name} ({item.unit})
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
className="vital-trend-chart"
|
className="w-full h-auto min-h-[220px] max-[768px]:min-h-[140px]"
|
||||||
viewBox="0 0 740 260"
|
viewBox="0 0 740 260"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${range} ${metric} simulated environmental readings`}
|
aria-label={`${range} ${metric} simulated environmental readings`}
|
||||||
@@ -303,23 +328,23 @@ function Trends() {
|
|||||||
x2="696"
|
x2="696"
|
||||||
y1={30 + index * 62}
|
y1={30 + index * 62}
|
||||||
y2={30 + index * 62}
|
y2={30 + index * 62}
|
||||||
stroke="var(--vital-border)"
|
className="stroke-vital-border"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x="46"
|
x="46"
|
||||||
y={35 + index * 62}
|
y={35 + index * 62}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
fill="var(--vital-muted)"
|
className="fill-vital-muted"
|
||||||
fontSize="12"
|
fontSize="12"
|
||||||
>
|
>
|
||||||
{["1,500", "1,000", "500", "0"][index]}
|
{["1,500", "1,000", "500", "0"][index]}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
<text x="6" y="14" fill="var(--vital-muted)" fontSize="12">
|
<text x="6" y="14" className="fill-vital-muted" fontSize="12">
|
||||||
ppm / %
|
ppm / %
|
||||||
</text>
|
</text>
|
||||||
<text x="712" y="14" fill="var(--vital-purple)" fontSize="12">
|
<text x="712" y="14" className="fill-vital-purple" fontSize="12">
|
||||||
°F
|
°F
|
||||||
</text>
|
</text>
|
||||||
<g transform="translate(56,26)">
|
<g transform="translate(56,26)">
|
||||||
@@ -335,7 +360,8 @@ function Trends() {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
points={points(values)}
|
points={points(values)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={`var(--vital-${item.tone})`}
|
data-tone={item.tone}
|
||||||
|
className="data-[tone=green]:stroke-vital-green data-[tone=blue]:stroke-vital-blue data-[tone=purple]:stroke-vital-purple data-[tone=yellow]:stroke-vital-yellow"
|
||||||
strokeWidth="2.3"
|
strokeWidth="2.3"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +373,7 @@ function Trends() {
|
|||||||
key={label}
|
key={label}
|
||||||
x={75 + (index * 610) / (labels.length - 1)}
|
x={75 + (index * 610) / (labels.length - 1)}
|
||||||
y="242"
|
y="242"
|
||||||
fill="var(--vital-muted)"
|
className="fill-vital-muted"
|
||||||
fontSize="12"
|
fontSize="12"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
@@ -362,31 +388,51 @@ function Trends() {
|
|||||||
function AirMonitor() {
|
function AirMonitor() {
|
||||||
const [synced, setSynced] = useState(false);
|
const [synced, setSynced] = useState(false);
|
||||||
return (
|
return (
|
||||||
<section className="vital-monitor vital-card">
|
<section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-20 max-[768px]:p-px-12">
|
||||||
<div className="vital-card-header">
|
<div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
|
||||||
<h2>Air monitor</h2>
|
<h2 className="text-ui-15 font-medium">Air monitor</h2>
|
||||||
<button className="vital-outline-button" type="button" onClick={() => setSynced(true)}>
|
<button
|
||||||
|
className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSynced(true)}
|
||||||
|
>
|
||||||
<RefreshCw size={15} />
|
<RefreshCw size={15} />
|
||||||
{synced ? "Synced" : "Sync now"}
|
{synced ? "Synced" : "Sync now"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-device">
|
<div className="flex items-center gap-px-12 pt-px-16 pb-px-16 px-px-12 border border-vital-border rounded-ui-12">
|
||||||
<Radio className="vital-device-icon" size={66} strokeWidth={1} />
|
<Radio
|
||||||
<div>
|
className="text-vital-muted shrink-0 max-[1101px]:w-[50px]"
|
||||||
<div className="vital-device-heading">
|
size={66}
|
||||||
<strong>Room</strong>
|
strokeWidth={1}
|
||||||
<span data-tone="green">● Connected</span>
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex gap-px-8 flex-wrap justify-between">
|
||||||
|
<strong className="text-ui-14">Room</strong>
|
||||||
|
<span
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow text-ui-10"
|
||||||
|
data-tone="green"
|
||||||
|
>
|
||||||
|
● Connected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>Air monitor</p>
|
<p className="text-ui-12 text-vital-muted mt-px-8">Air monitor</p>
|
||||||
<p role="status">{synced ? "Sample readings refreshed" : "Updated just now"}</p>
|
<p className="text-ui-12 text-vital-muted mt-px-8" role="status">
|
||||||
<div className="vital-device-badges">
|
{synced ? "Sample readings refreshed" : "Updated just now"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-px-5 mt-px-10">
|
||||||
{["CO₂", "PM2.5", "Temp", "Humidity", "VOC", "NOx"].map((item) => (
|
{["CO₂", "PM2.5", "Temp", "Humidity", "VOC", "NOx"].map((item) => (
|
||||||
<span key={item}>{item}</span>
|
<span
|
||||||
|
className="text-ui-10 text-vital-muted bg-muted pt-px-3 pb-px-3 px-px-6 rounded-ui-12"
|
||||||
|
key={item}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="vital-monitor-footnote">
|
<p className="mt-px-48 text-ui-11 text-vital-muted">
|
||||||
{synced
|
{synced
|
||||||
? "71 sample readings synced. No device connected."
|
? "71 sample readings synced. No device connected."
|
||||||
: "71 sample readings · simulated device"}
|
: "71 sample readings · simulated device"}
|
||||||
@@ -397,60 +443,98 @@ function AirMonitor() {
|
|||||||
|
|
||||||
function Insights() {
|
function Insights() {
|
||||||
return (
|
return (
|
||||||
<aside className="vital-env-insights">
|
<aside className="grid gap-px-16 max-[1101px]:grid-cols-[1fr_1fr] max-[768px]:grid-cols-[1fr]">
|
||||||
<section className="vital-insights vital-card">
|
<section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
|
||||||
<div className="vital-card-header">
|
<div className="flex items-center justify-between gap-px-10 mb-px-18 max-[768px]:flex-wrap">
|
||||||
<h2>Insights</h2>
|
<h2 className="text-ui-15 font-medium m-0">Insights</h2>
|
||||||
<span>today</span>
|
<span className="text-ui-12 text-vital-muted">today</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<TriangleAlert data-tone="yellow" size={20} />
|
<TriangleAlert
|
||||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
|
||||||
<i />
|
data-tone="yellow"
|
||||||
<i />
|
size={20}
|
||||||
<i />
|
/>
|
||||||
<i />
|
<div
|
||||||
|
className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[105px]"
|
||||||
|
role="img"
|
||||||
|
aria-label="Text obscured in reference image"
|
||||||
|
>
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<Check data-tone="green" size={20} />
|
<Check
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
|
||||||
|
data-tone="green"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3>Particulates stayed low</h3>
|
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">
|
||||||
<p>The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in Trends.</p>
|
Particulates stayed low
|
||||||
|
</h3>
|
||||||
|
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
|
||||||
|
The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in Trends.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<Check data-tone="green" size={20} />
|
<Check
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
|
||||||
|
data-tone="green"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3>Humidity through the day</h3>
|
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">
|
||||||
<p>The room’s current sample reading is 64%. Select Humidity to explore the chart.</p>
|
Humidity through the day
|
||||||
|
</h3>
|
||||||
|
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
|
||||||
|
The room’s current sample reading is 64%. Select Humidity to explore the chart.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<Check data-tone="green" size={20} />
|
<Check
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
|
||||||
|
data-tone="green"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3>Room temperature</h3>
|
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">Room temperature</h3>
|
||||||
<p>
|
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
|
||||||
The current sample temperature is 64.0°F. Recent readings are shown alongside the
|
The current sample temperature is 64.0°F. Recent readings are shown alongside the
|
||||||
other room metrics.
|
other room metrics.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="vital-insights vital-card">
|
<section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
|
||||||
<h2>Recommended actions</h2>
|
<h2 className="text-ui-15 font-medium mb-px-20">Recommended actions</h2>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<Grid2X2 size={20} />
|
<Grid2X2 className="shrink-0" size={20} />
|
||||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
<div
|
||||||
<i />
|
className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[105px]"
|
||||||
<i />
|
role="img"
|
||||||
|
aria-label="Text obscured in reference image"
|
||||||
|
>
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-insight">
|
<div className="flex items-start gap-px-14 pt-px-22 pb-px-22 px-0 border-b border-vital-border last:border-b-0 last:pb-0">
|
||||||
<Droplets data-tone="yellow" size={20} />
|
<Droplets
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow shrink-0"
|
||||||
|
data-tone="yellow"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3>Review room humidity</h3>
|
<h3 className="text-ui-14 m-0 text-vital-text leading-normal">Review room humidity</h3>
|
||||||
<p>Explore the climate controls with simulated settings.</p>
|
<p className="text-ui-13 text-vital-muted leading-chat mt-px-5">
|
||||||
|
Explore the climate controls with simulated settings.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -463,13 +547,16 @@ function ClimateControl() {
|
|||||||
const [mode, setMode] = useState("Auto");
|
const [mode, setMode] = useState("Auto");
|
||||||
const [purifier, setPurifier] = useState(true);
|
const [purifier, setPurifier] = useState(true);
|
||||||
return (
|
return (
|
||||||
<section className="vital-climate vital-card">
|
<section className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden max-w-[650px] mt-px-20 p-px-30 grid gap-px-24 max-[768px]:p-px-18">
|
||||||
<h2>Room climate</h2>
|
<h2 className="text-ui-15 font-medium">Room climate</h2>
|
||||||
<p>Simulated controls. Changes stay on this screen.</p>
|
<p className="text-vital-muted text-ui-13">
|
||||||
<label htmlFor="vital-temperature">
|
Simulated controls. Changes stay on this screen.
|
||||||
|
</p>
|
||||||
|
<label className="flex justify-between text-ui-14" htmlFor="vital-temperature">
|
||||||
Target temperature <strong>{temperature}°F</strong>
|
Target temperature <strong>{temperature}°F</strong>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
className="accent-vital-green w-full"
|
||||||
id="vital-temperature"
|
id="vital-temperature"
|
||||||
type="range"
|
type="range"
|
||||||
min="60"
|
min="60"
|
||||||
@@ -477,9 +564,13 @@ function ClimateControl() {
|
|||||||
value={temperature}
|
value={temperature}
|
||||||
onChange={(event) => setTemperature(Number(event.target.value))}
|
onChange={(event) => setTemperature(Number(event.target.value))}
|
||||||
/>
|
/>
|
||||||
<fieldset className="vital-segmented" aria-label="Climate mode">
|
<fieldset
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
aria-label="Climate mode"
|
||||||
|
>
|
||||||
{["Auto", "Cool", "Heat", "Off"].map((item) => (
|
{["Auto", "Cool", "Heat", "Off"].map((item) => (
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
|
||||||
type="button"
|
type="button"
|
||||||
key={item}
|
key={item}
|
||||||
aria-pressed={item === mode}
|
aria-pressed={item === mode}
|
||||||
@@ -490,7 +581,7 @@ function ClimateControl() {
|
|||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button
|
<button
|
||||||
className="vital-outline-button"
|
className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={purifier}
|
aria-pressed={purifier}
|
||||||
onClick={() => setPurifier(!purifier)}
|
onClick={() => setPurifier(!purifier)}
|
||||||
@@ -512,9 +603,18 @@ export function EnvironmentPanel() {
|
|||||||
setSearch({ environmentDetail: value?.name });
|
setSearch({ environmentDetail: value?.name });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<fieldset className="vital-segmented" aria-label="Environment view">
|
<fieldset
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
aria-label="Environment view"
|
||||||
|
>
|
||||||
{(["Overview", "Climate control"] as const).map((item) => (
|
{(["Overview", "Climate control"] as const).map((item) => (
|
||||||
<button type="button" key={item} aria-pressed={tab === item} onClick={() => setTab(item)}>
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer aria-pressed:text-vital-text aria-pressed:bg-muted aria-pressed:inset-ring-1 aria-pressed:inset-ring-vital-border max-[768px]:text-ui-12 max-[768px]:pt-px-7 max-[768px]:pb-px-7 max-[768px]:px-px-9"
|
||||||
|
type="button"
|
||||||
|
key={item}
|
||||||
|
aria-pressed={tab === item}
|
||||||
|
onClick={() => setTab(item)}
|
||||||
|
>
|
||||||
{item}
|
{item}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -522,37 +622,49 @@ export function EnvironmentPanel() {
|
|||||||
{tab === "Climate control" ? (
|
{tab === "Climate control" ? (
|
||||||
<ClimateControl />
|
<ClimateControl />
|
||||||
) : (
|
) : (
|
||||||
<div className="vital-env-layout">
|
<div className="grid grid-cols-[minmax(0,_1fr)_290px] gap-px-20 mt-px-20 items-start min-[1700px]:grid-cols-[minmax(0,_1fr)_350px] max-[1401px]:grid-cols-[minmax(0,_1fr)_230px] max-[1101px]:grid-cols-[1fr]">
|
||||||
<div className="vital-env-main">
|
<div className="min-w-0">
|
||||||
<div className="vital-env-scores">
|
<div className="grid grid-cols-[repeat(4,_minmax(0,_1fr))] gap-px-14 max-[1401px]:grid-cols-[repeat(2,_minmax(0,_1fr))] max-[768px]:gap-px-10">
|
||||||
{scores.map((score, index) => (
|
{scores.map((score, index) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vital-env-score vital-card"
|
className="bg-vital-card border border-vital-border min-w-0 overflow-hidden text-left pt-px-20 pb-0 px-px-18 cursor-pointer rounded-ui-15 min-[1700px]:pt-px-24 max-[768px]:pt-px-14 max-[768px]:pb-0 max-[768px]:px-px-10"
|
||||||
key={score.name}
|
key={score.name}
|
||||||
onClick={() => setDetail(score)}
|
onClick={() => setDetail(score)}
|
||||||
>
|
>
|
||||||
<span className="vital-score-heading">
|
<span className="flex items-center justify-between gap-px-8 text-ui-14 text-vital-muted max-[768px]:text-ui-11">
|
||||||
{score.name}
|
{score.name}
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</span>
|
</span>
|
||||||
<div className="vital-score-reading">
|
<div className="flex items-center gap-px-15 mt-px-24 max-[1401px]:mt-px-16 max-[768px]:gap-px-6 max-[768px]:flex-wrap">
|
||||||
<Gauge value={score.progress} />
|
<Gauge value={score.progress} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{score.value}</strong>
|
<strong className="text-ui-36 font-light tracking-score min-[1700px]:text-ui-42 max-[768px]:text-ui-30">
|
||||||
<small>{score.unit}</small>
|
{score.value}
|
||||||
<p data-tone="green">{score.status}</p>
|
</strong>
|
||||||
|
<small className="text-ui-10 text-vital-muted">{score.unit}</small>
|
||||||
|
<p
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow text-ui-13"
|
||||||
|
data-tone="green"
|
||||||
|
>
|
||||||
|
{score.status}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="vital-delta">
|
<p className="text-vital-muted text-ui-12 mt-px-16 max-[768px]:text-ui-10">
|
||||||
<span data-tone={index === 3 ? "green" : "yellow"}>{score.change}</span>{" "}
|
<span
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
|
||||||
|
data-tone={index === 3 ? "green" : "yellow"}
|
||||||
|
>
|
||||||
|
{score.change}
|
||||||
|
</span>{" "}
|
||||||
{score.suffix} vs yesterday
|
{score.suffix} vs yesterday
|
||||||
</p>
|
</p>
|
||||||
<Sparkline index={index} />
|
<Sparkline index={index} variant="score" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-env-metrics">
|
<div className="grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-px-14 mt-px-14 max-[1401px]:grid-cols-[repeat(3,_minmax(0,_1fr))] max-[1101px]:grid-cols-[repeat(3,_minmax(0,_1fr))] max-[768px]:grid-cols-[repeat(2,_minmax(0,_1fr))] max-[768px]:gap-px-10">
|
||||||
{metrics.map((metric, index) => {
|
{metrics.map((metric, index) => {
|
||||||
const MetricIcon =
|
const MetricIcon =
|
||||||
index === 0
|
index === 0
|
||||||
@@ -565,43 +677,63 @@ export function EnvironmentPanel() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vital-metric vital-card"
|
className="bg-vital-card border border-vital-border min-w-0 overflow-hidden pt-px-16 pb-px-8 px-px-12 flex gap-px-8 flex-wrap text-left rounded-ui-12 cursor-pointer max-[768px]:pt-px-12 max-[768px]:pb-px-12 max-[768px]:px-px-10"
|
||||||
key={metric.name}
|
key={metric.name}
|
||||||
|
data-testid="vital-metric"
|
||||||
onClick={() => setDetail(metric)}
|
onClick={() => setDetail(metric)}
|
||||||
>
|
>
|
||||||
<MetricIcon size={17} data-tone={metric.tone} />
|
<MetricIcon
|
||||||
<div className="vital-metric-content">
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow max-[768px]:w-[14px]"
|
||||||
<span>{metric.name}</span>
|
size={17}
|
||||||
<div className="vital-metric-reading">
|
data-tone={metric.tone}
|
||||||
<strong>{metric.value}</strong>
|
/>
|
||||||
<small>{metric.unit}</small>
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-vital-muted text-ui-12">{metric.name}</span>
|
||||||
|
<div className="flex items-baseline flex-wrap gap-px-3">
|
||||||
|
<strong className="text-ui-23 font-normal">{metric.value}</strong>
|
||||||
|
<small className="text-ui-10 text-vital-muted">{metric.unit}</small>
|
||||||
{metric.status && (
|
{metric.status && (
|
||||||
<em data-tone={metric.status === "Watch" ? "yellow" : "green"}>
|
<em
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow not-italic text-ui-11 ml-auto"
|
||||||
|
data-tone={metric.status === "Watch" ? "yellow" : "green"}
|
||||||
|
>
|
||||||
{metric.status}
|
{metric.status}
|
||||||
</em>
|
</em>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-metric-spark">
|
<div className="flex items-end w-full gap-px-4">
|
||||||
<small>{metric.min}</small>
|
<small className="text-ui-9 text-vital-muted">{metric.min}</small>
|
||||||
<Sparkline index={index} tone={metric.tone} />
|
<Sparkline index={index} tone={metric.tone} />
|
||||||
<small>{metric.max}</small>
|
<small className="text-ui-9 text-vital-muted">{metric.max}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="vital-metric vital-card vital-room">
|
<div className="bg-vital-card border border-vital-border min-w-0 overflow-hidden pt-px-16 pb-px-8 px-px-12 flex gap-px-8 flex-wrap text-left rounded-ui-12 cursor-pointer max-[768px]:pt-px-12 max-[768px]:pb-px-12 max-[768px]:px-px-10">
|
||||||
<Wifi size={17} data-tone="green" />
|
<Wifi
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow max-[768px]:w-[14px]"
|
||||||
|
size={17}
|
||||||
|
data-tone="green"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span>Room</span>
|
<span className="text-vital-muted text-ui-12">Room</span>
|
||||||
<strong>Connected</strong>
|
<strong className="block text-ui-14 font-medium mt-px-3 mb-px-3 mx-0">
|
||||||
<p>
|
Connected
|
||||||
<span data-tone="green">●</span> Updated just now
|
</strong>
|
||||||
|
<p className="text-ui-10 text-vital-muted">
|
||||||
|
<span
|
||||||
|
className="data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
|
||||||
|
data-tone="green"
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>{" "}
|
||||||
|
Updated just now
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-env-bottom">
|
<div className="grid grid-cols-[1.65fr_1fr] gap-px-14 mt-px-14 max-[1401px]:grid-cols-[1fr]">
|
||||||
<Trends />
|
<Trends />
|
||||||
<AirMonitor />
|
<AirMonitor />
|
||||||
</div>
|
</div>
|
||||||
@@ -611,14 +743,18 @@ export function EnvironmentPanel() {
|
|||||||
)}
|
)}
|
||||||
{detail && (
|
{detail && (
|
||||||
<Dialog title={detail.name} onClose={() => setDetail(null)}>
|
<Dialog title={detail.name} onClose={() => setDetail(null)}>
|
||||||
<p className="vital-detail-value">
|
<p className="text-ui-32">
|
||||||
{detail.value} <small>{detail.unit}</small>
|
{detail.value} <small className="text-ui-14">{detail.unit}</small>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This is a sample reading for the dashboard preview. No health or environmental device is
|
This is a sample reading for the dashboard preview. No health or environmental device is
|
||||||
connected.
|
connected.
|
||||||
</p>
|
</p>
|
||||||
<button className="vital-outline-button" type="button" onClick={() => setDetail(null)}>
|
<button
|
||||||
|
className="inline-flex items-center justify-center gap-px-7 text-ui-12 pt-px-7 pb-px-7 px-px-10 border border-vital-border rounded-ui-7 cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetail(null)}
|
||||||
|
>
|
||||||
Back to overview
|
Back to overview
|
||||||
</button>
|
</button>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
+207
-110
@@ -78,33 +78,35 @@ export function VitalsPage() {
|
|||||||
const [mobileMenu, setMobileMenu] = useState(false);
|
const [mobileMenu, setMobileMenu] = useState(false);
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<SidebarProvider className="vital-workspace">
|
<SidebarProvider className="h-[100dvh] min-h-0 overflow-hidden bg-vital-bg text-vital-text font-arial">
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<div className="vital-app">
|
<div className="flex flex-1 min-w-0 h-full">
|
||||||
<aside
|
<aside
|
||||||
className={`vital-navigation ${mobileMenu ? "is-open" : ""}`}
|
className="w-[208px] shrink-0 flex flex-col bg-vital-bg border-r border-vital-border pt-0 pb-0 px-px-10 max-[1401px]:w-[170px] max-[1101px]:w-[150px] max-[768px]:hidden max-[768px]:data-[open=true]:flex max-[768px]:data-[open=true]:fixed max-[768px]:data-[open=true]:left-[56px] max-[768px]:data-[open=true]:top-0 max-[768px]:data-[open=true]:bottom-0 max-[768px]:data-[open=true]:z-[40] max-[768px]:data-[open=true]:w-[220px] max-[768px]:data-[open=true]:shadow-vital-menu"
|
||||||
|
data-open={mobileMenu}
|
||||||
aria-label="Health navigation"
|
aria-label="Health navigation"
|
||||||
>
|
>
|
||||||
<div className="vital-brand">
|
<div className="h-[64px] flex items-center gap-px-12 pt-0 pb-0 px-px-12 text-ui-18 font-semibold whitespace-nowrap max-[1101px]:text-ui-14 max-[1101px]:px-px-4">
|
||||||
<Activity />
|
<Activity className="w-[24px] text-vital-muted" />
|
||||||
<span>
|
<span>
|
||||||
1F2T <span>Health</span>
|
1F2T <span className="text-vital-muted font-normal">Health</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="vital-mobile-close"
|
className="items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text hidden max-[768px]:flex max-[768px]:ml-auto"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
onClick={() => setMobileMenu(false)}
|
onClick={() => setMobileMenu(false)}
|
||||||
>
|
>
|
||||||
<X />
|
<X className="w-[17px] h-[17px] shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav className="grid gap-px-3 mt-px-14">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={section === item.name ? "is-active" : ""}
|
className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted data-[active=true]:bg-muted data-[active=true]:text-vital-text hover:text-vital-text"
|
||||||
|
data-active={section === item.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.name === "Body" || item.name === "Environment") {
|
if (item.name === "Body" || item.name === "Environment") {
|
||||||
setSection(item.name);
|
setSection(item.name);
|
||||||
@@ -112,63 +114,80 @@ export function VitalsPage() {
|
|||||||
} else setDetail(item.name);
|
} else setDetail(item.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon className="w-[17px] h-[17px] shrink-0" />
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="vital-navigation-bottom">
|
<div className="mt-auto pb-px-20">
|
||||||
<button type="button" onClick={() => setDetail("Log with Claude")}>
|
<button
|
||||||
<Plus />
|
className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetail("Log with Claude")}
|
||||||
|
>
|
||||||
|
<Plus className="w-[17px] h-[17px] shrink-0" />
|
||||||
Log with Claude
|
Log with Claude
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setDetail("Help")}>
|
<button
|
||||||
<CircleHelp />
|
className="flex items-center gap-px-14 pt-px-10 pb-px-10 px-px-12 rounded-ui-8 text-left text-ui-14 cursor-pointer text-vital-muted hover:text-vital-text"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetail("Help")}
|
||||||
|
>
|
||||||
|
<CircleHelp className="w-[17px] h-[17px] shrink-0" />
|
||||||
Help
|
Help
|
||||||
</button>
|
</button>
|
||||||
<p>Reference preview</p>
|
<p className="text-ui-11 pt-px-8 pb-px-8 px-px-12 text-vital-muted">
|
||||||
|
Reference preview
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="vital-surface">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<header className="vital-topbar">
|
<header className="h-[64px] shrink-0 flex items-center pt-0 pb-0 px-px-28 border-b border-vital-border gap-px-12 max-[768px]:pt-0 max-[768px]:pb-0 max-[768px]:px-px-12 max-[768px]:h-[56px]">
|
||||||
<button
|
<button
|
||||||
className="vital-mobile-toggle"
|
className="hidden max-[768px]:flex"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open Health menu"
|
aria-label="Open Health menu"
|
||||||
onClick={() => setMobileMenu(true)}
|
onClick={() => setMobileMenu(true)}
|
||||||
>
|
>
|
||||||
<Menu />
|
<Menu className="w-[17px] h-[17px] text-vital-muted" />
|
||||||
</button>
|
</button>
|
||||||
<h1>{section}</h1>
|
<h1 className="text-ui-16 font-medium">{section}</h1>
|
||||||
<div className="vital-topbar-right">
|
<div className="ml-auto flex items-center gap-px-28 text-ui-14 max-[768px]:gap-px-14">
|
||||||
<span className="vital-pulse">
|
<span className="flex items-center gap-px-10 text-vital-muted max-[768px]:hidden">
|
||||||
<Heart />
|
<Heart className="w-[17px] h-[17px] text-vital-red" />
|
||||||
86 bpm
|
86 bpm
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
className="flex items-center gap-px-10 cursor-pointer max-[768px]:hidden"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open notifications"
|
aria-label="Open notifications"
|
||||||
onClick={() => setDetail("Notifications")}
|
onClick={() => setDetail("Notifications")}
|
||||||
>
|
>
|
||||||
<Bell />
|
<Bell className="w-[17px] h-[17px] text-vital-muted" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vital-profile"
|
className="flex items-center gap-px-10 cursor-pointer"
|
||||||
onClick={() => setDetail("Profile")}
|
onClick={() => setDetail("Profile")}
|
||||||
>
|
>
|
||||||
<span className="vital-avatar">{auth.owner?.name.slice(0, 1).toUpperCase()}</span>
|
<span className="grid place-items-center w-[30px] h-[30px] rounded-full bg-muted text-ui-12">
|
||||||
|
{auth.owner?.name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
{auth.owner?.name}
|
{auth.owner?.name}
|
||||||
<ChevronDown />
|
<ChevronDown className="w-[17px] h-[17px] text-vital-muted max-[768px]:hidden" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="vital-scroll">
|
<main className="overflow-auto flex-1 min-h-0 p-px-32 max-[1401px]:p-px-24 max-[768px]:pt-px-16 max-[768px]:pb-px-16 max-[768px]:px-px-12">
|
||||||
{section === "Environment" ? (
|
{section === "Environment" ? (
|
||||||
<EnvironmentPanel />
|
<EnvironmentPanel />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="vital-tabs" role="group" aria-label="Body systems">
|
<div
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
role="group"
|
||||||
|
aria-label="Body systems"
|
||||||
|
>
|
||||||
{[
|
{[
|
||||||
"Overview",
|
"Overview",
|
||||||
"Cardiovascular",
|
"Cardiovascular",
|
||||||
@@ -181,28 +200,46 @@ export function VitalsPage() {
|
|||||||
"Nutrients",
|
"Nutrients",
|
||||||
].map((name) => (
|
].map((name) => (
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
|
||||||
type="button"
|
type="button"
|
||||||
key={name}
|
key={name}
|
||||||
className={tab === name ? "is-active" : ""}
|
data-active={tab === name}
|
||||||
onClick={() => setTab(name)}
|
onClick={() => setTab(name)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-body-columns">
|
<div className="grid grid-cols-[minmax(0,_1fr)_290px] gap-px-20 mt-px-20 items-start min-[1700px]:grid-cols-[minmax(0,_1fr)_350px] max-[1401px]:grid-cols-[minmax(0,_1fr)_250px] max-[1101px]:grid-cols-[1fr] max-[768px]:gap-px-16">
|
||||||
<div className="vital-card">
|
<div className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden">
|
||||||
<div className="vital-lab-row">
|
<div className="grid grid-cols-[0.95fr_1.15fr] border-b border-vital-border max-[1401px]:grid-cols-[1fr]">
|
||||||
<section className="vital-lab-score">
|
<section className="p-px-26 min-w-0 max-[768px]:p-px-16">
|
||||||
<header>
|
<header className="flex items-center justify-between pb-px-16 border-b border-vital-border gap-px-12">
|
||||||
<h2>Lab score</h2>
|
<h2 className="text-ui-15 font-medium">Lab score</h2>
|
||||||
<span>Latest draw · Jul 28</span>
|
<span className="text-ui-10 text-vital-muted">
|
||||||
|
Latest draw · Jul 28
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="vital-lab-value">
|
<div className="flex items-center gap-px-20 mt-px-24 max-[768px]:gap-px-12">
|
||||||
<div className="vital-ring">
|
<div className="w-[142px] h-[160px] relative shrink-0 max-[768px]:w-[90px] max-[768px]:h-[120px]">
|
||||||
<svg viewBox="0 0 160 160" aria-hidden="true">
|
<svg
|
||||||
<circle cx="80" cy="80" r="67" />
|
className="w-full h-full -rotate-90"
|
||||||
|
viewBox="0 0 160 160"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<circle
|
<circle
|
||||||
|
fill="none"
|
||||||
|
className="stroke-vital-border"
|
||||||
|
strokeWidth={9}
|
||||||
|
cx="80"
|
||||||
|
cy="80"
|
||||||
|
r="67"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
fill="none"
|
||||||
|
className="stroke-vital-green drop-shadow-vital-glow"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
cx="80"
|
cx="80"
|
||||||
cy="80"
|
cy="80"
|
||||||
r="67"
|
r="67"
|
||||||
@@ -210,24 +247,36 @@ export function VitalsPage() {
|
|||||||
strokeDasharray="92 100"
|
strokeDasharray="92 100"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span className="absolute inset-0 flex flex-col justify-center items-center text-ui-40 font-light max-[768px]:text-ui-28">
|
||||||
92<small>Lab score</small>
|
92
|
||||||
|
<small className="text-ui-12 text-vital-muted mt-px-4">
|
||||||
|
Lab score
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-lab-description">
|
<div className="border-l border-vital-border pl-px-20 min-w-0 max-[768px]:pl-px-12">
|
||||||
<h3>
|
<h3 className="text-ui-23 whitespace-nowrap max-[768px]:text-ui-19">
|
||||||
Optimal <span>▾ 1</span>
|
Optimal <span className="text-ui-13 text-vital-yellow">▾ 1</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p>7 of 8 systems optimal · 21 of 161 biomarkers out of range</p>
|
<p className="text-ui-11 text-vital-muted leading-normal">
|
||||||
<div className="vital-lab-change">
|
7 of 8 systems optimal · 21 of 161 biomarkers out of range
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-px-16 mt-px-20 border-t border-vital-border pt-px-16">
|
||||||
<div>
|
<div>
|
||||||
<span>Change ⓘ</span>
|
<span className="text-ui-13 text-vital-muted">Change ⓘ</span>
|
||||||
<strong>
|
<strong className="block text-ui-20 font-medium text-vital-yellow">
|
||||||
−1 <small>pt</small>
|
−1 <small className="text-ui-12">pt</small>
|
||||||
</strong>
|
</strong>
|
||||||
<p>Since last draw</p>
|
<p className="text-ui-11 text-vital-muted leading-normal">
|
||||||
|
Since last draw
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<svg viewBox="0 0 190 65" aria-label="Lab score trend" role="img">
|
<svg
|
||||||
|
className="w-[120px] min-w-[40px] text-vital-green max-[768px]:hidden"
|
||||||
|
viewBox="0 0 190 65"
|
||||||
|
aria-label="Lab score trend"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51"
|
d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -239,42 +288,53 @@ export function VitalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="vital-recent">
|
<section className="border-l border-vital-border p-px-24 max-[1401px]:hidden">
|
||||||
<h2>Recent results</h2>
|
<h2 className="text-ui-15 font-medium mb-px-10">Recent results</h2>
|
||||||
{[1, 2, 3].map((index) => (
|
{[1, 2, 3].map((index) => (
|
||||||
<button
|
<button
|
||||||
|
className="flex items-center gap-px-16 w-full pt-px-16 pb-px-16 px-0 border-b border-vital-border cursor-pointer last:border-0"
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail("Recent result")}
|
onClick={() => setDetail("Recent result")}
|
||||||
>
|
>
|
||||||
<span className="vital-warning">△</span>
|
<span className="grid place-items-center w-[34px] h-[34px] rounded-ui-9 bg-muted text-vital-yellow text-ui-23">
|
||||||
|
△
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="vital-obscured"
|
className="flex flex-col gap-px-10 flex-1 min-w-0"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Reference text obscured"
|
aria-label="Reference text obscured"
|
||||||
>
|
>
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight />
|
<ChevronRight className="w-[16px] text-vital-muted" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section className="vital-systems">
|
<section>
|
||||||
<header>
|
<header className="flex items-center justify-between p-px-20 gap-px-12 max-[768px]:p-px-16 max-[768px]:flex-wrap">
|
||||||
<h2>{tab === "Overview" ? "Body systems" : tab}</h2>
|
<h2 className="text-ui-15 font-medium">
|
||||||
<div className="vital-tabs" role="group" aria-label="Body systems">
|
{tab === "Overview" ? "Body systems" : tab}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="inline-flex max-w-full overflow-x-auto border border-vital-border rounded-ui-10 p-px-3 gap-px-2 whitespace-nowrap"
|
||||||
|
role="group"
|
||||||
|
aria-label="Body systems"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
|
||||||
type="button"
|
type="button"
|
||||||
className={!history ? "is-active" : ""}
|
data-active={!history}
|
||||||
onClick={() => setHistory(false)}
|
onClick={() => setHistory(false)}
|
||||||
>
|
>
|
||||||
Systems
|
Systems
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="pt-px-7 pb-px-7 px-px-12 text-vital-muted text-ui-14 rounded-ui-7 cursor-pointer data-[active=true]:text-vital-text data-[active=true]:bg-muted data-[active=true]:inset-ring-1 data-[active=true]:inset-ring-vital-border"
|
||||||
type="button"
|
type="button"
|
||||||
className={history ? "is-active" : ""}
|
data-active={history}
|
||||||
onClick={() => setHistory(true)}
|
onClick={() => setHistory(true)}
|
||||||
>
|
>
|
||||||
Lab history
|
Lab history
|
||||||
@@ -282,25 +342,28 @@ export function VitalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{history ? (
|
{history ? (
|
||||||
<div className="vital-history">
|
<div data-testid="vital-history" className="min-h-[440px] p-px-30">
|
||||||
<h3>Lab history</h3>
|
<h3 className="mb-px-28">Lab history</h3>
|
||||||
{[
|
{[
|
||||||
["Jul 28", "92"],
|
["Jul 28", "92"],
|
||||||
["May 16", "93"],
|
["May 16", "93"],
|
||||||
["Mar 04", "89"],
|
["Mar 04", "89"],
|
||||||
].map(([date, score]) => (
|
].map(([date, score]) => (
|
||||||
<div key={date}>
|
<div
|
||||||
|
className="flex justify-between pt-px-24 pb-px-24 px-0 border-b border-vital-border"
|
||||||
|
key={date}
|
||||||
|
>
|
||||||
<span>{date}</span>
|
<span>{date}</span>
|
||||||
<span>
|
<span>
|
||||||
{score}
|
{score}
|
||||||
<small> Lab score</small>
|
<small className="text-vital-muted"> Lab score</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="vital-anatomy-grid">
|
<div className="grid grid-cols-[minmax(0,_1fr)_230px_minmax(0,_1fr)] items-center min-h-[505px] pt-px-22 pb-0 px-px-20 min-[1700px]:grid-cols-[minmax(0,_1fr)_280px_minmax(0,_1fr)] max-[1401px]:grid-cols-[minmax(0,_1fr)_155px_minmax(0,_1fr)] max-[1401px]:px-px-12 max-[1101px]:grid-cols-[minmax(0,_1fr)_210px_minmax(0,_1fr)] max-[768px]:grid-cols-[1fr_1fr] max-[768px]:gap-px-12 max-[768px]:p-px-12">
|
||||||
<div className="vital-system-list">
|
<div className="grid gap-px-12 relative z-[1]">
|
||||||
{systems
|
{systems
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.filter((item) => tab === "Overview" || tab === item.name)
|
.filter((item) => tab === "Overview" || tab === item.name)
|
||||||
@@ -313,13 +376,17 @@ export function VitalsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="vital-anatomy"
|
className="h-[500px] w-[230px] relative overflow-hidden self-end min-[1700px]:w-[280px] max-[1401px]:w-[155px] max-[1101px]:w-[210px] max-[768px]:col-span-full max-[768px]:row-start-1 max-[768px]:justify-self-center max-[768px]:h-[420px] max-[768px]:w-[230px]"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Reference illustration of the body and its systems"
|
aria-label="Reference illustration of the body and its systems"
|
||||||
>
|
>
|
||||||
<img src="/vitals/body-reference.jpg" alt="" />
|
<img
|
||||||
|
className="absolute max-w-none w-[2008px] h-auto left-[-831px] top-[-563px] mix-blend-lighten min-[1700px]:left-[-806px] max-[1401px]:left-[-868px] max-[1101px]:left-[-841px] max-[768px]:w-[1687px] max-[768px]:left-[-695px] max-[768px]:top-[-473px]"
|
||||||
|
src="/vitals/body-reference.jpg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="vital-system-list">
|
<div className="grid gap-px-12 relative z-[1]">
|
||||||
{systems
|
{systems
|
||||||
.slice(4)
|
.slice(4)
|
||||||
.filter((item) => tab === "Overview" || tab === item.name)
|
.filter((item) => tab === "Overview" || tab === item.name)
|
||||||
@@ -335,60 +402,77 @@ export function VitalsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<aside className="vital-card vital-insights">
|
<aside className="bg-vital-card border border-vital-border rounded-ui-16 min-w-0 overflow-hidden p-px-22 max-[1101px]:block max-[768px]:p-px-16">
|
||||||
<h2>Insights</h2>
|
<h2 className="text-ui-15 font-medium mb-px-20">Insights</h2>
|
||||||
<div className="vital-insight-feature">
|
<div className="border border-vital-border rounded-ui-12 p-px-16 flex items-start gap-px-12 flex-wrap">
|
||||||
<span className="vital-red">△</span>
|
<span className="text-vital-red">△</span>
|
||||||
<div
|
<div
|
||||||
className="vital-obscured"
|
className="flex flex-col gap-px-10 flex-1 min-w-0 min-h-[80px]"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Reference text obscured"
|
aria-label="Reference text obscured"
|
||||||
>
|
>
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setHistory(true)}>
|
<button
|
||||||
|
className="border border-vital-border rounded-ui-7 pt-px-6 pb-px-6 px-px-10 text-ui-12 cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistory(true)}
|
||||||
|
>
|
||||||
View lab history
|
View lab history
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3>Summary</h3>
|
<h3 className="text-ui-14 mt-px-24 mb-px-10 mx-0 text-vital-muted">
|
||||||
<div className="vital-summary">
|
Summary
|
||||||
<span>
|
</h3>
|
||||||
<b className="vital-dot vital-red" />4 need attention
|
<div className="flex flex-wrap gap-px-8">
|
||||||
|
<span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
|
||||||
|
<b className="text-vital-red inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
|
||||||
|
4 need attention
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
|
||||||
<b className="vital-dot vital-yellow" />3 to watch
|
<b className="text-vital-yellow inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
|
||||||
|
3 to watch
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="border border-vital-border rounded-ui-7 pt-px-5 pb-px-5 px-px-8 text-ui-12 text-vital-muted flex items-center gap-px-7">
|
||||||
<b className="vital-dot vital-green" />4 going well
|
<b className="text-vital-green inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0" />
|
||||||
|
4 going well
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>More insights</h3>
|
<h3 className="text-ui-14 mt-px-24 mb-px-10 mx-0 text-vital-muted">
|
||||||
|
More insights
|
||||||
|
</h3>
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
<div className="vital-insight-placeholder" key={index}>
|
<div
|
||||||
|
className="flex items-start gap-px-10 mb-px-20 min-h-[85px]"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
<b
|
<b
|
||||||
className={`vital-dot ${index === 2 ? "vital-yellow" : "vital-red"}`}
|
className="inline-block w-[7px] h-[7px] rounded-full bg-current shrink-0 data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
|
||||||
|
data-tone={index === 2 ? "yellow" : "red"}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="vital-obscured"
|
className="flex flex-col gap-px-10 flex-1 min-w-0"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Reference text obscured"
|
aria-label="Reference text obscured"
|
||||||
>
|
>
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
<i />
|
<i className="block h-[9px] w-[90%] bg-vital-muted opacity-30 blur-xs nth-2:w-[72%] nth-3:w-[84%]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
className="vital-text-button"
|
className="text-ui-13 text-vital-muted cursor-pointer"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail("Insights")}
|
onClick={() => setDetail("Insights")}
|
||||||
>
|
>
|
||||||
Show 6 more
|
Show 6 more
|
||||||
</button>
|
</button>
|
||||||
<p className="vital-updated">Reference data · Sep 25</p>
|
<p className="text-ui-12 text-vital-muted mt-px-28">
|
||||||
|
Reference data · Sep 25
|
||||||
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,17 +503,30 @@ export function VitalsPage() {
|
|||||||
}
|
}
|
||||||
function SystemCard({ item, onSelect }: { item: (typeof systems)[number]; onSelect: () => void }) {
|
function SystemCard({ item, onSelect }: { item: (typeof systems)[number]; onSelect: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="vital-system-card" onClick={onSelect}>
|
<button
|
||||||
<span className={`vital-system-icon vital-${item.tone}`}>
|
data-testid="vital-system-card"
|
||||||
<item.icon />
|
type="button"
|
||||||
|
className="flex items-center gap-px-12 border border-vital-border rounded-ui-10 p-px-16 cursor-pointer text-left min-w-0 relative hover:bg-muted min-[1700px]:p-px-18 max-[1401px]:pt-px-12 max-[1401px]:pb-px-12 max-[1401px]:px-px-8 max-[1401px]:gap-px-8 max-[1401px]:flex-wrap max-[768px]:p-px-12"
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="grid place-items-center w-[34px] h-[34px] border border-vital-border rounded-ui-7 shrink-0 max-[768px]:w-[28px] max-[768px]:h-[28px] data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
|
||||||
|
data-tone={item.tone}
|
||||||
|
>
|
||||||
|
<item.icon className="w-[17px] h-[17px]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="vital-system-label">
|
<span className="flex-1 text-ui-14 max-[1401px]:text-ui-12 max-[768px]:text-ui-11">
|
||||||
{item.name}
|
{item.name}
|
||||||
<small>{item.count} biomarkers</small>
|
<small className="block text-ui-11 mt-px-4 whitespace-nowrap text-vital-muted max-[768px]:text-ui-9">
|
||||||
|
{item.count} biomarkers
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="vital-system-score">
|
<span className="text-right text-ui-23 max-[1401px]:ml-auto max-[768px]:text-ui-20">
|
||||||
{item.score}
|
{item.score}
|
||||||
<small className={item.score < 70 ? "vital-yellow" : "vital-green"}>
|
<small
|
||||||
|
className="block text-ui-11 mt-px-4 whitespace-nowrap max-[768px]:text-ui-9 data-[tone=red]:text-vital-red data-[tone=green]:text-vital-green data-[tone=blue]:text-vital-blue data-[tone=purple]:text-vital-purple data-[tone=yellow]:text-vital-yellow"
|
||||||
|
data-tone={item.score < 70 ? "yellow" : "green"}
|
||||||
|
>
|
||||||
● {item.score < 70 ? "Watch" : "Optimal"}
|
● {item.score < 70 ? "Watch" : "Optimal"}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+250
-15
@@ -1,15 +1,250 @@
|
|||||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
|
@import "tailwindcss";
|
||||||
@import "./ui.css";
|
@import "tw-animate-css";
|
||||||
@import "../tokens.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "./base.css";
|
|
||||||
@import "./features/connections/connection-manager.css";
|
@custom-variant dark (&:is(.dark *));
|
||||||
@import "./features/research/citation-navigation.css";
|
|
||||||
@import "./features/research/research-panel.css";
|
:root {
|
||||||
@import "./features/decks/decks.css";
|
--radius: 0.625rem;
|
||||||
@import "./features/auth/auth.css";
|
--background: oklch(0.145 0 0);
|
||||||
@import "./features/inbox/inbox.css";
|
--foreground: oklch(0.985 0 0);
|
||||||
@import "./features/journal/journal.css";
|
--card: oklch(0.205 0 0);
|
||||||
@import "./features/support/support.css";
|
--card-foreground: oklch(0.985 0 0);
|
||||||
@import "./features/home/home.css";
|
--popover: oklch(0.205 0 0);
|
||||||
@import "./features/home/execution-workbench.css";
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
@import "./features/vitals/vitals.css";
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.922 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
--overlay: oklch(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--inset-shadow-selection: 2px 0 var(--primary);
|
||||||
|
--radius-ui-2: 2px;
|
||||||
|
--radius-ui-7: 7px;
|
||||||
|
--radius-ui-9: 9px;
|
||||||
|
--radius-ui-15: 15px;
|
||||||
|
--tracking-score: -2px;
|
||||||
|
--drop-shadow-vital-glow: 0 0 10px var(--color-vital-green);
|
||||||
|
--shadow-vital-menu: 10px 0 50px var(--color-vital-bg);
|
||||||
|
--spacing-px-10: 10px;
|
||||||
|
--spacing-px-12: 12px;
|
||||||
|
--spacing-px-4: 4px;
|
||||||
|
--spacing-px-14: 14px;
|
||||||
|
--spacing-px-3: 3px;
|
||||||
|
--spacing-px-20: 20px;
|
||||||
|
--spacing-px-8: 8px;
|
||||||
|
--spacing-px-28: 28px;
|
||||||
|
--spacing-px-32: 32px;
|
||||||
|
--spacing-px-24: 24px;
|
||||||
|
--spacing-px-16: 16px;
|
||||||
|
--spacing-px-2: 2px;
|
||||||
|
--spacing-px-7: 7px;
|
||||||
|
--spacing-px-26: 26px;
|
||||||
|
--spacing-px-30: 30px;
|
||||||
|
--spacing-px-22: 22px;
|
||||||
|
--spacing-px-6: 6px;
|
||||||
|
--spacing-px-5: 5px;
|
||||||
|
--spacing-px-18: 18px;
|
||||||
|
--spacing-px-34: 34px;
|
||||||
|
--spacing-px-9: 9px;
|
||||||
|
--spacing-px-15: 15px;
|
||||||
|
--spacing-px-48: 48px;
|
||||||
|
--spacing-px-40: 40px;
|
||||||
|
--spacing-px-80: 80px;
|
||||||
|
--spacing-px-36: 36px;
|
||||||
|
--spacing-px-13: 13px;
|
||||||
|
--spacing-px-55: 55px;
|
||||||
|
--spacing-px-25: 25px;
|
||||||
|
--spacing-px-44: 44px;
|
||||||
|
--spacing-px-64: 64px;
|
||||||
|
--spacing-px-11: 11px;
|
||||||
|
--spacing-px-52: 52px;
|
||||||
|
--spacing-px-54: 54px;
|
||||||
|
--font-arial: Arial, sans-serif;
|
||||||
|
--text-ui-36: 36px;
|
||||||
|
--text-ui-40: 40px;
|
||||||
|
--text-ui-42: 42px;
|
||||||
|
--text-research-label: 0.75rem;
|
||||||
|
--text-research-field: 0.8rem;
|
||||||
|
--text-research-empty: 0.85rem;
|
||||||
|
--text-research-body: 0.875rem;
|
||||||
|
--leading-compact: 1.3;
|
||||||
|
--leading-heading: 1.4;
|
||||||
|
--leading-post: 1.55;
|
||||||
|
--leading-body: 1.6;
|
||||||
|
--leading-chat: 1.65;
|
||||||
|
--leading-relaxedcopy: 1.8;
|
||||||
|
--text-home-greeting: clamp(24px, 3vw, 34px);
|
||||||
|
--tracking-home: -0.035em;
|
||||||
|
--tracking-title: -0.04em;
|
||||||
|
--tracking-subtitle: -0.015em;
|
||||||
|
--leading-relaxed-copy: 1.8;
|
||||||
|
--leading-brief: 1.95;
|
||||||
|
--leading-loose-copy: 1.75;
|
||||||
|
--leading-message: 1.7;
|
||||||
|
--color-activity-expanded: color-mix(in oklch, var(--foreground) 28%, var(--border));
|
||||||
|
--radius-ui-3: 3px;
|
||||||
|
--radius-ui-6: 6px;
|
||||||
|
--radius-ui-8: 8px;
|
||||||
|
--radius-ui-10: 10px;
|
||||||
|
--radius-ui-12: 12px;
|
||||||
|
--radius-ui-14: 14px;
|
||||||
|
--radius-ui-16: 16px;
|
||||||
|
--radius-ui-20: 20px;
|
||||||
|
--color-activity-focus: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
|
||||||
|
--color-activity-routine: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
|
||||||
|
--color-activity-reply: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
|
||||||
|
--color-activity-weight: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
|
||||||
|
--color-activity-optional: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
|
||||||
|
--font-body:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
|
||||||
|
--font-display: var(--font-body);
|
||||||
|
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||||
|
--font-weight-book: 550;
|
||||||
|
--font-weight-demibold: 650;
|
||||||
|
--text-xs: 0.8571rem;
|
||||||
|
--text-sm: 1rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-md: 1.125rem;
|
||||||
|
--text-display-s: 1.125rem;
|
||||||
|
--text-display-long: 1.125rem;
|
||||||
|
--text-reader-title: clamp(24px, 2.4vw, 34px);
|
||||||
|
--leading-cozy: 1.6;
|
||||||
|
--leading-reader: 2.15;
|
||||||
|
--color-canvas: var(--background);
|
||||||
|
--color-paper: var(--background);
|
||||||
|
--color-paper-2: var(--card);
|
||||||
|
--color-paper-3: var(--muted);
|
||||||
|
--color-ink: var(--foreground);
|
||||||
|
--color-ink-2: var(--foreground);
|
||||||
|
--color-accent-hover: var(--foreground);
|
||||||
|
--color-accent-ink: var(--primary-foreground);
|
||||||
|
--color-rule: var(--border);
|
||||||
|
--color-control-rule: var(--input);
|
||||||
|
--color-focus: var(--ring);
|
||||||
|
--color-error: var(--destructive);
|
||||||
|
--color-activity-lilac: oklch(78% 0.09 295);
|
||||||
|
--color-activity-sage: oklch(80% 0.07 155);
|
||||||
|
--color-activity-peach: oklch(82% 0.08 60);
|
||||||
|
--color-success: oklch(78% 0.13 155);
|
||||||
|
--color-shadow: oklch(10% 0.015 250 / 45%);
|
||||||
|
--color-vital-bg: #0c0d10;
|
||||||
|
--color-vital-card: #101115;
|
||||||
|
--color-vital-border: #282a30;
|
||||||
|
--color-vital-text: #e5e7eb;
|
||||||
|
--color-vital-muted: #90959f;
|
||||||
|
--color-vital-green: #55c9a1;
|
||||||
|
--color-vital-blue: #7e9fe5;
|
||||||
|
--color-vital-purple: #a892e5;
|
||||||
|
--color-vital-yellow: #d9ac58;
|
||||||
|
--color-vital-red: #df6e87;
|
||||||
|
--color-vital-teal: #59b9b2;
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--text-ui-9: 9px;
|
||||||
|
--text-ui-10: 10px;
|
||||||
|
--text-ui-11: 11px;
|
||||||
|
--text-ui-12: 12px;
|
||||||
|
--text-ui-13: 13px;
|
||||||
|
--text-ui-14: 14px;
|
||||||
|
--text-ui-15: 15px;
|
||||||
|
--text-ui-16: 16px;
|
||||||
|
--text-ui-17: 17px;
|
||||||
|
--text-ui-18: 18px;
|
||||||
|
--text-ui-19: 19px;
|
||||||
|
--text-ui-20: 20px;
|
||||||
|
--text-ui-21: 21px;
|
||||||
|
--text-ui-22: 22px;
|
||||||
|
--text-ui-23: 23px;
|
||||||
|
--text-ui-24: 24px;
|
||||||
|
--text-ui-25: 25px;
|
||||||
|
--text-ui-26: 26px;
|
||||||
|
--text-ui-27: 27px;
|
||||||
|
--text-ui-28: 28px;
|
||||||
|
--text-ui-29: 29px;
|
||||||
|
--text-ui-30: 30px;
|
||||||
|
--text-ui-31: 31px;
|
||||||
|
--text-ui-32: 32px;
|
||||||
|
--text-ui-33: 33px;
|
||||||
|
--text-ui-34: 34px;
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-overlay: var(--overlay);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply min-w-[320px] overflow-x-clip bg-background text-foreground font-sans text-ui-14 scheme-dark;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply m-0 min-w-[320px] overflow-x-clip bg-background text-foreground;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
@apply text-primary underline-offset-2 active:text-foreground;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-2 outline-offset-2 outline-ring;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
@apply m-0 min-w-0 font-display text-display-s font-bold leading-tight wrap-anywhere;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
@apply animate-none! transition-none!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
/* Hallmark · macrostructure: sidebar-09 Workbench · tone: compact · anchor hue: neutral */
|
|
||||||
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · mobile: pass 320/375/414/768 */
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
@import "shadcn/tailwind.css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.922 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
--overlay: oklch(0 0 0 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--font-sans: var(--font-body);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-overlay: var(--overlay);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
|
|
||||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
|
||||||
:root {
|
|
||||||
--color-canvas: var(--background);
|
|
||||||
--color-paper: var(--background);
|
|
||||||
--color-paper-2: var(--card);
|
|
||||||
--color-paper-3: var(--muted);
|
|
||||||
--color-ink: var(--foreground);
|
|
||||||
--color-ink-2: var(--foreground);
|
|
||||||
--color-muted: var(--muted-foreground);
|
|
||||||
--color-accent: var(--primary);
|
|
||||||
--color-accent-hover: var(--foreground);
|
|
||||||
--color-accent-ink: var(--primary-foreground);
|
|
||||||
--color-rule: var(--border);
|
|
||||||
--color-control-rule: var(--input);
|
|
||||||
--color-focus: var(--ring);
|
|
||||||
--color-error: var(--destructive);
|
|
||||||
--color-activity-lilac: oklch(78% 0.09 295);
|
|
||||||
--color-activity-sage: oklch(80% 0.07 155);
|
|
||||||
--color-activity-peach: oklch(82% 0.08 60);
|
|
||||||
--color-success: oklch(78% 0.13 155);
|
|
||||||
--color-shadow: oklch(10% 0.015 250 / 45%);
|
|
||||||
--color-overlay: oklch(10% 0.015 250 / 70%);
|
|
||||||
--font-body:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
|
|
||||||
--font-display: var(--font-body);
|
|
||||||
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
|
||||||
--space-3xs: 0.125rem;
|
|
||||||
--space-2xs: 0.25rem;
|
|
||||||
--space-xs: 0.5rem;
|
|
||||||
--space-sm: 0.75rem;
|
|
||||||
--space-md: 1rem;
|
|
||||||
--space-lg: 1.5rem;
|
|
||||||
--space-xl: 2.5rem;
|
|
||||||
--space-2xl: 4rem;
|
|
||||||
--space-3xl: 6rem;
|
|
||||||
--text-xs: 0.8571rem;
|
|
||||||
--text-sm: 1rem;
|
|
||||||
--text-base: 1rem;
|
|
||||||
--text-md: 1.125rem;
|
|
||||||
--text-lg: 1.25rem;
|
|
||||||
--text-xl: 1.5rem;
|
|
||||||
--text-display-s: 1.125rem;
|
|
||||||
--text-display-long: 1.125rem;
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
|
||||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
|
||||||
--dur-instant: 100ms;
|
|
||||||
--dur-short: 150ms;
|
|
||||||
--dur-medium: 300ms;
|
|
||||||
--rule-hairline: 1px;
|
|
||||||
--rule-active: 2px;
|
|
||||||
--radius-sm: 0.25rem;
|
|
||||||
--radius-md: 0.375rem;
|
|
||||||
--radius-lg: 0.5rem;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user