From dbfa4589f8c284548bb0d35869259e7f56ff3d5c Mon Sep 17 00:00:00 2001 From: xixn2 Date: Sun, 26 Jul 2026 14:59:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20(feat)=20#32=20::=20=ED=91=B8?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EB=B3=B4=20=EA=B5=AC=EC=A1=B0=20=EC=B9=B4?= =?UTF-8?q?=ED=94=BC=EB=A5=BC=20=ED=95=9C/=EC=98=81=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en.json | 114 ++++++++++++++++++++++++++++++++++++++++++++--- messages/ko.json | 114 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 214 insertions(+), 14 deletions(-) diff --git a/messages/en.json b/messages/en.json index 8133d31..fc17177 100644 --- a/messages/en.json +++ b/messages/en.json @@ -192,13 +192,113 @@ "email": "contact@efface.dev" }, "Footer": { - "tagline": "Erase the complexity. Keep the effect.", - "studio": "AI · Web · App Studio", - "founderLabel": "Founder", - "founder": "Seojiwan Suh", + "tagline": "Erase the complexity. ~Keep~ the effect.", + "groups": [ + { + "label": "COMPANY", + "rows": [ + { + "k": "Name", + "v": "efface" + }, + { + "k": "Founder", + "v": "Seojiwan Suh ↗", + "href": "https://www.linkedin.com/in/xixn2" + }, + { + "k": "Based in", + "v": "Seoul, Korea" + } + ] + }, + { + "label": "CONTACT", + "rows": [ + { + "k": "Business", + "v": "sales@efface.dev", + "href": "mailto:sales@efface.dev" + }, + { + "k": "General", + "v": "contact@efface.dev", + "href": "mailto:contact@efface.dev" + }, + { + "k": "Support", + "v": "support@efface.dev", + "href": "mailto:support@efface.dev" + } + ] + }, + { + "label": "CHANNEL", + "rows": [ + { + "k": "KakaoTalk", + "v": "@efface", + "href": "https://pf.kakao.com/_zxmWKX/chat" + }, + { + "k": "GitHub", + "v": "efface-studio", + "href": "https://github.com/efface-studio" + }, + { + "k": "Response", + "v": "Within 24h on weekdays" + } + ] + } + ], + "policyLabel": "POLICY", + "policies": [ + { + "t": "Privacy Policy", + "href": "/policies#privacy", + "strong": true + }, + { + "t": "Terms of Service", + "href": "/policies#terms" + }, + { + "t": "Open Source Licenses", + "href": "/policies#oss" + }, + { + "t": "Cookie Policy", + "href": "/policies#cookies" + }, + { + "t": "No Email Collection", + "href": "/policies#no-email-collection" + }, + { + "t": "Report a Vulnerability", + "href": "/policies#security" + }, + { + "t": "Copyright Notice", + "href": "/policies#copyright" + }, + { + "t": "User Rights & Notices", + "href": "/policies#user-rights" + } + ], "rights": "© 2026 efface. All rights reserved.", - "backToTop": "Back to top", - "navTitle": "Menu", - "contactTitle": "Contact" + "bottomLinks": [ + { + "t": "Privacy", + "href": "/policies#privacy" + }, + { + "t": "Terms", + "href": "/policies#terms" + } + ], + "toTop": "Back to top ↑" } } diff --git a/messages/ko.json b/messages/ko.json index 7d7ca90..32a28b2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -192,13 +192,113 @@ "email": "contact@efface.dev" }, "Footer": { - "tagline": "복잡함은 지우고, 효과만 남깁니다.", - "studio": "AI · 웹 · 앱 개발 스튜디오", - "founderLabel": "대표", - "founder": "서지완", + "tagline": "복잡함은 ~지우고~, 효과만 남깁니다.", + "groups": [ + { + "label": "COMPANY", + "rows": [ + { + "k": "상호", + "v": "efface" + }, + { + "k": "대표", + "v": "서지완 ↗", + "href": "https://www.linkedin.com/in/xixn2" + }, + { + "k": "소재지", + "v": "서울, 대한민국" + } + ] + }, + { + "label": "CONTACT", + "rows": [ + { + "k": "비즈니스", + "v": "sales@efface.dev", + "href": "mailto:sales@efface.dev" + }, + { + "k": "일반 문의", + "v": "contact@efface.dev", + "href": "mailto:contact@efface.dev" + }, + { + "k": "고객 지원", + "v": "support@efface.dev", + "href": "mailto:support@efface.dev" + } + ] + }, + { + "label": "CHANNEL", + "rows": [ + { + "k": "카카오톡", + "v": "@efface", + "href": "https://pf.kakao.com/_zxmWKX/chat" + }, + { + "k": "GitHub", + "v": "efface-studio", + "href": "https://github.com/efface-studio" + }, + { + "k": "응답", + "v": "평일 24시간 내" + } + ] + } + ], + "policyLabel": "POLICY", + "policies": [ + { + "t": "개인정보 처리방침", + "href": "/policies#privacy", + "strong": true + }, + { + "t": "서비스 이용약관", + "href": "/policies#terms" + }, + { + "t": "오픈소스 라이선스", + "href": "/policies#oss" + }, + { + "t": "쿠키 정책", + "href": "/policies#cookies" + }, + { + "t": "이메일무단수집거부", + "href": "/policies#no-email-collection" + }, + { + "t": "보안 취약점 신고", + "href": "/policies#security" + }, + { + "t": "저작권 안내", + "href": "/policies#copyright" + }, + { + "t": "이용자의 권리 및 유의사항", + "href": "/policies#user-rights" + } + ], "rights": "© 2026 efface. All rights reserved.", - "backToTop": "맨 위로", - "navTitle": "메뉴", - "contactTitle": "연락처" + "bottomLinks": [ + { + "t": "개인정보처리방침", + "href": "/policies#privacy" + }, + { + "t": "이용약관", + "href": "/policies#terms" + } + ], + "toTop": "맨 위로 ↑" } } From e6e83b5dfe48ed6ef22868ced23e4e3dab2aa355 Mon Sep 17 00:00:00 2001 From: xixn2 Date: Sun, 26 Jul 2026 14:59:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20(feat)=20#32=20::=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EC=9B=8C=EB=93=9C=EB=A7=88=ED=81=AC?= =?UTF-8?q?=EC=99=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=AA=A8=EC=85=98?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=EC=83=88=20=ED=91=B8=ED=84=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[locale]/layout.tsx | 5 +- app/globals.css | 92 ++++++++ components/Footer.tsx | 480 ++++++++++++++++++++++++++++++++++------ 3 files changed, 506 insertions(+), 71 deletions(-) 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 ( -