feat(ui): refine gem-blue acrylic palette and focus borders

Derive the gem-blue palette with OKLCH tonal and complementary-color
rules while preserving acrylic opacity, blur and surface hierarchy.

Darker focus tokens exposed stacked field rings, fallback outlines and
persistent basemap selection rings. Keep fallback focus styles in the
base layer and use one themed field boundary or inset card stroke.
Preserve keyboard focus, invalid states and native input semantics.

Add browser regression coverage for pointer and keyboard focus, all
basemap presentations, narrow layouts and rendered contrast.

Validation in an isolated checkout: pnpm lint, pnpm typecheck,
pnpm test (393 tests), focused browser suites and pnpm build.
This commit is contained in:
2026-10-09 10:57:06 +08:00
parent d2d705ed4c
commit 617ba4d87c
69 changed files with 1239 additions and 765 deletions
@@ -53,9 +53,9 @@ import {
} from "./scheduled-condition-feed-utils";
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
"surface-reading rounded-xl border border-slate-200/70";
"surface-reading rounded-xl border border-[var(--surface-divider)]/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
"surface-control rounded-xl border border-slate-200/70";
"surface-control rounded-xl border border-[var(--surface-divider)]/70";
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
export type ScheduledConditionFeedPresentation =
@@ -416,7 +416,7 @@ export function ScheduledConditionFeed({
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
"grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
"rounded-lg"
)}
>
@@ -424,7 +424,7 @@ export function ScheduledConditionFeed({
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
<p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">{headerSummary}</p>
</div>
{onRefresh ? (
<button
@@ -433,7 +433,7 @@ export function ScheduledConditionFeed({
title={refreshing ? "正在刷新" : "刷新工况任务"}
disabled={refreshing}
onClick={() => void handleRefresh()}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 disabled:cursor-wait disabled:text-blue-600 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors active:scale-95 disabled:cursor-wait disabled:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
>
<RefreshCw
size={17}
@@ -447,7 +447,7 @@ export function ScheduledConditionFeed({
aria-expanded={expanded}
onClick={handleToggleExpanded}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
"grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
"rounded-lg"
)}
>
@@ -594,17 +594,17 @@ function TaskCategorySwitch({
aria-current={active ? "page" : undefined}
onClick={() => onChange(category.id)}
className={cn(
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200",
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
active
? "bg-white text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-800"
? "bg-[var(--surface-reading)] text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-800"
)}
>
<span className="flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-semibold">{category.label}</span>
<span className="shrink-0 text-xs tabular-nums text-slate-400">{counts[category.id]}</span>
<span className="shrink-0 text-xs tabular-nums text-[var(--ink-muted)]">{counts[category.id]}</span>
</span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-slate-400">
<span className="mt-0.5 block truncate text-[10px] leading-3 text-[var(--ink-muted)]">
{category.sourceLabel}
</span>
</button>
@@ -772,7 +772,7 @@ function ConditionFilterBar({
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700">筛选</span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
<span className="shrink-0 text-xs font-semibold text-[var(--ink-muted)]">
{filteredConditionCount}/{totalConditionCount}
</span>
</div>
@@ -800,7 +800,7 @@ function ConditionFilterBar({
<button
type="button"
onClick={onResetFilters}
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50"
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--surface-divider)]/70 px-2 text-xs font-semibold text-[var(--action-blue-hover)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--action-soft)]"
>
<XCircle size={12} aria-hidden="true" />
重置筛选
@@ -831,13 +831,13 @@ function ConditionFilterDropdown<TValue extends string>({
return (
<div className="min-w-0">
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
<span className="mb-1 block text-xs font-semibold text-[var(--ink-muted)]">{label}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-hidden transition-colors hover:bg-blue-50 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-[var(--surface-divider)]/70 px-2 text-left text-xs outline-hidden transition-colors hover:bg-[var(--action-soft)] focus-visible:border-[var(--surface-border)] focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--action-soft)] data-[state=open]:ring-2 data-[state=open]:ring-[var(--surface-border)]",
"rounded-xl"
)}
>
@@ -848,21 +848,21 @@ function ConditionFilterDropdown<TValue extends string>({
>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">
<span className="block text-xs leading-3 text-[var(--ink-muted)]">
{selectedOption?.count ?? 0} 条
</span>
</span>
<ChevronDown
size={13}
aria-hidden="true"
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
className="shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)]"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-[var(--surface-divider)]/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
>
<DropdownMenuRadioGroup
value={value}
@@ -873,12 +873,12 @@ function ConditionFilterDropdown<TValue extends string>({
key={option.value}
value={option.value}
className={cn(
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-800",
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-[var(--action-soft)] focus:text-[var(--action-blue-pressed)] data-[state=checked]:border-[var(--surface-border)] data-[state=checked]:bg-[var(--action-soft)] data-[state=checked]:text-[var(--action-blue-pressed)]",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-[var(--ink-muted)]">
{option.count}
</span>
</DropdownMenuRadioItem>
@@ -892,12 +892,12 @@ function ConditionFilterDropdown<TValue extends string>({
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-[var(--surface-divider)]/80 px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-[var(--surface-control)] text-[var(--ink-muted)]">
<ChevronRight size={15} aria-hidden="true" />
</span>
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
<p className="mt-1 text-xs leading-4 text-[var(--ink-muted)]">{description}</p>
</div>
);
}
@@ -910,23 +910,23 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
key={index}
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
>
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-[var(--surface-divider)]/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-[var(--surface-divider)]"
aria-hidden="true"
/>
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-[var(--surface-divider)] ring-4 ring-white" />
</span>
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2 py-1.5">
<span className="surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70 px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-[var(--surface-divider)]/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-[var(--surface-control)]" />
</span>
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-[var(--surface-control)]" />
</span>
</span>
</div>
@@ -944,26 +944,26 @@ function ConditionDetailSkeleton() {
aria-label="工况详情加载中"
>
<div className="flex gap-2.5">
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-[var(--surface-control)]", "rounded-lg")} />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
<span className="h-4 w-12 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="h-5 w-14 animate-pulse rounded-full bg-[var(--action-selection-soft)]" />
</div>
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
<span className="block h-4 w-44 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="block h-3 w-full animate-pulse rounded-full bg-[var(--surface-control)]" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-[var(--surface-control)]" />
</div>
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
className="surface-reading rounded-xl border border-[var(--surface-divider)]/70 px-3 py-3"
>
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-[var(--surface-control)]" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-[var(--surface-control)]" />
</div>
))}
</div>
@@ -1006,7 +1006,7 @@ function ConditionGroupHeader({
return (
<div
className={cn(
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-[var(--ink-muted)]",
compact ? "mb-1" : "mb-2"
)}
>
@@ -1064,19 +1064,19 @@ function ConditionTimelineRow({
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-[var(--action-soft)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
selected
? "surface-reading border-blue-100 text-blue-900"
: "border-transparent hover:bg-slate-50"
? "surface-reading border-[var(--surface-border)] text-[var(--action-blue-pressed)]"
: "border-transparent hover:bg-[var(--surface-reading)]"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)]">
{formatTime(condition.scheduledAt)}
</span>
<span className="relative flex min-h-12 justify-center pt-1.5">
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-[var(--surface-divider)] transition-colors group-hover:bg-[var(--action-selection-soft)] group-focus-visible:bg-[var(--action-blue)]"
aria-hidden="true"
/>
) : null}
@@ -1087,7 +1087,7 @@ function ConditionTimelineRow({
data-selected={selected ? "true" : undefined}
className={cn(
"relative z-10 ring-4 ring-white",
selected && "group-focus-visible:ring-blue-100"
selected && "group-focus-visible:ring-[var(--action-focus-ring)]"
)}
>
<Icon size={12} />
@@ -1096,7 +1096,7 @@ function ConditionTimelineRow({
<span
className={cn(
"min-w-0 px-2 py-1.5 transition-colors",
selected ? "text-blue-900" : "text-slate-700"
selected ? "text-[var(--action-blue-pressed)]" : "text-slate-700"
)}
>
<span className="flex min-w-0 items-center gap-2">
@@ -1104,7 +1104,7 @@ function ConditionTimelineRow({
<span className="block truncate text-xs font-semibold leading-4">
{condition.title}
</span>
<span className="block truncate text-xs leading-4 text-slate-500">
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{condition.summary}
</span>
</span>