feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
Some checks failed
ci / build (push) Failing after 5s

- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다
- 5단계 기계 검사 4번째: design-gate 실행 의무화
- references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙
- tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합)
- 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0)

feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18)

- 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판
- LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png)
- 시간표 자동배치 결함 수리(명시적 격자 좌표)
- QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
This commit is contained in:
Yun Chan 2026-08-22 21:22:05 +09:00
parent 78ddc8b61a
commit 72337b7ee0
226 changed files with 18212 additions and 380 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,427 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumina Optics · Refraction Bench</title>
<meta
name="description"
content="A WebGL bench where a single beam meets a prism: refraction and dispersion computed per frame. Drag the prism, change the index."
/>
<meta property="og:title" content="Lumina Optics · Refraction Bench" />
<meta
property="og:description"
content="One beam, one prism, per-frame refraction."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#101214" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23101214'/%3E%3Cpath d='M6 16h8m8 0h4M14 16l10-7v14z' fill='none' stroke='%23BFE8EE' stroke-width='1.4'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">Skip to main content</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">lumina<span class="wordmark-dot">·</span>optics</a>
<nav class="nav" aria-label="Sections">
<a href="#bench">Bench</a>
<a href="#anatomy">Anatomy</a>
<a href="#mtf">MTF</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main id="main">
<!--
The bench. The product is light behaviour, so the hero IS the instrument:
one beam, one prism, per-frame refraction. Drag rotates; sliders change
the index and the dispersion — they are the two honest knobs a bench
has. Everything degrades to a static photograph.
-->
<section id="bench" class="hero" aria-labelledby="hero-h">
<div class="bench-stage reveal">
<canvas
id="gl"
aria-label="Interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the prism."
></canvas>
<img
class="bench-fallback"
src="./assets/prism-bench.webp"
alt="A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum"
width="1440"
height="810"
/>
</div>
<div class="wrap hero-inner">
<div class="hero-text">
<p class="hero-kicker reveal">Refraction bench · WebGL</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
One beam,<br />bent honestly
</h1>
<p class="lead reveal" style="--delay: 120ms">
No photograph on this screen is doing the work — the prism is
being refracted right now, per frame, on your GPU. Drag it. Change
the index. The spectrum you see is the same arithmetic lens
designers use, minus the glasses.
</p>
</div>
<div class="hud reveal" style="--delay: 160ms">
<div class="hud-row">
<label for="ior">Index of refraction <output id="ior-out" for="ior">1.52</output></label>
<input type="range" id="ior" min="1.45" max="2.20" step="0.01" value="1.52" />
</div>
<div class="hud-row">
<label for="disp">Dispersion (Abbe ↓) <output id="disp-out" for="disp">0.18</output></label>
<input type="range" id="disp" min="0" max="0.6" step="0.01" value="0.18" />
</div>
<p class="hud-note" id="hud-note">Drag the prism to rotate · beam is collimated at 632.8 nm reference</p>
</div>
</div>
</section>
<!--
Anatomy — cut lens photography. Real glass, not renders.
-->
<section id="anatomy" class="section" aria-labelledby="anatomy-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/lens-section.webp"
alt="Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>L-85 cutaway · 7 elements in 6 groups</figcaption>
</figure>
<div class="two-text">
<h2 id="anatomy-h">Glass decides<br />before software does</h2>
<p>
A lens is a chain of refractions, and every surface answers before
any firmware runs. Our L-85 uses two low-dispersion elements to
keep the spectrum you just dragged from fanning out across the
frame — dispersion corrected in glass, not in a profile.
</p>
<p class="muted">
The cutaway above is a physical section, milled for measurement.
Element count and spacing are measured from it, not from the
drawing.
</p>
</div>
</div>
</section>
<!--
MTF — the only curve that argues. Static, thin, honest:
measured at infinity, full aperture, both field directions.
-->
<section id="mtf" class="section band" aria-labelledby="mtf-h">
<div class="wrap mtf-grid">
<div class="mtf-text">
<h2 id="mtf-h">MTF, not adjectives</h2>
<p>
“Tack sharp” is not a specification. Contrast transfer at given
line pairs per millimetre is. These curves are measured on an
optical bench at full aperture — sagittal and meridional, out to
the corner.
</p>
<ul class="mtf-legend">
<li><span class="key key-solid" aria-hidden="true"></span>10 lp/mm · sagittal</li>
<li><span class="key key-solid-m" aria-hidden="true"></span>10 lp/mm · meridional</li>
<li><span class="key key-dash" aria-hidden="true"></span>30 lp/mm · sagittal</li>
<li><span class="key key-dash-m" aria-hidden="true"></span>30 lp/mm · meridional</li>
</ul>
</div>
<figure class="mtf-plot reveal">
<svg viewBox="0 0 560 320" role="img" aria-label="MTF curves: contrast stays above 0.85 at 10 lp/mm across the field; at 30 lp/mm it falls from 0.75 at center to 0.42 at the corner.">
<g class="chart-grid">
<line x1="48" y1="16" x2="48" y2="272" />
<line x1="48" y1="272" x2="536" y2="272" />
<line x1="48" y1="88" x2="536" y2="88" stroke-dasharray="2 6" />
<line x1="48" y1="160" x2="536" y2="160" stroke-dasharray="2 6" />
<line x1="48" y1="232" x2="536" y2="232" stroke-dasharray="2 6" />
<line x1="172" y1="16" x2="172" y2="272" stroke-dasharray="2 6" />
<line x1="296" y1="16" x2="296" y2="272" stroke-dasharray="2 6" />
<line x1="420" y1="16" x2="420" y2="272" stroke-dasharray="2 6" />
</g>
<g class="axis-labels">
<text x="40" y="20" text-anchor="end">1.0</text>
<text x="40" y="92" text-anchor="end">0.75</text>
<text x="40" y="164" text-anchor="end">0.5</text>
<text x="40" y="236" text-anchor="end">0.25</text>
<text x="48" y="292">center</text>
<text x="292" y="292" text-anchor="middle">image field (mm)</text>
<text x="536" y="292" text-anchor="end">corner 21.6</text>
</g>
<!-- 10 lp/mm sagittal: high, gentle fall -->
<polyline class="mtf mtf-10s" points="48,32 172,36 296,44 420,60 536,76" />
<!-- 10 lp/mm meridional -->
<polyline class="mtf mtf-10m" points="48,34 172,40 296,52 420,72 536,96" />
<!-- 30 lp/mm sagittal -->
<polyline class="mtf mtf-30s" points="48,84 172,96 296,120 420,160 536,196" />
<!-- 30 lp/mm meridional -->
<polyline class="mtf mtf-30m" points="48,90 172,108 296,140 420,186 536,232" />
</svg>
<figcaption>L-85 · f/1.8 · infinity focus · bench-measured 2026-07</figcaption>
</figure>
</div>
</section>
<!--
Spec — with conditions.
-->
<section class="section" aria-labelledby="spec-h">
<div class="wrap">
<h2 id="spec-h" class="reveal">Specification</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">L-85 specification — item, value, condition</caption>
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col" class="num">Value</th>
<th scope="col">Condition</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>nominal · ±1.5% at infinity</td></tr>
<tr><th scope="row">Aperture</th><td class="num">f/1.8 f/16</td><td>9-blade · near-circular</td></tr>
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>2 LD · measured from cutaway</td></tr>
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>1:7.2 magnification</td></tr>
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>brass, front-threaded</td></tr>
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>without caps, with hood</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Contact -->
<section id="contact" class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">Bench visits</h2>
<p>
We measure loaner samples for free and publish the curves —
manufacturers welcome or not. Bench time and MTF requests:
</p>
<a class="btn" href="mailto:bench@lumina.optics">bench@lumina.optics</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>lumina optics</p>
<p class="foot-meta">measurements reproducible on request · bench cal. 2026-06</p>
</div>
</footer>
<script type="module">
/*
Refraction bench — three.js, one prism, two honest uniforms.
Budget note: three.module.js ≈ 165KB gzip. 예산 상향 — 이 페이지의
제품이 WebGL 자체다(데모 브리프 예산 상향 허용).
폴백: WebGL 실패 시 사진이 보이고 캔버스는 뜨지 않는다(이미 마크업에 있음).
*/
const canvas = document.getElementById("gl");
const fallback = document.querySelector(".bench-fallback");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let gl = null;
try {
gl = canvas.getContext("webgl2", { antialias: true, alpha: true });
} catch (e) { /* fall through */ }
if (!gl) {
canvas.remove();
document.getElementById("hud-note").textContent =
"WebGL unavailable — showing the bench photograph instead.";
} else {
fallback.remove();
const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js");
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
const DPR = Math.min(devicePixelRatio || 1, 1.5);
renderer.setPixelRatio(DPR);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
camera.position.set(0, 0, 9);
// ----- prism: triangular cross-section (CylinderGeometry, 3 segments)
const prismGeo = new THREE.CylinderGeometry(2.1, 2.1, 2.6, 3, 1, false);
prismGeo.rotateX(Math.PI / 2); // axis toward camera
prismGeo.rotateZ(Math.PI / 2); // flat edge up to the beam
const uniforms = {
uTime: { value: 0 },
uIor: { value: 1.52 },
uDisp: { value: 0.18 },
};
const prismMat = new THREE.ShaderMaterial({
uniforms,
transparent: true,
vertexShader: /* glsl */`
varying vec3 vN;
varying vec3 vView;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal);
vView = normalize(-mv.xyz);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
precision highp float;
varying vec3 vN;
varying vec3 vView;
uniform float uIor;
uniform float uDisp;
// wavelength ramp: violet → cyan → yellow → red
vec3 spectrum(float t) {
t = clamp(t, 0.0, 1.0);
return clamp(
vec3(
smoothstep(0.30, 0.00, t) + smoothstep(0.70, 1.00, t),
smoothstep(0.00, 0.35, t) - smoothstep(0.60, 1.00, t),
smoothstep(0.00, 0.30, t) - smoothstep(0.45, 0.75, t)
), 0.0, 1.0);
}
void main() {
float ndv = abs(dot(normalize(vN), normalize(vView)));
// fresnel edge (schlick): rim is where refraction lives
float f = pow(1.0 - ndv, 3.0);
// refraction deflection grows with index
float bend = (uIor - 1.0) * ndv;
// dispersion: split position across the rim into wavelengths
float d = uDisp * bend;
vec3 col = spectrum(0.5 + (1.0 - ndv) * (0.6 + d * 4.0));
// glass body: near-black with faint interior glow
vec3 body = vec3(0.02, 0.025, 0.03) * (0.4 + ndv);
vec3 rim = col * f * 1.35;
float alpha = clamp(f * 0.95 + 0.06, 0.0, 1.0);
gl_FragColor = vec4(body + rim, alpha);
}`,
});
const prism = new THREE.Mesh(prismGeo, prismMat);
scene.add(prism);
// ----- beam: incoming line + dispersed fan (additive planes)
const beamGroup = new THREE.Group();
const mkBeam = (w, h, color1, color2, op) => {
const m = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uOp: { value: op }, uC1: { value: new THREE.Color(color1) }, uC2: { value: new THREE.Color(color2) } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec2 vUv; uniform float uOp; uniform vec3 uC1; uniform vec3 uC2;
void main(){
float a = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
float b = smoothstep(0.0, 0.5, vUv.y) * smoothstep(1.0, 0.5, vUv.y);
vec3 c = mix(uC1, uC2, vUv.x);
gl_FragColor = vec4(c, a * b * uOp);
}`,
})
);
return m;
};
const beamIn = mkBeam(9, 0.10, 0xdfe8ec, 0xffffff, 0.55);
beamIn.position.set(0, 1.45, -0.2);
beamGroup.add(beamIn);
const fan = mkBeam(9, 1.7, 0x8fd8e8, 0xf2b060, 0.30);
fan.position.set(-0.4, 0.35, -0.25);
fan.rotation.z = 0.085;
beamGroup.add(fan);
scene.add(beamGroup);
// ----- interaction
let dragging = false, lastX = 0, vel = 0.0016, rot = 0;
canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
rot += dx * 0.006;
vel = dx * 0.0006;
});
const end = () => (dragging = false);
canvas.addEventListener("pointerup", end);
canvas.addEventListener("pointercancel", end);
canvas.style.touchAction = "none";
const iorEl = document.getElementById("ior");
const dispEl = document.getElementById("disp");
const iorOut = document.getElementById("ior-out");
const dispOut = document.getElementById("disp-out");
const bind = (el, out, uni, fmt) =>
el.addEventListener("input", () => {
uni.value = parseFloat(el.value);
out.textContent = fmt(uni.value);
});
bind(iorEl, iorOut, uniforms.uIor, (v) => v.toFixed(2));
bind(dispEl, dispOut, uniforms.uDisp, (v) => v.toFixed(2));
// ----- sizing
const stage = canvas.parentElement;
function fit() {
const r = stage.getBoundingClientRect();
const h = Math.max(320, Math.min(r.height, 760));
renderer.setSize(r.width, h, false);
camera.aspect = r.width / h;
camera.updateProjectionMatrix();
}
fit();
new ResizeObserver(fit).observe(stage);
// ----- loop: paused offscreen, static under reduced motion
let visible = true, t0 = performance.now();
const io = new IntersectionObserver((en) => { visible = en[0].isIntersecting; });
io.observe(canvas);
function frame() {
if (!visible) { requestAnimationFrame(frame); return; }
const t = (performance.now() - t0) / 1000;
uniforms.uTime.value = t;
if (!dragging) { vel *= 0.98; rot += Math.max(0.0016, Math.abs(vel)) * Math.sign(vel || 1); }
prism.rotation.z = rot;
beamGroup.rotation.z = Math.sin(t * 0.4) * 0.01;
renderer.render(scene, camera);
if (!reduced) requestAnimationFrame(frame);
}
frame(); // reduced-motion: renders exactly one static frame
}
const io2 = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io2.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
</script>
</body>
</html>

View file

@ -0,0 +1,477 @@
@import url("./tokens.css");
/* ==========================================================================
Lumina Optics 광학 시험대. 색은 캔버스 안에서만.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, th, td, figcaption, li) {
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 36em;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-mono);
font-size: var(--step-1);
font-weight: 500;
text-decoration: none;
letter-spacing: -0.02em;
}
.wordmark-dot {
color: var(--accent-stroke);
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent-stroke);
}
/* 시험대(히어로) 캔버스와 본문은 세로로 완전히 분리.
R2 원칙(design.md): 3D 위에 본문을 얹지 않는다. */
.hero {
border-bottom: 1px solid var(--line);
}
.bench-stage {
position: relative;
height: clamp(320px, 56vh, 560px);
background:
radial-gradient(80% 90% at 50% 30%, rgba(127, 185, 196, 0.05), transparent 60%),
var(--surface);
border-bottom: 1px solid var(--line);
}
#gl,
.bench-fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#gl {
cursor: grab;
}
#gl:active {
cursor: grabbing;
}
.hero-inner {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-5) var(--space-7);
align-items: start;
padding-block: var(--space-6);
}
.hero-kicker {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--accent-stroke);
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
.hero .lead {
margin-top: var(--space-3);
}
.hud {
background: color-mix(in srgb, var(--surface) 78%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-4);
display: grid;
gap: var(--space-3);
}
.hud-row label {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--step--1);
color: var(--ink-muted);
}
.hud-row output {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent-stroke);
}
.hud-note {
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 단면 2단 ─────────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.two-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.two-photo figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
/* ── MTF ──────────────────────────────────────────────────────────────── */
.mtf-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-5) var(--space-7);
align-items: center;
}
.mtf-text p {
margin-top: var(--space-3);
color: var(--ink-muted);
max-width: 30em;
}
.mtf-legend {
list-style: none;
padding: 0;
margin-top: var(--space-4);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2) var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.key {
display: inline-block;
width: 16px;
height: 0;
border-top: 2px solid var(--accent-stroke);
margin-right: var(--space-2);
vertical-align: middle;
}
.key-solid {
border-top: 2.5px solid var(--accent-stroke);
}
.key-solid-m {
border-top: 2.5px solid var(--ink-faint);
}
.key-dash {
border-top: 2px dashed var(--accent-stroke);
}
.key-dash-m {
border-top: 2px dashed var(--ink-faint);
}
.mtf-plot svg {
display: block;
width: 100%;
height: auto;
}
.chart-grid line {
stroke: var(--line);
stroke-width: 1;
}
.chart-grid line:first-child,
.chart-grid line:nth-child(2) {
stroke: var(--line-strong);
}
.axis-labels text {
font-size: 12px;
fill: var(--ink-faint);
}
.mtf {
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.mtf-10s {
stroke: var(--accent-stroke);
}
.mtf-10m {
stroke: var(--ink-faint);
}
.mtf-30s {
stroke: var(--accent-stroke);
stroke-dasharray: 6 5;
}
.mtf-30m {
stroke: var(--ink-faint);
stroke-dasharray: 6 5;
}
.mtf-plot figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 사양 표 ──────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-4);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-weight: 500;
font-size: var(--step--1);
color: var(--ink-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line-strong);
}
tbody th {
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
tbody td:not(.num) {
color: var(--ink-muted);
font-size: var(--step--1);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: center;
}
.contact-grid p {
color: var(--ink-muted);
max-width: 34em;
}
.btn {
display: inline-block;
border: 1px solid var(--accent-stroke);
color: var(--accent);
text-decoration: none;
font-weight: 500;
padding: 0.7rem 1.4rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-stroke);
color: var(--surface);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-inner {
position: static;
grid-template-columns: 1fr;
padding-block: var(--space-5);
}
.bench-stage {
height: clamp(300px, 48vh, 460px);
}
.two,
.mtf-grid,
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.section {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
}

View file

@ -0,0 +1,57 @@
/* ==========================================================================
Lumina Optics 디자인 토큰
프리셋: refraction / WebGL instrument
컨셉 문장: 광학 시험대 유일한 색은 프리즘이 스스로 만드는 스펙트럼.
레퍼런스: 방향 앵커 dogfood-optics(광학업종의 데이터 노출). 파일의 수치는
audit-references.md 날조 판명 토큰은 브리프 재정의. UI는 무채색에
가깝게: 색은 캔버스 안에서만 정당하다(design.md R2 원리와 같은 ).
성능 예산: **상향 명시** three.module.js 165KB(gzip) 지연 import.
페이지의 제품이 WebGL 데모 자체이므로 데모 예산 상향 규칙 적용.
UI JS(슬라이더 바인딩) 인라인 <1KB.
하지 않은 : 스펙텔러 그라디언트 배경, 'unrivaled clarity' 카피,
3열 카드. 이유: 광학은 곡선으로 말한다.
========================================================================== */
:root {
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.1875rem;
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);
--leading-tight: 1.24;
--leading-normal: 1.72;
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — 시험실 무채색. 살짝 청록 기운의 액센트는 '레이저 라인' 하나뿐. */
--surface: #101214;
--surface-raised: #15181b;
--ink: #e8ecee;
--ink-muted: #9ba4a9;
--ink-faint: #6b7479;
--line: #23272b;
--line-strong: #394046;
--accent: #bfe8ee; /* 레이저 라인(밝게 — 텍스트용) */
--accent-stroke: #7fb9c4;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}