@@ -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.5 rem 1.5rem 5rem ;
377+ padding : 2 rem 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 >
0 commit comments