From d520749252cd265ac6f27ae54625384114565388 Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 23 Jul 2026 20:49:17 +0800 Subject: [PATCH] refine heart breath preparation and contraction --- static/app.js | 29 ++++++++++++++++++----------- static/index.html | 6 +++--- static/styles.css | 24 ++++++++++++++++-------- tests/e2e/app-shell.spec.js | 27 +++++++++++++++++++++------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/static/app.js b/static/app.js index 96d8841..f264955 100644 --- a/static/app.js +++ b/static/app.js @@ -13,8 +13,10 @@ const { const HEART_BREATH_INHALE_MS = 3_000; const HEART_BREATH_HOLD_MS = 2_000; const HEART_BREATH_EXHALE_MS = 4_000; +const HEART_BREATH_PREPARE_MS = 1_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 HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5; +const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS; const state = { user: null, @@ -3831,23 +3833,28 @@ function updateBreathingDisplay() { ? Math.max(0, state.heartBreathingEndsAt - Date.now()) : Math.max(0, state.heartSeconds * 1000); const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs; - 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 activeElapsedMs = Math.max(0, elapsedMs - HEART_BREATH_PREPARE_MS); + const cycleElapsedMs = activeElapsedMs % HEART_BREATH_CYCLE_MS; + const breathPhase = elapsedMs < HEART_BREATH_PREPARE_MS + ? "prepare" + : 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" ? "已静" : phase === "inhale" ? "吸气" : phase === "hold" ? "停驻" : "呼气"); + setText("breathingPhase", phase === "settled" ? "静" : phase === "prepare" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "顿" : "呼"); const prompt = state.heartSeconds <= 0 ? "静心已成,可以起卦" - : phase === "hold" + : phase === "prepare" + ? "放松片刻,准备呼吸" + : phase === "hold" ? "停驻片刻,让念头自然沉下" - : elapsedMs < 18_000 + : activeElapsedMs < 18_000 ? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开" - : elapsedMs < 36_000 + : activeElapsedMs < 36_000 ? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案" : phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头"; setText("breathingPrompt", prompt); diff --git a/static/index.html b/static/index.html index 6b3f114..c84c149 100644 --- a/static/index.html +++ b/static/index.html @@ -597,11 +597,11 @@
观心 · 二 -
+
- 吸气 +
-

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

+

放松片刻,准备呼吸

diff --git a/static/styles.css b/static/styles.css index e56b2e9..71a8961 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 45s linear forwards; } +.heart-incense i.is-burning { animation: heart-incense-burn 45s linear 1s forwards; } #heavenHeartPanel #beginCastingButton { opacity: 0.35; @@ -11001,7 +11001,9 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { 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; + opacity: 0.12; + transform: scale(0.7); + animation: heart-breath-ripple 9s cubic-bezier(0.37, 0.01, 0.22, 1) 1s infinite both; will-change: transform, opacity; } @@ -11031,25 +11033,26 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { 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; + animation: heart-breath-ripple-core 9s ease-in-out 1s infinite both; } @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); } + 100% { opacity: 0.1; transform: scale(0.62); } } @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); } + 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); transform: translate(-50%, -50%) scale(0.76); } + 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); transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0.3; box-shadow: 0 0 0 2px rgba(194, 159, 90, 0), 0 0 5px rgba(154, 91, 69, 0.03); transform: translate(-50%, -50%) scale(0.64); } } @keyframes heart-breath-ripple-soft { - 0%, 100% { opacity: 0.24; transform: scale(0.94); } + 0% { opacity: 0.24; transform: scale(0.86); } 33.333%, 55.556% { opacity: 0.68; transform: scale(0.94); } + 100% { opacity: 0.18; transform: scale(0.82); } } #heavenHeartPanel .breathing-phase { @@ -11064,6 +11067,11 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { transition: color 480ms ease, opacity 480ms ease; } +#heavenHeartPanel .breathing-scene[data-phase="prepare"] .breathing-phase { + color: #858980; + opacity: 0.62; +} + #heavenHeartPanel .breathing-scene[data-phase="hold"] .breathing-phase { color: #8a6b3e; opacity: 0.9; diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 17f8bf5..e3b3584 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 nine-second ripple cycles", async ({ page }) => { +test("heart breathing prepares once then contracts on each exhale", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="heavenView"]').first().click(); @@ -350,26 +350,41 @@ test("heart breathing uses five silent nine-second ripple cycles", async ({ page const timing = await page.evaluate(() => ({ remaining: state.heartBreathingEndsAt - Date.now(), incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration, + incenseDelay: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDelay, rippleDuration: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDuration, + rippleDelay: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationDelay, rippleAnimation: getComputedStyle(document.querySelector(".heart-breath-ripple span")).animationName, + rippleKeyframes: document.querySelector(".heart-breath-ripple span").getAnimations()[0].effect.getKeyframes(), })); - expect(timing.remaining).toBeGreaterThan(44_000); - expect(timing.remaining).toBeLessThanOrEqual(45_000); + expect(timing.remaining).toBeGreaterThan(45_000); + expect(timing.remaining).toBeLessThanOrEqual(46_000); expect(timing.incenseDuration).toBe("45s"); + expect(timing.incenseDelay).toBe("1s"); expect(timing.rippleDuration).toBe("9s"); + expect(timing.rippleDelay).toBe("1s"); expect(timing.rippleAnimation).toContain("heart-breath-ripple"); - await expect(page.locator("#breathingPhase")).toHaveText("吸气"); + expect(timing.rippleKeyframes.at(-1).transform).toContain("0.62"); + await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "prepare"); + await expect(page.locator("#breathingPhase")).toHaveText("静"); await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0); await page.evaluate(() => { clearInterval(state.heartTimer); state.heartTimer = null; + state.heartSeconds = 45; + state.heartBreathingEndsAt = Date.now() + 44_500; + updateBreathingDisplay(); + }); + await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "inhale"); + await expect(page.locator("#breathingPhase")).toHaveText("吸"); + + await page.evaluate(() => { 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 expect(page.locator("#breathingPhase")).toHaveText("顿"); await page.evaluate(() => { state.heartSeconds = 39; @@ -377,7 +392,7 @@ test("heart breathing uses five silent nine-second ripple cycles", async ({ page updateBreathingDisplay(); }); await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale"); - await expect(page.locator("#breathingPhase")).toHaveText("呼气"); + await expect(page.locator("#breathingPhase")).toHaveText("呼"); }); test("mobile shell stays within the viewport", async ({ page }) => {