Skip to content

Commit 8ac0308

Browse files
RuitingMaclaude
andcommitted
Layout: orientation-aware mobile, edge-feather, rotate hint
Portrait mobile collapses to a single column with a 30svh sticky canvas strip at the top; landscape on any device inherits the desktop two- column grid. First-visit portrait gets a dismissable "请横屏" overlay (persisted in localStorage, auto-dismisses on rotation). Added edge-feather strips that dissolve the canvas↔text boundary and fade text at the viewport bottom — body::before/::after on portrait, .column::before/::after on desktop, scoped so the left pane isn't touched. Both sets share the same view-transition pacing as stage/ column and live in named groups so SPA nav doesn't flicker them. Also: last-section gap on portrait now matches preceding sections, with the 35vh trailing quiet still in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8380318 commit 8ac0308

2 files changed

Lines changed: 251 additions & 12 deletions

File tree

src/layouts/BaseLayout.astro

Lines changed: 238 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
5959

6060
<div class="grain" aria-hidden="true"></div>
6161

62+
{/* Rotate hint — shown once to first-time visitors on narrow portrait
63+
screens. CSS only renders it when the media query matches, so it
64+
stays out of the way on desktop/landscape. Dismisses on tap or on
65+
rotate-to-landscape; seen-state persists in localStorage. */}
66+
<div class="rotate-hint" role="dialog" aria-label="提示">
67+
<div class="rotate-hint-card">
68+
<svg class="rotate-hint-icon" viewBox="0 0 24 24" width="38" height="38"
69+
fill="none" stroke="currentColor" stroke-width="1.1"
70+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
71+
<rect x="8.5" y="2.5" width="7" height="19" rx="1" />
72+
<line x1="11" y1="19" x2="13" y2="19" />
73+
</svg>
74+
<p class="rotate-hint-text">横屏阅读体验更佳</p>
75+
<span class="rotate-hint-dismiss">轻触继续</span>
76+
</div>
77+
</div>
78+
6279
<script>
6380
// --- Space key → toggle stage pause ---
6481
// Canvas components listen for `stage:pause` and freeze their rAF loop.
@@ -68,7 +85,11 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
6885
// ever does, the flag keeps us from stacking keydown listeners or
6986
// losing the paused state. We also re-query the stage element inside
7087
// the handler because it's a fresh DOM node after each navigation.
71-
const w = window as unknown as { __stagePauseInstalled?: boolean; __stagePaused?: boolean };
88+
const w = window as unknown as {
89+
__stagePauseInstalled?: boolean;
90+
__stagePaused?: boolean;
91+
__rotateHintInstalled?: boolean;
92+
};
7293
if (!w.__stagePauseInstalled) {
7394
w.__stagePauseInstalled = true;
7495
w.__stagePaused = false;
@@ -94,6 +115,44 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
94115
window.dispatchEvent(new CustomEvent('stage:pause', { detail: { paused: true } }));
95116
});
96117
}
118+
119+
// Rotate-to-landscape hint. Shown only on narrow portrait screens and
120+
// only until the user dismisses (tap) or rotates the device. CSS
121+
// gates actual visibility via the media query — JS just toggles the
122+
// body class and remembers the dismiss in localStorage so it's
123+
// one-shot across visits.
124+
if (!w.__rotateHintInstalled) {
125+
w.__rotateHintInstalled = true;
126+
const KEY = 'mien:rotate-hint-seen';
127+
128+
const dismiss = () => {
129+
try { localStorage.setItem(KEY, '1'); } catch {}
130+
document.body.classList.remove('show-rotate-hint');
131+
};
132+
133+
const wire = () => {
134+
try {
135+
if (localStorage.getItem(KEY)) return;
136+
} catch { return; }
137+
document.body.classList.add('show-rotate-hint');
138+
const hint = document.querySelector<HTMLElement>('.rotate-hint');
139+
if (hint && !hint.dataset.wired) {
140+
hint.dataset.wired = '1';
141+
hint.addEventListener('click', dismiss);
142+
}
143+
};
144+
145+
wire();
146+
document.addEventListener('astro:page-load', wire);
147+
148+
// Auto-dismiss the moment the user rotates to landscape, so they
149+
// don't come back to the hint after rotating back.
150+
try {
151+
window.matchMedia('(orientation: landscape)').addEventListener('change', (e) => {
152+
if (e.matches) dismiss();
153+
});
154+
} catch {}
155+
}
97156
</script>
98157

