- 핵심 규칙 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:
parent
78ddc8b61a
commit
72337b7ee0
226 changed files with 18212 additions and 380 deletions
|
|
@ -23,6 +23,17 @@ const alt = lang === "ko" ? "/en/" : "/";
|
|||
// 하드코딩하면 두 언어판이 어긋난다.
|
||||
const nav = (lang === "ko" ? ko : en).nav;
|
||||
|
||||
// 내비 앵커는 홈의 섹션을 가리킨다. 홈이 아닌 페이지(쇼케이스 등)에서는
|
||||
// "#pipeline" 이 그 페이지 안의 없는 앵커가 되어 아무 일도 하지 않는다 —
|
||||
// 홈 경로를 붙여 홈 섹션으로 건너가게 한다.
|
||||
const homePath = lang === "ko" ? "/" : "/en/";
|
||||
const normalizedPath = Astro.url.pathname.replace(/\/+$/, "") || "/";
|
||||
const isHome = normalizedPath === (lang === "ko" ? "/" : "/en");
|
||||
const navItems = nav.items.map((i) => ({
|
||||
...i,
|
||||
href: !isHome && i.href.startsWith("#") ? `${homePath}${i.href}` : i.href,
|
||||
}));
|
||||
|
||||
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
|
||||
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
|
||||
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
|
||||
|
|
@ -99,7 +110,7 @@ const FAVICON =
|
|||
</a>
|
||||
|
||||
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
|
||||
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
|
||||
{navItems.map((i) => <a href={i.href}>{i.label}</a>)}
|
||||
{/* 페이지를 벗어나는 유일한 링크다. 화살표로 그렇다고 알린다 */}
|
||||
<a class="nav-repo" href={nav.repo} rel="noopener">
|
||||
{nav.repoLabel}
|
||||
|
|
@ -111,9 +122,10 @@ const FAVICON =
|
|||
</nav>
|
||||
|
||||
<div class="header-end">
|
||||
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다 */}
|
||||
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다.
|
||||
좁은 화면(<480px)에서는 이 버튼이 사라지고 메덹 안 행으로 들어간다 */}
|
||||
<a
|
||||
class="btn btn-quiet btn-sm"
|
||||
class="btn btn-quiet btn-sm lang-btn"
|
||||
href={alt}
|
||||
hreflang={lang === "ko" ? "en" : "ko"}
|
||||
lang={lang === "ko" ? "en" : "ko"}
|
||||
|
|
@ -121,18 +133,40 @@ const FAVICON =
|
|||
{lang === "ko" ? "English" : "한국어"}
|
||||
</a>
|
||||
|
||||
{/* 모바일 메뉴. details 라 JS 가 0바이트이고, 키보드와
|
||||
스크린리더 대응이 브라우저 기본으로 온다 */}
|
||||
{/* 모바일 메뉴. details 라 JS 가 0바이트에 가깝고, 열림 상태,
|
||||
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
|
||||
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
|
||||
시트는 알약 전체 폭으로 열린다 — 11rem 짜리 조각 상자가 아니라 */}
|
||||
<details class="nav-mobile">
|
||||
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu}>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu} aria-haspopup="menu">
|
||||
<svg class="i-burger" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg class="i-close" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</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>
|
||||
<nav class="nav-sheet glass" aria-label={nav.menu}>
|
||||
{navItems.map((i, idx) => (
|
||||
<a href={i.href}>
|
||||
<span class="sheet-num" aria-hidden="true">{String(idx + 1).padStart(2, "0")}</span>
|
||||
{i.label}
|
||||
</a>
|
||||
))}
|
||||
<a href={nav.repo} rel="noopener">
|
||||
<span class="sheet-num" aria-hidden="true">↗</span>
|
||||
{nav.repoLabel}
|
||||
</a>
|
||||
<a
|
||||
class="sheet-lang"
|
||||
href={alt}
|
||||
hreflang={lang === "ko" ? "en" : "ko"}
|
||||
lang={lang === "ko" ? "en" : "ko"}
|
||||
>
|
||||
<span class="sheet-num" aria-hidden="true">A</span>
|
||||
{lang === "ko" ? "English" : "한국어"}
|
||||
</a>
|
||||
</nav>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,6 +178,25 @@ const FAVICON =
|
|||
|
||||
|
||||
<script is:inline>
|
||||
// 메덥 닫기 두 가지. details 의 나머지(Esc·포커스·aria)는 브라우저 몫.
|
||||
// 1) 행을 누르면 앵커로 스크롤하고 메덥은 스스로 닫는다 —
|
||||
// 열린 채로 남아 본문을 가리는 것이 이 메뉴의 유일한 결함이었다.
|
||||
// 2) 바깥을 누르면 닫는다. scroll 리스너는 쓰지 않는다(게이트 10).
|
||||
(function () {
|
||||
var menu = document.querySelector(".nav-mobile");
|
||||
if (!menu) return;
|
||||
menu.addEventListener("click", function (e) {
|
||||
if (e.target && e.target.closest && e.target.closest("a")) {
|
||||
menu.removeAttribute("open");
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (menu.open && !(e.target && e.target.closest && e.target.closest(".nav-mobile"))) {
|
||||
menu.removeAttribute("open");
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// 진입 연출. 한 번 보이면 계속 보인다.
|
||||
// scroll 리스너를 쓰지 않는다(하드 게이트 10).
|
||||
(function () {
|
||||
|
|
@ -206,6 +259,7 @@ const FAVICON =
|
|||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-frame);
|
||||
position: relative; /* 모바일 시트의 기준 — 알약 전체 폭으로 펼친다 */
|
||||
}
|
||||
.wordmark {
|
||||
display: inline-flex;
|
||||
|
|
@ -246,42 +300,93 @@ const FAVICON =
|
|||
}
|
||||
|
||||
/* ---- 모바일 메뉴 ------------------------------------------
|
||||
details 를 쓴 이유는 JS 0바이트 때문만이 아니다. 열림 상태,
|
||||
details 를 쓴 이유는 JS 를 줄이기 위해서만이 아니다. 열림 상태,
|
||||
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
|
||||
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다. */
|
||||
.nav-mobile { position: relative; }
|
||||
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
|
||||
1′ 국소 레퍼런스(navbar.gallery → browserbase.com): 항목이 많은 사이트는
|
||||
내비 마크업을 아예 복제해 분리한다. 우리는 5행이라 복제하지 않고
|
||||
시트를 알약 전체 폭으로 펼친다 — 조각 상자보다 계기판답다. */
|
||||
.nav-mobile { position: static; }
|
||||
.nav-mobile > summary {
|
||||
list-style: none;
|
||||
padding-inline: 0;
|
||||
width: var(--control-h-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--control-h); /* 터치 대상은 44px — 36px 는 마우스용 */
|
||||
min-height: var(--control-h);
|
||||
padding: 0;
|
||||
}
|
||||
.nav-mobile > summary::-webkit-details-marker { display: none; }
|
||||
.nav-mobile > summary::marker { content: ""; }
|
||||
.nav-mobile[open] > summary {
|
||||
background: var(--glass-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
.i-close { display: none; }
|
||||
.nav-mobile[open] .i-burger { display: none; }
|
||||
.nav-mobile[open] .i-close { display: block; }
|
||||
|
||||
.nav-sheet {
|
||||
/* 닫힌 details 의 자식에 display 를 주면 UA 숨김을 덮어써
|
||||
'보이지 않지만 레이아웃에 사는' 시트가 된다(320px 오버플로의 원인).
|
||||
닫힘을 명시적으로 다시 선언한다 — details 패턴의 필수 안전장치 */
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 11rem;
|
||||
padding: var(--space-2);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
z-index: 4;
|
||||
border-radius: var(--radius-frame);
|
||||
/* 메뉴는 콘텐츠가 아니라 크롬이다. .glass 의 4.5% 잉크는 알약에는
|
||||
맞는 재질이지만 시트에는 가독성 버그다 — 뒤 글자가 비쳐 겹친다.
|
||||
배경만 채우고 테두리 광(::after rim)·그림자는 유리 재질 그대로 */
|
||||
background: color-mix(in oklab, var(--surface) 94%, white 6%);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.nav-mobile[open] .nav-sheet { display: grid; }
|
||||
.nav-sheet a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
gap: var(--space-3);
|
||||
min-height: calc(var(--control-h) + 4px);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-sheet a:hover { background: var(--glass-tint-2); }
|
||||
.sheet-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--mono-size);
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
.sheet-lang { color: var(--ink-muted); }
|
||||
.sheet-lang:hover { color: var(--ink); }
|
||||
|
||||
/* 열림 연출 — transform/opacity 만(게이트 8) */
|
||||
.nav-mobile[open] .nav-sheet {
|
||||
animation: sheet-in 180ms var(--ease-out) both;
|
||||
}
|
||||
@keyframes sheet-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-mobile[open] .nav-sheet { animation: none; }
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.nav-main { display: flex; }
|
||||
.nav-mobile { display: none; }
|
||||
}
|
||||
|
||||
/* 480px 미만: 언어 버튼은 헤더에서 물러나고 메덥 행으로 간다.
|
||||
320px 실측에서 워드마크+English+버거가 알약을 7px 밀어냈다 */
|
||||
@media (max-width: 480px) {
|
||||
.lang-btn { display: none; }
|
||||
.header-inner { gap: var(--space-2); }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue