diff --git a/memhub/apps/web/src/MemoryModeCards.tsx b/memhub/apps/web/src/MemoryModeCards.tsx index 3a72d9d..088f96d 100644 --- a/memhub/apps/web/src/MemoryModeCards.tsx +++ b/memhub/apps/web/src/MemoryModeCards.tsx @@ -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>; onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void; ultraDetail?: ReactNode; + ultraDetailBusy?: boolean; "aria-label": string; "aria-busy"?: boolean; }) { @@ -118,13 +120,16 @@ export function MemoryModeCards({ ))} - {mode.id === "ultra" && selected && ultraDetail ? ( -
-
- ) : null} + {mode.id === "ultra" && selected && ultraDetail ? ( +
+
+ ) : null} ); })} diff --git a/memhub/apps/web/src/OnboardingFlow.test.tsx b/memhub/apps/web/src/OnboardingFlow.test.tsx index 2e236a7..3accc61 100644 --- a/memhub/apps/web/src/OnboardingFlow.test.tsx +++ b/memhub/apps/web/src/OnboardingFlow.test.tsx @@ -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" })); diff --git a/memhub/apps/web/src/OnboardingFlow.tsx b/memhub/apps/web/src/OnboardingFlow.tsx index 687d7b0..c48dbac 100644 --- a/memhub/apps/web/src/OnboardingFlow.tsx +++ b/memhub/apps/web/src/OnboardingFlow.tsx @@ -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" && ( - - )} + ) + } + /> {(modeError || error) && (

{text(modeError ?? error ?? "")} diff --git a/memhub/apps/web/src/R6Controls.test.tsx b/memhub/apps/web/src/R6Controls.test.tsx index 14d7039..7050e31 100644 --- a/memhub/apps/web/src/R6Controls.test.tsx +++ b/memhub/apps/web/src/R6Controls.test.tsx @@ -25,6 +25,7 @@ describe("R6 shared controls", () => { , ); 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(); diff --git a/memhub/apps/web/src/R6Controls.tsx b/memhub/apps/web/src/R6Controls.tsx index 223720c..9fcd391 100644 --- a/memhub/apps/web/src/R6Controls.tsx +++ b/memhub/apps/web/src/R6Controls.tsx @@ -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} > diff --git a/memhub/apps/web/src/memory-mode-cards.css b/memhub/apps/web/src/memory-mode-cards.css index 2a927f0..8bad015 100644 --- a/memhub/apps/web/src/memory-mode-cards.css +++ b/memhub/apps/web/src/memory-mode-cards.css @@ -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; } diff --git a/memhub/apps/web/src/onboarding-r6.css b/memhub/apps/web/src/onboarding-r6.css index 6410c33..d817c33 100644 --- a/memhub/apps/web/src/onboarding-r6.css +++ b/memhub/apps/web/src/onboarding-r6.css @@ -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 { diff --git a/memhub/apps/web/src/r6-controls.css b/memhub/apps/web/src/r6-controls.css index e914d36..aba412e 100644 --- a/memhub/apps/web/src/r6-controls.css +++ b/memhub/apps/web/src/r6-controls.css @@ -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%; diff --git a/memhub/packages/i18n/src/gui-i18n-mode.ts b/memhub/packages/i18n/src/gui-i18n-mode.ts index 200a03e..b3ac814 100644 --- a/memhub/packages/i18n/src/gui-i18n-mode.ts +++ b/memhub/packages/i18n/src/gui-i18n-mode.ts @@ -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",