99158
<style is:global>
@@ -148,14 +207,27 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
148207
outgoing page so navigations feel like slow dissolves, not cuts.
149208
The fade-in of the new page is kept short — .enter takes over
150209
from there for the actual reveal, so doubling up would just
151-
make things muddy. */
210+
make things muddy.
211+
212+
Root is included so that anything left in the default group
213+
(currently just the grain overlay and page background) dissolves
214+
on the same clock as stage/column, instead of snapping with the
215+
default snappy timing. The edge-feather strips get their own
216+
named groups below so they morph between identical positions
217+
without flickering. */
218+
::view-transition-old(root),
152219
::view-transition-old(stage),
153-
::view-transition-old(column) {
220+
::view-transition-old(column),
221+
::view-transition-old(edge-feather-top),
222+
::view-transition-old(edge-feather-bottom) {
154223
animation-duration: 600ms;
155224
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
156225
}
226+
::view-transition-new(root),
157227
::view-transition-new(stage),
158-
::view-transition-new(column) {
228+
::view-transition-new(column),
229+
::view-transition-new(edge-feather-top),
230+
::view-transition-new(edge-feather-bottom) {
159231
animation-duration: 300ms;
160232
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
161233
}
@@ -226,6 +298,43 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
226298
padding: 5rem 3rem 8rem;
227299
box-sizing: border-box;
228300
max-width: calc(var(--max-reading) + 6rem);
301+
position: relative;
302+
}
303+
304+
/* Desktop (and landscape phones that inherit the desktop layout):
305+
feather the column's top and bottom at the viewport edges, the
306+
same gesture we give the portrait-mobile layout. Sticky pseudo-
307+
elements pin to viewport top/bottom; negative margins tuck them
308+
into the column's padding area so they occupy no flow space and
309+
are invisible at scrollY=0 (when bg shows through bg). Text that
310+
scrolls past the top or approaches the bottom passes through the
311+
gradient and dissolves into the page color.
312+
313+
Scoped to the right column only — the left canvas is its own
314+
pane and its hard top/bottom edges are already the viewport's. */
315+
@media (min-width: 861px), (orientation: landscape) {
316+
.column::before,
317+
.column::after {
318+
content: '';
319+
display: block;
320+
position: sticky;
321+
height: 2.5em;
322+
margin-left: -3rem;
323+
margin-right: -3rem;
324+
pointer-events: none;
325+
z-index: 2;
326+
}
327+
.column::before {
328+
top: 0;
329+
margin-top: -5rem; /* tuck into padding-top */
330+
margin-bottom: calc(5rem - 2.5em); /* keep first child at 5rem */
331+
background: linear-gradient(to bottom, var(--bg), transparent);
332+
}
333+
.column::after {
334+
bottom: 0;
335+
margin-top: calc(8rem - 2.5em); /* sit inside padding-bottom */
336+
background: linear-gradient(to top, var(--bg), transparent);
337+
}
229338
}
230339

231340
/* --- Film-grain overlay, subtle, above everything --- */
@@ -239,23 +348,141 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
239348
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.9 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
240349
}
241350

