designpaca/packages/skill/references/svg-glass.md
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
2026-10-06 16:55:38 +09:00

247 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# svg-glass — 유리 재질: 헤어라인·굴절·머티리얼 위계
[svg-filters.md](svg-filters.md)에서 분리한 하위 문서다. 4-2에서 **재질을 유리로 정한 뒤에만** 연다. 재질을 고르는 표(§1), 공통 뼈대, 성능(§6), 접근성·폴백(§7), 채택 체크리스트(§8)는 svg-filters.md가 정본이고 여기서 다시 정의하지 않는다. 깊이·그림자 단계는 [elevation.md](elevation.md)를 따른다.
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| 유리 패널 가장자리에 1px 광을 두른다 | §1 |
| 테두리 광을 회전시킨다 | §1-1 |
| 배경이 실제로 휘는 굴절 유리(리퀴드 글래스)를 만든다 | §2 — **코드를 짜기 전에 §2-3 함정부터** |
| 굴절이 안 보인다 | §2-3 → §2-4 진단법 |
| 유리 위 텍스트 가독성, 반투명 겹침, 스티키 헤더, 진입 전환, reduced transparency | §3 |
| 감사 직전 | svg-filters.md §8 + 아래 §4 |
## 1. 유리 테두리(헤어라인)를 그릴 때
1px 테두리에 광을 흘리는 흔한 방법은 그라디언트를 깔고 마스크로 테두리만 남기는 것이다.
```css
.glass::after {
content: ""; position: absolute; inset: 0; padding: 1px;
border-radius: inherit;
background: var(--glass-rim);
mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
mask-composite: exclude; /* -webkit-mask-composite: xor */
}
```
**`conic-gradient` 를 쓰지 마라.** 중심에서 각도로 돌기 때문에, 납작한 형태
(내비게이션 바처럼 1180×54)에서는 **각도가 급변해 변마다 광이 끊긴다.**
긴 변에서는 색이 거의 안 변하고 짧은 변에서 한꺼번에 바뀐다.
```css
/* 네 변이 같은 그라디언트를 공유한다 — 테두리를 따라 이어진다 */
--glass-rim: linear-gradient(100deg,
rgb(255 255 255 / 0.34) 0%,
rgb(255 255 255 / 0.10) 26%,
rgb(255 255 255 / 0.06) 58%,
rgb(255 255 255 / 0.28) 100%);
```
정사각형에 가까운 카드라면 conic 도 자연스럽다. **비율을 보고 골라라.**
글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 —
`0 0 22px -8px` 처럼 **음수 spread** 를 써서 형태 밖으로 새지 않게 한다.
밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다.
### 1-1. 테두리 광을 회전시키려면
`conic-gradient(from var(--angle), …)` 를 `@property` 로 애니메이션하는 방법이 흔히 보이는데,
**그건 매 프레임 배경을 다시 칠한다.** 컴포지터에서 못 돌고 메인 스레드가 페인트한다.
저비용 기본 선택에서 벗어나는 사례다. 목적과 성능 측정, 감소 모션 경로를 프로젝트 계약에 기록한다.
**정사각형 원뿔 레이어를 마스크 안에서 회전시켜라.** 움직이는 것은 `rotate` 뿐이다.
```css
.rim { /* 테두리만 남기는 마스크 */
position: absolute; inset: 0; border-radius: inherit;
overflow: hidden; padding: 1px;
mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
mask-composite: exclude;
}
.rim::before { /* 실제로 도는 것 */
content: ""; position: absolute; top: 50%; left: 50%;
width: 200%; aspect-ratio: 1; /* 정사각형이라 회전해도 밝기가 균일하다 */
translate: -50% -50%;
background: conic-gradient(from 0deg, transparent 0 58%, var(--glow) 74%, transparent 86%);
animation: rim-spin 11s linear infinite;
}
@keyframes rim-spin { to { rotate: 360deg; } }
```
**두 가지를 지켜라.**
- `aspect-ratio: 1` — 정사각형이어야 회전 중 밝기가 일정하다. 납작하면 변마다 달라진다
- `width: 200%` — 요소보다 크게 잡아야 납작한 형태에서도 모서리까지 닿는다
`prefers-reduced-motion` 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.
## 2. 리퀴드 글래스 — `backdrop-filter: url()` 은 살아 있다
**정정부터.** 이 문서는 한때 "Chrome 은 `CSS.supports` 로 true 를 주면서 렌더에서
`backdrop-filter` 선언 전체를 버린다" 고 적고 있었다. **틀렸다.**
Chrome 151 에서 실측하면 computed 값이 `none` 이 아니라 `url("#id")` 로 유지되고,
배경이 실제로 굴절된다. 우리가 겪은 실패는 다른 원인이었다(아래 §2-3).
### 2-1. 구조
```html
<svg style="position:absolute;width:0;height:0">
<filter id="lg" color-interpolation-filters="sRGB"
x="0" y="0" width="100%" height="100%">
<!-- 변위 지도. feTurbulence 가 아니라 사전계산 이미지다 -->
<feImage href="data:image/png;base64,…" x="0" y="0" width="240" height="160" result="map"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="34"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</svg>
```
```css
.glass { backdrop-filter: url(#lg); }
```
### 2-2. 변위 지도를 어떻게 만드나
**노이즈가 아니다.** 유리는 무작위로 휘지 않는다 — 가장자리에서 **경계 법선 방향**으로 휜다.
- `R` 채널 = X 변위, `G` 채널 = Y 변위, **128 이 중립**(변위 없음)
- 값 인코딩: `r = 128 + x * 127`, `g = 128 + y * 127`
- 가장자리에서 거리 `d` 를 구해 그 함수로 변위 크기를 정한다
- 감쇠 곡선은 **Squircle** `y = ⁴√(1 - (1-x)⁴)` 이 원보다 자연스럽다(Apple 이 쓰는 곡선)
캔버스로 즉석 생성해 `toDataURL()` 로 넘기면 이미지 파일이 필요 없다.
실측: 240×160 맵이 **2.7KB** data URI.
### 2-3. 함정 — 여기서 실패한다
1. **`feImage` 의 `width`/`height` 를 요소 크기와 맞춰라.**
`backdrop-filter` 는 요소 크기에 맞춰 필터 이미지를 자동 조정하지 **않는다.**
크기가 어긋나면 굴절이 엉뚱한 곳에 걸리거나 아예 안 보인다.
요소가 리사이즈되면 맵을 다시 만들어야 한다
2. **Chrome 전용이다.** Safari·Firefox 는 `backdrop-filter` 에 SVG 필터를 받지 않는다.
`@supports` 로 감싸고 `blur()` 폴백을 둬라
3. **모양·크기를 애니메이션하지 마라.** 변위 맵 전체가 다시 계산된다.
`<feDisplacementMap scale>` 만 애니메이션해라 — 그건 재계산이 없다
4. `color-interpolation-filters="sRGB"` 를 빼면 색이 어둡게 뜬다
```css
.glass { backdrop-filter: blur(18px) saturate(150%); } /* 기본 */
@supports (backdrop-filter: url(#lg)) {
.glass { backdrop-filter: url(#lg); } /* 굴절 */
}
```
> 표준화가 진행 중이다(w3c/svgwg#1142). 지금은 Chrome 한정 기법으로 다루고,
> **굴절이 없어도 성립하는 화면**을 먼저 만든 뒤에 얹어라.
### 2-4. 실패 기록 — `feTurbulence` 를 변위 지도로 쓰면 안 된다
위에 "노이즈가 아니다" 라고 적어놓고, 나중에 이 문서를 안 보고 이렇게 썼다.
```xml
<feTurbulence baseFrequency="0.02" numOctaves="2" result="n"/>
<feGaussianBlur in="n" stdDeviation="2.5" result="soft"/>
<feDisplacementMap in="SourceGraphic" in2="soft" scale="42" .../>
```
`backdrop-filter` 에 걸고 재보니 필터 전후 **평균 채널차가 1.66** 이었다.
`scale` 을 42 에서 120 으로 세 배 올려도 2.96 — 사실상 아무 일도 안 일어난다.
그때 내린 결론은 "Chrome 이 `backdrop-filter` 체인에서 displacement 를 못 쓴다" 였고,
**그것도 틀렸다.** 원인은 두 가지 다 이 문서에 이미 적혀 있었다.
1. **블러한 난류는 중립값으로 수렴한다.** 변위 지도에서 128 은 "움직이지 마라" 다.
`fractalNoise` 의 평균이 그 근처인데 거기에 블러까지 걸면 지도 전체가 128 이 된다.
`scale` 을 아무리 키워도 0 을 곱하는 것이라 변하지 않는다.
2. **`feImage` 의 크기를 요소에 맞추지 않았다.** 자동 조정되지 않는다.
교훈은 필터가 아니라 작업 방식이다 — **이미 기록해 둔 함정에 다시 빠지는 것은
문서가 없어서가 아니라 쓰기 전에 읽지 않아서다.** 4단계에서 필터를 쓰기로 했으면
레시피를 짜기 **전에** 이 문서의 §2-3 함정을 먼저 편다.
> 진단법: 굴절이 안 보일 때 `scale` 부터 올리지 마라. 지도를 화면에 직접 그려
> **128 이 아닌 픽셀이 실제로 있는지** 먼저 봐라. 없으면 지도가 문제다.
### 2-5. 지도를 SVG 로 그릴 때 — R 과 G 를 한 장에 못 담는다
`linearGradient` 하나로는 R 과 G 를 각각 다르게 변화시킬 수 없다.
겹쳐 그리면 뒤엣것이 앞엣것을 덮고, 블렌드 모드로는 채널이 섞인다.
**지도를 두 장 놓고 displacement 를 두 번 건다.** 쓰지 않는 축에는
128 로 고정된 `B` 채널을 물려 이동량을 0 으로 만든다.
```xml
<filter id="lens" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">
<feImage href={MAP_X} x="0" y="0" width="216" height="72" result="mx"/>
<feDisplacementMap in="SourceGraphic" in2="mx" scale="30"
xChannelSelector="R" yChannelSelector="B" result="dx"/>
<feImage href={MAP_Y} x="0" y="0" width="216" height="72" result="my"/>
<feDisplacementMap in="dx" in2="my" scale="30"
xChannelSelector="B" yChannelSelector="G"/>
</filter>
```
지도 한 장은 가장자리에서만 중립을 벗어나는 그라디언트다.
data URI 안에서 `#` 은 URI 를 끊으므로 색은 `rgb()` 로 적는다.
```js
`<svg xmlns="http://www.w3.org/2000/svg" width="216" height="72">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0">` +
`<stop offset="0" stop-color="rgb(255,128,128)"/>` + // 왼쪽 끝: +X 최대
`<stop offset="0.30" stop-color="rgb(128,128,128)"/>` + // 가운데: 중립
`<stop offset="0.70" stop-color="rgb(128,128,128)"/>` +
`<stop offset="1" stop-color="rgb(0,128,128)"/>` + // 오른쪽 끝: -X 최대
`</linearGradient></defs>` +
`<rect width="216" height="72" fill="url(#g)"/></svg>`
```
**확인 방법**: 배경에 격자를 깔아라. 사진만 놓으면 무엇이 어떻게 밀렸는지 알 수 없다.
제대로 걸리면 **패널 가장자리에서 격자 셀이 눌린다** — 그것이 렌즈의 지문이다.
실측: 필터 전후 평균 채널차 1.66(난류 지도) → **9.29**(그린 지도), 최대 126.
`backdrop-filter` 앞의 `blur()` 는 약하게 둬라. 세게 걸면 굴절이 뭉개져서
지도를 고쳐도 차이가 안 보인다.
## 3. 머티리얼 위계와 가독성 (vibrancy)
언제: 브리프나 골라 둔 방향이 유리·Apple풍 질감을 채택했을 때 연다. `swiss-minimal`처럼 평면 표면을 기본으로 하는 방향에서는 기본 비채택이다 — 리믹스 카드나 브리프에서 재질 축을 유리에 명시적으로 배정했을 때만 쓴다([svg-filters.md](svg-filters.md) §1). 아래 규칙은 "모든 nav·toolbar·sheet를 backdrop-filter로 만들어라"는 지시가 아니다 — 유리를 쓰기로 한 뒤의 마감 규칙이다[SKILL-APPLE-DESIGN].
**블러 위 텍스트 가독성(vibrancy)**: 반투명·블러 표면 위에 플랫 그레이 텍스트를 얹지 마라. 배경이 바뀌어도 읽히도록 대비를 올리고, 자간을 미세하게 늘리고, 굵기를 한 단계 올린다. 색은 솔리드 레이어에 두고 반투명 전경에 두지 않는다 — 반투명 위에서는 배경에 따라 채도가 흔들린다.
**반투명 표면끼리 겹치지 않기**: 밝은 반투명 표면을 다른 반투명 표면 위에 쌓지 마라. 겹칠수록 대비가 무너져 가독성이 붕괴한다. 겹쳐야 하면 아래쪽을 솔리드에 가깝게 불투명도를 올려라.
**무거운 재질은 구조, 가벼운 재질은 상호작용**: 더 어둡고 무거운 유리(강한 블러 + 깊은 그림자)는 사이드바 같은 구조적 영역을 분리하는 데 쓰고, 더 밝고 가벼운 유리는 버튼 같은 인터랙티브 요소로 주의를 끄는 데 쓴다. 큰 표면일수록 더 두껍게(강한 블러 + 깊은 그림자) 읽혀야 한다 — 작은 칩에 큰 표면급 블러를 걸지 않는다. 그림자 자체의 단계·값은 [elevation.md](elevation.md)를 따른다. 이 절은 그중 유리 재질에만 적용되는 위계 규칙이다.
**스티키 헤더는 그래디언트 마스크로**: 스티키 헤더 아래에 1px 하드 보더를 긋지 마라. 플로팅 크롬이 콘텐츠와 실제로 겹치는 부분에만 작은 블러 또는 그래디언트 마스크를 페이드시켜, 경계가 아니라 스크롤 엣지 효과로 읽히게 한다.
**materialize 전환**: 유리·블러 표면의 진입·퇴장은 `opacity` 하나만 바꾸지 마라. 블러 반경과 `scale`을 함께 애니메이션해 "머티리얼이 실제로 도착한다"는 인상을 만든다. 필터 애니메이션의 비용·감소 모션 경로는 [svg-filters.md](svg-filters.md) §6·§7을 그대로 따른다 — 이 절이 그 규칙을 면제하지 않는다.
```css
.sheet {
opacity: 0; transform: scale(0.96); backdrop-filter: blur(0px);
transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out),
backdrop-filter var(--dur-quick) var(--ease-out); /* 새 duration 리터럴을 만들지 않는다(tokens.md §4) */
}
.sheet.is-open {
opacity: 1; transform: scale(1); backdrop-filter: blur(18px) saturate(1.4);
}
```
**`prefers-reduced-transparency` 대체**: 이 미디어 쿼리는 Chromium 계열(Chrome·Edge)에만 있고 Firefox·Safari는 지원하지 않는다[WEB-BASELINE] — 지원 브라우저에서만 통하는 점진적 향상으로 다룬다. 감지되면 반투명 표면의 배경 불투명도를 올리고 블러를 낮춘다. 미지원 브라우저에서도 [svg-filters.md](svg-filters.md) §7의 저사양 감지(`reduce-effects` 클래스)와 "필터를 전부 끈 상태에서 성립해야 한다"는 원칙이 안전망 역할을 한다.
```css
@media (prefers-reduced-transparency: reduce) {
.glass { backdrop-filter: blur(6px); background: rgba(255, 255, 255, 0.82); }
}
```
그라디언트를 재질의 일부로 쓸 때(예: 듀오톤·머티리얼 틴트)의 보간 공간 선택은 [color.md](color.md)를 참조한다.
## 4. 유리 체크리스트(svg-filters.md §8에 더한다 — 구현 중 자기 점검, 납품 판정은 preflight.md X3·A3)
- 유리 위 본문 텍스트가 배경이 바뀌는 모든 스크롤 위치에서 대비 기준을 넘는다(§3 vibrancy)
- 반투명 표면끼리 겹치지 않거나, 아래쪽 불투명도를 올렸다(§3)
- 굴절(`backdrop-filter: url()`)은 `@supports` 안에 있고 `blur()` 폴백이 있다. 굴절 없이도 화면이 성립한다(§2-3)
- `feImage` 변위 지도 크기가 요소 크기와 같고, 리사이즈 시 다시 만든다(§2-3)
- 테두리 광을 돌린다면 `@property` 배경 재도색이 아니라 회전 레이어이며, 감소 모션에서 멈춘다(§1-1)
- `prefers-reduced-transparency` 지원 브라우저에서 불투명도가 올라간다(§3)