diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index ce86837..d6b79a4 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -90,10 +90,11 @@ export default async function LocaleLayout({ - {/* Archivo Black — the oversized display word behind the app cards. */} + {/* Archivo Black — the oversized display word behind the app cards. + Space Grotesk — the footer wordmark (also drawn into a canvas). */} {/* Pretendard Variable dynamic-subset — only the unicode ranges used on the page are fetched (~200KB vs 2MB). SRI pinned to 1.3.9. */} diff --git a/app/globals.css b/app/globals.css index af7776c..fcbfffb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -256,6 +256,98 @@ body { } } +/* ── Footer ────────────────────────────────────────────────────── + Meta columns collapse 4 → 2 → 1 as the viewport narrows. The policy + column is pulled up on wide screens to sit level with the others. */ +.footer-meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, auto)); + justify-content: space-between; + gap: 36px min(108px, 5vw); + margin-top: 34px; + will-change: transform, opacity; +} +.footer-col { + display: flex; + flex-direction: column; + gap: 13px; + min-width: 120px; + will-change: transform, opacity, filter; +} +.footer-col--policy { + margin-top: -140px; + align-self: start; +} +.footer-col__label { + font-size: 10.5px; + letter-spacing: 0.28em; + color: #4a4a52; + margin-bottom: 3px; +} +.footer-link { + position: relative; + display: inline-block; + transition: color 0.2s; +} +.footer-link:hover { + color: #ededef !important; +} +.footer-hl { + position: absolute; + left: 0; + right: 0; + bottom: -3px; + height: 1.5px; + border-radius: 1px; + background: #3b82f6; + transform: scaleX(0); + transform-origin: right; + transition: transform 0.22s cubic-bezier(0.65, 0, 0.35, 1); +} +.footer-link:hover .footer-hl { + transform: scaleX(1); + transform-origin: left; +} +.footer-totop { + font-size: 12px; + letter-spacing: 0.08em; + color: #8a8a93; + background: transparent; + border: 1px solid #26262e; + border-radius: 8px; + padding: 9px 15px; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, transform 0.2s; +} +.footer-totop:hover { + color: #ededef; + border-color: #3b82f6; + transform: translateY(-2px); +} + +@media (max-width: 1023px) { + .footer-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: start; + gap: 32px 40px; + } + .footer-col--policy { + margin-top: 0; + } +} +@media (max-width: 560px) { + .footer-meta { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .footer-hl, + .footer-totop { + transition: none; + } +} + /* Animated underline for text links. */ .link-underline { background-image: linear-gradient(currentColor, currentColor); diff --git a/components/Footer.tsx b/components/Footer.tsx index 3c7f797..485ade3 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,86 +1,428 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Link } from "@/i18n/navigation"; -import LogoMark from "./LogoMark"; -const NAV = [ - { key: "apps", href: "#apps" }, - { key: "capabilities", href: "#capabilities" }, - { key: "about", href: "#about" }, - { key: "contact", href: "#contact" }, -] as const; +type Row = { k: string; v: string; href?: string }; +type Group = { label: string; rows: Row[] }; +type Policy = { t: string; href: string; strong?: boolean }; +type Link = { t: string; href: string }; + +const MONO = "'JetBrains Mono', ui-monospace, monospace"; +const DISPLAY = "'Space Grotesk', 'Pretendard Variable', sans-serif"; + +const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v)); +const ease = (t: number) => 1 - Math.pow(1 - t, 3); export default function Footer() { const t = useTranslations("Footer"); - const tn = useTranslations("Nav"); - const tc = useTranslations("Contact"); + const groups = t.raw("groups") as Group[]; + const policies = t.raw("policies") as Policy[]; + const bottomLinks = t.raw("bottomLinks") as Link[]; + // `~word~` marks the struck-through word — same convention as the efface scene + const tagline = t.raw("tagline") as string; + const [taglineA, taglineStruck, taglineB] = (() => { + const m = tagline.match(/^(.*?)~(.+?)~(.*)$/); + return m ? [m[1], m[2], m[3]] : [tagline, "", ""]; + })(); + + const rootRef = useRef(null); + const wipeRef = useRef(null); + const headRef = useRef(null); + const metaRef = useRef(null); + const strikeRef = useRef(null); + const barRef = useRef(null); + const canvasRef = useRef(null); + const cwrapRef = useRef(null); + + const toTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + + useEffect(() => { + const root = rootRef.current; + const canvas = canvasRef.current; + if (!root || !canvas) return; + + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const mouse = { x: -9999, y: -9999, t: 0 }; + let raf = 0; + let seq = 0; + let lastT = 0; + + // Offscreen layers: a dim wordmark, a bright gradient one, and a trail mask + // that reveals the bright copy only where the cursor has swept. + let cw = 0, ch = 0; + let ctx: CanvasRenderingContext2D | null = null; + let trail: HTMLCanvasElement, work: HTMLCanvasElement; + let dimSrc: HTMLCanvasElement, brightSrc: HTMLCanvasElement; + + const setupCanvas = () => { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (!w || !h) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + cw = w; + ch = h; + const mk = () => { + const cv = document.createElement("canvas"); + cv.width = Math.round(w * dpr); + cv.height = Math.round(h * dpr); + cv.getContext("2d")!.setTransform(dpr, 0, 0, dpr, 0, 0); + return cv; + }; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx = canvas.getContext("2d"); + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + trail = mk(); + work = mk(); + dimSrc = mk(); + brightSrc = mk(); + + const fitDraw = (cv: HTMLCanvasElement, fill: string | CanvasGradient) => { + const x = cv.getContext("2d")!; + const fs = h * 1.04; + x.font = `700 ${fs}px ${DISPLAY}`; + const tw = Math.max(1, x.measureText("EFFACE").width); + const sx = w / tw; + x.save(); + x.scale(sx, 1); + x.textAlign = "center"; + x.textBaseline = "middle"; + x.fillStyle = fill; + x.strokeStyle = fill; + x.lineWidth = fs * 0.045; + x.lineJoin = "miter"; + x.miterLimit = 4; + x.fillText("EFFACE", w / sx / 2, h * 0.56); + x.strokeText("EFFACE", w / sx / 2, h * 0.56); + x.restore(); + }; + fitDraw(dimSrc, "#18181E"); + const g = brightSrc.getContext("2d")!.createLinearGradient(0, 0, w, 0); + g.addColorStop(0, "#3B82F6"); + g.addColorStop(0.36, "#8B5CF6"); + g.addColorStop(0.68, "#14B8B0"); + g.addColorStop(1, "#F59E0B"); + fitDraw(brightSrc, g); + if (reduced) { + const tc = trail.getContext("2d")!; + tc.fillStyle = "#fff"; + tc.fillRect(0, 0, w, h); + } + }; + + const onMouse = (e: MouseEvent) => { + mouse.x = e.clientX; + mouse.y = e.clientY; + mouse.t = performance.now(); + }; + const onResize = () => setupCanvas(); + window.addEventListener("mousemove", onMouse); + window.addEventListener("resize", onResize); + + const fontsReady = document.fonts?.ready ?? Promise.resolve(); + fontsReady.then(() => setupCanvas()); + + const tick = () => { + raf = requestAnimationFrame(tick); + const vh = window.innerHeight; + const rect = root.getBoundingClientRect(); + const now = performance.now(); + const dt = Math.min(0.05, (now - (lastT || now)) / 1000); + lastT = now; + + // Runs forward while the footer is in view, rewinds when it leaves. + const visible = rect.top < vh * 0.72 && rect.bottom > vh * 0.2; + seq = clamp(seq + (visible ? dt / 1.6 : -dt / 0.45), 0, 1); + const p = reduced ? 1 : seq; + + if (wipeRef.current) { + const wt = ease(clamp(p / 0.55, 0, 1)); + const el = wipeRef.current; + if (wt >= 1) { + if (el.style.maskImage !== "none") { + el.style.maskImage = "none"; + el.style.webkitMaskImage = "none"; + } + } else { + const e2 = -15 + wt * 145; + const m = `linear-gradient(115deg, rgba(0,0,0,1) ${e2 - 14}%, rgba(0,0,0,0) ${e2}%)`; + el.style.maskImage = m; + el.style.webkitMaskImage = m; + } + } + if (headRef.current) { + const tt = ease(clamp(p / 0.2, 0, 1)); + headRef.current.style.opacity = String(0.1 + 0.9 * tt); + headRef.current.style.transform = `translateY(${(1 - tt) * 14}px)`; + headRef.current.style.filter = tt >= 1 ? "none" : `blur(${(1 - tt) * 8}px)`; + } + if (metaRef.current) { + metaRef.current.style.opacity = String(clamp(p * 6, 0, 1)); + const gs = Array.from(metaRef.current.children) as HTMLElement[]; + gs.forEach((g, i) => { + const tt = ease(clamp((p - 0.14 - i * 0.07) / 0.24, 0, 1)); + g.style.opacity = String(tt); + g.style.transform = `translateY(${(1 - tt) * 18}px)`; + g.style.filter = tt >= 1 ? "none" : `blur(${(1 - tt) * 8}px)`; + }); + } + if (strikeRef.current) { + strikeRef.current.style.transform = `scaleX(${ease(clamp((p - 0.1) / 0.18, 0, 1))})`; + } + if (cwrapRef.current) { + const tt = ease(clamp((p - 0.38) / 0.32, 0, 1)); + cwrapRef.current.style.clipPath = `inset(0 ${(1 - tt) * 100}% 0 0)`; + } + if (barRef.current) { + barRef.current.style.opacity = String(ease(clamp((p - 0.66) / 0.22, 0, 1))); + } + + if (!ctx) return; + if (canvas.clientWidth && canvas.clientWidth !== cw) setupCanvas(); + const r = canvas.getBoundingClientRect(); + if (r.bottom < -80 || r.top > vh + 80) return; + + const tctx = trail.getContext("2d")!; + if (!reduced) { + // fade the trail out slowly so the sweep leaves a tail + tctx.save(); + tctx.globalCompositeOperation = "destination-out"; + tctx.globalAlpha = 0.05; + tctx.fillStyle = "#000"; + tctx.fillRect(0, 0, cw, ch); + tctx.restore(); + + const time = now / 1000; + let px: number | null = cw * (0.5 + 0.44 * Math.sin(time * 0.32)); + let py: number | null = ch * (0.5 + 0.14 * Math.sin(time * 1.05)); + if ( + now - mouse.t < 1400 && + mouse.x >= r.left && mouse.x <= r.right && + mouse.y >= r.top - 50 && mouse.y <= r.bottom + 50 + ) { + px = mouse.x - r.left; + py = clamp(mouse.y - r.top, -10, ch + 10); + } + if (px !== null && py !== null) { + const R = ch * 0.62; + const gr = tctx.createRadialGradient(px, py, 0, px, py, R); + gr.addColorStop(0, "rgba(255,255,255,0.5)"); + gr.addColorStop(0.7, "rgba(255,255,255,0.18)"); + gr.addColorStop(1, "rgba(255,255,255,0)"); + tctx.save(); + tctx.fillStyle = gr; + tctx.beginPath(); + tctx.arc(px, py, R, 0, 7); + tctx.fill(); + tctx.restore(); + } + } + + const wctx = work.getContext("2d")!; + wctx.save(); + wctx.setTransform(1, 0, 0, 1, 0, 0); + wctx.clearRect(0, 0, work.width, work.height); + wctx.restore(); + wctx.drawImage(brightSrc, 0, 0, cw, ch); + wctx.save(); + wctx.globalCompositeOperation = "destination-in"; + wctx.drawImage(trail, 0, 0, cw, ch); + wctx.restore(); + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.restore(); + ctx.drawImage(dimSrc, 0, 0, cw, ch); + ctx.drawImage(work, 0, 0, cw, ch); + }; + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("mousemove", onMouse); + window.removeEventListener("resize", onResize); + }; + }, []); + + const rowLink = (row: Row) => + row.href ? ( + + {row.v} + + + ) : ( + {row.v} + ); return ( -