From 91c292bb2215c509746907f8c11772bb9a778d67 Mon Sep 17 00:00:00 2001 From: dev-goraebap Date: Mon, 9 Nov 2026 20:24:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=9D=98=20=ED=99=94=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EC=97=85=EB=AC=B4=20=EC=95=B1=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=97=90=20=EB=A7=9E=EC=B6=94=EA=B3=A0=20=EA=B2=AC?= =?UTF-8?q?=EB=B3=B8=C2=B7=EB=8F=84=ED=91=9C=EB=A5=BC=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=20=EC=A4=80=EB=B9=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로켓 덮개와 이동마다의 페이드 인을 빼고, 150ms 뒤 카드 위 진행 줄과 콘텐츠 영역 200ms View Transition 교차 페이드로 바꾼다. - 빌드 때 문서의 견본 id·도표 원문을 뽑아 loader에서 견본 정보와 도표 SVG를 미리 준비한다. 새 문서가 처음부터 제 높이로 그려진다. - 크는 견본은 미리 잰 크기(npm run site:sizes → sample-sizes.json)와 브라우저에 기억한 크기로 자리를 잡고, 다 그려진 뒤 나타난다. - 개요 페이지가 있는 메뉴 묶음은 첫 하위 문서가 아니라 그 페이지로 간다. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- site/content-plugin.ts | 17 +++- site/scripts/measure-samples.mjs | 52 ++++++++++ site/src/app/main.tsx | 17 +++- site/src/pages/document/DocPage.tsx | 2 +- site/src/shared/markdown/Diagram.tsx | 83 ++++++++++------ site/src/shared/styles/prose.module.css | 34 +++++-- site/src/shared/styles/samples.css | 6 +- site/src/shared/ui/foundations/base.css | 13 +++ site/src/widgets/samples/Sample.tsx | 40 ++++++-- site/src/widgets/samples/index.ts | 2 +- site/src/widgets/samples/sample-sizes.json | 106 +++++++++++++++++++++ site/src/widgets/site-shell/GuideShell.tsx | 47 +++++---- 13 files changed, 349 insertions(+), 73 deletions(-) create mode 100644 site/scripts/measure-samples.mjs create mode 100644 site/src/widgets/samples/sample-sizes.json diff --git a/package.json b/package.json index 8dd1ef0..a984302 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "site:preview": "vite preview --config site/vite.config.ts", "site:check": "tsc -p site/tsconfig.json", "site:test": "vitest run --config site/vitest.config.ts", - "site:icons": "node site/scripts/generate-icons.mjs" + "site:icons": "node site/scripts/generate-icons.mjs", + "site:sizes": "node site/scripts/measure-samples.mjs" }, "devDependencies": { "@astryxdesign/core": "^0.5.2", diff --git a/site/content-plugin.ts b/site/content-plugin.ts index 09eb918..44cda7e 100644 --- a/site/content-plugin.ts +++ b/site/content-plugin.ts @@ -24,6 +24,9 @@ export type GuideDoc = { hasTitle: boolean; /** 아키텍처 정의서(arc42)에서 이 문서가 맡는 절. 첫 값이 메뉴에서 놓일 절이다. 해당 없으면 비운다. */ arc42?: string[]; + /** 본문이 띄우는 견본 id와 도표(Mermaid) 원문. 문서로 옮기기 전에 미리 준비해 빈 자리로 그렸다가 채우지 않게 한다. */ + samples?: string[]; + diagrams?: string[]; kind?: 'post'; date?: string; updated?: string; @@ -78,6 +81,13 @@ const walk = (dir: string): string[] => readdirSync(dir).flatMap(name => { const posix = (file: string) => file.split(path.sep).join('/'); const repoPath = (file: string) => posix(path.relative(path.resolve(here, '..'), file)); +/** 본문에서 미리 준비할 것: 의 id와 ```mermaid 코드 블록의 원문. */ +const assetsOf = (text: string) => { + const samples = [...new Set([...text.matchAll(/ match[1]))]; + const diagrams = [...text.matchAll(/^```mermaid\r?\n([\s\S]*?)\r?\n```/gm)].map(match => match[1].replace(/\r\n/g, '\n')); + return { ...(samples.length ? { samples } : {}), ...(diagrams.length ? { diagrams } : {}) }; +}; + /** 지침 Markdown 파일의 사이트 주소. SKILL.md는 지침 첫 장, 나머지는 하위 경로. */ export function skillRoute(file: string) { const relative = posix(path.relative(skillsRoot, file)).replace(/\.md$/, ''); @@ -106,7 +116,7 @@ function skillDocs(): GuideDoc[] { const meta = frontmatter(text); return { path: skillRoute(file), title: /^# (.+)$/m.exec(text)?.[1].trim() ?? relative, - description: isEntry ? meta.description : undefined, arc42: meta.arc42, + description: isEntry ? meta.description : undefined, arc42: meta.arc42, ...assetsOf(text), group: meta.arc42 ? sectionPath(meta.arc42[0]) : `/webcraft/rules/${skill}/`, order: isEntry ? 0 : index >= 0 ? index + 1 : 500, source: repoPath(file), hasTitle: true, }; @@ -117,9 +127,10 @@ function skillDocs(): GuideDoc[] { function contentDocs(): GuideDoc[] { return walk(contentRoot).filter(file => /\.mdx?$/.test(file) && !posix(path.relative(contentRoot, file)).startsWith('blog/')).map(file => { const relative = posix(path.relative(contentRoot, file)).replace(/\.mdx?$/, '').replace(/(^|\/)index$/, ''); - const meta = frontmatter(readFileSync(file, 'utf8')); + const text = readFileSync(file, 'utf8'); + const meta = frontmatter(text); const group = contentGroups.find(item => relative === item.dir || relative.startsWith(`${item.dir}/`)); - return { path: relative ? `/webcraft/${relative}/` : '/webcraft/', title: meta.title ?? relative, description: meta.description, arc42: meta.arc42, + return { path: relative ? `/webcraft/${relative}/` : '/webcraft/', title: meta.title ?? relative, description: meta.description, arc42: meta.arc42, ...assetsOf(text), group: meta.arc42 ? sectionPath(meta.arc42[0]) : group ? `/webcraft/${group.dir}/` : '/webcraft/', order: meta.order, source: repoPath(file), hasTitle: false }; }).sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); } diff --git a/site/scripts/measure-samples.mjs b/site/scripts/measure-samples.mjs new file mode 100644 index 0000000..968c679 --- /dev/null +++ b/site/scripts/measure-samples.mjs @@ -0,0 +1,52 @@ +// 내용에 맞춰 크는 견본(부품 견본)의 크기를 미리 잰다. 첫 방문에도 견본이 처음부터 제 높이로 자리를 잡아 글이 밀리지 +// 않게 하려는 것이다. 실행 중인 가이드 개발 서버(기본 http://localhost:4451)에서 크는 견본마다 실제 기기 폭으로 띄워, +// 견본이 알려 온 크기를 site/src/widgets/samples/sample-sizes.json에 쓴다. 견본을 더하거나 모양을 바꾸면 +// `npm run site` 를 띄운 채로 `npm run site:sizes` 를 다시 돌린다. +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const output = path.resolve(here, '../src/widgets/samples/sample-sizes.json'); +const origin = process.argv[2] ?? 'http://localhost:4451'; + +// 견본 화면(sample.html)은 내용에 맞춰 크는 견본이면 부모 창에 크기를 알린다(SampleFrame의 useReportSize). 가이드 사이트와 +// 같은 출처의 빈 문서에 견본마다 실제 기기 폭의 iframe을 띄워 그 크기를 모은다. 키는 견본 부품(Sample)과 같은 "id:기기"다. +const WIDTH = { desktop: 1280, phone: 390 }; +const browser = await chromium.launch(); +const page = await (await browser.newContext({ viewport: { width: 1440, height: 1000 } })).newPage(); +await page.goto(`${origin}/webcraft/`); +await page.waitForLoadState('networkidle'); +const sizes = await page.evaluate(async width => { + const { loadAll } = await import('/samples/catalog/registry.ts'); + const auto = (await loadAll()).filter(entry => entry.layout !== 'fullscreen' && !entry.height); + const found = {}; + const host = document.createElement('div'); + Object.assign(host.style, { position: 'fixed', left: '-99999px', top: '0' }); + document.body.append(host); + // 한 번에 몇 개씩 띄워 잰다. + for (let index = 0; index < auto.length; index += 6) { + await Promise.all(auto.slice(index, index + 6).map(entry => new Promise(resolve => { + const frame = document.createElement('iframe'); + let timer = 0; + const done = () => { window.removeEventListener('message', receive); clearTimeout(timer); frame.remove(); resolve(); }; + const receive = event => { + if (event.source !== frame.contentWindow || event.data?.type !== 'sample-size') return; + found[`${entry.id}:${entry.device}`] = { width: Math.ceil(event.data.width), height: Math.ceil(event.data.height) }; + // 글꼴·그림이 늦게 붙어 커질 수 있으니 잠깐 더 받은 뒤 닫는다. + clearTimeout(timer); timer = setTimeout(done, 700); + }; + window.addEventListener('message', receive); + timer = setTimeout(done, 10000); + Object.assign(frame.style, { width: `${width[entry.device]}px`, height: '160px', border: '0' }); + frame.src = `/sample.html?id=${encodeURIComponent(entry.id)}`; + host.append(frame); + }))); + } + return found; +}, WIDTH); +await browser.close(); +const sorted = Object.fromEntries(Object.entries(sizes).sort(([a], [b]) => a.localeCompare(b))); +writeFileSync(output, `${JSON.stringify(sorted, null, 2)}\n`); +console.log(`${Object.keys(sorted).length}개 견본 크기를 ${path.relative(process.cwd(), output)}에 썼습니다.`); diff --git a/site/src/app/main.tsx b/site/src/app/main.tsx index 89c78d8..0edb800 100644 --- a/site/src/app/main.tsx +++ b/site/src/app/main.tsx @@ -6,6 +6,8 @@ import { docAt, firstOf, loadDoc } from '../entities/document'; import { DocPage } from '../pages/document'; import { SettingsPage } from '../pages/settings'; import { BlogPage } from '../pages/blog'; +import { prepareDiagrams } from '../shared/markdown/Diagram'; +import { prepareSamples } from '../widgets/samples'; import { SiteApp } from './providers'; /** 주소의 문서를 찾아 본문을 불러온다. 묶음 주소(/app-shell/ 등)는 그 묶음의 첫 문서로 보낸다. */ @@ -28,7 +30,9 @@ async function load(pathname: string) { if (first) throw redirect({ href: first, replace: true } as never); return { doc: null, Content: null }; } - return { doc, Content: await loadDoc(doc) }; + // 본문 코드와 함께 견본 정보와 도표 그림을 준비한다. 새 문서는 처음부터 제 높이로 그려지고 뒤늦게 밀리지 않는다. + const [Content] = await Promise.all([loadDoc(doc), prepareSamples(doc.samples ?? []), prepareDiagrams(doc.diagrams ?? [])]); + return { doc, Content }; } const loader = ({ location }: { location: { pathname: string } }) => load(location.pathname); @@ -43,8 +47,17 @@ const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute, docRoute]), trailingSlash: 'always', defaultPreload: 'intent', - // 다음 문서가 준비될 때까지 지금 문서를 그대로 둔다. 덮개는 셸이 180ms 뒤에 씌운다. + // 다음 문서가 준비될 때까지 지금 문서를 그대로 둔다. 진행 표시는 셸이 150ms 뒤에 띄운다. defaultPendingMs: Infinity, + // 준비된 문서끼리는 콘텐츠 영역만 교차 페이드한다(업무 앱과 같은 규칙). 라우터는 loader가 끝난 뒤 전환을 시작하므로 + // 준비하는 동안 진행 표시가 멈추지 않는다. 동작 줄이기 설정, 진행 중인 전환, 크게 보기 등 열린 창 위에서는 하지 않는다. + defaultViewTransition: typeof CSS !== 'undefined' && CSS.supports('selector(:active-view-transition-type(guide-content))') && { + types: ({ fromLocation, pathChanged }) => { + if (!fromLocation || !pathChanged || matchMedia('(prefers-reduced-motion: reduce)').matches + || document.documentElement.matches(':active-view-transition') || document.querySelector('dialog[open], .yarl__portal_open')) return false; + return ['guide-content']; + }, + }, }); declare module '@tanstack/react-router' { interface Register { router: typeof router } } diff --git a/site/src/pages/document/DocPage.tsx b/site/src/pages/document/DocPage.tsx index 669bc66..574a1ec 100644 --- a/site/src/pages/document/DocPage.tsx +++ b/site/src/pages/document/DocPage.tsx @@ -34,7 +34,7 @@ export function DocPage({ doc, Content }: { doc: GuideDoc | null; Content: Compo observer.observe(node, { childList: true, subtree: true }); return () => observer.disconnect(); }, [doc?.path]); - return
+ return
{doc ? <>{doc.hasTitle ? null :

{doc.title}

}{doc.kind === 'post' ?
{doc.updated && doc.updated !== doc.date ? 수정 {doc.updated} : null}{doc.tags?.map(tag => {tag})}
: null}{Content ? : null} : <>

없는 문서

diff --git a/site/src/shared/markdown/Diagram.tsx b/site/src/shared/markdown/Diagram.tsx index e3847aa..de5de7a 100644 --- a/site/src/shared/markdown/Diagram.tsx +++ b/site/src/shared/markdown/Diagram.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithRef } from 'react'; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ComponentPropsWithRef } from 'react'; import type { DiagramSlide } from './ZoomViewer'; import styles from './diagram.module.css'; @@ -26,10 +26,52 @@ let renderQueue: Promise = Promise.resolve(); const ZoomViewer = lazy(() => import('./ZoomViewer')); const LABEL = '본문의 구조와 흐름을 설명하는 도표'; +/** 그린 도표: 색표와 원문이 같으면 다시 그리지 않는다. 문서로 옮기기 전에 미리 그려 두면 첫 그림부터 제 높이로 나온다. */ +const drawn = new Map>(); +const keyOf = (scheme: Scheme, source: string) => `${scheme} +${source.trim()}`; +let serial = 0; +function draw(source: string, scheme: Scheme) { + const key = keyOf(scheme, source); + const found = drawn.get(key); + if (found) return found; + const work = renderQueue.then(async () => { + const { default: mermaid } = await import('mermaid'); + await document.fonts.ready; + mermaid.initialize({ startOnLoad: false, theme: 'base', themeVariables: { ...palettes[scheme], fontFamily: 'Pretendard Variable, Pretendard, sans-serif', fontSize: '14px' }, + securityLevel: 'strict', htmlLabels: false, flowchart: { htmlLabels: false }, suppressErrorRendering: true }); + // 그릴 동안 문서 높이가 늘지 않도록 높이 0의 숨은 상자 안에서 잰다. + const measuring = document.createElement('div'); + measuring.setAttribute('aria-hidden', 'true'); + Object.assign(measuring.style, { position: 'fixed', inset: '0 0 auto 0', height: '0', overflow: 'hidden', visibility: 'hidden', pointerEvents: 'none' }); + document.body.append(measuring); + try { return (await mermaid.render(`diagram-${++serial}`, source.trim(), measuring)).svg; } + finally { measuring.remove(); } + }); + renderQueue = work.catch(() => undefined); + drawn.set(key, work); + work.catch(() => drawn.delete(key)); + return work; +} +/** 다 그린 도표(동기). 없으면 undefined. */ +const ready = new Map(); +/** 문서 준비(라우트 loader)에서 부른다: 본문의 도표를 지금 색표로 미리 그려 둔다. 실패한 도표는 본문에서 원문으로 보인다. */ +export async function prepareDiagrams(sources: readonly string[]) { + const scheme = pageScheme(); + await Promise.all(sources.map(source => draw(source, scheme).then(svg => { ready.set(keyOf(scheme, source), svg); }, () => undefined))); +} + +/** 전체가 한눈에 보이게 본문 폭에 맞춰 줄이고(스크롤 없음), 작은 도표는 원래 크기보다 키우지 않는다. 자세히 볼 때는 크게 보기. */ +function fit(root: SVGSVGElement | null | undefined) { + const width = Number(root?.getAttribute('viewBox')?.split(/[ ,]+/)[2]); + if (root && Number.isFinite(width) && width > 0) { root.style.width = '100%'; root.style.maxWidth = `${width}px`; root.style.height = 'auto'; } +} + export function Diagram({ 'data-diagram': source, ...props }: ComponentPropsWithRef<'div'> & { 'data-diagram'?: string }) { const scheme = useSyncExternalStore(subscribeTheme, pageScheme); - const id = useId().replace(/:/g, ''); + const key = source ? keyOf(scheme, source) : ''; const node = useRef(null); + const [svg, setSvg] = useState(() => ready.get(key)); const [error, setError] = useState(false); const [zoomed, setZoomed] = useState(null); const open = () => { @@ -38,35 +80,20 @@ export function Diagram({ 'data-diagram': source, ...props }: ComponentPropsWith if (!root || !width || !height) return; setZoomed({ type: 'diagram', svg: root.outerHTML, width, height, label: LABEL, background: String(palettes[scheme].background) }); }; + // 미리 그린 SVG는 칠하기 전에 넣는다(빈 자리가 한 번도 보이지 않는다). + useLayoutEffect(() => { + if (!svg || !node.current) return; + node.current.innerHTML = svg; + fit(node.current.querySelector('svg')); + }, [svg]); useEffect(() => { - if (!source) return; + if (!source) return undefined; + const prepared = ready.get(key); + if (prepared) { setSvg(prepared); return undefined; } let cancelled = false; - const work = renderQueue.then(async () => { - if (cancelled) return; - const { default: mermaid } = await import('mermaid'); - await document.fonts.ready; - if (cancelled) return; - mermaid.initialize({ startOnLoad: false, theme: 'base', themeVariables: { ...palettes[scheme], fontFamily: 'Pretendard Variable, Pretendard, sans-serif', fontSize: '14px' }, - securityLevel: 'strict', htmlLabels: false, flowchart: { htmlLabels: false }, suppressErrorRendering: true }); - // 그릴 동안 문서 높이가 늘지 않도록 높이 0의 숨은 상자 안에서 잰다. - const measuring = document.createElement('div'); - measuring.setAttribute('aria-hidden', 'true'); - Object.assign(measuring.style, { position: 'fixed', inset: '0 0 auto 0', height: '0', overflow: 'hidden', visibility: 'hidden', pointerEvents: 'none' }); - document.body.append(measuring); - let svg: string; - try { ({ svg } = await mermaid.render(`diagram-${id}`, source, measuring)); } - finally { measuring.remove(); } - if (cancelled || !node.current) return; - node.current.innerHTML = svg; - // 전체가 한눈에 보이게 본문 폭에 맞춰 줄이고(스크롤 없음), 작은 도표는 원래 크기보다 키우지 않는다. 자세히 볼 때는 크게 보기. - const root = node.current.querySelector('svg'); - const width = Number(root?.getAttribute('viewBox')?.split(/[ ,]+/)[2]); - if (root && Number.isFinite(width) && width > 0) { root.style.width = '100%'; root.style.maxWidth = `${width}px`; root.style.height = 'auto'; } - setError(false); - }); - renderQueue = work.catch(() => { if (!cancelled) setError(true); }); + draw(source, scheme).then(next => { ready.set(key, next); if (!cancelled) { setSvg(next); setError(false); } }, () => { if (!cancelled) setError(true); }); return () => { cancelled = true; }; - }, [source, id, scheme]); + }, [key, source, scheme]); if (!source) return
; return
{error ?
{source}
: <> diff --git a/site/src/shared/styles/prose.module.css b/site/src/shared/styles/prose.module.css index 770fcdc..91f25e4 100644 --- a/site/src/shared/styles/prose.module.css +++ b/site/src/shared/styles/prose.module.css @@ -66,16 +66,30 @@ * 본문 자리: 다음 문서가 180ms 넘게 걸릴 때만 덮개를 씌운다. 새 문서는 160ms에 걸쳐 나타난다(교차 페이드의 들어오는 쪽). * 화면 전환 API(view transition)는 쓰지 않는다. 전환 중인 본문을 맨 위층에 따로 그려 휴대폰 하단 탭·머리 줄 위로 겹치기 때문이다. */ -.stage { position: relative; min-block-size: 100%; } -.cover { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; gap: var(--spacing-3); - padding-block-start: 25vh; background: var(--color-background-surface); } -/* 전환 덮개의 로켓: 첫 방문 덮개(10rem)의 절반. 선은 보조 글자색, 면은 덮개 면 색으로 칠한다. */ -.coverMark { inline-size: 5rem; block-size: 5rem; } -.coverMark :global(svg) [stroke="rgb(0,0,0)"] { stroke: var(--color-text-secondary); } -.coverMark :global(svg) [fill="rgb(254,254,255)"] { fill: var(--color-background-surface); } -.body[data-arrive] { animation: arrive 160ms ease-out; } -@keyframes arrive { from { opacity: 0; } to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { .body[data-arrive] { animation: none; } } +.stage { position: relative; isolation: isolate; min-block-size: 100%; } +/* 화면이 바뀔 때 교차 페이드하는 단위(View Transition): 콘텐츠 영역 하나. 진행 표시 줄은 이 밖에 있어 그대로 움직인다. */ +.content { view-transition-name: guide-content; min-block-size: 100%; } +/* + * 다음 문서를 준비하는 동안 콘텐츠 카드의 위쪽 테두리가 빛난다(업무 앱의 진행 표시와 같다). 카드 맨 위에 2px 줄을 겹쳐 + * 배치를 밀지 않는다. 밝은 덩어리가 왼쪽에서 오른쪽으로 흐르고(1.6s), 줄 전체가 숨 쉬듯 밝아졌다 어두워진다(2.4s). + */ +.glow { + position: absolute; inset-block-start: 0; inset-inline: 0; z-index: 4; block-size: 2px; pointer-events: none; + background: + linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 85%, transparent), transparent) 0 0 / 40% 100% no-repeat, + color-mix(in srgb, var(--color-accent) 22%, transparent); + box-shadow: 0 0 10px 0 color-mix(in srgb, var(--color-accent) 35%, transparent); + opacity: 0; transition: opacity 240ms ease; +} +.glow[data-visible] { opacity: 1; animation: glow-flow 1.6s ease-in-out infinite, glow-breathe 2.4s ease-in-out infinite; } +@keyframes glow-flow { from { background-position: -40% 0, 0 0; } to { background-position: 140% 0, 0 0; } } +@keyframes glow-breathe { 0%, 100% { filter: brightness(0.85); } 50% { filter: brightness(1.15); } } +@media (prefers-reduced-motion: reduce) { + .glow { transition: none; } + .glow[data-visible] { animation: none; background: color-mix(in srgb, var(--color-accent) 55%, transparent); } +} +/* 화면에는 보이지 않고 화면 읽기 프로그램만 읽는 상태 문구. */ +.spoken { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .identity { display: flex; flex-direction: column; gap: .125rem; min-inline-size: 0; } .identity > span { padding-inline-start: .5rem; color: var(--color-text-secondary); font-size: .6875rem; line-height: 1.2; } diff --git a/site/src/shared/styles/samples.css b/site/src/shared/styles/samples.css index 9cff92d..4948d16 100644 --- a/site/src/shared/styles/samples.css +++ b/site/src/shared/styles/samples.css @@ -7,7 +7,11 @@ border-radius: var(--radius-container, 0.75rem); background: var(--color-background-body); } -.sample iframe { display: block; border: 0; transform-origin: top left; background: transparent; } +.sample iframe { display: block; border: 0; transform-origin: top left; background: transparent; opacity: 0; transition: opacity 200ms ease; } +/* 견본은 다 그려지고 크기가 정해진 뒤에 나타난다. 크기를 몰라 늘어나는 동안은 높이를 부드럽게 바꾼다. */ +.sample iframe[data-ready] { opacity: 1; } +.sample-stage { transition: block-size 200ms ease; } +@media (prefers-reduced-motion: reduce) { .sample iframe, .sample-stage { transition: none; } } /* 틀 위 한 줄: 왼쪽 견본 이름, 오른쪽 기기 전환과 새 창. 기기 전환은 키트의 보기 알약과 같은 모양(고른 쪽만 옅게 채움)이다. */ .sample-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-3, 0.75rem); margin-block-end: var(--spacing-2, 0.5rem); diff --git a/site/src/shared/ui/foundations/base.css b/site/src/shared/ui/foundations/base.css index 9865591..42f9dd9 100644 --- a/site/src/shared/ui/foundations/base.css +++ b/site/src/shared/ui/foundations/base.css @@ -28,3 +28,16 @@ html, body, #root { block-size: 100%; margin: 0; } ::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; background-color: color-mix(in srgb, var(--color-text-primary) 16%, transparent); } ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent); } + +/* 화면 전환(View Transition): 문서 전체 대신 이름 붙인 콘텐츠 영역(guide-content)만 200ms 교차 페이드한다(업무 앱과 같은 값). */ +:root { view-transition-name: none; } +::view-transition { pointer-events: none; } +::view-transition-group(guide-content) { animation-duration: 200ms; animation-timing-function: ease-in-out; } +::view-transition-image-pair(guide-content) { isolation: isolate; } +::view-transition-old(guide-content) { animation: guide-content-out 200ms ease-in-out both; mix-blend-mode: plus-lighter; } +::view-transition-new(guide-content) { animation: guide-content-in 200ms ease-in-out both; mix-blend-mode: plus-lighter; } +@keyframes guide-content-out { from { opacity: 1; } to { opacity: 0; } } +@keyframes guide-content-in { from { opacity: 0; } to { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(guide-content), ::view-transition-old(guide-content), ::view-transition-new(guide-content) { animation: none; } +} diff --git a/site/src/widgets/samples/Sample.tsx b/site/src/widgets/samples/Sample.tsx index a6aa11f..625301e 100644 --- a/site/src/widgets/samples/Sample.tsx +++ b/site/src/widgets/samples/Sample.tsx @@ -1,20 +1,40 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { loadSample, type SampleEntry } from '@samples/catalog/registry'; import type { Device } from '@samples/catalog/types'; +import measured from './sample-sizes.json'; /** 견본을 그리는 기기 폭. 데스크톱은 키트가 데스크톱으로 판정하는 최소 폭, 휴대폰은 390px. */ const WIDTH: Record = { desktop: 1280, phone: 390 }; const rem = (value?: string) => (value?.endsWith('rem') ? Math.round(parseFloat(value) * 16) : undefined); -/** 부품 견본이 알려 온 크기. 같은 견본을 다시 열 때 처음부터 그 크기로 자리를 잡아 글이 밀리지 않게 한다. */ -const sizes = new Map(); +/** + * 부품 견본이 알려 온 크기. 같은 견본을 다시 열 때 처음부터 그 크기로 자리를 잡아 글이 밀리지 않게 한다. 브라우저에 남겨 + * 다음 방문에도 쓴다(저장소를 쓸 수 없으면 이번 방문 동안만). + */ +const SIZE_KEY = 'webcraft-sample-sizes'; +// 빌드 전에 잰 크기(npm run site:sizes) 위에, 이 브라우저에서 잰 크기를 덮는다. +const sizes = new Map([...Object.entries(measured as unknown as Record), ...(() => { + try { return Object.entries(JSON.parse(localStorage.getItem(SIZE_KEY) ?? '{}') as Record); } + catch { return []; } +})()]); +const remember = (key: string, size: { width: number; height: number }) => { + sizes.set(key, size); + try { localStorage.setItem(SIZE_KEY, JSON.stringify({ ...JSON.parse(localStorage.getItem(SIZE_KEY) ?? '{}'), [key]: size })); } catch { /* 저장소를 쓸 수 없으면 기억만 한다. */ } +}; + +/** 견본 정보(전체 화면인지, 정한 높이, 기기). 문서로 옮기기 전에 받아 두면 첫 그림부터 제 높이로 자리를 잡는다. */ +const entries = new Map(); +/** 문서 준비(라우트 loader)에서 부른다: 본문이 띄우는 견본의 정보를 미리 받는다. */ +export async function prepareSamples(ids: readonly string[]) { + await Promise.all(ids.filter(id => !entries.has(id)).map(id => loadSample(id).then(found => { entries.set(id, found ?? null); }, () => undefined))); +} /** * 규칙 옆에 견본 하나를 띄운다. 오른쪽 위에서 데스크톱·휴대폰을 바꿔 같은 견본을 두 기기 폭으로 본다. 견본은 실제 기기 * 폭으로 그리고 본문 칸이 좁으면 비율대로 줄인다. 부품 견본은 내용이 칸에 들어가면 줄이지 않고 가운데만 보인다. */ export function Sample({ id, caption, device }: { id: string; caption?: string; device?: Device }) { - const [entry, setEntry] = useState(null); + const [entry, setEntry] = useState(() => entries.get(id) ?? null); const [shown, setShown] = useState(device); const [isNear, setNear] = useState(false); const [isLoaded, setLoaded] = useState(false); @@ -22,10 +42,13 @@ export function Sample({ id, caption, device }: { id: string; caption?: string; const [size, setSize] = useState<{ width: number; height: number } | undefined>(); const stage = useRef(null); const frame = useRef(null); - useEffect(() => { void loadSample(id).then(found => setEntry(found ?? null)); }, [id]); + useEffect(() => { + if (entries.has(id)) { setEntry(entries.get(id) ?? null); return; } + void loadSample(id).then(found => { entries.set(id, found ?? null); setEntry(found ?? null); }); + }, [id]); const current: Device = shown ?? entry?.device ?? 'desktop'; const key = `${id}:${current}`; - useEffect(() => { setSize(sizes.get(key)); setLoaded(false); }, [key]); + useLayoutEffect(() => { setSize(sizes.get(key)); setLoaded(false); }, [key]); // 보이는 견본만 불러온다. useLayoutEffect(() => { const node = stage.current; @@ -40,7 +63,7 @@ export function Sample({ id, caption, device }: { id: string; caption?: string; const receive = (event: MessageEvent) => { if (event.origin !== location.origin || event.data?.type !== 'sample-size' || event.source !== frame.current?.contentWindow) return; const next = { width: Math.ceil(event.data.width), height: Math.ceil(event.data.height) }; - sizes.set(key, next); + remember(key, next); setSize(next); }; window.addEventListener('message', receive); @@ -68,8 +91,11 @@ export function Sample({ id, caption, device }: { id: string; caption?: string; {src ? 새 창에서 열기 : null}
-
+ {/* 크기를 아는 견본(정한 높이, 기억한 크기)은 처음부터 그 높이로, 모르는 견본은 크기가 오면 높이를 부드럽게 늘린다. + 견본은 다 그려지고 크기가 정해진 뒤에 나타난다(그리는 도중의 모습을 보이지 않는다). */} +
{src && isNear ?