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

View file

@ -5,114 +5,161 @@ import BgPhoto from "../components/BgPhoto.astro";
import { WORK } from "../data/work.ts";
/*
쇼케이스. 소개 페이지 안의 섹션이 아니라 **별도 페이지**다.
쇼케이스. 카드는 간단한 정보만 싣고(1′ 레퍼런스 tailwindcss.com/showcase:
이미지 + 이름 + 한 줄 종류 + 태그), 상세는 네이티브 <dialog> 모달로.
showModal() 이 Esc·포커스 트랩·aria를 브라우저가 처리한다 —
<details> 메뉴와 같은 결정이다. 사이트 자체는 모달의 큰 CTA로 새 탭.
홈에 있던 예전 섹션은 스크린샷 두 장을 카드에 넣고 각도를 살짝 준 것이었고,
들어갈 곳이 없었다 — 그림만 보여주고 끝나는 자리는 증거가 아니라 장식이다.
여기서는 타일을 누르면 **실제로 도는 사이트**로 들어간다.
타일 크기를 섞는다. 균일한 격자는 이 스킬이 금지하는 형태이고,
사례마다 중요도가 다른데 같은 크기로 두면 그 차이가 사라진다.
그리드는 균일하지 않다: 첫 사례(목요일의 화원)만 2열 폭으로 features,
나머지는 3열. 중요도가 다른데 같은 크기로 두면 그 차이가 사라진다.
*/
const title = "사례 · designpaca";
const description =
"designpaca 파이프라인으로 만든 사이트들. 각 사례의 컨셉과 감수한 리스크, 실측값을 함께 적었습니다. 타일을 누르면 실제 사이트가 열립니다.";
"designpaca 파이프라인으로 만든 사이트들. 카드를 누르면 컨셉과 리스크, 실측값이 담긴 상세가 열립니다.";
const ariaClose = "닫기";
const ariaDetail = "자세히 보기";
const siteLabel = "사이트 보기";
---
<Base lang="ko" title={title} description={description}>
<section class="section intro has-bg" aria-labelledby="sc-h">
<BgPhoto variant="align" mode="fixed" opacity={0.85} />
<p class="eyebrow reveal">사례</p>
<p class="eyebrow reveal">사례 아카이브</p>
<h1 id="sc-h" class="reveal">파이프라인을 돌려서 나온 것들</h1>
<p class="lead intro-lead reveal">
모두 0단계부터 6단계까지 그대로 돌려서 나온 결과물입니다. 각 사례마다
<strong>감수한 리스크</strong>를 함께 적었습니다 — 그것이 없으면 안전하지만 잊히는 디자인이 됩니다.
</p>
<p class="muted intro-note reveal">
타일을 누르면 실제 사이트가 열립니다. 목업이 아니라 동작하는 페이지입니다.
카드를 누르면 상세 화면이 열리고, 사이트는 새 탭에서 열립니다. 목업이 아니라
동작하는 페이지입니다.
</p>
</section>
<section class="section grid-section" aria-label="사례 목록">
<ul class="full mosaic">
<ul class="full cards">
{
WORK.map((w, i) => (
<li class:list={["tile", `tile-${w.size}`, "reveal"]} style={`--reveal-delay:${i * 80}ms`}>
<a class="tile-link" href={w.href}>
<span class="tile-shot">
<li class:list={["card", { featured: w.size === "lg" }, "reveal"]} style={`--reveal-delay:${i * 70}ms`}>
<article>
<figure class="card-shot">
<Image
src={w.shot}
alt={w.alt}
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
widths={[520, 900, 1200]}
sizes={w.size === "lg" ? "(max-width: 900px) 92vw, 62vw" : "(max-width: 900px) 92vw, 30vw"}
loading={i === 0 ? "eager" : "lazy"}
/>
{w.shotAlt && (
<Image
class="tile-shot-alt"
class="card-shot-alt"
src={w.shotAlt}
alt=""
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
widths={[520, 900, 1200]}
sizes={w.size === "lg" ? "(max-width: 900px) 92vw, 62vw" : "(max-width: 900px) 92vw, 30vw"}
loading="lazy"
aria-hidden="true"
/>
)}
</span>
<span class="card-badge mono">{w.preset}</span>
</figure>
<span class="tile-body">
<span class="tile-head">
<span class="tile-name">{w.name}</span>
<span class="tile-kind muted">{w.kind}</span>
<div class="card-body">
<h2 class="card-name">{w.name}</h2>
<p class="card-kind">{w.kind}</p>
<p class="card-concept">{w.concept}</p>
<span class="card-more" aria-hidden="true">
{ariaDetail}
<svg viewBox="0 0 12 12" width="10" height="10">
<path d="M2.5 9.5 9.5 2.5M4 2.5h5.5V8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
</span>
<span class="tile-concept">{w.concept}</span>
<span class="tile-risk">
<span class="risk-label">감수한 리스크</span>
{w.risk}
</span>
<span class="tile-metrics mono">
{w.metrics.map((m) => (
<span class="metric">{m}</span>
))}
</span>
</span>
</a>
</div>
<button type="button" class="card-open" data-dialog={w.slug} aria-label={`${w.name} ${ariaDetail}`} />
</article>
{/* 상세 모달. 네이티브 <dialog>: Esc·포커스·inert 를 브라우저가 처리 */}
<dialog id={`wd-${w.slug}`} class="work-dialog" aria-labelledby={`wd-${w.slug}-t`}>
<div class="dialog-inner">
<button type="button" class="dialog-close" data-close aria-label={ariaClose}>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
<figure class="dialog-shot">
<Image
src={w.shot}
alt={w.alt}
widths={[680, 1080, 1440]}
sizes="min(92vw, 720px)"
loading="lazy"
/>
</figure>
<div class="dialog-body">
<p class="dialog-meta mono">{w.kind} · {w.preset}</p>
<h2 class="dialog-name" id={`wd-${w.slug}-t`}>{w.name}</h2>
<p class="dialog-concept">{w.concept}</p>
<div class="dialog-risk">
<span class="risk-label">감수한 리스크</span>
<p>{w.risk}</p>
</div>
<ul class="dialog-metrics mono">
{w.metrics.map((m) => (
<li>{m}</li>
))}
</ul>
<a class="btn btn-solid dialog-site" href={w.href} target="_blank" rel="noopener">
{siteLabel}
<span class="site-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" width="11" height="11">
<path d="M2.5 9.5 9.5 2.5M4 2.5h5.5V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
</span>
</a>
</div>
</div>
</dialog>
</li>
))
}
</ul>
</section>
</Base>
<style>
/* .page 그리드는 base.css 가 준다. 여기서 다시 정의하면
main 칼럼이 본문 폭(--measure)에서 벗어나 전 페이지가 어긋난다. */
.intro {
padding-block: calc(var(--header-h) + var(--space-7)) var(--space-7);
}
/* 목록 페이지의 제목이라 홈 히어로만큼 클 이유가 없다.
h1 기본값(--display)은 여기서 세 줄로 접힌다. */
#sc-h {
font-size: clamp(2rem, 1.3rem + 2.4vw, 3.4rem);
max-width: 18ch;
text-wrap: balance;
word-break: keep-all;
}
.intro-lead {
margin-bottom: var(--space-4);
max-width: var(--measure);
text-wrap: pretty;
word-break: keep-all;
}
.intro-note {
max-width: var(--measure);
text-wrap: pretty;
word-break: keep-all;
}
.grid-section {
padding-block: 0 var(--space-8);
}
/* ── 모자이크 ────────────────────────────────────────
타일 크기가 섞인다. 사례마다 무게가 다른데 같은 크기로 두면
그 차이가 사라지고, 그때부터는 격자가 내용을 지운다. */
.mosaic {
/* ── 카드 그리드: 첫 사례만 2열, 나머지 3열 ── */
.cards {
list-style: none;
margin: 0;
max-width: 1180px;
@ -120,37 +167,33 @@ const description =
padding: 0 var(--gutter);
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-6) var(--space-5);
gap: var(--space-7) var(--space-5);
}
.tile {
.card {
margin: 0;
min-width: 0;
}
.tile-lg {
grid-column: span 6;
}
.tile-md {
grid-column: span 3;
}
.tile-sm {
grid-column: span 2;
}
.tile-link {
display: grid;
gap: var(--space-4);
color: inherit;
text-decoration: none;
.featured {
grid-column: span 6;
}
/* 큰 타일은 그림과 글을 나란히 둔다. 세로로 쌓으면 화면을 통째로 먹는다 */
.tile-lg .tile-link {
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
.card article {
position: relative;
display: grid;
gap: var(--space-3);
height: 100%;
}
.featured article {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: center;
}
.tile-shot {
/* 액자: 유리 한 장이 아니라 사진이 든 판. 겉 테두리(쉘) + 속 이미지(코어) */
.card-shot {
position: relative;
margin: 0;
display: block;
overflow: hidden;
border-radius: var(--radius-md);
@ -158,99 +201,336 @@ const description =
background: var(--surface-2);
aspect-ratio: 16 / 10;
}
.tile-shot :global(img) {
.card-shot :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
/* 두 번째 컷은 위에 겹쳐 두고 호버에서 바꾼다.
스크롤이나 자동 전환으로 바꾸지 않는다 — 읽는 사람의 속도를 뺏지 않는다 */
.tile-shot-alt {
.card-shot-alt {
position: absolute;
inset: 0;
opacity: 0;
}
.tile-body {
display: grid;
gap: var(--space-3);
align-content: start;
}
.tile-head {
display: grid;
gap: var(--space-1);
}
.tile-name {
font-size: var(--step-1);
font-weight: var(--weight-medium);
}
.tile-kind {
font-size: var(--step-0);
}
.tile-concept {
color: var(--ink);
max-width: 34em;
}
.tile-risk {
display: grid;
gap: var(--space-1);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
color: var(--ink-muted);
max-width: 38em;
}
.risk-label {
font-size: var(--step-0);
letter-spacing: var(--tracking-label);
.card-badge {
position: absolute;
top: var(--space-3);
right: var(--space-3);
background: rgba(11, 14, 18, 0.85);
backdrop-filter: blur(4px);
border: 1px solid var(--line);
color: var(--accent);
font-size: var(--mono-size);
padding: 2px 8px;
border-radius: var(--radius-sm);
letter-spacing: var(--tracking-label);
z-index: 2;
}
.tile-metrics {
.card-body {
display: grid;
gap: var(--space-1);
align-content: start;
}
.card-name {
margin: 0;
font-size: var(--step-1);
font-weight: var(--weight-medium);
text-wrap: balance;
word-break: keep-all;
}
.card-kind {
margin: 0;
color: var(--ink-muted);
font-size: var(--step-0);
}
.card-concept {
margin: var(--space-2) 0 0;
color: var(--ink-muted);
font-size: var(--step-0);
line-height: var(--leading-normal);
text-wrap: pretty;
word-break: keep-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.featured .card-concept {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.card-more {
margin-top: var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--step--1);
letter-spacing: var(--tracking-label);
color: var(--ink-faint);
justify-self: start;
}
/* 카드 전체가 하나의 버튼이다 — 시각 요소는 비활성 텍스트로 남고
실제 포커스 대상은 이 하나 (키보드 탭 순서 = 카드 순서) */
.card-open {
position: absolute;
inset: 0;
z-index: 3;
border: 0;
padding: 0;
background: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.card-open:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
/* ── 호버: 테두리 각성 + 스크린샷 교체 + 자세히 화살 이동 ── */
@media (prefers-reduced-motion: no-preference) {
.card-shot {
transition: border-color var(--dur-quick) var(--ease-out),
box-shadow var(--dur-quick) var(--ease-out);
}
.card-shot :global(img) {
transition: opacity var(--dur-slow) var(--ease-soft);
}
.card-more svg {
transition: transform var(--dur-quick) var(--ease-out);
}
article:has(.card-open:hover) .card-shot,
article:has(.card-open:focus-visible) .card-shot {
border-color: var(--accent);
box-shadow: 0 4px 24px rgba(79, 214, 196, 0.08);
}
article:has(.card-open:hover) .card-shot-alt,
article:has(.card-open:focus-visible) .card-shot-alt {
opacity: 1;
}
article:has(.card-open:hover) .card-more {
color: var(--ink);
}
article:has(.card-open:hover) .card-more svg {
transform: translate(2px, -2px);
}
article:has(.card-open:active) {
scale: 0.99;
}
}
/* ── 상세 모달 ── */
.work-dialog {
width: min(92vw, 720px);
max-height: min(88dvh, 900px);
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-frame);
/* 모달은 유리가 아니라 판이다 — 메뉴 시트와 같은 교훈: 크롬은 불투명 */
background: var(--surface);
color: var(--ink);
overflow-y: auto;
overscroll-behavior: contain;
}
.work-dialog::backdrop {
background: rgba(4, 6, 9, 0.62);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
@media (prefers-reduced-motion: no-preference) {
.work-dialog[open] {
animation: dialog-in 220ms var(--ease-soft) both;
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
}
}
.dialog-inner {
position: relative;
display: grid;
}
.dialog-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 4;
width: var(--control-h);
height: var(--control-h);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
/* 밝은 스크린샷 위에 얹힌다 — 반투명은 가독성이 아니라 배경과의 타협이다 */
background: var(--surface);
color: var(--ink-muted);
cursor: pointer;
transition: color var(--dur-quick) var(--ease-out),
border-color var(--dur-quick) var(--ease-out);
}
.dialog-close:hover {
color: var(--ink);
border-color: var(--ink-faint);
}
.dialog-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dialog-shot {
margin: 0;
overflow: hidden;
border-bottom: 1px solid var(--line);
background: var(--surface-2);
}
.dialog-shot :global(img) {
display: block;
width: 100%;
height: auto;
}
.dialog-body {
display: grid;
gap: var(--space-3);
padding: var(--space-5) var(--space-5) var(--space-6);
}
.dialog-meta {
margin: 0;
color: var(--ink-faint);
font-size: var(--mono-size);
letter-spacing: var(--tracking-label);
}
.dialog-name {
margin: 0;
font-size: var(--step-2);
line-height: var(--leading-tight);
text-wrap: balance;
word-break: keep-all;
}
.dialog-concept {
margin: 0;
color: var(--ink-muted);
line-height: var(--leading-normal);
max-width: 38em;
text-wrap: pretty;
word-break: keep-all;
}
.dialog-risk {
display: grid;
gap: var(--space-1);
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.risk-label {
font-size: var(--step--1);
letter-spacing: var(--tracking-label);
color: var(--accent);
font-weight: 500;
}
.dialog-risk p {
margin: 0;
color: var(--ink-muted);
font-size: var(--step-0);
line-height: var(--leading-normal);
max-width: 40em;
text-wrap: pretty;
word-break: keep-all;
}
.dialog-metrics {
margin: var(--space-1) 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
gap: var(--space-2);
font-size: var(--mono-size);
color: var(--ink-muted);
}
.metric + .metric::before {
content: "·";
margin-right: var(--space-4);
color: var(--line-strong);
.dialog-metrics li {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 3px 10px;
}
/* ── 호버: 그림이 바뀌고 테두리가 선다 ─────────────── */
@media (prefers-reduced-motion: no-preference) {
.tile-shot :global(img) {
transition: opacity var(--dur-slow) var(--ease-soft);
}
.tile-shot {
transition: border-color var(--dur-quick) var(--ease-out);
}
.tile-link:hover .tile-shot,
.tile-link:focus-visible .tile-shot {
border-color: var(--accent);
}
.tile-link:hover .tile-shot-alt,
.tile-link:focus-visible .tile-shot-alt {
opacity: 1;
}
/* 큰 CTA — 버튼 안의 버튼(둥근 화살 칸)은 cmd-copy 아이콘과 같은 어휘 */
.dialog-site {
margin-top: var(--space-4);
justify-self: start;
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding-inline: var(--space-4) var(--space-3);
font-size: var(--step-1);
font-weight: var(--weight-medium);
}
.site-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--accent-ink) 18%, transparent);
transition: transform var(--dur-quick) var(--ease-out);
}
.dialog-site:hover .site-icon {
transform: translate(2px, -2px);
}
@media (max-width: 900px) {
.mosaic {
grid-template-columns: 1fr;
.cards {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6) var(--space-4);
}
.tile-lg,
.tile-md,
.tile-sm {
.card {
grid-column: span 1;
}
.tile-lg .tile-link {
.featured {
grid-column: span 2;
}
.featured article {
grid-template-columns: 1fr;
gap: var(--space-4);
}
}
@media (max-width: 600px) {
.cards {
grid-template-columns: 1fr;
}
.featured {
grid-column: span 1;
}
}
</style>
<script>
// 모달 열기/닫기. showModal 이 포커스 트랩·Esc·inert 를 처리하므로
// 여기서는 여는 것과 배경 탭 닫기만.
const openers = document.querySelectorAll<HTMLButtonElement>(".card-open");
openers.forEach((btn) => {
btn.addEventListener("click", () => {
const dialog = document.getElementById(`wd-${btn.dataset.dialog}`);
if (dialog instanceof HTMLDialogElement) dialog.showModal();
});
});
document.querySelectorAll<HTMLDialogElement>(".work-dialog").forEach((dialog) => {
// 배경(=dialog 자신의 영역)을 누르면 닫는다. 내부 클릭은 target이 다르다
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
const closeBtn = dialog.querySelector("[data-close]");
closeBtn?.addEventListener("click", () => dialog.close());
});
</script>