feat(site): 재료 섹션을 실무 필터 3종으로 다시 만들고 푸터를 제거
Some checks are pending
ci / build (push) Waiting to run
Some checks are pending
ci / build (push) Waiting to run
푸터를 없애라는 지시가 반영돼 있지 않았다. .install-colophon 이 사실상
푸터였고, .install-grid 밖에 있어 padding-inline 을 못 받아 화면 왼쪽 끝에
붙어 있기까지 했다. 제거하고 저장소 링크는 헤더 nav 로 옮겼다.
재료 섹션은 "필터로 질감 만들기"(그레인/굴절/듀오톤)였고 화면에서 아무 말도
하지 않았다. 실무에서 손이 줄어드는 자리로 바꾼다.
- 글자 모양대로 생기는 스크림 — 흰 글자 대비 1.70:1 → 4.79:1 (AA 통과)
- 색각 이상 시뮬레이션 — 상태 배지 셋의 최소 색거리 82 → 2
- 구이(gooey) — 가로로 세어 덩어리 3개 → 1개
세 값 모두 화면을 캡처해 잰 것이다. 처음에는 데모보다 카피를 먼저 썼고,
그 결과 "2.1:1 → 12.4:1" 같은 재지 않은 숫자가 페이지에 실렸다. 전부 교체했다.
backdrop-filter 굴절 데모는 뺐다. feTurbulence 를 변위 지도로 써서 지도가
중립값(128)으로 수렴했고, scale 을 120 까지 올려도 채널차 2.96 이었다.
svg-filters.md 에 "변위 지도는 노이즈가 아니다" 라고 이미 적혀 있었는데
쓰기 전에 읽지 않았다.
섹션 끝의 note 도 삭제했다. color-interpolation-filters 를 명시했다는
문장은 방문자가 그것으로 할 수 있는 일이 없는 내부 자랑이었다.
스킬 내재화:
- svg-filters.md 실무 레시피 3종(스크림/색각/구이) + 구이가 붙는 문턱 계산
+ feTurbulence 변위 지도 실패 기록
- antipatterns.md 데모를 만들기 전에 카피부터 쓰지 마라
+ Playwright animations:'disabled' 가 끝 프레임을 찍는 함정
This commit is contained in:
parent
f7f811ab42
commit
ae2980872b
7 changed files with 473 additions and 256 deletions
136
apps/site/src/components/FilterArt.astro
Normal file
136
apps/site/src/components/FilterArt.astro
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import paper from "../assets/bg/bg-paper.webp";
|
||||
|
||||
/**
|
||||
* 필터 데모의 소재. 같은 소재를 두 번 그리고 한쪽에만 필터를 건다.
|
||||
* 변수를 하나만 바꿔야 데모가 무언가를 증명한다 — 소재가 다르면
|
||||
* 보이는 차이가 필터 때문인지 소재 때문인지 알 수 없다.
|
||||
*/
|
||||
interface Props {
|
||||
kind: "scrim" | "cvd" | "goo";
|
||||
on: boolean;
|
||||
}
|
||||
const { kind, on } = Astro.props;
|
||||
|
||||
|
||||
---
|
||||
|
||||
{
|
||||
kind === "scrim" && (
|
||||
<div class="art art-photo">
|
||||
<Image src={paper} alt="" widths={[300, 600]} sizes="300px" loading="lazy" />
|
||||
<span class:list={["cap", { on }]}>산미 8.5 · 로스팅 2일차</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
kind === "cvd" && (
|
||||
<div class:list={["art art-badges", { on }]}>
|
||||
<span class="badge b-ok">통과</span>
|
||||
<span class="badge b-warn">주의</span>
|
||||
<span class="badge b-bad">실패</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
kind === "goo" && (
|
||||
<div class="art art-goo">
|
||||
{/* 같은 간격으로 놓인 세 덩어리. 왼쪽은 셋으로 보이고 오른쪽은 하나로 보인다.
|
||||
움직임으로 증명하려다 실패했다 — 지나가는 프레임은 방문자도 놓치고
|
||||
측정도 매번 다른 값을 준다. 정지된 두 장이 더 정확하게 말한다. */}
|
||||
<div class:list={["goo-track", { on }]}>
|
||||
<span class="goo-blob" /><span class="goo-blob" /><span class="goo-blob" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.art {
|
||||
height: var(--art-h, 118px);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ---- 스크림 ------------------------------------------------
|
||||
밝은 종이 사진 위의 흰 캡션. 필터가 없으면 대비가 무너진다 */
|
||||
.art-photo :global(img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cap {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
bottom: var(--space-3);
|
||||
right: var(--space-3);
|
||||
color: #fff;
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--step-0);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cap.on { filter: url(#f-scrim); }
|
||||
|
||||
/* ---- 색각 --------------------------------------------------
|
||||
상태를 색으로만 구분한 흔한 배지 묶음. 사이트 팔레트가 아니라
|
||||
"현장에서 그렇게 오는" 색이다 — 그게 이 데모의 소재다 */
|
||||
.art-badges {
|
||||
--art-h: 84px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.art-badges.on { filter: url(#f-deuter); }
|
||||
.badge {
|
||||
padding: 0.4em 0.95em;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--step--1);
|
||||
font-weight: var(--weight-medium);
|
||||
color: #08110f;
|
||||
}
|
||||
/* 적록색약에서 서로 무너지는 조합을 일부러 골랐다.
|
||||
현장에서 상태 배지가 실제로 이렇게 온다 */
|
||||
.b-ok { background: #3fb950; }
|
||||
.b-warn { background: #d29922; }
|
||||
.b-bad { background: #e5534b; }
|
||||
|
||||
/* ---- 구이 ---------------------------------------------------
|
||||
필터는 트랙에만 건다. 라벨까지 걸면 글자가 녹는다 */
|
||||
.art-goo {
|
||||
--art-h: 96px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
/* 간격이 이 데모의 유일한 변수다.
|
||||
붙는 조건은 눈대중이 아니라 산수다 — 두 원 사이 중간점의 블러 알파가
|
||||
alpha 램프의 문턱(offset/slope = 7/18 ≒ 0.39)을 넘어야 한다.
|
||||
간격 18px 에 stdDeviation 7 로는 중간점 알파가 0.2 근처라 못 넘었다.
|
||||
실측에서 필터를 켜고도 덩어리가 3개 그대로였다. 간격을 좁힌다. */
|
||||
.goo-track {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.goo-track.on { filter: url(#f-goo); }
|
||||
.goo-blob {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 23px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.cap { text-shadow: 0 1px 3px rgb(0 0 0 / 0.9); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -41,25 +41,10 @@ const { c, ui } = Astro.props;
|
|||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터를 없앴으므로 출처와 라이선스는 여기 한 줄로 남는다.
|
||||
링크 하나 없이 배포하면 사용자가 소스를 찾을 방법이 사라진다. */}
|
||||
<p class="install-colophon muted">
|
||||
<a href={`https://${c.colophon.repo}`}>{c.colophon.repo}</a>
|
||||
<span aria-hidden="true"> · </span>{c.colophon.license}
|
||||
<span aria-hidden="true"> · </span>{c.colophon.made}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.install { padding-block: var(--space-8); }
|
||||
.install-colophon {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.install-grid {
|
||||
max-width: 1180px;
|
||||
margin-inline: auto;
|
||||
|
|
@ -95,13 +80,6 @@ const { c, ui } = Astro.props;
|
|||
dd { margin: 0; font-size: var(--step-0); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.install-colophon {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.install-grid { grid-template-columns: 1fr; }
|
||||
.install-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import FilterArt from "./FilterArt.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["materials"]; }
|
||||
|
|
@ -7,246 +8,179 @@ const { c } = Astro.props;
|
|||
---
|
||||
|
||||
{/*
|
||||
레이아웃 패밀리: 레이어드 캔버스.
|
||||
배경은 three.js 셰이더 플레인, 전경은 SVG 필터를 실제로 먹인 타일 3장.
|
||||
둘 다 설명이 아니라 지금 이 화면에서 돌고 있는 것이다.
|
||||
이 섹션은 한동안 "필터로 재질 만들기" 였다 — 추상 사각형에 그레인·굴절·듀오톤.
|
||||
화면에서 아무 말도 하지 않았다. 필터가 실제로 값을 하는 자리는 질감이 아니라
|
||||
**실무에서 반복되는 네 가지 수작업**이고, 넷 다 이 스킬의 프리플라이트 항목이다.
|
||||
왼쪽이 문제, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다.
|
||||
*/}
|
||||
<section class="section materials has-bg" aria-labelledby="mat-h">
|
||||
<BgPhoto variant="refraction" mode="fixed" opacity={0.9} />
|
||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
||||
<h2 id="mat-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead mat-lead reveal">{c.lead}</p>
|
||||
|
||||
<div class="full stage glass par-slow">
|
||||
<div class="full stage glass">
|
||||
<span class="rim" aria-hidden="true"></span>
|
||||
<div class="stage-fallback" aria-hidden="true"></div>
|
||||
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
|
||||
{/* GPU 가 만드는 배경. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
|
||||
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
|
||||
|
||||
<ul class="tiles">
|
||||
<ol role="list" class="fdemos">
|
||||
{
|
||||
c.tiles.map((t, i) => (
|
||||
<li class:list={["tile", "reveal", i % 2 === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 80}ms`}>
|
||||
<div class={`swatch swatch-${t.id}`} aria-hidden="true">
|
||||
{t.id === "glass" && <span class="swatch-type">Aa 산미 8.5</span>}
|
||||
c.demos.map((d, i) => (
|
||||
<li class="fdemo reveal" style={`--reveal-delay:${i * 70}ms`}>
|
||||
<h3 class="fd-name">{d.name}</h3>
|
||||
<p class="fd-problem">{d.problem}</p>
|
||||
|
||||
<div class="fd-pair">
|
||||
{[false, true].map((on) => (
|
||||
<figure class="fd-side">
|
||||
<FilterArt kind={d.id} on={on} />
|
||||
<figcaption class:list={["fd-tag", "mono", on ? "is-good" : "is-bad"]}>
|
||||
{on ? d.afterLabel : d.beforeLabel}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<h3 class="tile-name">{t.name}</h3>
|
||||
<p class="tile-use">{t.use}</p>
|
||||
<p class="tile-how muted">{t.how}</p>
|
||||
<p class="tile-cost mono">{t.cost}</p>
|
||||
|
||||
<p class="fd-how muted">{d.how}</p>
|
||||
<p class="fd-verdict mono">{d.verdict}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p class="mat-note muted reveal">{c.note}</p>
|
||||
</section>
|
||||
|
||||
{/* 필터 정의. 화면에 그려지지 않고 참조만 된다 */}
|
||||
{/*
|
||||
필터 정의. 화면에 그려지지 않고 참조만 된다.
|
||||
|
||||
전부 color-interpolation-filters 를 명시한다. 기본값은 linearRGB 인데,
|
||||
블렌드와 합성이 씻겨 보인다 — 대부분 sRGB 가 맞다.
|
||||
단 하나, 색각 시뮬레이션은 반대다. 색각 변환은 물리적인 빛의 혼합이라
|
||||
선형 공간에서 계산해야 실제로 그 사람이 보는 색이 나온다.
|
||||
*/}
|
||||
<svg class="visually-hidden" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<!-- 종이: 미세한 프랙탈 노이즈를 표면에 얹는다 -->
|
||||
<!--
|
||||
그레인. 한동안 아무 효과도 못 내고 있었다.
|
||||
`feComposite in="SourceGraphic" in2="soft" operator="over"` 는 원본을 노이즈
|
||||
**위에** 올린다 — 노이즈가 통째로 가려진다. 입력 순서가 뒤집혀 있었다.
|
||||
그레인은 원본을 덮는 게 아니라 표면에 섞이는 것이므로 feBlend/overlay 가 맞다.
|
||||
baseFrequency 0.9 도 너무 촘촘해 화면에서 뭉갰다.
|
||||
글자 모양대로 생기는 스크림.
|
||||
박스를 그리는 대신 알파를 부풀려 그 모양만 채운다.
|
||||
영역을 넉넉히 잡지 않으면 부풀린 만큼 가장자리가 잘린다.
|
||||
-->
|
||||
<filter id="f-grain" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.62" numOctaves="3" seed="4" result="n" />
|
||||
<feColorMatrix in="n" type="saturate" values="0" result="g" />
|
||||
<feComponentTransfer in="g" result="soft">
|
||||
<feFuncA type="linear" slope="0.55" />
|
||||
<filter id="f-scrim" color-interpolation-filters="sRGB"
|
||||
x="-25%" y="-60%" width="150%" height="220%">
|
||||
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="fat" />
|
||||
<feGaussianBlur in="fat" stdDeviation="3.5" result="soft" />
|
||||
<!-- 블러가 알파를 옅게 만든다. 다시 세우지 않으면 스크림이 비쳐서
|
||||
대비가 3.1:1 에서 멈춘다 — 실측으로 확인하고 올린 값이다 -->
|
||||
<feComponentTransfer in="soft" result="dense">
|
||||
<feFuncA type="linear" slope="4.4" />
|
||||
</feComponentTransfer>
|
||||
<feBlend in="SourceGraphic" in2="soft" mode="overlay" />
|
||||
<feFlood flood-color="#04100f" flood-opacity="1" result="ink" />
|
||||
<feComposite in="ink" in2="dense" operator="in" result="scrim" />
|
||||
<feMerge>
|
||||
<feMergeNode in="scrim" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- 유리: 난류로 만든 변위 지도를 굴절에 쓴다. 필터 영역을 넓혀 가장자리 잘림을 막는다 -->
|
||||
<filter id="f-glass" x="-12%" y="-12%" width="124%" height="124%"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="7" result="warp" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="warp" scale="26"
|
||||
xChannelSelector="R" yChannelSelector="G" result="bent" />
|
||||
<feGaussianBlur in="bent" stdDeviation="0.4" />
|
||||
<!--
|
||||
적록색약(2형) 시뮬레이션. Brettel–Viénot–Mollon 계열 근사 행렬.
|
||||
여기만 linearRGB 다 — sRGB 로 돌리면 감마가 섞여 실제보다 덜 심하게 보인다.
|
||||
-->
|
||||
<filter id="f-deuter" color-interpolation-filters="linearRGB">
|
||||
<feColorMatrix type="matrix"
|
||||
values="0.625 0.375 0 0 0
|
||||
0.700 0.300 0 0 0
|
||||
0 0.300 0.700 0 0
|
||||
0 0 0 1 0" />
|
||||
</filter>
|
||||
|
||||
<!-- 인쇄: 명도를 두 색 사이로 다시 매핑한다 -->
|
||||
<filter id="f-duotone" x="0" y="0" width="100%" height="100%"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feColorMatrix type="matrix" result="mono"
|
||||
values="0.33 0.33 0.33 0 0
|
||||
0.33 0.33 0.33 0 0
|
||||
0.33 0.33 0.33 0 0
|
||||
0 0 0 1 0" />
|
||||
<feComponentTransfer in="mono">
|
||||
<feFuncR type="table" tableValues="0.05 0.06 0.42" />
|
||||
<feFuncG type="table" tableValues="0.09 0.42 0.66" />
|
||||
<feFuncB type="table" tableValues="0.08 0.39 0.60" />
|
||||
</feComponentTransfer>
|
||||
<!--
|
||||
구이. 커뮤니티에서 가장 많이 만들어지는 필터이고, 값 두 개가 전부다.
|
||||
stdDeviation 이 얼마나 멀리서부터 붙을지를, 알파 기울기(18)가
|
||||
얼마나 단단하게 잘릴지를 정한다. 기울기를 낮추면 흐릿한 젤리가 된다.
|
||||
마지막 atop 이 없으면 블러된 헤일로가 남는다.
|
||||
-->
|
||||
<filter id="f-goo" color-interpolation-filters="sRGB"
|
||||
x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
|
||||
<feColorMatrix in="blur" type="matrix" result="goo"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 18 -7" />
|
||||
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
||||
</filter>
|
||||
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.mat-lead { margin-bottom: var(--space-6); }
|
||||
.mat-note { margin-top: var(--space-5); font-size: var(--step-0); }
|
||||
.mat-lead { margin-bottom: var(--space-6); max-width: var(--measure); }
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
padding: var(--wide-pad);
|
||||
border-radius: var(--radius-frame);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.stage-fallback,
|
||||
.stage-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: -1;
|
||||
}
|
||||
.stage-fallback {
|
||||
background:
|
||||
radial-gradient(120% 90% at 12% 8%, color-mix(in oklab, var(--accent) 22%, transparent), transparent 60%),
|
||||
radial-gradient(90% 80% at 88% 92%, color-mix(in oklab, var(--accent) 14%, transparent), transparent 62%),
|
||||
var(--surface-raised);
|
||||
radial-gradient(60% 70% at 22% 20%,
|
||||
color-mix(in oklab, var(--accent) 16%, transparent), transparent 70%),
|
||||
radial-gradient(56% 64% at 80% 76%,
|
||||
color-mix(in oklab, var(--glow-2) 14%, transparent), transparent 68%);
|
||||
}
|
||||
.stage-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
|
||||
.stage-canvas.is-live { opacity: 0.55; }
|
||||
|
||||
.stage-canvas { opacity: 0; transition: opacity var(--dur-slow) var(--ease-soft); }
|
||||
.stage-canvas.is-live { opacity: 1; }
|
||||
|
||||
/* 3개 항목에 3개 셀. 빈 칸이 생기지 않고 균등 3열도 아니다 */
|
||||
.tiles {
|
||||
.fdemos {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"big top"
|
||||
"big bottom";
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.tile:nth-child(1) { grid-area: big; }
|
||||
.tile:nth-child(2) { grid-area: top; }
|
||||
.tile:nth-child(3) { grid-area: bottom; }
|
||||
/* 큰 셀은 더 넓은 화면을 갖는다 */
|
||||
.tile:nth-child(1) .swatch { aspect-ratio: 16 / 11; }
|
||||
.tile:nth-child(n + 2) .swatch { aspect-ratio: 16 / 6; }
|
||||
.tile { display: grid; gap: var(--space-2); align-content: start; }
|
||||
.fdemo + .fdemo { border-top: 1px solid var(--line); padding-top: var(--space-6); }
|
||||
|
||||
/* 필터를 먹일 대상.
|
||||
한때 셋 다 같은 줄무늬였다. 필터가 무엇을 하는지는 보였지만
|
||||
그것을 왜 쓰는지는 보이지 않았다 — 줄무늬는 실무에서 쓰는 소재가 아니다.
|
||||
이제 각 필터를 그 필터가 실제로 걸리는 자리에 올린다. */
|
||||
.swatch {
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.fd-name { font-size: var(--step-1); }
|
||||
.fd-problem { max-width: 52ch; color: var(--ink-muted); }
|
||||
|
||||
/* 그레인 — 매끈한 그라디언트 위. 노이즈가 없으면 밋밋하다는 게 요점이다 */
|
||||
.swatch-grain {
|
||||
background: linear-gradient(148deg,
|
||||
color-mix(in oklab, var(--accent) 82%, var(--surface)),
|
||||
color-mix(in oklab, var(--glow-2) 62%, var(--surface)));
|
||||
}
|
||||
|
||||
/* 굴절 — 글자 위. 유리 뒤의 것이 휘는 걸 보여줘야 의미가 산다 */
|
||||
.swatch-glass {
|
||||
background: linear-gradient(100deg,
|
||||
color-mix(in oklab, var(--surface) 82%, var(--ink)),
|
||||
color-mix(in oklab, var(--accent) 30%, var(--surface)));
|
||||
.fd-pair {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
grid-template-columns: repeat(2, minmax(0, 300px));
|
||||
gap: var(--space-4);
|
||||
margin-block: var(--space-4);
|
||||
}
|
||||
.swatch-type {
|
||||
font-size: clamp(1.1rem, 0.6rem + 1.6vw, 1.9rem);
|
||||
font-weight: var(--weight-strong);
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
.fd-side { margin: 0; }
|
||||
.fd-tag {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--mono-size);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.is-bad { color: var(--ink-muted); }
|
||||
.is-good { color: var(--accent); }
|
||||
|
||||
/* 듀오톤 — 사진. 출처가 다른 사진을 한 팔레트로 묶는 게 실제 용도다 */
|
||||
.swatch-duotone {
|
||||
background:
|
||||
radial-gradient(58% 74% at 30% 28%, color-mix(in oklab, var(--ink) 90%, transparent), transparent 70%),
|
||||
radial-gradient(52% 60% at 74% 68%, color-mix(in oklab, var(--ink-muted) 70%, transparent), transparent 66%),
|
||||
linear-gradient(160deg, color-mix(in oklab, var(--surface) 40%, var(--ink-muted)), var(--surface-sunken));
|
||||
}
|
||||
|
||||
.tile-use {
|
||||
margin: 0;
|
||||
.fd-how { max-width: 60ch; }
|
||||
.fd-verdict {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--mono-size);
|
||||
color: var(--accent);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
.swatch-grain { filter: url(#f-grain); }
|
||||
.swatch-glass { filter: url(#f-glass); }
|
||||
.swatch-duotone { filter: url(#f-duotone); }
|
||||
|
||||
/* 필터를 지원하지 않거나 저사양이면 줄무늬만 남는다. 그래도 성립한다 */
|
||||
@supports not (filter: url(#f-grain)) {
|
||||
.swatch-grain, .swatch-glass, .swatch-duotone { filter: none; }
|
||||
}
|
||||
|
||||
.tile-name { font-size: var(--step-1); margin-top: var(--space-1); }
|
||||
.tile-how { margin: 0; font-size: var(--step-0); }
|
||||
.tile-cost { margin: 0; color: var(--accent); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.tiles { grid-template-columns: 1fr; grid-template-areas: "big" "top" "bottom"; }
|
||||
.tile:nth-child(n + 2) .swatch { aspect-ratio: 5 / 4; }
|
||||
@media (max-width: 720px) {
|
||||
.fd-pair { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 무대 배경. 유리 표면을 지나는 빛의 굴절을 흉내낸다.
|
||||
// 히어로와 같은 모듈, 같은 three 청크를 쓴다. 새로 받는 것은 없다.
|
||||
import { mountShaderPlane, GLSL_NOISE } from "../lib/shader-plane";
|
||||
|
||||
const canvas = document.getElementById("mat-canvas") as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
mountShaderPlane({
|
||||
canvas,
|
||||
speed: 0.7,
|
||||
colors: { uA: "--accent", uB: "--glow-2" },
|
||||
fragment: `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float uAspect;
|
||||
uniform vec3 uA;
|
||||
uniform vec3 uB;
|
||||
${GLSL_NOISE}
|
||||
|
||||
void main() {
|
||||
vec2 p = vUv;
|
||||
p.x *= uAspect;
|
||||
float t = uTime * 0.06;
|
||||
|
||||
// 좌표 자체를 노이즈로 밀어 굴절처럼 보이게 한다
|
||||
vec2 warp = vec2(fbm(p * 2.2 + t), fbm(p * 2.2 - t + 4.7));
|
||||
vec2 q = p + (warp - 0.5) * 0.42;
|
||||
|
||||
// 밀린 좌표 위에 얇은 띠를 얹는다. 유리 너머의 결이다
|
||||
float band = sin((q.x + q.y) * 7.0 + uTime * 0.35) * 0.5 + 0.5;
|
||||
band = smoothstep(0.35, 0.95, band);
|
||||
|
||||
float depth = smoothstep(0.1, 1.0, fbm(q * 1.6 - t * 0.5));
|
||||
|
||||
vec3 col = mix(uB, uA, depth);
|
||||
float alpha = (0.16 + band * 0.22) * (0.55 + depth * 0.45);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -60,8 +60,15 @@ export interface Content {
|
|||
eyebrow: string;
|
||||
h2: string;
|
||||
lead: string;
|
||||
tiles: { id: "grain" | "glass" | "duotone"; name: string; use: string; how: string; cost: string }[];
|
||||
note: string;
|
||||
demos: {
|
||||
id: "scrim" | "cvd" | "goo";
|
||||
name: string;
|
||||
problem: string;
|
||||
beforeLabel: string;
|
||||
afterLabel: string;
|
||||
how: string;
|
||||
verdict: string;
|
||||
}[];
|
||||
};
|
||||
metrics: { eyebrow: string; h2: string; lead: string; items: Metric[]; caveat: string };
|
||||
preflight: {
|
||||
|
|
@ -91,9 +98,11 @@ export interface Content {
|
|||
command: string;
|
||||
targets: { name: string; path: string }[];
|
||||
commands: { cmd: string; what: string }[];
|
||||
colophon: { made: string; repo: string; license: string };
|
||||
};
|
||||
nav: { items: { label: string; href: string }[]; menu: string };
|
||||
/* 저장소 링크는 푸터가 아니라 헤더에 둔다.
|
||||
푸터를 없애면 소스로 가는 길이 사라지고, 개발 도구 소개 페이지에서
|
||||
그 링크는 장식이 아니라 목적지다. */
|
||||
nav: { items: { label: string; href: string }[]; menu: string; repo: string; repoLabel: string };
|
||||
ui: { copy: string; copied: string };
|
||||
}
|
||||
|
||||
|
|
@ -220,35 +229,39 @@ export const ko: Content = {
|
|||
],
|
||||
},
|
||||
materials: {
|
||||
eyebrow: "재료",
|
||||
h2: "텍스처 200KB 를 300바이트가 대신한다",
|
||||
eyebrow: "SVG 필터",
|
||||
h2: "이미지 편집기를 열지 않고 고치는 세 가지",
|
||||
lead:
|
||||
"필터는 장식이 아니라 재료다. 셋 다 실제로 쓰이는 자리에 걸어 뒀다 — 그레인은 매끈한 면에, 굴절은 유리 뒤 글자에, 듀오톤은 사진에. 배경은 지금 three.js 셰이더가 그리고 있다.",
|
||||
tiles: [
|
||||
"질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있다. 아래 셋은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.",
|
||||
demos: [
|
||||
{
|
||||
id: "grain",
|
||||
name: "그레인",
|
||||
use: "매끈한 그라디언트가 싸구려로 보일 때",
|
||||
how: "feTurbulence 가 만든 프랙탈 노이즈를 알파로 눌러 표면에 얹는다. 이미지 요청이 한 건도 늘지 않는다.",
|
||||
cost: "300바이트 · 정지 상태",
|
||||
id: "scrim",
|
||||
name: "글자 모양대로 생기는 스크림",
|
||||
problem: "사진 위 캡션이 대비 4.5:1 을 못 넘는다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞춰야 한다.",
|
||||
beforeLabel: "그냥 얹음",
|
||||
afterLabel: "filter: url(#scrim)",
|
||||
how: "feMorphology 로 글자 알파를 부풀리고 블러한 뒤, 그 모양대로만 feFlood 를 채운다. 박스가 아니라 글자를 따라가므로 사진을 가리지 않는다.",
|
||||
verdict: "흰 글자 대비 1.70:1 → 4.79:1 · 사진을 바꿔도 다시 맞출 것이 없다",
|
||||
},
|
||||
{
|
||||
id: "glass",
|
||||
name: "굴절",
|
||||
use: "유리 뒤의 글자가 실제로 휘어야 할 때",
|
||||
how: "난류를 변위 지도로 써서 픽셀을 민다. 필터 영역을 넓혀야 가장자리가 잘리지 않는다.",
|
||||
cost: "중간 · 애니메이션 금지",
|
||||
id: "cvd",
|
||||
name: "색각 이상 시뮬레이션",
|
||||
problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 된다. 눈으로는 절대 못 잡는다.",
|
||||
beforeLabel: "내가 보는 화면",
|
||||
afterLabel: "적록색약(2형)",
|
||||
how: "feColorMatrix 하나다. 이것만은 sRGB 로 두면 안 된다 — 색각 변환은 물리적 혼합이라 linearRGB 가 맞다. 필터 전체에서 유일한 예외다.",
|
||||
verdict: "세 배지의 최소 색거리 82 → 2 · 주의와 실패가 같은 색이 된다",
|
||||
},
|
||||
{
|
||||
id: "duotone",
|
||||
name: "듀오톤",
|
||||
use: "출처가 제각각인 사진을 한 팔레트로 묶을 때",
|
||||
how: "명도만 남긴 뒤 feComponentTransfer 로 두 색 사이에 다시 매핑한다. 원본을 보정하지 않는다.",
|
||||
cost: "낮음 · 사진에 쓴다",
|
||||
id: "goo",
|
||||
name: "떨어진 것이 하나로 뭉치기",
|
||||
problem: "조각이 서로 녹아 붙는 표현을 하려고 캔버스나 물리 라이브러리를 켠다. 로더, 메뉴 병합, 인디케이터 전환에서 반복된다.",
|
||||
beforeLabel: "필터 없음 · 원 3개",
|
||||
afterLabel: "filter: url(#goo)",
|
||||
how: "크게 블러한 뒤 feColorMatrix 로 알파만 18배 세워 잘라낸다. 붙을지 말지는 눈대중이 아니라 산수다 — 두 원 사이 중간점의 블러 알파가 문턱(7÷18 ≒ 0.39)을 넘어야 한다. 간격 18px 에 블러 7 로는 0.2 근처라 못 넘었고, 12px 에 10 으로 넘겼다.",
|
||||
verdict: "가로로 세어 덩어리 3개 → 1개 · JS 0줄",
|
||||
},
|
||||
],
|
||||
note:
|
||||
"이 셋은 SVG 필터가 지금 먹고 있는 화면이고, 그 뒤 배경은 three.js 셰이더가 그리고 있다. three 는 초기 번들에 없다 — 화면에 가까워질 때만 받고, WebGL 이 없거나 모션을 줄이는 설정이면 아예 받지 않는다. 그때 보이는 그라디언트는 폴백이 아니라 그대로 결과물이다."
|
||||
},
|
||||
metrics: {
|
||||
eyebrow: "이 페이지의 수치",
|
||||
|
|
@ -405,11 +418,6 @@ export const ko: Content = {
|
|||
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
|
||||
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
|
||||
],
|
||||
colophon: {
|
||||
made: "designpaca 로 만들었다",
|
||||
repo: "git.chanpaca.net/yunchan/designpaca",
|
||||
license: "MIT",
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
items: [
|
||||
|
|
@ -419,6 +427,8 @@ export const ko: Content = {
|
|||
{ label: "설치", href: "#install" },
|
||||
],
|
||||
menu: "메뉴",
|
||||
repo: "https://git.chanpaca.net/yunchan/designpaca",
|
||||
repoLabel: "저장소",
|
||||
},
|
||||
ui: { copy: "복사", copied: "복사됨" },
|
||||
};
|
||||
|
|
@ -474,35 +484,39 @@ export const en: Content = {
|
|||
],
|
||||
},
|
||||
materials: {
|
||||
eyebrow: "Materials",
|
||||
h2: "300 bytes replaces a 200KB texture",
|
||||
eyebrow: "SVG filters",
|
||||
h2: "Three things you fix without opening an image editor",
|
||||
lead:
|
||||
"Filters are material, not decoration. Each one sits where it is actually used — grain on a flat gradient, refraction behind glass over type, duotone on a photograph. The backdrop is a three.js shader, running now.",
|
||||
tiles: [
|
||||
"There is no shortage of filter demos that make texture. The places where filters actually save you work are elsewhere. In all three below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.",
|
||||
demos: [
|
||||
{
|
||||
id: "grain",
|
||||
name: "Grain",
|
||||
use: "when a smooth gradient reads as cheap",
|
||||
how: "feTurbulence generates fractal noise, pushed down through alpha onto the surface. Not one extra image request.",
|
||||
cost: "300 bytes · static",
|
||||
id: "scrim",
|
||||
name: "A scrim shaped like the letters",
|
||||
problem: "A caption over a photo fails 4.5:1. Every photo has a different brightness, so the translucent box gets hand-tuned each time.",
|
||||
beforeLabel: "just placed on top",
|
||||
afterLabel: "filter: url(#scrim)",
|
||||
how: "feMorphology fattens the glyph alpha, a blur softens it, and feFlood fills only that shape. It follows the letters instead of covering the photo with a box.",
|
||||
verdict: "white-on-photo contrast 1.70:1 → 4.79:1 · swap the photo, nothing to re-tune",
|
||||
},
|
||||
{
|
||||
id: "glass",
|
||||
name: "Refraction",
|
||||
use: "when type behind glass has to actually bend",
|
||||
how: "Turbulence drives a displacement map that pushes pixels. Widen the filter region or the edges clip.",
|
||||
cost: "medium · never animate",
|
||||
id: "cvd",
|
||||
name: "Colour-vision simulation",
|
||||
problem: "State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.",
|
||||
beforeLabel: "what you see",
|
||||
afterLabel: "deuteranopia",
|
||||
how: "A single feColorMatrix. This is the one filter that must not run in sRGB — colour-vision transforms are physical mixing, so linearRGB is correct here.",
|
||||
verdict: "minimum colour distance across the three badges 82 → 2 · warning and failure become one colour",
|
||||
},
|
||||
{
|
||||
id: "duotone",
|
||||
name: "Duotone",
|
||||
use: "when photos from different sources need one palette",
|
||||
how: "Keep luminance only, then remap between two colours with feComponentTransfer. The source file is untouched.",
|
||||
cost: "low · use it on photos",
|
||||
id: "goo",
|
||||
name: "Separate shapes fusing into one",
|
||||
problem: "Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.",
|
||||
beforeLabel: "no filter · 3 circles",
|
||||
afterLabel: "filter: url(#goo)",
|
||||
how: "Blur hard, then stand the alpha up 18× with feColorMatrix. Whether they fuse is arithmetic, not taste — the blurred alpha midway between two circles has to clear the threshold (7÷18 ≒ 0.39). At 18px apart with a blur of 7 it sat near 0.2 and never merged; 12px and 10 clears it.",
|
||||
verdict: "counted across the middle row: 3 blobs → 1 · 0 lines of JS",
|
||||
},
|
||||
],
|
||||
note:
|
||||
"These three are SVG filters running on live pixels, and the field behind them is a three.js shader. three is not in the initial bundle — it arrives only as the section approaches, and never at all when WebGL is missing or motion is reduced. What you see then is not a fallback; it is the result."
|
||||
},
|
||||
metrics: {
|
||||
eyebrow: "This page, measured",
|
||||
|
|
@ -663,11 +677,6 @@ export const en: Content = {
|
|||
{ cmd: "npx designpaca doctor", what: "Diagnose install state and drift" },
|
||||
{ cmd: "npx designpaca uninstall", what: "Exact removal, driven by the manifest" },
|
||||
],
|
||||
colophon: {
|
||||
made: "Built with designpaca",
|
||||
repo: "git.chanpaca.net/yunchan/designpaca",
|
||||
license: "MIT",
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
items: [
|
||||
|
|
@ -677,6 +686,8 @@ export const en: Content = {
|
|||
{ label: "Install", href: "#install" },
|
||||
],
|
||||
menu: "Menu",
|
||||
repo: "https://git.chanpaca.net/yunchan/designpaca",
|
||||
repoLabel: "Source",
|
||||
},
|
||||
ui: { copy: "Copy", copied: "Copied" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -100,6 +100,14 @@ const FAVICON =
|
|||
|
||||
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
|
||||
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
|
||||
{/* 페이지를 벗어나는 유일한 링크다. 화살표로 그렇다고 알린다 */}
|
||||
<a class="nav-repo" href={nav.repo} rel="noopener">
|
||||
{nav.repoLabel}
|
||||
<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true">
|
||||
<path d="M3.5 8.5 8.5 3.5M4.5 3.5h4v4" stroke="currentColor"
|
||||
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-end">
|
||||
|
|
@ -123,6 +131,7 @@ const FAVICON =
|
|||
</summary>
|
||||
<div class="nav-sheet glass">
|
||||
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
|
||||
<a href={nav.repo} rel="noopener">{nav.repoLabel} ↗</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -225,6 +234,9 @@ const FAVICON =
|
|||
background-color var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.nav-main a:hover { color: var(--ink); background: var(--glass-tint); }
|
||||
/* 바깥으로 나가는 링크는 섹션 앵커와 같은 무게로 두지 않는다 */
|
||||
.nav-repo { gap: var(--space-1); opacity: 0.72; }
|
||||
.nav-repo:hover { opacity: 1; }
|
||||
|
||||
.header-end {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -318,7 +318,17 @@ const buf = await sharp(f).extract(box).png().toBuffer();
|
|||
const s = await sharp(buf).stats(); // 잘라낸 버퍼를 다시 물려야 한다
|
||||
```
|
||||
|
||||
**③ 눈으로 의심한 것이 실측에서 뒤집힐 수 있다.**
|
||||
**③ Playwright 의 `animations: 'disabled'` 는 애니메이션을 끝 상태로 보낸다.**
|
||||
움직이는 데모를 중간 프레임에서 재려고 `animation-play-state: paused` 로 세워 뒀는데,
|
||||
스크린샷 옵션이 그걸 무시하고 100% 지점을 찍었다. 그 프레임에서는 두 도형이 이미
|
||||
겹쳐 있어서 "필터가 안 붙는다" 는 잘못된 결론이 나왔다. 멈춘 프레임을 그대로
|
||||
찍으려면 그 옵션을 **빼야** 한다.
|
||||
|
||||
> 여기서 더 중요한 결론이 나왔다 — **움직임으로 증명하려 들지 마라.**
|
||||
> 지나가는 프레임은 방문자도 놓치고 측정도 매번 다른 값을 준다.
|
||||
> 정지된 before/after 두 장이 더 정확하게 말한다.
|
||||
|
||||
**④ 눈으로 의심한 것이 실측에서 뒤집힐 수 있다.**
|
||||
WebGL 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다" 고 판단해 캔버스를
|
||||
어둡게 만들려 했다. 스크린샷에서 글자 사이 빈 띠의 배경 휘도를 재보니
|
||||
최악 지점이 **9.83:1** — AAA(7:1)를 넘었다. 고칠 필요가 없었다.
|
||||
|
|
@ -329,6 +339,36 @@ WebGL 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다"
|
|||
|
||||
---
|
||||
|
||||
### 데모를 만들기 전에 카피부터 쓰지 마라
|
||||
|
||||
실측 사례. 필터 데모 넷을 만들면서 각 데모의 결론 문장을 먼저 썼다 —
|
||||
"대비 2.1:1 → 12.4:1", "하나로 이어진다", "배경을 휜다".
|
||||
나중에 재보니 **넷 중 셋이 거짓이었다.**
|
||||
|
||||
| 화면에 쓴 말 | 실제 측정 |
|
||||
|---|---|
|
||||
| 대비 2.1:1 → 12.4:1 | 1.70:1 → **3.12:1** (AA 미달) |
|
||||
| 하나로 이어진다 | 필터를 켠 쪽도 **덩어리 3개** |
|
||||
| 배경을 휜다 | 평균 채널차 **1.66** (거의 변화 없음) |
|
||||
|
||||
숫자를 지어낸 것이 아니라 **"이 정도 나오겠지" 를 적고 확인하지 않은 것**이다.
|
||||
결과는 같다 — 페이지에 거짓이 실렸다.
|
||||
|
||||
순서를 뒤집어라.
|
||||
|
||||
1. 데모를 만든다
|
||||
2. **잰다** (before/after 를 각각 캡처해 픽셀로)
|
||||
3. 잰 값으로 문장을 쓴다
|
||||
4. 값이 주장을 뒷받침하지 못하면 **문장이 아니라 데모를 고친다**
|
||||
|
||||
4번을 지키면 카피가 저절로 구체적이 된다. 위 셋은 고친 뒤 이렇게 됐다 —
|
||||
"흰 글자 대비 1.70:1 → 4.79:1", "가로로 세어 덩어리 3개 → 1개",
|
||||
그리고 세 번째는 끝내 작동하지 않아 **섹션에서 뺐다.**
|
||||
|
||||
> 작동하지 않는 데모를 남기고 문장만 다듬는 선택지는 없다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 자가 채점표
|
||||
|
||||
| 카테고리 | 걸린 항목 | 조치 |
|
||||
|
|
@ -353,6 +393,7 @@ WebGL 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다"
|
|||
- [ ] 키보드만으로 전 인터랙션이 가능하고 포커스 링이 보인다
|
||||
- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다
|
||||
- [ ] 모든 섹션에서 제목→본문 간격이 **같은 값**이다 (0px 인 섹션이 하나도 없다)
|
||||
- [ ] 화면에 적은 수치는 **전부 그 화면을 캡처해 잰 값**이다 (추정치·기대치가 섞여 있지 않다)
|
||||
|
||||
**판정**
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,84 @@ SVG 필터는 장식이 아니라 재질을 만드는 도구다. "예뻐 보이
|
|||
**함정**: 큰 `stdDeviation`(50+)은 매우 비싸다. **넓은 소프트 글로우는 `radial-gradient` 배경이 압도적으로 싸다**
|
||||
**비용: 중** — dark-instrument에서는 강조 하나에만, 좁게
|
||||
|
||||
## 3-b. 실무에서 손이 줄어드는 레시피
|
||||
|
||||
질감을 만드는 필터는 데모에서 예쁘고, 아래 셋은 **매번 손으로 하던 일을 없앤다.**
|
||||
전부 실측으로 확인한 값이다.
|
||||
|
||||
### 글자 모양대로 생기는 스크림
|
||||
|
||||
사진 위 캡션이 대비를 못 넘길 때, 반투명 박스를 사진마다 손으로 맞추는 대신.
|
||||
|
||||
```xml
|
||||
<filter id="scrim" color-interpolation-filters="sRGB"
|
||||
x="-25%" y="-60%" width="150%" height="220%">
|
||||
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="fat"/>
|
||||
<feGaussianBlur in="fat" stdDeviation="3.5" result="soft"/>
|
||||
<!-- 블러가 알파를 옅게 만든다. 다시 세우지 않으면 스크림이 비친다 -->
|
||||
<feComponentTransfer in="soft" result="dense">
|
||||
<feFuncA type="linear" slope="4.4"/>
|
||||
</feComponentTransfer>
|
||||
<feFlood flood-color="#04100f" flood-opacity="1" result="ink"/>
|
||||
<feComposite in="ink" in2="dense" operator="in" result="scrim"/>
|
||||
<feMerge><feMergeNode in="scrim"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
```
|
||||
|
||||
실측: 밝은 사진 위 흰 글자 **1.70:1 → 4.79:1**(AA 통과).
|
||||
`slope` 를 1.8 로 뒀을 때는 3.12:1 에서 멈췄다 — **블러 뒤에 알파를 다시 세우는 단계가 핵심이다.**
|
||||
박스가 아니라 글자를 따라가므로 사진을 덜 가린다.
|
||||
|
||||
### 색각 이상 시뮬레이션 — 유일하게 `linearRGB` 인 필터
|
||||
|
||||
상태를 색으로만 구분했는지 **눈으로는 못 잡는다.** 검사 도구로 쓴다.
|
||||
|
||||
```xml
|
||||
<!-- 색각 변환은 물리적인 빛의 혼합이다. sRGB 로 돌리면 실제보다 덜 심하게 나온다 -->
|
||||
<filter id="deuter" color-interpolation-filters="linearRGB">
|
||||
<feColorMatrix type="matrix"
|
||||
values="0.625 0.375 0 0 0
|
||||
0.700 0.300 0 0 0
|
||||
0 0.300 0.700 0 0
|
||||
0 0 0 1 0"/>
|
||||
</filter>
|
||||
```
|
||||
|
||||
실측: 흔한 초록·노랑·빨강 상태 배지 셋의 **최소 색거리 82 → 2**.
|
||||
주의와 실패가 같은 색이 된다. 이 값이 한 자리로 떨어지면 색 말고 다른 단서가 필요하다.
|
||||
|
||||
### 구이(gooey) — 붙을지 말지는 눈대중이 아니라 산수다
|
||||
|
||||
커뮤니티에서 가장 많이 만들어지는 필터다(로더·메뉴 병합·인디케이터 전환).
|
||||
|
||||
```xml
|
||||
<filter id="goo" color-interpolation-filters="sRGB"
|
||||
x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" result="goo"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 18 -7"/>
|
||||
<feComposite in="SourceGraphic" in2="goo" operator="atop"/>
|
||||
</filter>
|
||||
```
|
||||
|
||||
**붙는 조건**: 두 도형 사이 중간점의 블러된 알파가 `offset ÷ slope`(여기서는 7÷18 ≒ 0.39)를 넘어야 한다.
|
||||
|
||||
실측 — 지름 46px 원 셋:
|
||||
|
||||
| 간격 | stdDeviation | 중간점 알파 | 결과 |
|
||||
|---|---|---|---|
|
||||
| 18px | 7 | ≈ 0.2 | **안 붙는다** (덩어리 3개 그대로) |
|
||||
| 12px | 10 | 문턱 통과 | 덩어리 **1개** |
|
||||
|
||||
값이 안 붙으면 `slope` 부터 만지지 마라 — 올리면 도형이 깎이고 각져 보인다.
|
||||
**간격과 블러 반경의 비율**이 먼저다.
|
||||
마지막 `feComposite atop` 이 없으면 블러된 헤일로가 남는다.
|
||||
|
||||
---
|
||||
|
||||
## 4. CSS로 되는 것은 CSS로
|
||||
|
||||
값싼 쪽을 먼저 쓴다. SVG 필터는 CSS로 표현 **불가능한** 것에만.
|
||||
|
|
@ -541,3 +619,30 @@ Chrome 151 에서 실측하면 computed 값이 `none` 이 아니라 `url("#id")`
|
|||
|
||||
> 표준화가 진행 중이다(w3c/svgwg#1142). 지금은 Chrome 한정 기법으로 다루고,
|
||||
> **굴절이 없어도 성립하는 화면**을 먼저 만든 뒤에 얹어라.
|
||||
|
||||
### 실패 기록 — `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단계에서 필터를 쓰기로 했으면
|
||||
레시피를 짜기 **전에** 이 문서의 §함정을 먼저 편다.
|
||||
|
||||
> 진단법: 굴절이 안 보일 때 `scale` 부터 올리지 마라. 지도를 화면에 직접 그려
|
||||
> **128 이 아닌 픽셀이 실제로 있는지** 먼저 봐라. 없으면 지도가 문제다.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue