style(memhub): refine onboarding memory mode layout (#374)

* style(memhub): clarify onboarding memory mode layout

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): simplify mode capability list

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): integrate ultra schedule detail

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): align mode dividers and clarify schedule

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): harden mode card edge states

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinhe1111andCursor authored and GitHub committed 2026-11-06 14:43:37 +08:00
1 parent 2e22399e2e
commit d2f1acc84a
9 files changed
+183 -115

No files matched your search

+11 -6
View File
@@ -36,6 +36,7 @@ export function MemoryModeCards({
describedBy,
onChange,
ultraDetail,
ultraDetailBusy = false,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
@@ -47,6 +48,7 @@ export function MemoryModeCards({
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
ultraDetailBusy?: boolean;
"aria-label": string;
"aria-busy"?: boolean;
}) {
@@ -118,13 +120,16 @@ export function MemoryModeCards({
</span>
))}
</span>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div
className="memory-mode-detail"
aria-busy={ultraDetailBusy || undefined}
>
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</div>
);
})}
+14 -5
View File
@@ -1091,15 +1091,16 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
const ultra = screen.getByRole("radio", { name: /ultra/i });
await user.click(ultra);
const schedule = screen.getByRole("complementary", {
name: "每天自动整合",
});
expect(schedule.closest(".memory-mode-card")).toBeNull();
const schedule = screen.getByText("每天自动整合");
expect(
schedule.closest(".memory-mode-card")?.getAttribute("data-mode"),
).toBe("ultra");
expect(schedule.closest("label")).toBeNull();
expect(ultra.getAttribute("aria-describedby")).toBe(
"onboarding-ultra-schedule-description",
);
expect(screen.getByText("每天自动整合")).toBeTruthy();
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
expect(screen.getByText("后续可在设置页自定义时间或关闭。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
@@ -1169,6 +1170,7 @@ describe("OnboardingFlow S1.3", () => {
),
);
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
expect(screen.queryByText("后续可在设置页自定义时间或关闭。")).toBeNull();
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() =>
@@ -1250,12 +1252,19 @@ describe("OnboardingFlow S1.3", () => {
);
const retry = screen.getByRole("button", { name: "重试" });
expect(retry.closest(".memory-mode-card")?.getAttribute("data-mode")).toBe(
"ultra",
);
expect(retry.closest("label")).toBeNull();
retry.focus();
await user.click(retry);
expect(screen.getByRole("button", { name: "重试" })).toHaveProperty(
"disabled",
true,
);
expect(
retry.closest(".memory-mode-detail")?.getAttribute("aria-busy"),
).toBe("true");
expect(document.activeElement).toBe(retry);
expect(api.getEvolveSchedule).toHaveBeenCalledTimes(2);
await act(async () => resolveRetry({ enabled: true, time: "04:15" }));
+18 -15
View File
@@ -7,7 +7,7 @@ import type {
SourceResponse,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { Check, Clock3, X } from "lucide-react";
import { Check, X } from "lucide-react";
import {
type CSSProperties,
type ReactNode,
@@ -1292,22 +1292,15 @@ export function OnboardingFlow({
: undefined,
}}
disabled={modeSaving || completionBusy}
ultraDetailBusy={scheduleLoading}
onChange={(mode) => {
modeChoiceTouched.current = true;
intentRef.current = mode;
setModeChoice(mode);
setModeError(null);
}}
/>
{modeChoice === "ultra" && (
<aside
id="onboarding-ultra-schedule"
className="onboarding-r6-ultra-status"
aria-label={text("每天自动整合")}
aria-busy={scheduleLoading || undefined}
>
<Clock3 size={18} aria-hidden="true" />
{scheduleLoaded ? (
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default" role="status">
<strong>{text("每天自动整合")}</strong>
<small id="onboarding-ultra-schedule-description">
@@ -1316,6 +1309,14 @@ export function OnboardingFlow({
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", ultraSchedule.time)}
{ultraSchedule.enabled ? (
<>
<br />
<span>
{text("后续可在设置页自定义时间或关闭。")}
</span>
</>
) : null}
</small>
</div>
) : scheduleReadFailed ? (
@@ -1329,7 +1330,9 @@ export function OnboardingFlow({
<R6Button
className="onboarding-r6-schedule-retry"
pending={scheduleLoading}
disabled={scheduleLoading}
disabled={
scheduleLoading || modeSaving || completionBusy
}
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
@@ -1343,9 +1346,9 @@ export function OnboardingFlow({
>
{text("正在读取自动整合设置…")}
</p>
)}
</aside>
)}
)
}
/>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
+1
View File
@@ -25,6 +25,7 @@ describe("R6 shared controls", () => {
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
expect(button.getAttribute("data-pending-capable")).toBe("true");
const reservedSpinner = button.querySelector(".r6-button-spinner");
expect(reservedSpinner).toBeTruthy();
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
+1
View File
@@ -28,6 +28,7 @@ export function R6Button({
type={type}
className={`r6-button ${className}`}
data-variant={variant}
data-pending-capable={supportsPending || undefined}
disabled={disabled || busy}
aria-busy={busy || undefined}
>
+94 -47
View File
@@ -50,19 +50,25 @@
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 166px;
}
.memory-mode-card[data-disabled="true"] {
opacity: 0.55;
}
.memory-mode-card-choice {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 100%;
height: auto;
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;
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
grid-template-rows: auto auto;
column-gap: 24px;
align-items: center;
cursor: pointer;
}
@@ -171,51 +177,59 @@
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 28px;
padding: 0 42px 0 24px;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: center;
gap: 8px;
border-left: 1px solid #dbe4ea;
gap: 0;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
}
.memory-mode-features > span {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
align-items: center;
min-width: 0;
min-height: 40px;
padding: 0 16px;
overflow-wrap: break-word;
hyphens: auto;
}
.memory-mode-features > span::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 22px;
background: rgb(199 211 220 / 78%);
transform: translateY(-50%);
}
.memory-mode-features i {
width: 5px;
height: 5px;
margin-top: 7px;
flex: 0 0 5px;
border-radius: 50%;
background: #7f95a4;
display: none;
}
.memory-mode-detail {
grid-column: 1;
grid-row: 3;
align-self: start;
margin-top: 6px;
padding: 7px 0 0;
width: auto;
margin: 7px 18px 12px;
padding: 10px 0 0;
display: flex;
align-items: flex-start;
gap: 7px;
border-top: 1px solid #d4e0e7;
gap: 9px;
border-top: 1px solid rgb(166 190 179 / 48%);
background: transparent;
color: #405867;
font-size: 11px;
line-height: 17px;
font-size: 12px;
line-height: 18px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
margin-top: 1px;
color: #527565;
flex: 0 0 auto;
}
@@ -272,17 +286,31 @@
width: 100%;
margin-top: auto;
padding: 16px 0 2px;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--grid .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 40px);
margin: auto -20px -20px;
padding: 14px 18px;
width: auto;
margin: 8px 18px 16px;
padding: 12px 0 0;
display: block;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
background: transparent;
font-size: 12px;
line-height: 20px;
}
@@ -308,14 +336,18 @@
.memory-mode-cards--rows .memory-mode-card {
min-height: 88px;
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 116px;
}
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 9px 16px;
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
column-gap: 22px;
padding: 11px 16px;
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
column-gap: 18px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 9px;
top: 11px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
@@ -330,14 +362,18 @@
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 36px 0 22px;
gap: 3px;
padding: 0 34px 0 18px;
gap: 0;
font-size: 11px;
line-height: 15px;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 32px;
padding: 0 10px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin-top: 5px;
padding: 5px 0 0;
margin: 6px 14px 10px;
padding: 8px 0 0;
gap: 6px;
font-size: 11px;
line-height: 16px;
@@ -381,15 +417,26 @@
width: 100%;
margin-top: 16px;
padding: 16px 0 0;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--rows .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span::before {
display: none;
}
.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;
width: auto;
margin: 8px 20px 14px;
padding: 12px 0 0;
background: transparent;
}
}
@@ -421,9 +468,9 @@
line-height: 18px;
}
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 32px);
margin: auto -16px -16px;
padding: 10px 14px;
width: auto;
margin: 6px 14px 14px;
padding: 10px 0 0;
font-size: 11px;
line-height: 18px;
}
+11 -41
View File
@@ -588,52 +588,25 @@
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-ultra-status {
min-height: 66px;
padding: 12px 14px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
border: 1px solid rgb(143 169 158 / 42%);
border-radius: 12px;
background:
linear-gradient(105deg, rgb(236 244 240 / 88%), rgb(255 255 255 / 56%)),
rgb(255 255 255 / 42%);
box-shadow:
inset 0 1px rgb(255 255 255 / 82%),
0 8px 24px rgb(47 107 87 / 5%);
color: #405867;
animation: onboarding-r6-ultra-status-in 180ms cubic-bezier(0.16, 1, 0.3, 1)
both;
}
.onboarding-r6-ultra-status > svg {
color: #527565;
}
.onboarding-r6-schedule-loading {
margin: 0;
color: #607482;
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-ultra-status .onboarding-r6-schedule-recovery {
.memory-mode-detail .onboarding-r6-schedule-recovery {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.onboarding-r6-ultra-status .onboarding-r6-schedule-retry {
.memory-mode-detail .onboarding-r6-schedule-retry {
flex: 0 0 auto;
min-width: 76px;
}
@keyframes onboarding-r6-ultra-status-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
@@ -662,11 +635,8 @@
line-height: 22px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
color: #944242;
font-size: 13px;
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
@@ -697,10 +667,6 @@
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-ultra-status {
min-height: 58px;
padding-block: 9px;
}
}
.onboarding-r6-empty {
margin: 0;
@@ -870,6 +836,13 @@
flex-wrap: wrap;
flex-basis: auto;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
flex-direction: column;
align-items: stretch;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
align-self: flex-start;
}
}
@media (prefers-reduced-transparency: reduce) {
.onboarding-r6-shell {
@@ -1042,9 +1015,6 @@
.onboarding-r6-badge[data-found="true"] i {
animation: none;
}
.onboarding-r6-ultra-status {
animation: none;
}
.onboarding-r6-row,
.onboarding-r6 .onboarding-r6-quiet,
.onboarding-r6-list-status {
+13 -1
View File
@@ -190,6 +190,10 @@ button.r6-button[data-variant="primary"] {
0 3px 8px -2px rgb(64 87 102 / 12%);
}
button.r6-button[data-pending-capable="true"] {
padding-inline: 32px;
}
button.r6-button[data-variant="quiet"] {
height: 36px;
min-height: 36px;
@@ -204,6 +208,10 @@ button.r6-button[data-variant="quiet"] {
color: var(--r6-muted);
}
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
padding-inline: 24px;
}
button.r6-button:hover:not(:disabled) {
background: rgb(231 239 244 / 60%);
border-color: #aabcc8;
@@ -242,12 +250,16 @@ button.r6-button[data-variant="quiet"]:disabled {
}
.r6-button-label {
min-width: 0;
text-align: center;
}
.r6-button-spinner {
position: static;
position: absolute;
top: 50%;
right: 12px;
flex: 0 0 auto;
width: 12px;
height: 12px;
margin-top: -6px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
+20
View File
@@ -10,6 +10,7 @@ const modeChineseMessages = {
选择记忆方式: "选择记忆方式",
选择记忆模式: "选择记忆模式",
"之后可在设置中更改。": "之后可在设置中更改。",
"后续可在设置页自定义时间或关闭。": "后续可在设置页自定义时间或关闭。",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra 可在完成首次设置后到设置页开启。",
"使用 Lite 进入": "使用 Lite 进入",
@@ -200,6 +201,8 @@ export const modeEnglishMessages: ModeMessages = {
选择记忆方式: "Choose how memory works",
选择记忆模式: "Choose memory mode",
"之后可在设置中更改。": "You can change this later in Settings.",
"后续可在设置页自定义时间或关闭。":
"You can later customize the time or turn this off in Settings.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra can be enabled in Settings after initial setup.",
"使用 Lite 进入": "Enter with Lite",
@@ -404,6 +407,8 @@ export const modeSpanishMessages: ModeMessages = {
选择记忆方式: "Elegir cómo funciona la memoria",
选择记忆模式: "Elegir el modo de memoria",
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
"后续可在设置页自定义时间或关闭。":
"Más adelante, puedes personalizar la hora o desactivarlo en Configuración.",
"Ultra 可在完成首次设置后到设置页开启。":
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
"使用 Lite 进入": "Entrar con Lite",
@@ -607,6 +612,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
选择记忆方式: "選擇記憶方式",
选择记忆模式: "選擇記憶模式",
"之后可在设置中更改。": "之後可在設定中更改。",
"后续可在设置页自定义时间或关闭。": "之後可在設定頁自訂時間或關閉。",
"Ultra 可在完成首次设置后到设置页开启。":
"完成首次設定後,可到設定頁開啟 Ultra。",
"使用 Lite 进入": "使用 Lite 進入",
@@ -795,6 +801,8 @@ export const modeJapaneseMessages: ModeMessages = {
选择记忆方式: "記憶方法を選択",
选择记忆模式: "記憶モードを選択",
"之后可在设置中更改。": "後から設定で変更できます。",
"后续可在设置页自定义时间或关闭。":
"後から設定画面で時刻を変更したり、無効にしたりできます。",
"Ultra 可在完成首次设置后到设置页开启。":
"初期設定の完了後、設定画面で Ultra を有効にできます。",
"使用 Lite 进入": "Lite で開始",
@@ -994,6 +1002,8 @@ export const modeGermanMessages: ModeMessages = {
选择记忆方式: "Erinnerungsmodus wählen",
选择记忆模式: "Erinnerungsmodus auswählen",
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
"后续可在设置页自定义时间或关闭。":
"Später können Sie die Uhrzeit in den Einstellungen anpassen oder die Funktion deaktivieren.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
"使用 Lite 进入": "Mit Lite starten",
@@ -1198,6 +1208,8 @@ export const modePortugueseMessages: ModeMessages = {
选择记忆方式: "Escolher o modo de memória",
选择记忆模式: "Escolher o modo de memória",
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
"后续可在设置页自定义时间或关闭。":
"Mais tarde, pode personalizar a hora ou desativar esta opção nas Definições.",
"Ultra 可在完成首次设置后到设置页开启。":
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
"使用 Lite 进入": "Entrar com Lite",
@@ -1404,6 +1416,8 @@ export const modeFrenchMessages: ModeMessages = {
选择记忆模式: "Choisir le mode de mémoire",
"之后可在设置中更改。":
"Vous pourrez le modifier plus tard dans les réglages.",
"后续可在设置页自定义时间或关闭。":
"Vous pourrez ensuite personnaliser l’heure ou désactiver cette option dans les réglages.",
"Ultra 可在完成首次设置后到设置页开启。":
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
"使用 Lite 进入": "Entrer avec Lite",
@@ -1607,6 +1621,8 @@ export const modeKoreanMessages: ModeMessages = {
选择记忆方式: "메모리 방식 선택",
选择记忆模式: "메모리 모드 선택",
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
"后续可在设置页自定义时间或关闭。":
"나중에 설정에서 시간을 변경하거나 끌 수 있습니다.",
"Ultra 可在完成首次设置后到设置页开启。":
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
"使用 Lite 进入": "Lite로 시작",
@@ -1805,6 +1821,8 @@ export const modeRussianMessages: ModeMessages = {
选择记忆方式: "Выберите режим памяти",
选择记忆模式: "Выберите режим памяти",
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
"后续可在设置页自定义时间或关闭。":
"Позже в настройках можно изменить время или отключить эту функцию.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra можно включить в настройках после завершения первоначальной настройки.",
"使用 Lite 进入": "Войти с Lite",
@@ -2009,6 +2027,8 @@ export const modeIndonesianMessages: ModeMessages = {
选择记忆方式: "Pilih cara kerja memori",
选择记忆模式: "Pilih mode memori",
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
"后续可在设置页自定义时间或关闭。":
"Nanti, Anda dapat menyesuaikan waktunya atau menonaktifkannya di Pengaturan.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
"使用 Lite 进入": "Masuk dengan Lite",