refine heart breathing ripple rhythm
This commit is contained in:
+20
-10
@@ -10,8 +10,11 @@ const {
|
|||||||
todayString,
|
todayString,
|
||||||
} = window.XiaobaiUI;
|
} = window.XiaobaiUI;
|
||||||
|
|
||||||
const HEART_BREATH_TOTAL_MS = 40_000;
|
const HEART_BREATH_INHALE_MS = 3_000;
|
||||||
const HEART_BREATH_PHASE_MS = 4_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 = {
|
const state = {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -3828,18 +3831,25 @@ function updateBreathingDisplay() {
|
|||||||
? Math.max(0, state.heartBreathingEndsAt - Date.now())
|
? Math.max(0, state.heartBreathingEndsAt - Date.now())
|
||||||
: Math.max(0, state.heartSeconds * 1000);
|
: Math.max(0, state.heartSeconds * 1000);
|
||||||
const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs;
|
const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs;
|
||||||
const inhaling = Math.floor(elapsedMs / HEART_BREATH_PHASE_MS) % 2 === 0;
|
const cycleElapsedMs = elapsedMs % HEART_BREATH_CYCLE_MS;
|
||||||
const phase = state.heartSeconds <= 0 ? "settled" : inhaling ? "inhale" : "exhale";
|
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");
|
const scene = document.querySelector("#breathingScene");
|
||||||
scene.dataset.phase = phase;
|
scene.dataset.phase = phase;
|
||||||
setText("breathingPhase", phase === "settled" ? "已静" : inhaling ? "吸气" : "呼气");
|
setText("breathingPhase", phase === "settled" ? "已静" : phase === "inhale" ? "吸气" : phase === "hold" ? "停驻" : "呼气");
|
||||||
const prompt = state.heartSeconds <= 0
|
const prompt = state.heartSeconds <= 0
|
||||||
? "静心已成,可以起卦"
|
? "静心已成,可以起卦"
|
||||||
: elapsedMs < 16_000
|
: phase === "hold"
|
||||||
? inhaling ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
|
? "停驻片刻,让念头自然沉下"
|
||||||
: elapsedMs < 32_000
|
: elapsedMs < 18_000
|
||||||
? inhaling ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
|
? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
|
||||||
: inhaling ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
|
: elapsedMs < 36_000
|
||||||
|
? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
|
||||||
|
: phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
|
||||||
setText("breathingPrompt", prompt);
|
setText("breathingPrompt", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -598,7 +598,7 @@
|
|||||||
<div class="heart-stage-inner breathing-stage">
|
<div class="heart-stage-inner breathing-stage">
|
||||||
<span class="heart-stage-index">观心 · 二</span>
|
<span class="heart-stage-index">观心 · 二</span>
|
||||||
<div id="breathingScene" class="breathing-scene" data-phase="inhale">
|
<div id="breathingScene" class="breathing-scene" data-phase="inhale">
|
||||||
<div class="heart-breath-enso" aria-hidden="true"><i></i></div>
|
<div class="heart-breath-ripple" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
|
||||||
<b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite">吸气</b>
|
<b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite">吸气</b>
|
||||||
</div>
|
</div>
|
||||||
<h3 id="breathingPrompt">缓慢吸气,放下对答案的预设</h3>
|
<h3 id="breathingPrompt">缓慢吸气,放下对答案的预设</h3>
|
||||||
|
|||||||
+57
-46
@@ -10392,7 +10392,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
|||||||
box-shadow: 0 0 14px 4px rgba(255, 180, 90, 0.35);
|
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 {
|
#heavenHeartPanel #beginCastingButton {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
@@ -10788,7 +10788,10 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
|||||||
transition-duration: 1ms !important;
|
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; }
|
#heavenHeartPanel .breathing-phase { transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10982,68 +10985,71 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
|||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fixed ink enso: breath is expressed through tone and light, never geometry. */
|
/* One nine-second breath: gather, suspend, then release as concentric ripples. */
|
||||||
#heavenHeartPanel .heart-breath-enso {
|
#heavenHeartPanel .heart-breath-ripple {
|
||||||
width: 176px;
|
width: 236px;
|
||||||
height: 176px;
|
height: 236px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 43%;
|
top: 43%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 2;
|
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%);
|
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 {
|
#heavenHeartPanel .heart-breath-ripple span {
|
||||||
content: "";
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 12px;
|
border: 1px solid rgba(126, 77, 52, 0.5);
|
||||||
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%;
|
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 {
|
#heavenHeartPanel .heart-breath-ripple span:nth-child(1) {
|
||||||
width: 8px;
|
inset: 76px;
|
||||||
height: 8px;
|
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;
|
position: absolute;
|
||||||
top: 30px;
|
top: 50%;
|
||||||
right: 31px;
|
left: 50%;
|
||||||
|
border: 1px solid rgba(126, 77, 52, 0.38);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
background: rgba(253, 252, 248, 0.92);
|
||||||
box-shadow: 0 0 11px rgba(154, 91, 69, 0.18);
|
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 {
|
@keyframes heart-breath-ripple {
|
||||||
color: rgba(126, 77, 52, 0.86);
|
0% { opacity: 0.12; transform: scale(0.7); }
|
||||||
opacity: 1;
|
33.333% { opacity: 0.82; transform: scale(0.94); }
|
||||||
filter: drop-shadow(0 0 13px rgba(194, 159, 90, 0.2));
|
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 {
|
@keyframes heart-breath-ripple-core {
|
||||||
color: rgba(91, 99, 91, 0.48);
|
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); }
|
||||||
opacity: 0.5;
|
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); }
|
||||||
filter: drop-shadow(0 0 3px rgba(91, 99, 91, 0.04));
|
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 {
|
@keyframes heart-breath-ripple-soft {
|
||||||
color: rgba(111, 76, 54, 0.64);
|
0%, 100% { opacity: 0.24; transform: scale(0.94); }
|
||||||
opacity: 0.72;
|
33.333%, 55.556% { opacity: 0.68; transform: scale(0.94); }
|
||||||
filter: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#heavenHeartPanel .breathing-phase {
|
#heavenHeartPanel .breathing-phase {
|
||||||
@@ -11055,7 +11061,12 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transform: translateY(-50%);
|
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 {
|
#heavenHeartPanel .breathing-scene[data-phase="exhale"] .breathing-phase {
|
||||||
|
|||||||
+19
-10
@@ -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);
|
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 mockApplication(page, session("user", true));
|
||||||
await page.goto("/index.html");
|
await page.goto("/index.html");
|
||||||
await page.locator('[data-view="heavenView"]').first().click();
|
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(() => ({
|
const timing = await page.evaluate(() => ({
|
||||||
remaining: state.heartBreathingEndsAt - Date.now(),
|
remaining: state.heartBreathingEndsAt - Date.now(),
|
||||||
incenseDuration: getComputedStyle(document.querySelector("#heartIncenseEmber")).animationDuration,
|
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).toBeGreaterThan(44_000);
|
||||||
expect(timing.remaining).toBeLessThanOrEqual(40_000);
|
expect(timing.remaining).toBeLessThanOrEqual(45_000);
|
||||||
expect(timing.incenseDuration).toBe("40s");
|
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("#breathingPhase")).toHaveText("吸气");
|
||||||
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
|
await expect(page.locator("#breathingSeconds, #breathingProgress, .breathing-orbit")).toHaveCount(0);
|
||||||
|
|
||||||
const exhaleTransform = await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
clearInterval(state.heartTimer);
|
clearInterval(state.heartTimer);
|
||||||
state.heartTimer = null;
|
state.heartTimer = null;
|
||||||
state.heartSeconds = 35;
|
state.heartSeconds = 42;
|
||||||
state.heartBreathingEndsAt = Date.now() + 35_000;
|
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();
|
updateBreathingDisplay();
|
||||||
return getComputedStyle(document.querySelector(".heart-breath-enso")).transform;
|
|
||||||
});
|
});
|
||||||
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
|
await expect(page.locator("#breathingScene")).toHaveAttribute("data-phase", "exhale");
|
||||||
await expect(page.locator("#breathingPhase")).toHaveText("呼气");
|
await expect(page.locator("#breathingPhase")).toHaveText("呼气");
|
||||||
expect(exhaleTransform).toBe(timing.inhaleTransform);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile shell stays within the viewport", async ({ page }) => {
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user