Compare commits

...
Author SHA1 Message Date
kristinheandCursor a8c833142a style(memhub): refine onboarding setup flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 18:13:20 +08:00
16 changed files with 813 additions and 386 deletions

No files matched your search

@@ -371,13 +371,11 @@ it("uses user-facing connection confirmation copy without implementation narrati
/>,
);
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "一键接入应用" })).toBeTruthy();
expect(
screen.getByText(
"点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。",
screen.queryByText(
/点击底部按钮后|选择草稿|按各应用回执|本页任务控制尚未接通/,
),
).toBeTruthy();
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
+47 -41
View File
@@ -148,6 +148,11 @@ export function FirstImportReview({
const canResume = batch.some((job) =>
["paused", "interrupted", "failed"].includes(job.status),
);
const allCompleted =
batch.length > 0 &&
ids.every((id) => batch.some((job) => job.source_id === id)) &&
batch.every((job) => job.status === "completed") &&
!Object.keys(failedSources).length;
const title = "对话正在汇入,记忆从这里开始";
const hostEligible = importHostEligible(sources, ids);
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
@@ -322,6 +327,16 @@ export function FirstImportReview({
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
<p
@@ -355,46 +370,39 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<p className="first-import-r6-feed-status">
{text("后台将持续为你导入,可以放心进入下一步")}
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
<div className="first-import-r6-options-head">
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
</div>
<SelectionTile
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
@@ -430,13 +438,11 @@ export function FirstImportReview({
{text(connectionNote)}
</p>
)}
<p className="first-import-r6-options-note">
{text(
draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。",
)}
</p>
{!draftReady && (
<p className="first-import-r6-options-note">
{text("暂时无法连接应用,当前不会安装连接。")}
</p>
)}
</aside>
</div>
</R6ColdStartFrame>
+122
View File
@@ -0,0 +1,122 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import "./memory-mode-cards.css";
const MODES: Array<{
id: MemoryPipelineMode;
index: string;
description: string;
}> = [
{
id: "lite",
index: "01",
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
},
{
id: "pro",
index: "02",
description: "使用当前模型从新对话中提取事实、偏好和计划。",
},
{
id: "ultra",
index: "03",
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
},
];
export function MemoryModeCards({
value,
current,
currentBadge,
disabled = false,
onChange,
ultraDetail,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
value: MemoryPipelineMode | null;
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
"aria-label": string;
"aria-busy"?: boolean;
}) {
const { text } = useOnboardingTranslation();
const name = useId();
const unavailable = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className="memory-mode-cards"
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)}
</span>
))}
</span>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">{ultraDetail}</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -96,7 +96,7 @@ it("renders all settings modes as one grouped shared choice list", async () => {
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
).toContain("智能记忆PRO");
});
it("confirms the data effects before switching to lite", async () => {
+48 -56
View File
@@ -25,12 +25,8 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -237,16 +233,11 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeChoiceList
<MemoryModeCards
aria-label={text("当前模式")}
aria-busy={state === null}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
current={state?.mode}
selected={state?.mode ?? null}
value={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
@@ -256,6 +247,50 @@ export function MemoryModeSettings({
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
ultraDetail={
state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings r6-evolve-settings--in-card"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null
}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
@@ -285,49 +320,6 @@ export function MemoryModeSettings({
)
) : null}
{state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
@@ -23,20 +23,20 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({
planMemoryMode: vi.fn().mockImplementation(async (target) => ({
current: "lite",
target: "pro",
effective_target: "pro",
target,
effective_target: target,
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [],
warnings: [],
blockers: ["active_model_required"],
}),
blockers: target === "lite" ? [] : ["active_model_required"],
})),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -156,9 +156,6 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
@@ -187,9 +184,6 @@ it("lets an empty first-time user enter without granting or importing a source",
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -213,9 +207,6 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
@@ -329,7 +329,6 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
@@ -447,9 +446,6 @@ it("translates completion recovery live without repeating completion writes", as
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(
await screen.findByRole("button", { name: "Confirmar selección" }),
);
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
@@ -353,7 +353,6 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -400,7 +399,6 @@ it("partial acceptance preserves the successful job and failure through model-st
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+5 -28
View File
@@ -251,7 +251,6 @@ async function selectMode(
name: RegExp,
) {
await user.click(screen.getByRole("radio", { name }));
await user.click(await screen.findByRole("button", { name: "确认选择" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
@@ -948,7 +947,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
expect(screen.queryByText("推荐")).toBeNull();
@@ -1001,24 +1000,8 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(
await screen.findByRole("heading", {
name: "选择深度记忆(ultra)?",
}),
).toBeTruthy();
expect(
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "确认选择" }));
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("true");
expect(
screen.getByRole("button", { name: "执行时间,03:00" }),
).toBeTruthy();
expect(screen.getByText("每天自动整合")).toBeTruthy();
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
@@ -1087,11 +1070,7 @@ describe("OnboardingFlow S1.3", () => {
false,
),
);
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("false");
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
@@ -1169,9 +1148,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "执行时间,04:15" }),
).toBeTruthy(),
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
+108 -173
View File
@@ -2,8 +2,6 @@ import type {
ConnectSourcesResponse,
EvolveSchedule,
ImportJob,
MemoryModePlan,
MemoryPipelineMode,
OnboardingModeIntent,
OnboardingState,
SourceResponse,
@@ -49,17 +47,12 @@ import {
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY } from "./memory-mode-ui";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
import { traceUiAction } from "./ui-trace";
import type { ImportSourcesController } from "./use-import-sources";
import { isDefiniteRefusal } from "./write-outcome";
@@ -115,7 +108,7 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t } = useOnboardingTranslation();
const { text, t, locale } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -159,14 +152,7 @@ export function OnboardingFlow({
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(null);
const [knownMode, setKnownMode] = useState<MemoryPipelineMode>("pro");
const [pendingMode, setPendingMode] = useState<OnboardingMemoryMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [modePlanBusy, setModePlanBusy] = useState(false);
const modeDialogOpener = useRef<HTMLElement | null>(null);
const modePlanOperation = useRef(0);
const [knownMode, setKnownMode] = useState<OnboardingMemoryMode>("pro");
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
enabled: true,
time: "03:00",
@@ -704,10 +690,6 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
if (setupStepRef.current === "model")
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
else {
@@ -724,45 +706,19 @@ export function OnboardingFlow({
[leaveModelStep, modeChoice],
);
const requestModeSelection = useCallback(
async (mode: OnboardingMemoryMode, opener: HTMLElement) => {
if (modePlanBusy || modeSaving || completionBusy) return;
const operation = ++modePlanOperation.current;
setModePlanBusy(true);
setModeError(null);
const loadModePlan = useCallback(
async (target: OnboardingMemoryMode) => {
try {
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) throw reason;
const profiles = await getModelProfiles();
plan = legacyMemoryModePlan(knownMode, mode, {
hasActiveModel: profiles.some((profile) => profile.is_active),
});
}
if (
!alive.current ||
modePlanOperation.current !== operation ||
setupStepRef.current !== "mode"
)
return;
modeDialogOpener.current = opener;
setPendingMode(mode);
setPendingPlan(plan);
} catch {
if (
alive.current &&
modePlanOperation.current === operation &&
setupStepRef.current === "mode"
)
setModeError("模式切换影响暂时无法读取,请重试。");
} finally {
if (alive.current && modePlanOperation.current === operation)
setModePlanBusy(false);
return await planMemoryMode(target);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) throw reason;
const profiles = await getModelProfiles();
return legacyMemoryModePlan(knownMode, target, {
hasActiveModel: profiles.some((profile) => profile.is_active),
});
}
},
[completionBusy, knownMode, modePlanBusy, modeSaving],
[knownMode],
);
const saveModeChoice = useCallback(async () => {
@@ -777,7 +733,7 @@ export function OnboardingFlow({
if (modeChoice !== "lite") {
const target = modeChoice;
const [plan, profiles] = await Promise.all([
planMemoryMode(target),
loadModePlan(target),
getModelProfiles(),
]);
if (!ownsModeOperation()) return;
@@ -856,6 +812,14 @@ export function OnboardingFlow({
ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
const plan = await loadModePlan("lite");
if (!liteStillCurrent()) return;
if (plan.blockers.length > 0) {
setModeError(
plan.blockers.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
);
return;
}
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
@@ -890,6 +854,7 @@ export function OnboardingFlow({
modeSaving,
completionBusy,
finishOnce,
loadModePlan,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
@@ -947,7 +912,7 @@ export function OnboardingFlow({
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode(target);
const plan = await loadModePlan(target);
if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite(target, ownsConfirmation);
@@ -994,6 +959,7 @@ export function OnboardingFlow({
finishOnce,
leave,
leaveModelStep,
loadModePlan,
modeDestination,
queueModeWrite,
queueProgressWrite,
@@ -1249,125 +1215,94 @@ export function OnboardingFlow({
/>
</div>
{setupStep === "mode" && (
<>
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
focusToken={modeFocusToken}
footer={
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
}
>
<section
className="onboarding-r6-mode"
aria-label={text("记忆模式")}
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
focusToken={modeFocusToken}
footer={
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
completionBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
selected={modeChoice}
disabled={modeSaving || completionBusy || modePlanBusy}
onChange={(mode, control) =>
void requestModeSelection(
mode as OnboardingMemoryMode,
control,
)
}
/>
{modeChoice === "ultra" && scheduleLoaded ? (
<section
className="r6-evolve-settings onboarding-r6-evolve-settings"
aria-label={text("记忆整合(Evolve)")}
>
<UltraEvolveScheduleFields
schedule={ultraSchedule}
disabled={modeSaving || !scheduleLoaded}
onEnabledChange={(enabled) =>
setUltraSchedule((current) => ({
...current,
enabled,
}))
}
onTimeChange={async (time) => {
setUltraSchedule((current) => ({ ...current, time }));
return true;
}}
/>
</section>
) : null}
{modeChoice === "ultra" && scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : null}
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</section>
</R6ColdStartFrame>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={modePlanBusy}
opener={modeDialogOpener.current}
allowedBlockers={["active_model_required"]}
onCancel={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
}}
onConfirm={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
</R6Button>
}
>
<section
className="onboarding-r6-mode"
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<h2>
{locale === "zh-Hans"
? "Choose how MemHub remembers"
: text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
disabled={modeSaving || completionBusy}
onChange={(mode) => {
modeChoiceTouched.current = true;
intentRef.current = pendingMode;
setModeChoice(pendingMode);
intentRef.current = mode;
setModeChoice(mode);
setModeError(null);
setPendingMode(null);
setPendingPlan(null);
}}
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default">
<strong>{text("每天自动整合")}</strong>
<small>
{text(
ultraSchedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", ultraSchedule.time)}
</small>
</div>
) : scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : (
<p>{text("正在读取自动整合设置…")}</p>
)
}
/>
) : null}
</>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
)}
{visitedModel && (
<div hidden={setupStep !== "model"}>
+1 -1
View File
@@ -9,7 +9,7 @@ function declaration(file: string, selector: string): string {
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-choice-list",
".r6-memory-mode-settings .memory-mode-cards",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
+145 -26
View File
@@ -1,11 +1,10 @@
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
.first-import-r6-layout {
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
min-height: 0;
height: 100%;
align-items: stretch;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100%;
height: auto;
}
.first-import-r6-main {
min-width: 0;
@@ -13,6 +12,22 @@
display: flex;
flex-direction: column;
}
.first-import-r6-intro {
margin: 0 0 8px;
}
.first-import-r6-intro h2 {
margin: 0 0 3px;
color: #262a2e;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.first-import-r6-intro p {
margin: 0;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.first-import-r6-history-window {
margin: 0 0 12px;
padding: 8px 10px;
@@ -25,6 +40,101 @@
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 auto;
width: 100%;
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
width: min(680px, 100%);
grid-auto-columns: 100%;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
width: min(720px, 100%);
grid-auto-columns: calc((100% - 16px) / 2);
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 112px;
padding: 12px 18px;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 16px;
text-align: left;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 76px;
height: 76px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
grid-column: 2;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
align-self: end;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: row;
grid-auto-columns: unset;
overflow-x: hidden;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4)):not(
:has(> :nth-child(5))
) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(5)):not(
:has(> :nth-child(6))
) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(6)):not(
:has(> :nth-child(7))
) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-card {
min-height: 112px;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 1px;
padding: 8px;
text-align: center;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-orbit {
width: 54px;
height: 54px;
grid-column: 1;
grid-row: auto;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
:is(h2, p, small) {
grid-column: 1;
align-self: auto;
margin-top: 1px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-icon {
width: 36px;
height: 36px;
}
.first-import-r6-feed {
flex: 0 0 auto;
@@ -76,9 +186,14 @@
margin: 0;
}
.first-import-r6-options {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
padding: 24px;
min-height: 132px;
padding: 22px 24px;
display: grid;
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
align-items: center;
gap: 0 28px;
border: 1px solid #ffffffe0;
border-radius: 22px;
background:
@@ -87,11 +202,14 @@
box-shadow:
inset 0 1px #fff,
0 12px 32px rgb(49 75 101 / 4%);
overflow: auto;
overflow: visible;
scrollbar-width: thin;
overscroll-behavior: contain;
align-self: stretch;
}
.first-import-r6-options-head {
min-width: 0;
}
.first-import-r6-eyebrow {
margin: 0 0 14px;
font-size: 12px;
@@ -117,12 +235,12 @@
position: relative;
display: block;
width: 100%;
min-height: 124px;
margin: 0 0 16px;
padding: 18px 16px 12px;
min-height: 104px;
margin: 0;
padding: 18px 52px 16px 18px;
border: 1px solid rgb(179 196 209 / 42%);
border-radius: 0;
background: rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(255 255 255 / 34%);
box-shadow: inset 0 1px 1px #ffffffd9;
backdrop-filter: blur(20px);
cursor: pointer;
@@ -151,8 +269,8 @@
.first-import-r6-selection input {
appearance: none;
position: absolute;
top: 22px;
right: 18px;
top: 20px;
right: 20px;
width: 16px;
height: 16px;
min-height: 16px;
@@ -190,10 +308,11 @@
display: block;
font-size: 12px;
line-height: 20px;
margin-top: 8px;
margin-top: 7px;
color: #5e6e7a;
}
.first-import-r6-options-note {
grid-column: 1 / -1;
font-size: 11px;
line-height: 22px;
margin: 0 0 12px;
@@ -390,8 +509,7 @@ dialog.first-import-r6-original[open] {
}
@media (max-width: 1150px) {
.first-import-r6-layout {
grid-template-columns: minmax(0, 1fr) 278px;
gap: 24px;
gap: 18px;
}
}
@media (min-width: 761px) and (max-height: 959px) {
@@ -401,12 +519,12 @@ dialog.first-import-r6-original[open] {
padding-bottom: 16px;
}
.first-import-r6-layout {
height: auto;
height: 100%;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 14px;
padding: 18px 20px;
overflow: visible;
}
.first-import-r6-eyebrow {
@@ -424,16 +542,16 @@ dialog.first-import-r6-original[open] {
}
.first-import-r6-selection,
.first-import-r6-selection + .first-import-r6-selection {
min-height: 86px;
margin-bottom: 8px;
padding: 9px 12px;
min-height: 96px;
margin-bottom: 0;
padding: 15px 48px 13px 16px;
}
.first-import-r6-selection strong {
line-height: 24px;
}
.first-import-r6-selection input {
top: 13px;
right: 12px;
top: 17px;
right: 16px;
}
.first-import-r6-selection span {
margin-top: 4px;
@@ -454,6 +572,7 @@ dialog.first-import-r6-original[open] {
overflow: visible;
}
.first-import-r6-options {
display: block;
overflow: visible;
}
.first-import-r6-footer-note {
+241
View File
@@ -0,0 +1,241 @@
.memory-mode-cards {
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.memory-mode-card {
min-width: 0;
min-height: 286px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #c7d3dc;
border-radius: 15px;
background: rgb(255 255 255 / 66%);
color: #304757;
box-shadow: inset 0 1px rgb(255 255 255 / 82%);
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out,
transform 140ms ease-out;
}
.memory-mode-card:has(.memory-mode-card-choice:hover):not(
[data-disabled="true"]
) {
border-color: #91a7b6;
background: rgb(255 255 255 / 82%);
transform: translateY(-1px);
}
.memory-mode-card[data-selected="true"] {
border: 2px solid #6f9183;
background: #fbfdfc;
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
}
.memory-mode-card[data-disabled="true"] {
opacity: 0.55;
}
.memory-mode-card-choice {
min-height: 284px;
padding: 20px;
display: flex;
flex-direction: column;
cursor: pointer;
}
.memory-mode-card[data-disabled="true"] .memory-mode-card-choice {
cursor: default;
}
.memory-mode-card-choice > input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.memory-mode-card:has(input:focus-visible) {
outline: 2px solid var(--r6-focus, #557c93);
outline-offset: 3px;
}
.memory-mode-title {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.memory-mode-index {
flex: 0 0 auto;
color: var(--ok, #2f6b57);
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
}
.memory-mode-title strong {
min-width: 0;
color: #263947;
font-size: 19px;
font-weight: 600;
line-height: 28px;
}
.memory-mode-tier {
flex: 0 0 auto;
color: var(--ok, #2f6b57);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.memory-mode-current {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 999px;
color: #526a60;
background: #edf4f0;
font-size: 10px;
line-height: 17px;
}
.memory-mode-radio {
width: 21px;
height: 21px;
margin-left: auto;
flex: 0 0 21px;
display: grid;
place-items: center;
border: 1px solid #8da0ae;
border-radius: 6px;
color: #fff;
background: rgb(255 255 255 / 82%);
}
.memory-mode-card[data-selected="true"] .memory-mode-radio {
border-color: var(--ok, #2f6b57);
background: var(--ok, #2f6b57);
}
.memory-mode-radio svg {
stroke: #fff;
stroke-width: 2;
}
.memory-mode-description {
min-height: 50px;
margin-top: 10px;
color: #526674;
font-size: 13px;
line-height: 22px;
text-wrap: pretty;
}
.memory-mode-features {
margin-top: auto;
padding: 16px 0 2px;
display: grid;
gap: 8px;
border-top: 1px solid #dbe4ea;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
}
.memory-mode-features > span {
display: flex;
align-items: flex-start;
gap: 8px;
}
.memory-mode-features i {
width: 5px;
height: 5px;
margin-top: 7px;
flex: 0 0 5px;
border-radius: 50%;
background: #7f95a4;
}
.memory-mode-detail {
margin-top: auto;
padding: 14px 18px;
border-top: 1px solid #d4e0e7;
background: #f1f6f8;
color: #405867;
font-size: 12px;
line-height: 20px;
animation: memory-mode-detail-in 180ms ease-out both;
}
@keyframes memory-mode-detail-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards {
gap: 14px;
}
.memory-mode-card {
min-height: 194px;
}
.memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
.memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
.memory-mode-detail {
padding: 10px 14px;
font-size: 11px;
line-height: 18px;
}
}
@media (max-width: 680px) {
.memory-mode-cards {
grid-template-columns: 1fr;
}
.memory-mode-card,
.memory-mode-card-choice {
min-height: 0;
}
.memory-mode-description {
min-height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.memory-mode-card,
.memory-mode-detail {
animation: none;
transition: none;
}
}
+29 -5
View File
@@ -594,7 +594,7 @@
}
.onboarding-model-layout {
display: block;
width: min(760px, 100%);
width: min(880px, 100%);
height: auto;
margin-inline: auto;
}
@@ -603,11 +603,13 @@
}
.onboarding-model-group {
display: grid;
gap: 20px;
padding: 24px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
padding: 24px 26px;
border: 1px solid #d8e2e8;
border-radius: 14px;
background: #fff;
border-radius: 16px;
background: rgb(255 255 255 / 72%);
box-shadow: inset 0 1px rgb(255 255 255 / 88%);
}
.onboarding-model-provider,
.onboarding-model-key {
@@ -639,6 +641,7 @@
line-height: 19px;
}
.onboarding-model-default-summary {
grid-column: 1 / -1;
display: grid;
gap: 6px;
padding: 14px 0;
@@ -656,10 +659,12 @@
font-weight: 600;
}
.onboarding-model-disclosure {
grid-column: 1 / -1;
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
grid-column: 1 / -1;
border-top: 1px solid #e4eaee;
padding-top: 4px;
}
@@ -750,8 +755,27 @@
overflow: visible;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: hidden;
}
.onboarding-model-layout,
.onboarding-model-form {
height: 100%;
min-height: 0;
}
.onboarding-model-form {
overflow-y: auto;
scrollbar-width: thin;
}
.onboarding-model-group {
gap: 14px 18px;
padding: 18px 22px;
}
}
@media (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
grid-template-columns: minmax(0, 1fr);
padding: 16px;
border-radius: 10px;
}
+36 -25
View File
@@ -225,6 +225,22 @@
overscroll-behavior: contain;
scrollbar-width: thin;
}
.onboarding-r6-body:has(> .onboarding-r6-mode) {
padding-block: 14px;
overflow-y: auto;
}
.onboarding-r6-mode {
width: 100%;
gap: 12px;
}
.onboarding-r6-mode-intro h2 {
font-size: 20px;
line-height: 28px;
}
.onboarding-r6-mode-intro p {
font-size: 12px;
line-height: 20px;
}
}
.onboarding-r6-discovery {
height: 100%;
@@ -541,41 +557,36 @@
margin: 0;
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.onboarding-r6-mode-intro {
text-align: center;
text-align: left;
}
.onboarding-r6-mode-intro h2 {
margin: 0;
color: #263947;
font-size: 22px;
line-height: 32px;
}
.onboarding-r6-mode-intro p {
margin: 0;
margin: 4px 0 0;
color: #637381;
font-size: 14px;
line-height: 22px;
}
.onboarding-r6-mode .memory-mode-choice-list {
background: rgb(255 255 255 / 72%);
.onboarding-r6-ultra-default {
display: grid;
gap: 2px;
}
.onboarding-r6-mode .memory-mode-choice {
min-height: 82px;
padding: 15px 18px;
}
.onboarding-r6-mode .memory-mode-choice-copy {
gap: 4px;
}
.onboarding-r6-mode .memory-mode-choice-copy strong {
font-size: 16px;
line-height: 25px;
}
.onboarding-r6-mode .memory-mode-choice-copy small {
font-size: 13px;
line-height: 22px;
}
.onboarding-r6-mode-ultra-note {
margin: -6px 0 0;
color: #637381;
.onboarding-r6-ultra-default strong {
color: #304757;
font-size: 12px;
line-height: 20px;
text-align: center;
font-weight: 600;
}
.onboarding-r6-ultra-default small {
color: #607482;
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
+18 -1
View File
@@ -227,10 +227,27 @@
font-size: 13px;
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-choice-list {
.r6-memory-mode-settings .memory-mode-cards {
width: 100%;
max-width: none;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
grid-column: 1 / -1;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"]
.memory-mode-card-choice {
min-height: 180px;
}
.r6-evolve-settings--in-card {
margin: 0;
padding: 0;
border-top: 0;
}
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
border-bottom: 0;
}
.r6-settings-note {
margin: 14px 0 0;
padding: 10px 12px;