designpaca/apps/site/src/layouts/Base.astro
Yun Chan 72337b7ee0
Some checks failed
ci / build (push) Failing after 5s
feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
- 핵심 규칙 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 실패 시 배포 금지 체인
2026-08-22 21:22:05 +09:00

392 lines
15 KiB
Text
Raw 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.

---
import { ko, en } from "../i18n/content";
import "../styles/fonts.css";
import "../styles/tokens.css";
import "../styles/base.css";
interface Props {
lang: "ko" | "en";
title: string;
description: string;
}
const { lang, title, description } = Astro.props;
// 언어별로 자기 폰트만 받는다. 두 벌을 한 페이지에 로드하면 예산을 넘긴다.
const FONT_CSS = {
ko: "https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css",
en: "https://api.fontshare.com/v2/css?f%5B%5D=switzer@400,500,600&display=swap",
} as const;
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 와 손으로 맞춘다.
// MARK_BG = --accent-inverse · MARK_FG = --surface-inverse
const MARK_BG = "#0E6B63";
const MARK_FG = "#F4F2EC";
const FAVICON =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="3" fill="${MARK_BG}"/>
<path d="M11 7v18" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round"/>
<circle cx="11" cy="10" r="3" fill="${MARK_FG}"/>
<circle cx="11" cy="16" r="3" fill="${MARK_FG}"/>
<circle cx="11" cy="22" r="3" fill="${MARK_FG}"/>
<path d="M18 10h7M18 16h5M18 22h7" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round" opacity=".55"/>
</svg>`,
);
---
<!doctype html>
<html lang={lang} class="no-js">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" href={FAVICON} />
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} />
<link rel="alternate" hreflang="ko" href={new URL("/", Astro.site)} />
<link rel="alternate" hreflang="en" href={new URL("/en/", Astro.site)} />
<link rel="alternate" hreflang="x-default" href={new URL("/", Astro.site)} />
<link
rel="preconnect"
href={lang === "ko" ? "https://cdn.jsdelivr.net" : "https://api.fontshare.com"}
crossorigin
/>
<link rel="stylesheet" href={FONT_CSS[lang]} />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
media="print"
onload="this.media='all'"
/>
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content={lang === "ko" ? "ko_KR" : "en_US"} />
<meta name="color-scheme" content="light dark" />
{/* JS 가 살아 있으면 no-js 를 떼고 진입 연출을 켠다 */}
<script is:inline>
document.documentElement.classList.remove("no-js");
</script>
</head>
<body>
<a class="skip" href="#main">{lang === "ko" ? "본문으로 건너뛰기" : "Skip to content"}</a>
<header class="site-header">
<div class="header-inner glass">
<span class="rim" aria-hidden="true"></span>
<a class="wordmark" href={lang === "ko" ? "/" : "/en/"} aria-label="designpaca">
<svg class="mark" viewBox="0 0 32 32" width="20" height="20" aria-hidden="true">
<path d="M11 7v18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
<circle cx="11" cy="10" r="3" fill="currentColor" />
<circle cx="11" cy="16" r="3" fill="currentColor" />
<circle cx="11" cy="22" r="3" fill="currentColor" />
</svg>
<span>designpaca</span>
</a>
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
{navItems.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">
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다.
좁은 화면(<480px)에서는 이 버튼이 사라지고 메덹 안 행으로 들어간다 */}
<a
class="btn btn-quiet btn-sm lang-btn"
href={alt}
hreflang={lang === "ko" ? "en" : "ko"}
lang={lang === "ko" ? "en" : "ko"}
>
{lang === "ko" ? "English" : "한국어"}
</a>
{/* 모바일 메뉴. details 라 JS 가 0바이트에 가깝고, 열림 상태,
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
시트는 알약 전체 폭으로 열린다 — 11rem 짜리 조각 상자가 아니라 */}
<details class="nav-mobile">
<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>
<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>
</header>
<main id="main" class="page">
<slot />
</main>
<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 () {
var els = document.querySelectorAll(".reveal");
if (!("IntersectionObserver" in window) || !els.length) {
els.forEach(function (el) { el.classList.add("is-in"); });
return;
}
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
e.target.classList.add("is-in");
io.unobserve(e.target);
});
},
{ rootMargin: "200px 0px 200px 0px", threshold: 0 },
);
els.forEach(function (el) { io.observe(el); });
})();
</script>
</body>
</html>
<style>
.skip {
position: absolute;
left: var(--space-3);
top: calc(var(--space-3) * -4);
padding: var(--space-2) var(--space-3);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius-sm);
z-index: 10;
transition: transform var(--dur-quick) var(--ease-out);
}
.skip:focus { transform: translateY(calc(var(--space-3) * 5)); }
/* 상단에 붙은 풀블리드 바 대신 떠 있는 헤더 */
/* 상단에 붙은 바가 아니라 떠 있는 유리 알약 */
/* 언어 전환 링크에는 lang 속성이 붙는다(스크린리더가 "English" 를
영어로 읽어야 하므로 옳다). 그런데 [lang] 셀렉터가 타입 스케일을
통째로 재정의해서 이 링크만 16px 이 됐다 — 스케일 밖 단계가 하나 생긴다.
크기는 헤더에서 확정하고 링크는 상속만 받는다. */
.site-header {
font-size: var(--step-0);
position: fixed;
top: var(--space-3);
left: 0;
right: 0;
z-index: 3;
padding-inline: var(--gutter);
}
.header-inner {
max-width: 1180px;
margin-inline: auto;
display: flex;
align-items: center;
justify-content: space-between;
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;
align-items: center;
gap: var(--space-2);
font-weight: var(--weight-medium);
letter-spacing: -0.01em;
text-decoration: none;
}
.mark { color: var(--accent); flex: none; }
/* ---- 섹션 내비 (데스크톱) --------------------------------- */
.nav-main {
display: none;
gap: var(--space-1);
margin-inline: auto;
}
.nav-main a {
display: inline-flex;
align-items: center;
min-height: var(--control-h-sm);
padding-inline: var(--space-3);
border-radius: var(--radius-sm);
color: var(--ink-muted);
text-decoration: none;
transition: color var(--dur-instant) var(--ease-out),
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;
align-items: center;
gap: var(--space-2);
flex: none;
}
/* ---- 모바일 메뉴 ------------------------------------------
details 를 쓴 이유는 JS 를 줄이기 위해서만이 아니다. 열림 상태,
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
1′ 국소 레퍼런스(navbar.gallery → browserbase.com): 항목이 많은 사이트는
내비 마크업을 아예 복제해 분리한다. 우리는 5행이라 복제하지 않고
시트를 알약 전체 폭으로 펼친다 — 조각 상자보다 계기판답다. */
.nav-mobile { position: static; }
.nav-mobile > summary {
list-style: none;
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;
padding: var(--space-2);
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;
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>