diff --git a/static/app.js b/static/app.js index ba384a4..96d8841 100644 --- a/static/app.js +++ b/static/app.js @@ -10,8 +10,11 @@ const { todayString, } = window.XiaobaiUI; -const HEART_BREATH_TOTAL_MS = 40_000; -const HEART_BREATH_PHASE_MS = 4_000; +const HEART_BREATH_INHALE_MS = 3_000; +const HEART_BREATH_HOLD_MS = 2_000; +const HEART_BREATH_EXHALE_MS = 4_000; +const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; +const HEART_BREATH_TOTAL_MS = HEART_BREATH_CYCLE_MS * 5; const state = { user: null, @@ -3828,18 +3831,25 @@ function updateBreathingDisplay() { ? Math.max(0, state.heartBreathingEndsAt - Date.now()) : Math.max(0, state.heartSeconds * 1000); 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 cycleElapsedMs = elapsedMs % HEART_BREATH_CYCLE_MS; + const breathPhase = cycleElapsedMs < HEART_BREATH_INHALE_MS + ? "inhale" + : cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + ? "hold" + : "exhale"; + const phase = state.heartSeconds <= 0 ? "settled" : breathPhase; const scene = document.querySelector("#breathingScene"); scene.dataset.phase = phase; - setText("breathingPhase", phase === "settled" ? "已静" : inhaling ? "吸气" : "呼气"); + setText("breathingPhase", phase === "settled" ? "已静" : phase === "inhale" ? "吸气" : phase === "hold" ? "停驻" : "呼气"); const prompt = state.heartSeconds <= 0 ? "静心已成,可以起卦" - : elapsedMs < 16_000 - ? inhaling ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" - : elapsedMs < 32_000 - ? inhaling ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" - : inhaling ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; + : phase === "hold" + ? "停驻片刻,让念头自然沉下" + : elapsedMs < 18_000 + ? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" + : elapsedMs < 36_000 + ? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" + : phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; setText("breathingPrompt", prompt); } diff --git a/static/index.html b/static/index.html index 23b1ac0..6b3f114 100644 --- a/static/index.html +++ b/static/index.html @@ -598,7 +598,7 @@
观心 · 二
- + 吸气

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

diff --git a/static/styles.css b/static/styles.css index 6c4e250..e56b2e9 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 40s linear forwards; } +.heart-incense i.is-burning { animation: heart-incense-burn 45s linear forwards; } #heavenHeartPanel #beginCastingButton { opacity: 0.35; @@ -10788,7 +10788,10 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { transition-duration: 1ms !important; } - #heavenHeartPanel .heart-breath-enso, + #heavenHeartPanel .heart-breath-ripple span { + animation-name: heart-breath-ripple-soft !important; + } + #heavenHeartPanel .breathing-phase { transition: none !important; } } @@ -10982,68 +10985,71 @@ 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; +/* One nine-second breath: gather, suspend, then release as concentric ripples. */ +#heavenHeartPanel .heart-breath-ripple { + width: 236px; + height: 236px; 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: ""; +#heavenHeartPanel .heart-breath-ripple span { 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: 1px solid rgba(126, 77, 52, 0.5); border-radius: 50%; + box-shadow: 0 0 18px rgba(194, 159, 90, 0.06); + animation: heart-breath-ripple 9s cubic-bezier(0.37, 0.01, 0.22, 1) infinite; + will-change: transform, opacity; } -#heavenHeartPanel .heart-breath-enso > i { - width: 8px; - height: 8px; +#heavenHeartPanel .heart-breath-ripple span:nth-child(1) { + inset: 76px; + border-color: rgba(126, 77, 52, 0.66); +} + +#heavenHeartPanel .heart-breath-ripple span:nth-child(2) { + inset: 45px; + border-color: rgba(139, 112, 77, 0.38); +} + +#heavenHeartPanel .heart-breath-ripple span:nth-child(3) { + inset: 13px; + border-color: rgba(91, 99, 91, 0.24); +} + +#heavenHeartPanel .heart-breath-ripple > i { + width: 14px; + height: 14px; position: absolute; - top: 30px; - right: 31px; + top: 50%; + left: 50%; + border: 1px solid rgba(126, 77, 52, 0.38); border-radius: 50%; - background: currentColor; - box-shadow: 0 0 11px rgba(154, 91, 69, 0.18); + background: rgba(253, 252, 248, 0.92); + box-shadow: 0 0 0 6px rgba(194, 159, 90, 0.06), 0 0 20px rgba(154, 91, 69, 0.12); + transform: translate(-50%, -50%); + animation: heart-breath-ripple-core 9s ease-in-out infinite; } -#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)); +@keyframes heart-breath-ripple { + 0% { opacity: 0.12; transform: scale(0.7); } + 33.333% { opacity: 0.82; transform: scale(0.94); } + 55.556% { opacity: 0.82; transform: scale(0.94); } + 100% { opacity: 0; transform: scale(1.22); } } -#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)); +@keyframes heart-breath-ripple-core { + 0% { opacity: 0.38; box-shadow: 0 0 0 3px rgba(194, 159, 90, 0.04), 0 0 8px rgba(154, 91, 69, 0.06); } + 33.333%, 55.556% { opacity: 1; box-shadow: 0 0 0 9px rgba(194, 159, 90, 0.09), 0 0 26px rgba(154, 91, 69, 0.18); } + 100% { opacity: 0.3; box-shadow: 0 0 0 14px rgba(194, 159, 90, 0), 0 0 5px rgba(154, 91, 69, 0.03); } } -#heavenHeartPanel .breathing-scene[data-phase="settled"] .heart-breath-enso { - color: rgba(111, 76, 54, 0.64); - opacity: 0.72; - filter: none; +@keyframes heart-breath-ripple-soft { + 0%, 100% { opacity: 0.24; transform: scale(0.94); } + 33.333%, 55.556% { opacity: 0.68; transform: scale(0.94); } } #heavenHeartPanel .breathing-phase { @@ -11055,7 +11061,12 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { font-size: 20px; font-weight: 500; transform: translateY(-50%); - transition: color 3.8s ease, opacity 3.8s ease; + transition: color 480ms ease, opacity 480ms ease; +} + +#heavenHeartPanel .breathing-scene[data-phase="hold"] .breathing-phase { + color: #8a6b3e; + opacity: 0.9; } #heavenHeartPanel .breathing-scene[data-phase="exhale"] .breathing-phase { diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index a7d62ab..17f8bf5 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -340,7 +340,7 @@ test("saved daily fortune opens in the reading dialog without regenerating", asy 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 }) => { +test("heart breathing uses five silent nine-second ripple cycles", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="heavenView"]').first().click(); @@ -350,25 +350,34 @@ test("heart breathing uses five silent eight-second cycles with a fixed enso", a const timing = await page.evaluate(() => ({ remaining: state.heartBreathingEndsAt - Date.now(), incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, - inhaleTransform: getComputedStyle(document.querySelector(".heart-breath-enso")).transform, + rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, + rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, })); - expect(timing.remaining).toBeGreaterThan(39_000); - expect(timing.remaining).toBeLessThanOrEqual(40_000); - expect(timing.incenseDuration).toBe("40s"); + expect(timing.remaining).toBeGreaterThan(44_000); + expect(timing.remaining).toBeLessThanOrEqual(45_000); + expect(timing.incenseDuration).toBe("45s"); + expect(timing.rippleDuration).toBe("9s"); + expect(timing.rippleAnimation).toContain("heart-breath-ripple"); await expect(page.locator("#breathingPhase")).toHaveText("吸气"); await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0); - const exhaleTransform = await page.evaluate(() => { + await page.evaluate(() => { clearInterval(state.heartTimer); state.heartTimer = null; - state.heartSeconds = 35; - state.heartBreathingEndsAt = Date.now() + 35_000; + state.heartSeconds = 42; + state.heartBreathingEndsAt = Date.now() + 41_500; + updateBreathingDisplay(); + }); + await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "hold"); + await expect(page.locator("#breathingPhase")).toHaveText("停驻"); + + await page.evaluate(() => { + state.heartSeconds = 39; + state.heartBreathingEndsAt = Date.now() + 38_500; 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 }) => {