feat: redesign daily execution and messaging interfaces

This commit is contained in:
2026-09-28 22:38:58 +09:00
parent 970553bd8f
commit 54d7a542f7
16 changed files with 1623 additions and 504 deletions
+6
View File
@@ -8,6 +8,12 @@ each dedicated workspace; its bottom avatar manages connected accounts.
The interface is in English. User-entered text and external posts retain their The interface is in English. User-entered text and external posts retain their
original language. original language.
Home uses a Tiimo-inspired day flow with expandable activities and direct
controls for routine steps, tasks, replies and weight entries. Messages uses
a Beeper-inspired conversation layout with visible source messages, editable
drafts and a contextual side panel. These execution surfaces remain mocks;
their records share browser-session state and external sending is simulated.
## Workspaces and current status ## Workspaces and current status
| Workspace | Route | Current implementation | | Workspace | Route | Current implementation |
+18 -1
View File
@@ -36,13 +36,30 @@ the new communication and journal prototypes do not consume those accounts.
## Daily home ## Daily home
`/` presents a greeting, a large input, and a flexible plan grouped as `/` presents a greeting, a large input, and a Tiimo-inspired flexible day flow grouped as
Start here, Routines, and If there’s room. There are no fixed time slots. Start here, Routines, and If there’s room. There are no fixed time slots.
Tasks can be reordered, completed, deferred, restored, or followed as routine Tasks can be reordered, completed, deferred, restored, or followed as routine
steps. Communications can be reviewed directly in a home dialog; changes also steps. Communications can be reviewed directly in a home dialog; changes also
appear in `/support`. Journal entries and corrections appear on the home, and appear in `/support`. Journal entries and corrections appear on the home, and
reader samples open for reading. Research remains a separate work surface. reader samples open for reading. Research remains a separate work surface.
Activity blocks expand in place into controls suited to their content: routine
steps, a task's next action and resumption note, or a weight input that records
an entry in Journal. Completed and deferred activities stay in their original positions with visible
status and undo controls. Measurements retain the recorded value in place, and
reply previews retain their outcome, so the user need not remember what was done. Expanded activity selection
uses the `activity` URL parameter. This remains a session-only mock; a reload
restores navigation, not recorded activity data.
Messages uses a Beeper-inspired person list and visible original conversation,
with reply drafting and review next to source-backed context. On narrow screens
the list and conversation become separate views. Inbox, set-aside, waiting and
archive filters describe communication state; a simulated reply does not prove
that a separate promise was fulfilled. References include the saved
[Beeper Copilot captures](references/beeper-copilot/README.md),
[Beeper Desktop](https://blog.beeper.com/2025/02/24/try-out-the-new-beeper-desktop-and-ios-beta/),
and [open-loop research](gbrain-open-loops-research.md).
The input currently uses a deterministic English/Japanese mock interpreter, not an LLM. The input currently uses a deterministic English/Japanese mock interpreter, not an LLM.
It proposes typed actions for task creation/status/reordering, routine steps, It proposes typed actions for task creation/status/reordering, routine steps,
contact reply/review or deferral, notes/weight/medication records, and navigation. contact reply/review or deferral, notes/weight/medication records, and navigation.
+88 -8
View File
@@ -1,5 +1,44 @@
import { expect, test } from "../fixtures"; import { expect, test } from "../fixtures";
test("executes routine steps and records a measurement in the shared journal", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Morning routine 0 of 3/ }).click();
await expect(page).toHaveURL(/activity=task/);
await page.getByRole("checkbox", { name: /Drink water/ }).check();
await expect(page.getByRole("button", { name: "Finish routine" })).toBeDisabled();
await page.getByRole("checkbox", { name: /Get ready/ }).check();
await page.getByRole("checkbox", { name: /Have breakfast/ }).check();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true });
await page.getByRole("button", { name: "Finish routine" }).click();
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("button", { name: "Save measurement" }).click();
await expect(
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
).toBeVisible();
await page.getByRole("link", { name: "Journal", exact: true }).first().click();
await expect(page.getByRole("article", { name: "Weight entry" })).toContainText("65.2 kg");
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(page.getByRole("button", { name: "Restore Morning routine" })).toBeVisible();
});
test("shows the day flow and conversation context at desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1080 });
await page.goto("/");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("region", { name: "Today’s plan" })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("home-desktop.png") });
await page.goto("/support?conversation=design-review");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") });
});
test("previews a natural-language memo and shares the executed record with journal", async ({ test("previews a natural-language memo and shares the executed record with journal", async ({
page, page,
}) => { }) => {
@@ -20,7 +59,7 @@ test("previews a natural-language memo and shares the executed record with journ
test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => { test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
for (const width of [320, 375, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }); await page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
@@ -47,11 +86,9 @@ test("adds tasks only after confirmation and can defer and restore them", async
await expect(complete).toBeVisible(); await expect(complete).toBeVisible();
await page.getByRole("button", { name: "Apply", exact: true }).click(); await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toHaveCount(0); await expect(complete).toHaveCount(0);
await page.locator(".home-rest summary").click();
await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click(); await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
await complete.click(); await complete.click();
await expect(complete).toHaveCount(0); await expect(complete).toHaveCount(0);
await page.locator(".home-rest summary").click();
await expect( await expect(
page.getByRole("button", { name: "Restore grocery shopping", exact: true }), page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
).toBeVisible(); ).toBeVisible();
@@ -73,13 +110,13 @@ test("resolves communication within home and carries the status to the shared qu
await expect(dialog).toHaveCount(0); await expect(dialog).toHaveCount(0);
await expect( await expect(
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toHaveCount(0); ).toContainText("Set aside");
await page.getByRole("link", { name: "Messages", exact: true }).first().click(); await page.getByRole("link", { name: "Messages", exact: true }).first().click();
const rows = page.locator(".support-row"); const rows = page.locator(".support-row");
await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0); await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0);
await page.getByRole("button", { name: "Later", exact: true }).click(); await page.getByRole("button", { name: "Set aside", exact: true }).click();
await rows.filter({ hasText: "Schedule next week’s design review" }).click(); await rows.filter({ hasText: "Schedule next week’s design review" }).click();
await page.getByRole("button", { name: "Move to Now" }).click(); await page.getByRole("button", { name: "Move to Inbox" }).click();
const back = page.getByRole("button", { name: "Back to messages" }); const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click(); if (await back.isVisible()) await back.click();
await page.getByRole("link", { name: "Home", exact: true }).first().click(); await page.getByRole("link", { name: "Home", exact: true }).first().click();
@@ -95,7 +132,7 @@ test("updates the prose brief as priorities change and opens the referenced conv
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const brief = page.getByRole("region", { name: "Daily brief" }); const brief = page.getByRole("region", { name: "Daily brief" });
await expect(brief).toContainText("Review the documents"); await expect(brief).toContainText("Review the documents");
await expect(brief).toContainText("4 conversations"); await expect(brief).toContainText("3 conversations");
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(brief).not.toContainText("Review the documents"); await expect(brief).not.toContainText("Review the documents");
await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click(); await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
@@ -104,10 +141,53 @@ test("updates the prose brief as priorities change and opens the referenced conv
exact: true, exact: true,
}); });
await dialog.getByRole("button", { name: "Leave for later" }).click(); await dialog.getByRole("button", { name: "Leave for later" }).click();
await expect(brief).toContainText("3 conversations"); await expect(brief).toContainText("2 conversations");
await expect(brief).not.toContainText("Haruka Aoki"); await expect(brief).not.toContainText("Haruka Aoki");
await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から"); await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
await page.getByRole("button", { name: "Preview action" }).click(); await page.getByRole("button", { name: "Preview action" }).click();
await page.getByRole("button", { name: "Apply", exact: true }).click(); await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(brief).toContainText("資料の2ページ目から"); await expect(brief).toContainText("資料の2ページ目から");
}); });
test("keeps completed actions in their original positions with visible outcomes", async ({
page,
a11y,
}, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const titles = page.locator(".execution-flow-title");
const before = await titles.allTextContents();
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
await expect(
page.getByRole("button", { name: "Restore Review the documents", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByRole("button", { name: "Review the documents Completed", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click();
const conversation = page.getByRole("dialog", {
name: "Conversation with Haruka Aoki",
exact: true,
});
await conversation.getByRole("button", { name: "Review reply", exact: true }).click();
await page
.getByRole("dialog", { name: "Review reply", exact: true })
.getByRole("button", { name: "Try sending" })
.click();
await expect(
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toContainText("Reply recorded");
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("button", { name: "Save measurement" }).click();
await expect(
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
).toBeVisible();
expect(await titles.allTextContents()).toEqual(before);
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") });
await page.getByRole("button", { name: "Restore Review the documents", exact: true }).click();
await expect(
page.getByRole("button", { name: "Complete Review the documents", exact: true }),
).toHaveAttribute("aria-pressed", "false");
});
+6 -5
View File
@@ -17,6 +17,7 @@ test("reviews an edited reply and keeps the simulated result in history", async
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();
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();
@@ -33,7 +34,7 @@ test("reviews an edited reply and keeps the simulated result in history", async
await dialog.getByRole("button", { name: "Try sending" }).click(); await dialog.getByRole("button", { name: "Try sending" }).click();
await expect(dialog).toHaveCount(0); await expect(dialog).toHaveCount(0);
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0); await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
await page.getByRole("button", { name: "History", 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(".support-outcome")).toContainText(edited);
await expect(page.locator(".support-outcome")).toContainText(slot); await expect(page.locator(".support-outcome")).toContainText(slot);
@@ -49,16 +50,16 @@ test("defers a conversation and restores it to the current queue", async ({ page
.fill("土曜の予定を確認してから返事するね。"); .fill("土曜の予定を確認してから返事するね。");
await page.getByRole("button", { name: "Leave for later" }).click(); await page.getByRole("button", { name: "Leave for later" }).click();
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0); await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
await page.getByRole("button", { name: "Later", exact: true }).click(); await page.getByRole("button", { name: "Set aside", exact: true }).click();
await rows.filter({ hasText: coffeeSubject }).click(); await rows.filter({ hasText: coffeeSubject }).click();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue( await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
"土曜の予定を確認してから返事するね。", "土曜の予定を確認してから返事するね。",
); );
await page.getByRole("button", { name: "Move to Now" }).click(); await page.getByRole("button", { name: "Move to Inbox" }).click();
const back = page.getByRole("button", { name: "Back to messages" }); const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click(); if (await back.isVisible()) await back.click();
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible(); await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute( await expect(page.getByRole("button", { name: "Inbox", exact: true })).toHaveAttribute(
"aria-pressed", "aria-pressed",
"true", "true",
); );
@@ -107,7 +108,7 @@ test("keeps corrections as new records and summarizes only selected current fact
}); });
test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => { test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => {
for (const width of [320, 375, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }); await page.setViewportSize({ width, height: 850 });
await page.goto("/support"); await page.goto("/support");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
+5 -1
View File
@@ -5,7 +5,11 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
const { tasks, statuses, entries } = useWorkspaceState(); const { tasks, statuses, entries } = useWorkspaceState();
const focus = tasks.find((task) => task.section === "focus" && task.status === "todo"); const focus = tasks.find((task) => task.section === "focus" && task.status === "todo");
const optional = tasks.find((task) => task.section === "optional" && task.status === "todo"); const optional = tasks.find((task) => task.section === "optional" && task.status === "todo");
const contacts = conversations.filter((person) => (statuses[person.id] ?? "now") === "now"); const contacts = conversations.filter(
(person) =>
(statuses[person.id] ?? "now") === "now" &&
(person.replyRequired || statuses[person.id] === "now"),
);
const firstContact = contacts[0]; const firstContact = contacts[0];
const resume = entries.find( const resume = entries.find(
(entry) => (entry) =>
+333
View File
@@ -0,0 +1,333 @@
/* 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;
}
}
+486
View File
@@ -0,0 +1,486 @@
import {
ArrowDown,
ArrowUp,
ArrowUpRight,
Check,
ChevronDown,
Circle,
ListChecks,
MessageCircle,
Pause,
RotateCcw,
Scale,
} from "lucide-react";
import { useState } from "react";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { Input } from "#/components/ui/input";
import { Textarea } from "#/components/ui/textarea";
import { conversations } from "#/features/support/mock-conversations";
import {
type JournalEntry,
type Task,
useWorkspaceState,
} from "#/features/workspace/workspace-state";
type ExecutionWorkbenchProps = {
selectedId?: string;
onSelect: (id: string) => void;
onOpenContact: (id: string) => void;
};
export function ExecutionWorkbench({
selectedId,
onSelect,
onOpenContact,
}: ExecutionWorkbenchProps) {
const { tasks, setTasks, statuses, entries } = useWorkspaceState();
const currentEntries = entries.filter(
(entry) => !entries.some((other) => other.correctionOf === entry.id),
);
const weightEntry = currentEntries.find((entry) => entry.kind === "体重");
const replies = conversations.filter(
(conversation) => conversation.replyRequired || statuses[conversation.id] === "now",
);
function toggle(id: string) {
onSelect(selectedId === id ? "" : id);
}
function finish(id: string) {
setTasks((current) =>
current.map((task) =>
task.id === id ? { ...task, status: task.status === "todo" ? "done" : "todo" } : task,
),
);
}
return (
<section className="execution-day" aria-label="Today’s plan">
{(
[
{ id: "focus", label: "Start here", subtitle: "Just the next thing" },
{ id: "routine", label: "Routines", subtitle: "Find your rhythm" },
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
] as const
).map((section) => (
<section className="execution-flow-group" key={section.id}>
<header className="execution-flow-heading">
<h2>{section.label}</h2>
</header>
<div className="execution-flow-items">
{tasks
.filter((task) => task.section === section.id)
.map((task) => {
const id = `task:${task.id}`;
const expanded = selectedId === id;
const steps = task.steps ?? [];
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
return (
<div
className="execution-flow-card"
key={task.id}
data-expanded={expanded}
data-kind={task.section}
data-status={task.status}
>
<div className="execution-flow-row">
<button
type="button"
className="execution-complete"
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
aria-pressed={task.status === "done"}
disabled={
task.status === "todo" && steps.length > 0 && checked < steps.length
}
onClick={() => finish(task.id)}
>
{task.status === "done" ? (
<Check />
) : task.status === "later" ? (
<Pause />
) : (
<Circle />
)}
</button>
<button
type="button"
className="execution-flow-open"
aria-expanded={expanded}
aria-controls={`detail-${task.id}`}
onClick={() => toggle(id)}
>
<span className="execution-flow-title">{task.title}</span>
<span className="execution-flow-description">
{task.status === "done"
? "Completed"
: task.status === "later"
? "Set aside"
: steps.length
? `${checked} of ${steps.length} steps · ${steps.find((step) => !task.completedSteps?.includes(step)) ?? "Ready to finish"}`
: task.id === "review"
? "Start with the first screen"
: "At your own pace"}
</span>
</button>
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
</div>
{expanded && (
<div className="execution-inline-detail" id={`detail-${task.id}`}>
<TaskDetail task={task} onAction={() => onSelect("")} />
</div>
)}
</div>
);
})}
{section.id === "focus" &&
replies.slice(0, 2).map((conversation) => (
<div
className="execution-flow-card"
key={conversation.id}
data-kind="reply"
data-status={
statuses[conversation.id] === "sent" ||
statuses[conversation.id] === "dismissed"
? "done"
: statuses[conversation.id]
}
>
<div className="execution-flow-row">
<span className="execution-flow-icon">
{statuses[conversation.id] === "sent" ||
statuses[conversation.id] === "dismissed" ? (
<Check />
) : (
<MessageCircle />
)}
</span>
<button
type="button"
className="execution-flow-open"
aria-label={`Open conversation with ${conversation.name}`}
onClick={() => onOpenContact(conversation.id)}
>
<span className="execution-flow-title">Reply to {conversation.name}</span>
<span className="execution-flow-description">
{statuses[conversation.id] === "sent"
? "Reply recorded · Preview only"
: statuses[conversation.id] === "dismissed"
? "No reply needed"
: statuses[conversation.id] === "later"
? "Set aside"
: statuses[conversation.id] === "waiting"
? "Waiting for their reply"
: conversation.subject}
</span>
</button>
<ArrowUpRight className="execution-expand-icon" aria-hidden="true" />
</div>
</div>
))}
{section.id === "routine" && (
<div
className="execution-flow-card"
data-expanded={selectedId === "weight"}
data-kind="weight"
data-status={weightEntry ? "done" : "todo"}
>
<div className="execution-flow-row">
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span>
<button
type="button"
className="execution-flow-open"
aria-expanded={selectedId === "weight"}
aria-controls="detail-weight"
onClick={() => toggle("weight")}
>
<span className="execution-flow-title">Record weight</span>
<span className="execution-flow-description">
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
</span>
</button>
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
</div>
{selectedId === "weight" && (
<div className="execution-inline-detail" id="detail-weight">
<WeightDetail
key={weightEntry?.id ?? "new"}
entry={weightEntry}
onAction={() => onSelect("")}
/>
</div>
)}
</div>
)}
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
(section.id !== "focus" || !replies.length) &&
section.id !== "routine" && (
<p className="execution-flow-empty">A little breathing room.</p>
)}
</div>
</section>
))}
</section>
);
}
function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
const { setTasks, entries, setEntries } = useWorkspaceState();
const source = `task:${task.id}`;
const savedNote = entries.find((entry) => entry.kind === "再開地点" && entry.source === source);
const [note, setNote] = useState(savedNote?.text ?? "");
const [notice, setNotice] = useState("");
const steps = task.steps ?? [];
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
function setStatus(status: Task["status"]) {
setTasks((current) =>
current.map((item) => (item.id === task.id ? { ...item, status } : item)),
);
onAction();
}
function move(direction: -1 | 1) {
setTasks((current) => {
const position = current.findIndex((item) => item.id === task.id);
const siblings = current
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.section === task.section && item.status === task.status);
const sibling = siblings.findIndex(({ item }) => item.id === task.id);
const target = siblings[sibling + direction];
const origin = current[position];
if (!target || !origin) return current;
const updated = [...current];
updated[position] = target.item;
updated[target.index] = origin;
return updated;
});
}
return (
<>
<div className="execution-detail-symbol">
<ListChecks />
</div>
<p className="execution-eyebrow">
{task.section === "routine"
? "Your routine"
: task.section === "optional"
? "If there’s room"
: "One next step"}
</p>
<h2>{task.title}</h2>
{steps.length > 0 ? (
<>
<div className="execution-routine-progress">
<span>
{checked} of {steps.length} steps
</span>
<span>
{task.status === "done"
? "Completed"
: checked === steps.length
? "Ready to finish"
: "At your pace"}
</span>
</div>
<div className="execution-step-list">
{steps.map((step, index) => (
<label
key={step}
htmlFor={`execution-step-${task.id}-${index}`}
data-checked={task.completedSteps?.includes(step) ?? false}
>
<Checkbox
id={`execution-step-${task.id}-${index}`}
checked={task.completedSteps?.includes(step) ?? false}
disabled={task.status === "done"}
onCheckedChange={(value) =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? {
...item,
completedSteps: value
? Array.from(new Set([...(item.completedSteps ?? []), step]))
: (item.completedSteps ?? []).filter(
(completed) => completed !== step,
),
}
: item,
),
)
}
/>
<span className="execution-step-text">{step}</span>
<span className="execution-step-number">{String(index + 1).padStart(2, "0")}</span>
</label>
))}
</div>
</>
) : (
<>
<p className="execution-description">
{task.id === "review"
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
: task.section === "optional"
? "There’s room to decide later. Start whenever it feels right."
: "Choose a small first step. You can leave a note if you need to pause."}
</p>
{task.id === "review" && (
<div className="execution-next-step">
<span>Start here</span>
<p>Open the materials and look at the first screen.</p>
</div>
)}
</>
)}
<div className="execution-detail-actions">
{task.status !== "done" && (
<Button
onClick={() => setStatus("done")}
disabled={steps.length > 0 && checked < steps.length}
>
<Check /> {steps.length > 0 ? "Finish routine" : "Mark complete"}
</Button>
)}
{task.status === "todo" && (
<Button variant="outline" onClick={() => setStatus("later")}>
<Pause /> Later
</Button>
)}
{task.status !== "todo" && (
<Button variant="outline" onClick={() => setStatus("todo")}>
<RotateCcw /> Bring back
</Button>
)}
</div>
<section className="execution-resume-note" aria-label="Resume note">
<label htmlFor={`resume-${task.id}`}>A place to pick up</label>
<Textarea
id={`resume-${task.id}`}
placeholder="Where did you leave off?"
value={note}
onChange={(event) => {
setNote(event.target.value);
setNotice("");
}}
/>
<div className="execution-note-footer">
<span role="status">{notice || "Leave a sentence for your future self."}</span>
<Button
variant="ghost"
size="sm"
disabled={!note.trim()}
onClick={() => {
setEntries((current) => [
{
id: crypto.randomUUID(),
kind: "再開地点",
text: note.trim(),
source,
occurredAt: "",
recordedAt: new Date().toISOString(),
...(savedNote ? { correctionOf: savedNote.id } : {}),
},
...current,
]);
setNotice("Saved");
}}
>
Save note
</Button>
</div>
</section>
<div className="execution-order">
<span>Order in your list</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Move ${task.title} up`}
onClick={() => move(-1)}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Move ${task.title} down`}
onClick={() => move(1)}
>
<ArrowDown />
</Button>
</div>
</>
);
}
function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: () => void }) {
const { setEntries } = useWorkspaceState();
const [weight, setWeight] = useState(entry?.text ?? "");
const [error, setError] = useState("");
return (
<>
<div className="execution-detail-symbol">
<Scale />
</div>
<p className="execution-eyebrow">A quick check-in</p>
<h2>Record your weight</h2>
<p className="execution-description">Just a measurement. No target to hit.</p>
<form
className="execution-weight-form"
onSubmit={(event) => {
event.preventDefault();
const value = Number(weight);
if (!weight.trim() || !Number.isFinite(value) || value <= 0 || value > 1000) {
setError("Enter a weight greater than 0 and no more than 1,000 kg.");
return;
}
setEntries((current) => [
{
id: crypto.randomUUID(),
kind: "体重",
text: String(value),
source: "Home check-in",
occurredAt: new Date().toISOString(),
recordedAt: new Date().toISOString(),
...(entry ? { correctionOf: entry.id } : {}),
},
...current,
]);
setError("");
onAction();
}}
>
<label htmlFor="execution-weight">Weight</label>
<div className="execution-weight-input">
<Input
id="execution-weight"
inputMode="decimal"
type="number"
min="0.1"
max="1000"
step="any"
value={weight}
required
aria-invalid={Boolean(error)}
aria-describedby={error ? "execution-weight-error" : undefined}
onChange={(event) => {
setWeight(event.target.value);
setError("");
}}
/>
<span>kg</span>
</div>
{error && (
<p id="execution-weight-error" role="alert">
{error}
</p>
)}
<Button type="submit">
<Check /> {entry ? "Save correction" : "Save measurement"}
</Button>
</form>
{entry && (
<div className="execution-next-step">
<span>Recorded</span>
<p>{entry.text} kg</p>
</div>
)}
<p className="execution-source-note">Your measurement also appears in Journal.</p>
</>
);
}
+8 -225
View File
@@ -1,21 +1,8 @@
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router"; import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
import { import { ArrowUp, ArrowUpRight, Plus, X } from "lucide-react";
ArrowDown,
ArrowUp,
ArrowUpRight,
Check,
ChevronRight,
Circle,
Coffee,
MessagesSquare,
Plus,
RotateCcw,
X,
} from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Dialog } from "#/components/dialog"; import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
import { Textarea } from "#/components/ui/textarea"; import { Textarea } from "#/components/ui/textarea";
import { TooltipProvider } from "#/components/ui/tooltip"; import { TooltipProvider } from "#/components/ui/tooltip";
@@ -26,11 +13,12 @@ import { ConversationDetail } from "#/features/support/conversation-detail";
import { conversations } from "#/features/support/mock-conversations"; import { conversations } from "#/features/support/mock-conversations";
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state"; import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
import { DailyBrief } from "./daily-brief"; import { DailyBrief } from "./daily-brief";
import { ExecutionWorkbench } from "./execution-workbench";
import { type HomeAction, interpretAction } from "./interpret-action"; import { type HomeAction, interpretAction } from "./interpret-action";
export function HomePage() { export function HomePage() {
const { auth } = useRouteContext({ from: "__root__" }); const { auth } = useRouteContext({ from: "__root__" });
const { tasks, setTasks, statuses, setStatuses, outcomes, setOutcomes, entries, setEntries } = const { tasks, setTasks, setStatuses, outcomes, setOutcomes, entries, setEntries } =
useWorkspaceState(); useWorkspaceState();
const navigate = useNavigate(); const navigate = useNavigate();
const input = useRef<HTMLTextAreaElement>(null); const input = useRef<HTMLTextAreaElement>(null);
@@ -49,11 +37,9 @@ export function HomePage() {
updateSearch({ article: id, conversation: undefined }); updateSearch({ article: id, conversation: undefined });
const contact = conversations.find((item) => item.id === contactId); const contact = conversations.find((item) => item.id === contactId);
const article = articles.find((item) => item.id === articleId); const article = articles.find((item) => item.id === articleId);
const waiting = conversations.filter((item) => (statuses[item.id] ?? "now") === "now");
const currentEntries = entries.filter( const currentEntries = entries.filter(
(entry) => !entries.some((other) => other.correctionOf === entry.id), (entry) => !entries.some((other) => other.correctionOf === entry.id),
); );
const rest = tasks.filter((task) => task.status !== "todo");
function suggest(text: string) { function suggest(text: string) {
setPrompt(text); setPrompt(text);
@@ -230,215 +216,12 @@ export function HomePage() {
{notice} {notice}
</p> </p>
<div className="home-columns"> <div className="home-columns">
<section aria-label="Today’s plan"> <ExecutionWorkbench
{( selectedId={search.activity}
[ onSelect={(id) => updateSearch({ activity: id || undefined })}
{ id: "focus", label: "Start here" }, onOpenContact={setContactId}
{ id: "routine", label: "Routines" }, />
{ id: "optional", label: "If there’s room" },
] as const
).map((section) => (
<section key={section.id} className="home-section">
<header className="home-section-heading">
<h2>{section.label}</h2>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Add to ${section.label}`}
onClick={() =>
suggest(
section.id === "routine"
? "Add routine:"
: section.id === "optional"
? "Optional: "
: "Add:",
)
}
>
<Plus />
</Button>
</header>
{tasks
.filter((task) => task.section === section.id && task.status === "todo")
.map((task) => (
<div className="home-task" key={task.id}>
<button
type="button"
className="home-check"
aria-label={`Complete ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id ? { ...item, status: "done" } : item,
),
)
}
>
<Circle className="size-5" />
</button>
<div className="home-task-body">
<p>{task.title}</p>
{task.steps && (
<details>
<summary>One step at a time</summary>
<div className="home-steps">
{task.steps.map((step) => (
<label
key={step}
htmlFor={`step-${task.id}-${step}`}
className="flex items-center gap-3"
>
<Checkbox
id={`step-${task.id}-${step}`}
checked={task.completedSteps?.includes(step) ?? false}
onCheckedChange={(checked) =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? {
...item,
completedSteps: checked
? [...(item.completedSteps ?? []), step]
: (item.completedSteps ?? []).filter(
(value) => value !== step,
),
}
: item,
),
)
}
/>
<span>{step}</span>
</label>
))}
</div>
</details>
)}
</div>
<div className="home-task-actions">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Move ${task.title} up`}
onClick={() => moveTask(task.id, -1)}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Move ${task.title} down`}
onClick={() => moveTask(task.id, 1)}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={`Defer ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id ? { ...item, status: "later" } : item,
),
)
}
>
Later
</Button>
</div>
</div>
))}
{section.id === "focus" &&
waiting.slice(0, 2).map((item) => (
<div className="home-task" key={item.id}>
<MessagesSquare className="size-5 text-muted-foreground mt-1" />
<button
type="button"
className="home-task-body text-left"
onClick={() => setContactId(item.id)}
>
<span className="block">Reply to {item.name}</span>
<span className="text-xs text-muted-foreground">{item.subject}</span>
</button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Open conversation with ${item.name}`}
onClick={() => setContactId(item.id)}
>
<ChevronRight />
</Button>
</div>
))}
{!tasks.some(
(task) => task.section === section.id && task.status === "todo",
) &&
(section.id !== "focus" || !waiting.length) && (
<p className="home-empty">It’s okay to leave some room.</p>
)}
</section>
))}
{rest.length > 0 && (
<details className="home-rest">
<summary>
Later & done <span>{rest.length}</span>
</summary>
{rest.map((task) => (
<div className="home-task" key={task.id}>
{task.status === "done" ? (
<Check className="size-4" />
) : (
<Coffee className="size-4" />
)}
<span className="flex-1 text-sm">{task.title}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Restore ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id ? { ...item, status: "todo" } : item,
),
)
}
>
<RotateCcw />
</Button>
</div>
))}
</details>
)}
</section>
<aside aria-label="Today’s context"> <aside aria-label="Today’s context">
<section className="home-section">
<header className="home-section-heading">
<h2>Messages</h2>
<Link to="/support" aria-label="View all messages">
<ArrowUpRight className="size-4" />
</Link>
</header>
{conversations
.filter((item) => !["sent", "dismissed"].includes(statuses[item.id] ?? "now"))
.map((item) => (
<button
className="home-context-row"
type="button"
key={item.id}
onClick={() => setContactId(item.id)}
>
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{statuses[item.id] === "waiting"
? "Waiting"
: statuses[item.id] === "later"
? "Later"
: item.channels.join(" / ")}
</span>
</button>
))}
</section>
<section className="home-section"> <section className="home-section">
<header className="home-section-heading"> <header className="home-section-heading">
<h2>Journal</h2> <h2>Journal</h2>
+4 -4
View File
@@ -14,9 +14,9 @@
overflow-y: auto; overflow-y: auto;
} }
.home-content { .home-content {
max-width: 1140px; max-width: 1200px;
margin: auto; margin: auto;
padding: 64px 48px 80px; padding: 40px 48px 80px;
} }
.home-greeting { .home-greeting {
margin-bottom: 32px; margin-bottom: 32px;
@@ -88,8 +88,8 @@
} }
.home-columns { .home-columns {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(240px, 1fr); grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
gap: 64px; gap: 40px;
margin-top: 24px; margin-top: 24px;
} }
.home-section { .home-section {
+1
View File
@@ -5,6 +5,7 @@ const flag = z.literal(true).optional().catch(undefined);
const workspaceSearch = z.object({ const workspaceSearch = z.object({
q: text, q: text,
article: text, article: text,
activity: text,
conversation: text, conversation: text,
view: z.enum(["now", "later", "waiting", "done", "all"]).optional().catch(undefined), view: z.enum(["now", "later", "waiting", "done", "all"]).optional().catch(undefined),
accounts: flag, accounts: flag,
+174 -157
View File
@@ -1,5 +1,5 @@
import { CalendarPlus, Check, ChevronDown, Send } from "lucide-react"; import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
import { useState } from "react"; import { useId, useState } from "react";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,182 +20,199 @@ export function ConversationDetail({
conversation: Conversation; conversation: Conversation;
onResolve: (result: "sent" | "waiting" | "later" | "dismissed", note?: string) => void; onResolve: (result: "sent" | "waiting" | "later" | "dismissed", note?: string) => void;
}) { }) {
const instanceId = useId();
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? ""); const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? "");
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? ""); const [tone, setTone] = useState(conversation.drafts[0]?.label ?? "");
const [review, setReview] = useState<"send" | "schedule" | null>(null); const [review, setReview] = useState<"send" | "schedule" | null>(null);
const [slot, setSlot] = useState(""); const [slot, setSlot] = useState("");
const [scheduled, setScheduled] = useState(""); const [scheduled, setScheduled] = useState("");
return ( return (
<article <article
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10" className="conversation-workbench"
aria-label={`Conversation with ${conversation.name}`} aria-label={`Conversation with ${conversation.name}`}
> >
<header className="mb-8 space-y-2"> <div className="conversation-thread">
<p className="text-xs text-muted-foreground"> <header className="conversation-summary">
{conversation.channels.join(" / ")} · {conversation.role} <span className="conversation-eyebrow">At a glance</span>
</p> <h2>{conversation.subject}</h2>
<h1 className="text-2xl font-medium tracking-tight">{conversation.name}</h1> <p>{conversation.summary}</p>
<p className="text-sm text-muted-foreground">{conversation.subject}</p> </header>
</header> <section className="conversation-messages" aria-label="Original messages">
<section className="mb-8 space-y-4" aria-label="Conversation summary">
<p className="text-sm leading-7">{conversation.summary}</p>
<div className="border-l-2 border-border pl-4">
<p className="text-sm leading-6">{conversation.nextStep}</p>
{conversation.deadline && (
<p className="mt-1 text-xs text-muted-foreground">
Requested deadline · {conversation.deadline}
</p>
)}
</div>
</section>
<details className="group mb-8 border-y border-border py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
Original messages{" "}
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-5 pt-5">
{conversation.messages.map((message) => ( {conversation.messages.map((message) => (
<div <div
key={message.id} key={message.id}
id={`source-${message.id}`} id={`${instanceId}-source-${message.id}`}
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none" className="conversation-message"
data-direction={message.direction}
tabIndex={-1} tabIndex={-1}
> >
<p className="mb-2 text-xs text-muted-foreground"> <p className="conversation-message-meta">
{message.direction === "in" ? conversation.name : "You"} · {message.channel} ·{" "} {message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
{message.time} <span>
{message.channel} · {message.time}
</span>
</p> </p>
<p className="whitespace-pre-wrap text-sm leading-7">{message.text}</p> <div className="conversation-bubble">{message.text}</div>
</div> </div>
))} ))}
</div> </section>
</details> <section aria-label="Compose a reply" className="conversation-compose">
<div className="conversation-compose-heading">
<section aria-label="Compose a reply" className="space-y-3"> <label htmlFor={`${instanceId}-reply`}>Reply</label>
<div className="flex flex-wrap items-center justify-between gap-2"> <span>via {conversation.replyChannel}</span>
<label htmlFor={`support-reply-${conversation.id}`} className="text-sm font-medium">
Reply
</label>
<span className="text-xs text-muted-foreground">
{conversation.replyChannel} → {conversation.name}
</span>
</div>
<div className="flex flex-wrap gap-1">
{conversation.drafts.map((option) => (
<Button
key={option.label}
variant={tone === option.label ? "secondary" : "ghost"}
size="sm"
aria-pressed={tone === option.label}
onClick={() => {
setDraft(option.text);
setTone(option.label);
}}
>
{option.label}
</Button>
))}
</div>
<Textarea
id={`support-reply-${conversation.id}`}
value={draft}
className="min-h-36 resize-y text-sm leading-7"
onChange={(event) => {
setDraft(event.target.value);
setTone("");
}}
/>
{conversation.scheduleOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label="Suggested times"
value={slot}
onChange={(event) => setSlot(event.target.value)}
>
<NativeSelectOption value="">Choose a time</NativeSelectOption>
{conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
<Button
size="sm"
variant="ghost"
disabled={!slot}
onClick={() => {
setDraft((current) => `${current}\n\nWould ${slot} work for you?`);
setTone("");
}}
>
Add to reply
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={!slot}
aria-label="Preview calendar hold"
onClick={() => setReview("schedule")}
>
<CalendarPlus />
</Button>
</div> </div>
)} <div className="conversation-draft-options" aria-label="Reply suggestions">
{scheduled && ( {conversation.drafts.map((option) => (
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground"> <button
<Check className="size-3" /> key={option.label}
{scheduled} held in this preview type="button"
</p> aria-pressed={tone === option.label}
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button disabled={!draft.trim()} onClick={() => setReview("send")}>
<Send />
Review reply
</Button>
<div className="flex flex-wrap gap-1">
<Button variant="ghost" size="sm" onClick={() => onResolve("later")}>
Leave for later
</Button>
<Button variant="ghost" size="sm" onClick={() => onResolve("waiting")}>
Waiting
</Button>
<Button variant="ghost" size="sm" onClick={() => onResolve("dismissed")}>
Close without replying
</Button>
</div>
</div>
</section>
<details className="group mt-10 border-t border-border pt-4">
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
About this person{" "}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary>
<ul className="mt-4 space-y-3 text-sm leading-6">
{conversation.facts.map((fact) => (
<li key={fact.sourceId} className="flex items-start justify-between gap-3">
<span>{fact.text}</span>
<a
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
href={`#source-${fact.sourceId}`}
onClick={() => { onClick={() => {
const source = document.getElementById(`source-${fact.sourceId}`); setDraft(option.text);
const details = source?.closest("details"); setTone(option.label);
if (details) details.open = true;
source?.focus();
}} }}
> >
Source {option.label}
</a> </button>
</li> ))}
))} </div>
</ul> <Textarea
</details> id={`${instanceId}-reply`}
value={draft}
className="min-h-28 resize-y text-sm leading-7"
onChange={(event) => {
setDraft(event.target.value);
setTone("");
}}
/>
{conversation.scheduleOptions.length > 0 && (
<details className="conversation-scheduling">
<summary>
<CalendarPlus aria-hidden="true" />
Suggest a time
<ChevronDown aria-hidden="true" />
</summary>
<div>
<NativeSelect
aria-label="Suggested times"
value={slot}
onChange={(event) => setSlot(event.target.value)}
>
<NativeSelectOption value="">Choose a time</NativeSelectOption>
{conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
<Button
size="sm"
variant="ghost"
disabled={!slot}
onClick={() => {
setDraft((current) => `${current}\n\nWould ${slot} work for you?`);
setTone("");
}}
>
Add to reply
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={!slot}
aria-label="Preview calendar hold"
onClick={() => setReview("schedule")}
>
<CalendarPlus />
</Button>
</div>
</details>
)}
{scheduled && (
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="size-3" />
{scheduled} held in this preview
</p>
)}
<div className="conversation-compose-footer">
<span>Nothing sends without review</span>
<Button disabled={!draft.trim()} onClick={() => setReview("send")}>
<Send />
Review reply
</Button>
</div>
<div className="conversation-triage">
<Button
variant="ghost"
size="sm"
aria-label="Leave for later"
onClick={() => onResolve("later")}
>
Set aside
</Button>
<Button variant="ghost" size="sm" onClick={() => onResolve("waiting")}>
Awaiting reply
</Button>
<Button variant="ghost" size="sm" onClick={() => onResolve("dismissed")}>
No reply needed
</Button>
</div>
</section>
</div>
<aside className="conversation-context" aria-label="Person and conversation context">
<details open className="conversation-context-details">
<summary>
Context
<ChevronDown aria-hidden="true" />
</summary>
<div className="conversation-person">
<span className="support-avatar" aria-hidden="true">
{conversation.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<h3>{conversation.name}</h3>
<p>{conversation.role}</p>
</div>
<section className="conversation-next">
<h3>
<CornerDownRight aria-hidden="true" />
Next step
</h3>
<p>{conversation.nextStep}</p>
{conversation.deadline && (
<div className="conversation-deadline">
<span>Requested by</span>
<strong>{conversation.deadline}</strong>
</div>
)}
</section>
<section className="conversation-facts">
<h3>From the conversation</h3>
<ul>
{conversation.facts.map((fact) => (
<li key={fact.sourceId}>
<p>{fact.text}</p>
<a
href={`#${instanceId}-source-${fact.sourceId}`}
onClick={(event) => {
event.preventDefault();
const source = document.getElementById(
`${instanceId}-source-${fact.sourceId}`,
);
source?.scrollIntoView({ block: "nearest", behavior: "smooth" });
source?.focus({ preventScroll: true });
}}
>
View message ↗
</a>
</li>
))}
</ul>
</section>
</details>
</aside>
<Dialog <Dialog
open={review !== null} open={review !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -7,6 +7,7 @@ export type Conversation = {
subject: string; subject: string;
channels: Channel[]; channels: Channel[];
replyChannel: Channel; replyChannel: Channel;
replyRequired: boolean;
updatedAt: string; updatedAt: string;
deadline?: string; deadline?: string;
summary: string; summary: string;
@@ -32,6 +33,7 @@ export const conversations: Conversation[] = [
subject: "Schedule next week’s design review", subject: "Schedule next week’s design review",
channels: ["Slack", "Gmail"], channels: ["Slack", "Gmail"],
replyChannel: "Slack", replyChannel: "Slack",
replyRequired: true,
updatedAt: "10:42", updatedAt: "10:42",
deadline: "Sep 29, 17:00", deadline: "Sep 29, 17:00",
summary: summary:
@@ -86,6 +88,7 @@ export const conversations: Conversation[] = [
subject: "Catch up over coffee", subject: "Catch up over coffee",
channels: ["LINE"], channels: ["LINE"],
replyChannel: "LINE", replyChannel: "LINE",
replyRequired: true,
updatedAt: "Yesterday", updatedAt: "Yesterday",
summary: "An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.", summary: "An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.",
nextStep: nextStep:
@@ -128,6 +131,7 @@ export const conversations: Conversation[] = [
subject: "Confirm the scope for an estimate", subject: "Confirm the scope for an estimate",
channels: ["Gmail"], channels: ["Gmail"],
replyChannel: "Gmail", replyChannel: "Gmail",
replyRequired: true,
updatedAt: "9:10", updatedAt: "9:10",
deadline: "Sep 30, 12:00", deadline: "Sep 30, 12:00",
summary: summary:
@@ -171,6 +175,7 @@ export const conversations: Conversation[] = [
subject: "The article we talked about", subject: "The article we talked about",
channels: ["Slack"], channels: ["Slack"],
replyChannel: "Slack", replyChannel: "Slack",
replyRequired: false,
updatedAt: "Friday", updatedAt: "Friday",
summary: summary:
"Yu shared an article discussed at the last study group. There are no questions or requests for a reply.", "Yu shared an article discussed at the last study group. There are no questions or requests for a reply.",
+98 -88
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, Search } from "lucide-react"; import { ArrowLeft, MessageSquare, Search } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input"; import { Input } from "#/components/ui/input";
@@ -18,67 +18,75 @@ import { ConversationDetail } from "./conversation-detail";
import { conversations } from "./mock-conversations"; import { conversations } from "./mock-conversations";
const views = [ const views = [
{ id: "now", label: "Now" }, { id: "now", label: "Inbox" },
{ id: "later", label: "Later" },
{ id: "waiting", label: "Waiting" },
{ id: "done", label: "History" },
{ id: "all", label: "All" }, { id: "all", label: "All" },
{ id: "later", label: "Set aside" },
{ id: "waiting", label: "Awaiting reply" },
{ id: "done", label: "Archive" },
] as const; ] as const;
export function SupportPage() { export function SupportPage() {
const [search, updateSearch] = useUrlState(); const [search, updateSearch] = useUrlState();
const view = search.view ?? "now"; const view = search.view ?? "now";
const query = search.q ?? ""; const query = search.q ?? "";
const searchState = useSearchInput("conversation"); const searchState = useSearchInput("conversation");
const searchInput = {
value: searchState.value,
onChange: searchState.onChange,
onCompositionStart: searchState.onCompositionStart,
onCompositionEnd: searchState.onCompositionEnd,
};
const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState(); const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState();
const selectedId = search.conversation; const selectedId = search.conversation;
const reading = !!selectedId;
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const matches = conversations.filter((item) => { const visible = conversations.filter((item) => {
const status = statuses[item.id] ?? "now"; const status = statuses[item.id] ?? "now";
return ( return (
(view === "all" (view !== "now" || item.replyRequired || statuses[item.id] === "now") &&
? true (view === "all" ||
: view === "done" (view === "done" ? status === "sent" || status === "dismissed" : status === view)) &&
? status === "sent" || status === "dismissed" `${item.name} ${item.subject} ${item.channels.join(" ")}`
: status === view) && `${item.name} ${item.subject}`.includes(query) .toLowerCase()
.includes(query.toLowerCase())
); );
}); });
const visible = view === "now" ? matches.slice(0, 3) : matches;
const selected = visible.find((item) => item.id === selectedId) ?? visible[0]; const selected = visible.find((item) => item.id === selectedId) ?? visible[0];
function resolve(id: string, status: Exclude<Status, "now">, note?: string) { function resolve(id: string, status: Exclude<Status, "now">, note?: string) {
setStatuses((current) => ({ ...current, [id]: status })); setStatuses((current) => ({ ...current, [id]: status }));
if (note) setOutcomes((current) => ({ ...current, [id]: note })); if (note) setOutcomes((current) => ({ ...current, [id]: note }));
setNotice( setNotice(
status === "sent" status === "sent"
? "Reply preview recorded" ? "Reply preview recorded. No message sent."
: status === "later" : status === "later"
? "Moved to Later" ? "Set aside for later"
: status === "waiting" : status === "waiting"
? "Moved to Waiting" ? "Awaiting their reply"
: "Marked as no action needed", : "Archived without replying",
); );
updateSearch({ conversation: undefined }); updateSearch({ conversation: undefined });
} }
return ( return (
<TooltipProvider> <TooltipProvider>
<SidebarProvider className="support-workspace" data-reading={reading}> <SidebarProvider className="support-workspace" data-reading={!!selectedId}>
<Sidebar collapsible="none" className="support-sidebar"> <Sidebar collapsible="none" className="support-sidebar">
<WorkspaceNavigation /> <WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list"> <Sidebar collapsible="none" className="support-list">
<SidebarHeader className="border-b p-5 gap-4"> <SidebarHeader className="p-5 gap-4">
<h1 className="text-base font-semibold">Messages</h1> <div className="support-list-title">
<div className="flex gap-1 flex-wrap"> <h1>Messages</h1>
<span>{visible.length}</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input
aria-label="Search messages"
placeholder="Search people or messages"
className="pl-9"
value={searchState.value}
onChange={searchState.onChange}
onCompositionStart={searchState.onCompositionStart}
onCompositionEnd={searchState.onCompositionEnd}
/>
</div>
<nav className="support-filters" aria-label="Conversation filters">
{views.map((tab) => ( {views.map((tab) => (
<Button <button
type="button"
key={tab.id} key={tab.id}
size="sm"
variant={view === tab.id ? "secondary" : "ghost"}
aria-pressed={view === tab.id} aria-pressed={view === tab.id}
onClick={() => { onClick={() => {
updateSearch({ updateSearch({
@@ -89,18 +97,9 @@ export function SupportPage() {
}} }}
> >
{tab.label} {tab.label}
</Button> </button>
))} ))}
</div> </nav>
<div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input
aria-label="Search messages"
placeholder="Search"
className="pl-9"
{...searchInput}
/>
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent className="gap-0"> <SidebarContent className="gap-0">
{visible.map((item) => ( {visible.map((item) => (
@@ -109,23 +108,33 @@ export function SupportPage() {
className="support-row" className="support-row"
aria-current={selected?.id === item.id ? "true" : undefined} aria-current={selected?.id === item.id ? "true" : undefined}
key={item.id} key={item.id}
onClick={() => { onClick={() => updateSearch({ conversation: item.id })}
updateSearch({ conversation: item.id });
}}
> >
<span className="text-xs text-muted-foreground">{item.name}</span> <span className="support-avatar" aria-hidden="true">
<span className="font-medium text-sm">{item.subject}</span> {item.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<span className="support-row-content">
<span className="support-row-heading">
<strong>{item.name}</strong>
<span>{item.updatedAt}</span>
</span>
<span className="support-row-preview">{item.subject}</span>
<span className="support-row-channel">{item.channels.join(" · ")}</span>
</span>
</button> </button>
))} ))}
{!visible.length && ( {!visible.length && (
<p className="p-5 text-sm text-muted-foreground"> <p className="p-5 text-sm text-muted-foreground">
{query ? "No results found" : "No messages to handle right now"} {query ? "No results found" : "Nothing here. You’re all caught up."}
</p> </p>
)} )}
{view === "now" && matches.length > 3 && (
<p className="p-5 text-xs text-muted-foreground">{matches.length - 3} more</p>
)}
</SidebarContent> </SidebarContent>
<div className="support-list-footer" role="status">
{notice || "Sample conversations"}
</div>
</Sidebar> </Sidebar>
</Sidebar> </Sidebar>
<SidebarInset className="support-main"> <SidebarInset className="support-main">
@@ -139,54 +148,55 @@ export function SupportPage() {
> >
<ArrowLeft /> <ArrowLeft />
</Button> </Button>
<span className="text-sm">{selected?.name}</span> {selected && (
<span role="status" className="ml-auto text-xs text-muted-foreground"> <>
{notice} <span className="support-avatar support-avatar-small" aria-hidden="true">
</span> {selected.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<div>
<strong>{selected.name}</strong>
<p>{selected.channels.join(" · ")}</p>
</div>
</>
)}
{selected && view !== "now" && (
<div className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
setStatuses((current) => ({ ...current, [selected.id]: "now" }));
updateSearch({ view: undefined, conversation: selected.id });
}}
>
Move to Inbox
</Button>
</div>
)}
</header> </header>
<section <section className="support-detail" aria-label="Message details">
className="support-detail"
aria-label="Message details"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Independent region needs keyboard scrolling.
tabIndex={0}
>
{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
conversation={conversation} conversation={conversation}
onResolve={(status, note) => resolve(conversation.id, status, note)} onResolve={(status, note) => resolve(conversation.id, status, note)}
/> />
</div> {outcomes[conversation.id] && (
))}
{selected ? (
<>
{outcomes[selected.id] && (
<div className="support-outcome"> <div className="support-outcome">
<h2 className="font-medium text-sm">Activity log</h2> <h2>Reply preview</h2>
<p className="whitespace-pre-wrap mt-2 text-sm">{outcomes[selected.id]}</p> <p>{outcomes[conversation.id]}</p>
</div> </div>
)} )}
{view !== "now" && ( </div>
<Button ))}
variant="outline" {!selected && (
className="m-6" <div className="support-empty">
onClick={() => { <MessageSquare aria-hidden="true" />
setStatuses((current) => ({ <p>Select a conversation</p>
...current, </div>
[selected.id]: "now",
}));
updateSearch({
view: undefined,
conversation: selected.id,
});
}}
>
Move to Now
</Button>
)}
</>
) : (
<div className="support-empty">Select a conversation</div>
)} )}
</section> </section>
</SidebarInset> </SidebarInset>
+387 -15
View File
@@ -1,11 +1,11 @@
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */ /* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 · existing neutral tokens */
.support-workspace { .support-workspace {
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.support-sidebar { .support-sidebar {
width: 390px; width: 370px;
flex-shrink: 0; flex-shrink: 0;
flex-direction: row; flex-direction: row;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
@@ -15,22 +15,126 @@
min-width: 0; min-width: 0;
width: auto; 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,
.conversation-draft-options 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"],
.conversation-draft-options button[aria-pressed="true"] {
background: var(--sidebar-accent);
color: var(--foreground);
}
.support-filters button:hover,
.conversation-draft-options button:hover {
background: var(--muted);
color: var(--foreground);
}
.support-row { .support-row {
display: flex; display: flex;
flex-direction: column; gap: 12px;
gap: 10px; padding: 17px 14px;
padding: 22px 20px; margin: 2px 8px;
border-bottom: 1px solid var(--border); border-radius: 10px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
line-height: 1.6; line-height: 1.5;
} }
.support-row:hover { .support-row:hover {
background: var(--sidebar-accent); background: var(--sidebar-accent);
} }
.support-row[aria-current="true"] { .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); background: var(--muted);
box-shadow: inset 2px 0 var(--primary); }
.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 { .support-main {
min-width: 0; min-width: 0;
@@ -40,33 +144,283 @@
.support-toolbar { .support-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
min-height: 56px; min-height: 70px;
padding: 12px 24px; padding: 12px 28px;
border-bottom: 1px solid var(--border); 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 { .support-detail {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} container-type: inline-size;
.support-detail > * {
max-width: 800px;
margin-inline: auto;
} }
.support-outcome { .support-outcome {
padding: 24px; padding: 24px;
border-top: 1px solid var(--border); 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 { .support-empty {
padding: 64px 24px; padding: 64px 24px;
color: var(--muted-foreground); color: var(--muted-foreground);
text-align: center; text-align: center;
font-size: 13px;
display: grid;
justify-items: center;
gap: 12px;
} }
.support-back { .support-back {
display: none; display: none;
} }
.conversation-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
}
.conversation-thread {
min-width: 0;
display: flex;
flex-direction: column;
}
.conversation-summary {
padding: 26px 30px 22px;
border-bottom: 1px solid var(--border);
}
.conversation-eyebrow {
color: var(--muted-foreground);
font-size: 10px;
}
.conversation-summary h2 {
margin-top: 7px;
font-size: 15px;
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: 28px 30px 34px;
display: flex;
flex-direction: column;
gap: 22px;
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: 14px 17px;
border-radius: 3px 14px 14px;
background: var(--muted);
font-size: 13px;
line-height: 1.8;
white-space: pre-wrap;
}
.conversation-message[data-direction="out"] .conversation-bubble {
background: var(--accent);
border-radius: 14px 3px 14px 14px;
}
.conversation-compose {
padding: 20px 30px 24px;
border-top: 1px solid var(--border);
display: grid;
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-draft-options {
display: flex;
gap: 4px;
}
.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: 25px 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;
min-height: calc(100dvh - 70px);
}
.conversation-context {
border-top: 0;
border-left: 1px solid var(--border);
}
}
@media (max-width: 767px) { @media (max-width: 767px) {
.support-sidebar { .support-sidebar {
width: 100%; width: 100%;
@@ -86,4 +440,22 @@
.support-toolbar { .support-toolbar {
padding: 12px; padding: 12px;
} }
.conversation-summary,
.conversation-compose {
padding: 20px;
}
.conversation-messages {
padding: 24px 20px;
}
.conversation-message {
max-width: 100%;
}
.conversation-compose-footer > span {
max-width: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.conversation-message {
scroll-behavior: auto;
}
} }
+1
View File
@@ -11,4 +11,5 @@
@import "./features/journal/journal.css"; @import "./features/journal/journal.css";
@import "./features/support/support.css"; @import "./features/support/support.css";
@import "./features/home/home.css"; @import "./features/home/home.css";
@import "./features/home/execution-workbench.css";
@import "./features/vitals/vitals.css"; @import "./features/vitals/vitals.css";
+3
View File
@@ -15,6 +15,9 @@
--color-control-rule: var(--input); --color-control-rule: var(--input);
--color-focus: var(--ring); --color-focus: var(--ring);
--color-error: var(--destructive); --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-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%); --color-shadow: oklch(10% 0.015 250 / 45%);
--color-overlay: oklch(10% 0.015 250 / 70%); --color-overlay: oklch(10% 0.015 250 / 70%);