diff options
| author | soryu-co <bot@soryu.co> | 2026-05-06 12:39:02 +0000 |
|---|---|---|
| committer | soryu-co <bot@soryu.co> | 2026-05-06 12:39:02 +0000 |
| commit | 30bfe2149fdead196c08a8cfaf5bb5415e6ee7c4 (patch) | |
| tree | 393934a1aadcca9fb42a517bad84faf679df2229 | |
| parent | 771ece20e5e669f93fc0aba0459ac1ff2fa0825b (diff) | |
| download | soryu-30bfe2149fdead196c08a8cfaf5bb5415e6ee7c4.tar.gz soryu-30bfe2149fdead196c08a8cfaf5bb5415e6ee7c4.zip | |
Align mission/makima body, fix mobile bg, add typewriter hero rotator
Three fixes coming out of QA on staging (soryu.eirin.xyz):
1. Mission / MAKIMA body alignment
- The amber/magenta accent rule + 18px indent used to live on
.mission-headline only, so paragraphs (and the mission image)
started 18px to the LEFT of the headline.
- Move the rule + padding-left onto the .mission-screen wrapper
itself. Headline, image, and all paragraph(s) now share one
consistent column edge.
- Tighten the makima-badge so it doesn't stretch the grid column
(justify-self: start + width: max-content). Image gets explicit
width: 100%; display: block to behave inside the column.
2. Mobile background coverage
- pc98.css mobile-block override ('@media max-width:768px') reset
.modern-landing-page to position: relative + margin-top: 100px,
leaving a 100-156px band at the top where only the body color
showed and pushing the wrapper bottom past the viewport.
- Force position: fixed on the wrapper at every breakpoint with
!important. Use 100dvh (dynamic viewport height) so the gradient
follows iOS Safari's collapsing URL bar rather than getting
clipped.
- Mirror the dusk gradient onto <body> as a fallback so any
sub-pixel rounding gap shows the same colour, not white.
3. Typewriter hero rotator
- New <TypewriterRotator/> component (frontend/src/components/
TypewriterRotator.tsx) — types, holds, deletes, gaps, advances.
Honours prefers-reduced-motion (renders the first phrase static).
- LandingPage.tsx renders it inside a new .hero-tagline wrapper
where the old static CSS ::after lived. Five phrases, all
bilingual JP/EN in the same Heisei tactical voice:
* 低遅延ストリーミング · LOW-LATENCY OBSERVABILITY
* リアルタイム監視 · REAL-TIME SURVEILLANCE
* ミッションクリティカル · MISSION-CRITICAL INFRASTRUCTURE
* エンドツーエンド可視化 · END-TO-END VISIBILITY
* 安全な意思決定 · SECURE DECISIONS AT THE EDGE
- Hide the old .hero::after pseudo-tagline; .hero-tagline keeps
the same centred position, amber colour, and tracking.
- Caret blink animation (steps(1), 1s) — separate from the typing
timing so it always blinks at human speed.
npm run build passes (CSS 94 kB / 18.2 kB gzip, JS 238 kB / 75 kB
gzip — +1.5 kB net for the rotator + new phrases).
Verified at 1280x720 / 390x844 via Playwright DOM probe:
- mission/makima: headline.x === paragraph.x === image.x ✓
- mobile: wrapper y=56, h=788, bottom=844 (full coverage) ✓
- typewriter: types, holds, deletes, advances to phrase 2 ✓
Screenshots in docs/heisei-screenshots/after-*.jpg refreshed.
Staging at soryu.eirin.xyz updated via rsync.
| -rw-r--r-- | docs/heisei-screenshots/after-01-desktop-default.jpg | bin | 105132 -> 106576 bytes | |||
| -rw-r--r-- | docs/heisei-screenshots/after-02-desktop-mission.jpg | bin | 299749 -> 230347 bytes | |||
| -rw-r--r-- | docs/heisei-screenshots/after-03-desktop-makima.jpg | bin | 97563 -> 96847 bytes | |||
| -rw-r--r-- | docs/heisei-screenshots/after-04-desktop-login-hover.jpg | bin | 105240 -> 105994 bytes | |||
| -rw-r--r-- | docs/heisei-screenshots/after-05-mobile-default.jpg | bin | 58794 -> 64257 bytes | |||
| -rw-r--r-- | docs/heisei-screenshots/after-06-mobile-mission.jpg | bin | 177836 -> 181240 bytes | |||
| -rw-r--r-- | frontend/src/components/LandingPage.tsx | 15 | ||||
| -rw-r--r-- | frontend/src/components/TypewriterRotator.tsx | 84 | ||||
| -rw-r--r-- | frontend/src/styles/heisei.css | 89 |
9 files changed, 178 insertions, 10 deletions
diff --git a/docs/heisei-screenshots/after-01-desktop-default.jpg b/docs/heisei-screenshots/after-01-desktop-default.jpg Binary files differindex 4a35ffb..c7e8cc7 100644 --- a/docs/heisei-screenshots/after-01-desktop-default.jpg +++ b/docs/heisei-screenshots/after-01-desktop-default.jpg diff --git a/docs/heisei-screenshots/after-02-desktop-mission.jpg b/docs/heisei-screenshots/after-02-desktop-mission.jpg Binary files differindex 6163d0e..6e5bb6a 100644 --- a/docs/heisei-screenshots/after-02-desktop-mission.jpg +++ b/docs/heisei-screenshots/after-02-desktop-mission.jpg diff --git a/docs/heisei-screenshots/after-03-desktop-makima.jpg b/docs/heisei-screenshots/after-03-desktop-makima.jpg Binary files differindex c66521e..d9de764 100644 --- a/docs/heisei-screenshots/after-03-desktop-makima.jpg +++ b/docs/heisei-screenshots/after-03-desktop-makima.jpg diff --git a/docs/heisei-screenshots/after-04-desktop-login-hover.jpg b/docs/heisei-screenshots/after-04-desktop-login-hover.jpg Binary files differindex 3c4b86f..523f7cd 100644 --- a/docs/heisei-screenshots/after-04-desktop-login-hover.jpg +++ b/docs/heisei-screenshots/after-04-desktop-login-hover.jpg diff --git a/docs/heisei-screenshots/after-05-mobile-default.jpg b/docs/heisei-screenshots/after-05-mobile-default.jpg Binary files differindex 0ac8b14..ddefbb1 100644 --- a/docs/heisei-screenshots/after-05-mobile-default.jpg +++ b/docs/heisei-screenshots/after-05-mobile-default.jpg diff --git a/docs/heisei-screenshots/after-06-mobile-mission.jpg b/docs/heisei-screenshots/after-06-mobile-mission.jpg Binary files differindex 618c0c7..6f35278 100644 --- a/docs/heisei-screenshots/after-06-mobile-mission.jpg +++ b/docs/heisei-screenshots/after-06-mobile-mission.jpg diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index 931fe68..708cbb1 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -1,6 +1,15 @@ import React, { useState, useEffect, useRef } from 'react' import { LoadingScreen } from './LoadingScreen' import { HeartLogo } from './HeartLogo' +import { TypewriterRotator } from './TypewriterRotator' + +const HERO_PHRASES = [ + '低遅延ストリーミング · LOW-LATENCY OBSERVABILITY', + 'リアルタイム監視 · REAL-TIME SURVEILLANCE', + 'ミッションクリティカル · MISSION-CRITICAL INFRASTRUCTURE', + 'エンドツーエンド可視化 · END-TO-END VISIBILITY', + '安全な意思決定 · SECURE DECISIONS AT THE EDGE', +] interface LandingPageProps { onLogin: () => void @@ -189,7 +198,11 @@ export function LandingPage({ onLogin }: LandingPageProps) { </p> </div> ) : ( - <div className="hero" /> + <div className="hero"> + <div className="hero-tagline" aria-label="Soryu capabilities"> + <TypewriterRotator phrases={HERO_PHRASES} /> + </div> + </div> )} {/* CTA row spanning full width: left Mission/MAKIMA, right Login */} diff --git a/frontend/src/components/TypewriterRotator.tsx b/frontend/src/components/TypewriterRotator.tsx new file mode 100644 index 0000000..4cd89fa --- /dev/null +++ b/frontend/src/components/TypewriterRotator.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef, useState } from 'react' + +/** + * TypewriterRotator + * + * Cycles through a list of phrases with a typing-and-deleting effect. + * Designed for the soryu landing hero — sits inside a centred container, + * so the caller controls position and the component just renders text. + * + * Honours `prefers-reduced-motion` by rendering the first phrase only, + * statically. + */ + +interface TypewriterRotatorProps { + phrases: string[] + /** ms per character while typing */ + typeMs?: number + /** ms per character while deleting */ + deleteMs?: number + /** ms to hold a fully-typed phrase before deleting */ + holdMs?: number + /** ms to wait after deletion before the next phrase types in */ + gapMs?: number + className?: string +} + +export function TypewriterRotator({ + phrases, + typeMs = 55, + deleteMs = 28, + holdMs = 2200, + gapMs = 320, + className, +}: TypewriterRotatorProps) { + const [text, setText] = useState('') + const [index, setIndex] = useState(0) + const [phase, setPhase] = useState<'typing' | 'holding' | 'deleting' | 'gap'>('typing') + const reduceMotion = useRef( + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ) + + useEffect(() => { + if (reduceMotion.current) { + setText(phrases[0] ?? '') + return + } + const current = phrases[index % phrases.length] ?? '' + let timer: number + + if (phase === 'typing') { + if (text.length < current.length) { + timer = window.setTimeout(() => setText(current.slice(0, text.length + 1)), typeMs) + } else { + timer = window.setTimeout(() => setPhase('holding'), 0) + } + } else if (phase === 'holding') { + // Skip the hold-then-delete cycle entirely if there's only one phrase + if (phrases.length <= 1) return + timer = window.setTimeout(() => setPhase('deleting'), holdMs) + } else if (phase === 'deleting') { + if (text.length > 0) { + timer = window.setTimeout(() => setText(current.slice(0, text.length - 1)), deleteMs) + } else { + timer = window.setTimeout(() => setPhase('gap'), 0) + } + } else if (phase === 'gap') { + timer = window.setTimeout(() => { + setIndex((i) => (i + 1) % phrases.length) + setPhase('typing') + }, gapMs) + } + + return () => window.clearTimeout(timer) + }, [text, phase, index, phrases, typeMs, deleteMs, holdMs, gapMs]) + + return ( + <span className={className} role="status" aria-live="polite"> + <span className="tw-text">{text}</span> + <span className="tw-caret" aria-hidden="true" /> + </span> + ) +} diff --git a/frontend/src/styles/heisei.css b/frontend/src/styles/heisei.css index 356080e..4017f83 100644 --- a/frontend/src/styles/heisei.css +++ b/frontend/src/styles/heisei.css @@ -37,13 +37,29 @@ body { background: var(--hz-night); color: var(--hz-ink); } /* Reset the legacy 120px top offset (was matched to the old 120px header). New header is 72px / 56px on mobile, so claim the full remaining viewport. */ .modern-landing-page { + position: fixed !important; top: 72px !important; - height: calc(100vh - 72px) !important; + left: 0 !important; + right: 0 !important; + width: 100vw !important; + height: calc(100dvh - 72px) !important; + margin-top: 0 !important; + min-height: 0 !important; + overflow: hidden !important; } @media (max-width: 768px) { .modern-landing-page { top: 56px !important; - height: calc(100vh - 56px) !important; + height: calc(100dvh - 56px) !important; + } + /* Body fallback so any rounding gap shows the same dusk gradient + instead of a flash of system-default white */ + body { + background: + radial-gradient(140% 70% at 50% 100%, rgba(232, 184, 122, 0.20) 0%, transparent 55%), + radial-gradient(120% 80% at 50% 0%, rgba(74, 58, 107, 0.55) 0%, transparent 60%), + linear-gradient(180deg, #0b1124 0%, #1a2548 35%, #3d3358 65%, #6b4d63 88%, #c87a8a 100%) !important; + background-attachment: fixed; } } @@ -303,27 +319,75 @@ body { background: var(--hz-night); color: var(--hz-ink); } opacity: 0.65; } .modern-landing-page.manga-style .hero::after { - content: '低遅延ストリーミング · LOW-LATENCY OBSERVABILITY'; + /* Replaced by the React TypewriterRotator component */ + display: none; +} + +/* Typewriter tagline — sits where the old static ::after lived */ +.hero-tagline { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-family: 'Noto Sans JP', 'Inter', sans-serif; - font-size: 11px; + font-size: 12px; font-weight: 400; letter-spacing: 0.3em; color: var(--hz-amber); - opacity: 0.75; + opacity: 0.92; white-space: nowrap; text-shadow: 0 0 12px rgba(232, 184, 122, 0.35); + text-align: center; + pointer-events: none; + max-width: 92%; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} +.hero-tagline .tw-text { display: inline; } +.hero-tagline .tw-caret { + display: inline-block; + width: 8px; + height: 1.05em; + margin-left: 4px; + vertical-align: -0.18em; + background: var(--hz-amber); + box-shadow: 0 0 8px rgba(232, 184, 122, 0.45); + animation: hz-caret-blink 1s steps(1) infinite; +} +@keyframes hz-caret-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} +@media (max-width: 768px) { + .hero-tagline { + font-size: 10px; + letter-spacing: 0.22em; + white-space: normal; /* allow wrap on narrow phones */ + line-height: 1.6; + max-width: 86%; + } + .hero-tagline .tw-caret { width: 6px; } +} +@media (prefers-reduced-motion: reduce) { + .hero-tagline .tw-caret { animation: none; opacity: 1; } } /* ---------------------------------------------------------------- 7. Mission / Makima screens — Heisei magazine column ---------------------------------------------------------------- */ +/* Move the accent rule + indent onto the wrapper so headline, image, + and body share one consistent column edge */ .mission-screen { gap: 20px; + padding-left: 18px; + border-left: 2px solid var(--hz-amber); + max-width: 720px; +} +.mission-screen.makima-screen { + border-left-color: var(--hz-mag); + gap: 14px; } .mission-headline { @@ -337,9 +401,9 @@ body { background: var(--hz-night); color: var(--hz-ink); } white-space: normal; text-overflow: clip; overflow: visible; - max-width: 56ch; - border-left: 2px solid var(--hz-amber); - padding-left: 18px; + margin: 0; + padding-left: 0; /* rule + indent now live on .mission-screen */ + border-left: 0; } .mission-image { @@ -350,6 +414,8 @@ body { background: var(--hz-night); color: var(--hz-ink); } 0 0 0 1px var(--hz-line); filter: saturate(0.85) brightness(0.92); height: clamp(180px, 36vh, 360px); + width: 100%; + display: block; } .mission-paragraph { @@ -359,14 +425,15 @@ body { background: var(--hz-night); color: var(--hz-ink); } line-height: 1.75; color: rgba(240, 234, 223, 0.86); letter-spacing: 0.01em; + margin: 0; max-width: 60ch; } +.mission-paragraph + .mission-paragraph { margin-top: 4px; } /* Makima panel — tactical accent */ .makima-headline { color: var(--hz-ink); letter-spacing: 0.04em; - border-left-color: var(--hz-mag); } .makima-badge { background: rgba(217, 106, 138, 0.10); @@ -377,6 +444,10 @@ body { background: var(--hz-night); color: var(--hz-ink); } font-size: 10px; letter-spacing: 0.22em; border-radius: 2px; + align-self: start; + justify-self: start; /* don't stretch across the grid column */ + width: max-content; + padding: 4px 10px; } .makima-logo { border: 0; |
