From e6db73f9bacacaa28946dec10c986c70b835421b Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 23 Jul 2026 20:15:30 +0800 Subject: [PATCH] fix: refine heaven readings and heart breathing --- static/app.js | 21 ++++--- static/index.html | 8 +-- static/styles.css | 122 ++++++++++++++++++++++++++++++++++-- tests/e2e/app-shell.spec.js | 42 ++++++++++++- 4 files changed, 170 insertions(+), 23 deletions(-) diff --git a/static/app.js b/static/app.js index d354fc4..ba384a4 100644 --- a/static/app.js +++ b/static/app.js @@ -10,6 +10,9 @@ const { todayString, } = window.XiaobaiUI; +const HEART_BREATH_TOTAL_MS = 40_000; +const HEART_BREATH_PHASE_MS = 4_000; + const state = { user: null, csrfToken: "", @@ -81,7 +84,7 @@ const state = { heavenReadingError: "", heartStage: "intro", heartTimer: null, - heartSeconds: 30, + heartSeconds: HEART_BREATH_TOTAL_MS / 1000, heartBreathingEndsAt: 0, heartLines: [], heartThrows: [], @@ -3791,11 +3794,11 @@ function waitForHeartMotion(duration, token = state.heartStageToken) { async function startHeartBreathing() { if (state.heartTimer) clearInterval(state.heartTimer); - state.heartSeconds = 30; + state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; document.querySelector("#beginCastingButton")?.classList.remove("is-ready"); if (!await transitionHeartStage("breathing")) return; - state.heartBreathingEndsAt = Date.now() + 30_000; + state.heartBreathingEndsAt = Date.now() + HEART_BREATH_TOTAL_MS; const ember = document.querySelector("#heartIncenseEmber"); ember?.classList.remove("is-burning"); if (ember) void ember.offsetWidth; @@ -3821,22 +3824,20 @@ function finishHeartBreathing() { } function updateBreathingDisplay() { - setText("breathingSeconds", Math.max(0, state.heartSeconds)); const remainingMs = state.heartBreathingEndsAt ? Math.max(0, state.heartBreathingEndsAt - Date.now()) : Math.max(0, state.heartSeconds * 1000); - const elapsedMs = 30_000 - remainingMs; - const inhaling = Math.floor(elapsedMs / 4000) % 2 === 0; + const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs; + const inhaling = Math.floor(elapsedMs / HEART_BREATH_PHASE_MS) % 2 === 0; const phase = state.heartSeconds <= 0 ? "settled" : inhaling ? "inhale" : "exhale"; const scene = document.querySelector("#breathingScene"); scene.dataset.phase = phase; setText("breathingPhase", phase === "settled" ? "已静" : inhaling ? "吸气" : "呼气"); - document.querySelector("#breathingProgress").style.width = `${clamp(elapsedMs / 300, 0, 100)}%`; const prompt = state.heartSeconds <= 0 ? "静心已成,可以起卦" - : state.heartSeconds > 20 + : elapsedMs < 16_000 ? inhaling ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" - : state.heartSeconds > 10 + : elapsedMs < 32_000 ? inhaling ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" : inhaling ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; setText("breathingPrompt", prompt); @@ -4185,7 +4186,7 @@ function resetHeartCoins() { async function resetHeartRitual() { if (state.heartTimer) clearInterval(state.heartTimer); state.heartTimer = null; - state.heartSeconds = 30; + state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; state.heartLines = []; state.heartThrows = []; diff --git a/static/index.html b/static/index.html index 5b4d315..23b1ac0 100644 --- a/static/index.html +++ b/static/index.html @@ -598,15 +598,11 @@
观心 · 二
- - - -
30
- 吸气 + + 吸气

缓慢吸气,放下对答案的预设

-
diff --git a/static/styles.css b/static/styles.css index 6da5980..6c4e250 100644 --- a/static/styles.css +++ b/static/styles.css @@ -10392,7 +10392,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { box-shadow: 0 0 14px 4px rgba(255, 180, 90, 0.35); } -.heart-incense i.is-burning { animation: heart-incense-burn 30s linear forwards; } +.heart-incense i.is-burning { animation: heart-incense-burn 40s linear forwards; } #heavenHeartPanel #beginCastingButton { opacity: 0.35; @@ -10787,6 +10787,9 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { animation-duration: 1ms !important; transition-duration: 1ms !important; } + + #heavenHeartPanel .heart-breath-enso, + #heavenHeartPanel .breathing-phase { transition: none !important; } } /* Shared paper mode: every heart stage uses the same surface as trend and fortune. */ @@ -10979,6 +10982,92 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { opacity: 0.78; } +/* Fixed ink enso: breath is expressed through tone and light, never geometry. */ +#heavenHeartPanel .heart-breath-enso { + width: 176px; + height: 176px; + position: absolute; + top: 43%; + left: 50%; + z-index: 2; + color: rgba(111, 76, 54, 0.7); + opacity: 0.74; + filter: drop-shadow(0 0 5px rgba(154, 91, 69, 0.06)); + transform: translate(-50%, -50%); + transition: color 3.8s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 3.8s cubic-bezier(0.22, 0.61, 0.36, 1), filter 3.8s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +#heavenHeartPanel .heart-breath-enso::before { + content: ""; + position: absolute; + inset: 12px; + border: 7px solid currentColor; + border-right-color: transparent; + border-bottom-color: rgba(111, 76, 54, 0.28); + border-radius: 49% 51% 47% 53% / 52% 48% 54% 46%; + box-shadow: inset 0 0 0 1px rgba(253, 252, 248, 0.76); + transform: rotate(-26deg); +} + +#heavenHeartPanel .heart-breath-enso::after { + content: ""; + position: absolute; + inset: 31px; + border: 1px solid rgba(139, 112, 77, 0.13); + border-radius: 50%; +} + +#heavenHeartPanel .heart-breath-enso > i { + width: 8px; + height: 8px; + position: absolute; + top: 30px; + right: 31px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 11px rgba(154, 91, 69, 0.18); +} + +#heavenHeartPanel .breathing-scene[data-phase="inhale"] .heart-breath-enso { + color: rgba(126, 77, 52, 0.86); + opacity: 1; + filter: drop-shadow(0 0 13px rgba(194, 159, 90, 0.2)); +} + +#heavenHeartPanel .breathing-scene[data-phase="exhale"] .heart-breath-enso { + color: rgba(91, 99, 91, 0.48); + opacity: 0.5; + filter: drop-shadow(0 0 3px rgba(91, 99, 91, 0.04)); +} + +#heavenHeartPanel .breathing-scene[data-phase="settled"] .heart-breath-enso { + color: rgba(111, 76, 54, 0.64); + opacity: 0.72; + filter: none; +} + +#heavenHeartPanel .breathing-phase { + top: 43%; + bottom: auto; + z-index: 3; + color: #76513d; + font-family: var(--heaven-serif); + font-size: 20px; + font-weight: 500; + transform: translateY(-50%); + transition: color 3.8s ease, opacity 3.8s ease; +} + +#heavenHeartPanel .breathing-scene[data-phase="exhale"] .breathing-phase { + color: #6f756e; + opacity: 0.72; +} + +#heavenHeartPanel .breathing-scene[data-phase="settled"] .breathing-phase { + color: #76513d; + opacity: 1; +} + /* Account identity and membership access states */ .account-menu-shell { position: relative; display: inline-flex; align-items: center; gap: 6px; } .account-menu-chevron { width: 13px !important; height: 13px !important; margin-left: 1px; transition: transform 180ms var(--ease-out); } @@ -11666,10 +11755,13 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin .heaven-reading-dialog { width: min(920px, calc(100vw - 32px)); + height: min(820px, calc(100dvh - 32px)); max-height: min(820px, calc(100dvh - 32px)); + grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; background: #fdfcf8; } +.heaven-reading-dialog[open] { display: grid; } .heaven-reading-tabs { display: flex; gap: 4px; @@ -11689,7 +11781,25 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin } .heaven-reading-tabs button.active { border-bottom-color: #9a5b45; color: #6f3f31; font-weight: 700; } .heaven-reading-tabs button:focus-visible { outline: 2px solid #9a5b45; outline-offset: 1px; } -.heaven-reading-current { min-height: 430px; max-height: calc(100dvh - 170px); overflow-y: auto; padding: 24px 28px 30px; } +.heaven-reading-current { + min-height: 0; + max-height: none; + overflow-y: auto; + padding: 24px 28px 30px; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} +.heaven-reading-current::-webkit-scrollbar, +.heaven-reading-history-detail::-webkit-scrollbar, +.heaven-reading-history-list-wrap::-webkit-scrollbar { width: 9px; } +.heaven-reading-current::-webkit-scrollbar-thumb, +.heaven-reading-history-detail::-webkit-scrollbar-thumb, +.heaven-reading-history-list-wrap::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 8px; + background: rgba(112, 94, 72, .34); + background-clip: padding-box; +} .heaven-reading-loading { min-height: 360px; display: grid; place-content: center; justify-items: center; gap: 10px; text-align: center; } .heaven-reading-loading[hidden] { display: none; } .heaven-reading-mark { @@ -11719,7 +11829,7 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin .heaven-reading-history-detail > p { margin: 12px 0 0; color: var(--text-secondary); font-size: 12px; } .heaven-reading-answer { margin-top: 24px; color: #303833; font-family: var(--heaven-serif); font-size: 15px; line-height: 1.95; } .heaven-reading-answer .mentor-answer-heading { color: #713f31; } -.heaven-reading-history { min-height: 500px; max-height: calc(100dvh - 170px); display: grid; grid-template-columns: 270px minmax(0, 1fr); overflow: hidden; } +.heaven-reading-history { min-height: 0; max-height: none; display: grid; grid-template-columns: 270px minmax(0, 1fr); overflow: hidden; } .heaven-reading-history[hidden] { display: none; } .heaven-reading-history-list-wrap { min-width: 0; overflow-y: auto; border-right: 1px solid var(--border); background: rgba(246, 244, 237, .72); } .heaven-reading-history-heading { display: flex; justify-content: space-between; gap: 10px; padding: 15px 16px 10px; color: var(--text-secondary); font-size: 11px; } @@ -11741,9 +11851,9 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin .heart-toolbar-controls .heart-history-button { width: 36px; min-width: 36px; min-height: 36px; padding: 0; } .heart-toolbar-controls span { display: none; } .heart-read-actions { flex-wrap: wrap; justify-content: flex-end; } - .heaven-reading-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); margin: 8px auto; } - .heaven-reading-current { min-height: 360px; max-height: calc(100dvh - 150px); padding: 20px 17px 24px; } - .heaven-reading-history { min-height: 0; max-height: calc(100dvh - 150px); grid-template-columns: 1fr; overflow-y: auto; } + .heaven-reading-dialog { width: calc(100vw - 16px); height: calc(100dvh - 16px); max-height: calc(100dvh - 16px); margin: 8px auto; } + .heaven-reading-current { min-height: 0; max-height: none; padding: 20px 17px 24px; } + .heaven-reading-history { min-height: 0; max-height: none; grid-template-columns: 1fr; overflow-y: auto; } .heaven-reading-history-list-wrap { max-height: 210px; border-right: 0; border-bottom: 1px solid var(--border); } .heaven-reading-history-detail { overflow: visible; padding: 20px 17px 24px; } .heaven-reading-result > header, diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 5f66c27..a7d62ab 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -313,7 +313,7 @@ test("saved daily fortune opens in the reading dialog without regenerating", asy context_date: "20260723", subject: "2026-07-23 观气", subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显", - answer: "三层气机已经合参,今日宜先定节奏,再看行动。", + answer: Array.from({ length: 36 }, (_, index) => `第${index + 1}节:三层气机已经合参,今日宜先定节奏,再看行动。`).join("\n\n"), created_at: "2026-07-23T09:12:00+08:00", }; updateHeavenInterpretationControls(); @@ -326,11 +326,51 @@ test("saved daily fortune opens in the reading dialog without regenerating", asy await page.locator("#interpretFortuneButton").click(); await expect(page.locator("#heavenReadingDialog")).toBeVisible(); await expect(page.locator("#heavenReadingAnswer")).toContainText("今日宜先定节奏"); + const readingScroll = await page.locator("#heavenReadingCurrent").evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + overflowY: getComputedStyle(element).overflowY, + })); + expect(readingScroll.scrollHeight).toBeGreaterThan(readingScroll.clientHeight); + expect(readingScroll.overflowY).toBe("auto"); + await page.locator("#heavenReadingCurrent").evaluate((element) => { element.scrollTop = element.scrollHeight; }); + expect(await page.locator("#heavenReadingCurrent").evaluate((element) => element.scrollTop)).toBeGreaterThan(0); expect(interpretRequests).toHaveLength(0); await page.locator('[data-heaven-reading-tab="history"]').click(); await expect(page.locator("#heavenReadingHistoryList .heaven-reading-history-item")).toHaveCount(1); }); +test("heart breathing uses five silent eight-second cycles with a fixed enso", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="heavenView"]').first().click(); + await page.locator('[data-heaven-panel="heart"]').click(); + await page.evaluate(() => startHeartBreathing()); + + const timing = await page.evaluate(() => ({ + remaining: state.heartBreathingEndsAt - Date.now(), + incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, + inhaleTransform: getComputedStyle(document.querySelector(".heart-breath-enso")).transform, + })); + expect(timing.remaining).toBeGreaterThan(39_000); + expect(timing.remaining).toBeLessThanOrEqual(40_000); + expect(timing.incenseDuration).toBe("40s"); + await expect(page.locator("#breathingPhase")).toHaveText("吸气"); + await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0); + + const exhaleTransform = await page.evaluate(() => { + clearInterval(state.heartTimer); + state.heartTimer = null; + state.heartSeconds = 35; + state.heartBreathingEndsAt = Date.now() + 35_000; + updateBreathingDisplay(); + return getComputedStyle(document.querySelector(".heart-breath-enso")).transform; + }); + await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale"); + await expect(page.locator("#breathingPhase")).toHaveText("呼气"); + expect(exhaleTransform).toBe(timing.inhaleTransform); +}); + test("mobile shell stays within the viewport", async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await mockApplication(page, session("user", true));