summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
authorsoryu-co <bot@soryu.co>2026-05-06 12:39:02 +0000
committersoryu-co <bot@soryu.co>2026-05-06 12:39:02 +0000
commit30bfe2149fdead196c08a8cfaf5bb5415e6ee7c4 (patch)
tree393934a1aadcca9fb42a517bad84faf679df2229 /frontend/src/components
parent771ece20e5e669f93fc0aba0459ac1ff2fa0825b (diff)
downloadsoryu-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.
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/LandingPage.tsx15
-rw-r--r--frontend/src/components/TypewriterRotator.tsx84
2 files changed, 98 insertions, 1 deletions
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>
+ )
+}