242-
/* --- Mobile: collapse to single column, stage becomes a short hero --- */
243-
@media (max-width: 860px) {
351+
/* --- Portrait mobile: collapse to a single column with a sticky
352+
canvas strip pinned to the top. Why sticky (not relative)? The
353+
canvas is meant to react to reading cues — if it scrolls away,
354+
the cue system keeps firing but nobody sees it. z-index keeps
355+
the pinned strip above the text that scrolls beneath it.
356+
357+
Landscape mobile (phones rotated, short tablets) inherits the
358+
desktop two-column layout — that's the point of the rotate hint
359+
we show on first visit in portrait. --- */
360+
@media (max-width: 860px) and (orientation: portrait) {
244361
main {
245362
grid-template-columns: 1fr;
246363
}
247364
.stage {
248-
position: relative;
365+
position: sticky;
366+
top: 0;
249367
/* svh = small viewport height, ignores dynamic URL bar on mobile.
250-
Without it, 45vh shifts when Safari collapses its chrome. */
251-
height: 45svh;
368+
Without it, heights jump when Safari collapses its chrome. */
369+
height: 30svh;
370+
z-index: 1;
252371
border-right: none;
253-
border-bottom: 1px solid var(--rule);
372+
/* Hard 1px rule replaced by the body::before fade strip below,
373+
which dissolves the canvas edge and fades text in together. */
374+
border-bottom: none;
254375
}
255376
.column {
256-
padding: 2.5rem 1.5rem 5rem;
377+
padding: 2rem 1.5rem 5rem;
257378
max-width: 100%;
258379
}
380+
381+
/* Edge-feather strips, fixed to the viewport. Sit above the text
382+
column (z-index 2 > default) and above the sticky stage (z-index
383+
1) so the top strip can reach up into the canvas and soften its
384+
bottom edge.
385+
386+
Top strip: overlays the last ~0.8em of the canvas with a
387+
transparent→bg ramp (softens the canvas's hard bottom edge),
388+
peaks opaque at the stage boundary (no half-character hard-
389+
clip), then fades bg→transparent across ~2.5em so text
390+
surfaces gradually from under the canvas.
391+
392+
Bottom strip: mirror — text dissolves into bg as it scrolls
393+
off the lower viewport edge. Symmetric gesture to the top. */
394+
body::before,
395+
body::after {
396+
content: '';
397+
position: fixed;
398+
left: 0;
399+
right: 0;
400+
pointer-events: none;
401+
z-index: 2;
402+
}
403+
body::before {
404+
top: calc(30svh - 0.8em);
405+
height: 3.3em;
406+
background: linear-gradient(
407+
to bottom,
408+
transparent 0%,
409+
var(--bg) 24%,
410+
transparent 100%
411+
);
412+
/* Named groups so these survive SPA navigation as their own
413+
transition layer. Without a name they fall into root, which
414+
Chromium sometimes drops fixed elements from — the strip
415+
would vanish mid-transition and snap back after, visible as
416+
a flicker. With a name, the element is captured directly
417+
and cross-fades against its identical counterpart on the
418+
new page (same position + same gradient = no visible
419+
change). */
420+
view-transition-name: edge-feather-top;
421+
}
422+
body::after {
423+
bottom: 0;
424+
height: 2.5em;
425+
background: linear-gradient(to top, var(--bg), transparent);
426+
view-transition-name: edge-feather-bottom;
427+
}
428+
}
429+
430+
/* --- Rotate-hint overlay (one-shot, first visit only) ---
431+
Hidden by default; the body class is toggled by JS, but we also
432+
gate on the media query here so rotating mid-display hides it
433+
without any JS coordination. */
434+
.rotate-hint {
435+
display: none;
436+
position: fixed;
437+
inset: 0;
438+
z-index: 10000;
439+
align-items: center;
440+
justify-content: center;
441+
padding: 2rem;
442+
cursor: pointer;
443+
background: rgba(18, 20, 22, 0.94);
444+
backdrop-filter: blur(8px);
445+
-webkit-backdrop-filter: blur(8px);
446+
animation: enter-soft 520ms ease-out both;
447+
}
448+
@media (max-width: 860px) and (orientation: portrait) {
449+
body.show-rotate-hint .rotate-hint { display: flex; }
450+
}
451+
.rotate-hint-card {
452+
text-align: center;
453+
color: var(--fg);
454+
max-width: 18em;
455+
}
456+
.rotate-hint-icon {
457+
display: block;
458+
margin: 0 auto 1.8em;
459+
color: var(--accent);
460+
animation: rotate-hint-turn 3.6s cubic-bezier(0.65, 0, 0.35, 1) infinite;
461+
}
462+
@keyframes rotate-hint-turn {
463+
0%, 20% { transform: rotate(0deg); }
464+
45%, 70% { transform: rotate(-90deg); }
465+
95%, 100% { transform: rotate(-90deg); }
466+
}
467+
.rotate-hint-text {
468+
font-family: var(--font-body);
469+
font-size: 1.05rem;
470+
letter-spacing: 0.12em;
471+
margin: 0;
472+
color: var(--fg);
473+
}
474+
.rotate-hint-dismiss {
475+
display: block;
476+
margin-top: 2.2em;
477+
font-family: var(--font-mono);
478+
text-transform: uppercase;
479+
letter-spacing: 0.32em;
480+
font-size: 0.66rem;
481+
color: var(--muted);
482+
}
483+
@media (prefers-reduced-motion: reduce) {
484+
.rotate-hint { animation: none; }
485+
.rotate-hint-icon { animation: none; transform: rotate(-90deg); }
259486
}
260487
</style>
261488
</body>

src/layouts/EssayLayout.astro

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,22 @@ const tintClass = tint ? `tint-${tint}` : '';
225225
color: var(--accent);
226226
}
227227

228-
@media (max-width: 860px) {
228+
/* Portrait-only: relax section min-heights, since a short 30svh sticky
229+
canvas + tall 72vh sections would force too many scrolls per piece.
230+
Landscape phones fall through to the desktop pacing.
231+
232+
Last-child also drops its min-height here — otherwise its desktop
233+
40vh + flex-center would push the final section's content to the
234+
middle of its box, creating a bigger lead-in gap than preceding
235+
sections. Its padding-bottom: 35vh stays (inherited from the
236+
desktop last-child rule) as the intentional trailing quiet. */
237+
@media (max-width: 860px) and (orientation: portrait) {
229238
.body :global(section) {
230239
min-height: 0;
231240
padding: 1.6em 0;
232241
}
242+
.body :global(section:last-child) {
243+
min-height: 0;
244+
}
233245
}
234246
</style>

0 commit comments

Comments
 (0)