refactor: migrate UI to Tailwind and unify chat with shadcn

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