Compare commits

...
Author SHA1 Message Date
kristinheandCursor bd0a6342b7 style(memhub): stack onboarding memory mode cards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:34:26 +08:00
5 changed files with 391 additions and 95 deletions

No files matched your search

@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { MemoryModeCards } from "./MemoryModeCards";
afterEach(cleanup);
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
const view = (value: "lite" | "pro" | "ultra") => (
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value={value}
layout="rows"
onChange={onChange}
ultraDetail={
<div>
<strong>每天自动整合</strong>
<small>默认 03:00,可在设置中修改。</small>
</div>
}
/>
</LanguageProvider>
);
const { rerender } = render(view("pro"));
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
rerender(view("ultra"));
expect(screen.getByText("每天自动整合")).toBeTruthy();
});
it("preserves per-mode disabled behavior", () => {
render(
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value="lite"
disabled={(mode) => mode !== "lite"}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
"disabled",
false,
);
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
"disabled",
true,
);
});
+69 -60
View File
@@ -1,5 +1,5 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check } from "lucide-react";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -32,6 +32,7 @@ export function MemoryModeCards({
current,
currentBadge,
disabled = false,
layout = "grid",
onChange,
ultraDetail,
"aria-label": ariaLabel,
@@ -41,6 +42,7 @@ export function MemoryModeCards({
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
layout?: "grid" | "rows";
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
"aria-label": string;
@@ -53,70 +55,77 @@ export function MemoryModeCards({
return (
<div
className="memory-mode-cards"
className={`memory-mode-cards memory-mode-cards--${layout}`}
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(/\s*·\s*/);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
<div className="memory-mode-card-list">
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
))}
</span>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">{ultraDetail}</div>
) : null}
</div>
);
})}
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
</span>
))}
</span>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</label>
</div>
);
})}
</div>
</div>
);
}
+1
View File
@@ -1266,6 +1266,7 @@ export function OnboardingFlow({
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
layout="rows"
disabled={modeSaving || completionBusy}
onChange={(mode) => {
modeChoiceTouched.current = true;
+245 -35
View File
@@ -1,14 +1,26 @@
.memory-mode-cards {
position: relative;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
min-height: 420px;
display: flex;
}
.memory-mode-card-list {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.memory-mode-card {
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: 286px;
min-height: 128px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -43,10 +55,15 @@
}
.memory-mode-card-choice {
min-height: 284px;
padding: 20px;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
height: 100%;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
grid-template-rows: auto auto minmax(0, 1fr);
column-gap: 28px;
align-items: center;
cursor: pointer;
}
@@ -68,10 +85,13 @@
}
.memory-mode-title {
grid-column: 1;
grid-row: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding-right: 34px;
}
.memory-mode-index {
@@ -110,9 +130,11 @@
}
.memory-mode-radio {
position: absolute;
top: 18px;
right: 20px;
width: 21px;
height: 21px;
margin-left: auto;
flex: 0 0 21px;
display: grid;
place-items: center;
@@ -133,8 +155,11 @@
}
.memory-mode-description {
min-height: 50px;
margin-top: 10px;
grid-column: 1;
grid-row: 2;
align-self: start;
min-height: 0;
margin-top: 6px;
color: #526674;
font-size: 13px;
line-height: 22px;
@@ -142,11 +167,16 @@
}
.memory-mode-features {
margin-top: auto;
padding: 16px 0 2px;
grid-column: 2;
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 28px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: center;
gap: 8px;
border-top: 1px solid #dbe4ea;
border-left: 1px solid #dbe4ea;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
@@ -168,14 +198,97 @@
}
.memory-mode-detail {
margin-top: auto;
padding: 14px 18px;
grid-column: 1;
grid-row: 3;
align-self: start;
margin-top: 6px;
padding: 7px 0 0;
display: flex;
align-items: flex-start;
gap: 7px;
border-top: 1px solid #d4e0e7;
background: #f1f6f8;
background: transparent;
color: #405867;
font-size: 11px;
line-height: 17px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
margin-top: 1px;
flex: 0 0 auto;
}
.memory-mode-detail > div {
min-width: 0;
flex: 1;
}
.memory-mode-cards--grid {
flex: none;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.memory-mode-cards--grid .memory-mode-card-list {
display: contents;
}
.memory-mode-cards--grid .memory-mode-card {
position: static;
flex: none;
min-height: 286px;
height: auto;
}
.memory-mode-cards--grid .memory-mode-card-choice {
position: static;
min-height: 284px;
height: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--grid .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--grid .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 50px;
margin-top: 10px;
}
.memory-mode-cards--grid .memory-mode-features {
width: 100%;
margin-top: auto;
padding: 16px 0 2px;
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 40px);
margin: auto -20px -20px;
padding: 14px 18px;
display: block;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
font-size: 12px;
line-height: 20px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-cards--grid .memory-mode-detail > svg {
display: none;
}
@keyframes memory-mode-detail-in {
@@ -185,34 +298,131 @@
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards {
gap: 14px;
@media (min-width: 761px) and (max-height: 959px) {
.memory-mode-cards--rows {
min-height: 360px;
}
.memory-mode-card {
min-height: 194px;
.memory-mode-cards--rows .memory-mode-card-list {
gap: 12px;
}
.memory-mode-card-choice {
min-height: 194px;
padding: 16px;
.memory-mode-cards--rows .memory-mode-card {
min-height: 112px;
}
.memory-mode-title strong {
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 13px 16px;
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
column-gap: 22px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 13px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-description {
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 4px;
font-size: 12px;
line-height: 18px;
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 36px 0 22px;
gap: 6px;
font-size: 11px;
line-height: 17px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin-top: 5px;
padding: 5px 0 0;
gap: 6px;
font-size: 11px;
line-height: 16px;
}
}
@media (max-width: 760px) {
.memory-mode-cards--rows {
flex: none;
min-height: 0;
}
.memory-mode-cards--rows .memory-mode-card-list {
display: grid;
grid-template-columns: 1fr;
}
.memory-mode-cards--rows .memory-mode-card {
flex: none;
min-height: 0;
height: auto;
}
.memory-mode-cards--rows .memory-mode-card-choice {
height: auto;
min-height: 0;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--rows .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--rows .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 8px;
}
.memory-mode-cards--rows .memory-mode-features {
width: 100%;
margin-top: 16px;
padding: 16px 0 0;
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-detail {
width: calc(100% + 40px);
margin: 16px -20px -20px;
padding: 14px 20px 16px;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards--grid {
gap: 14px;
}
.memory-mode-cards--grid .memory-mode-card {
min-height: 194px;
}
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-cards--grid .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
.memory-mode-features {
.memory-mode-cards--grid .memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
.memory-mode-detail {
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 32px);
margin: auto -16px -16px;
padding: 10px 14px;
font-size: 11px;
line-height: 18px;
@@ -220,14 +430,14 @@
}
@media (max-width: 680px) {
.memory-mode-cards {
.memory-mode-cards--grid {
grid-template-columns: 1fr;
}
.memory-mode-card,
.memory-mode-card-choice {
.memory-mode-cards--grid .memory-mode-card,
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 0;
}
.memory-mode-description {
.memory-mode-cards--grid .memory-mode-description {
min-height: 0;
}
}
+6
View File
@@ -634,6 +634,12 @@
font-size: 13px;
line-height: 20px;
}
@media (min-width: 761px) {
.onboarding-r6-mode {
min-height: 100%;
gap: 24px;
}
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;