feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E

- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출
- 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동
- safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거
- PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록)
- tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과
- 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체
- 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7
- 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
This commit is contained in:
Yun Chan 2026-08-23 14:33:15 +09:00
parent 72337b7ee0
commit 14cb138e1a
133 changed files with 2139 additions and 208 deletions

View file

@ -2,8 +2,12 @@
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>두레대학 수강신청 · 시간표</title>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<meta property="og:type" content="website" />
<meta property="og:title" content="두레대학 수강신청 — 시간표 도장 앱" />
<meta property="og:url" content="https://designpaca.chanpaca.net/work/dure-enrollment/" />
@ -18,7 +22,7 @@
<meta property="og:title" content="두레대학 수강신청 · 시간표" />
<meta property="og:description" content="시간표에 얹히는 수강신청." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#FBFAF7" />
<meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@ -31,10 +35,10 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=IBM+Plex+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=18" />
<link rel="stylesheet" href="./styles/main.css?v=22" />
<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='%23FBFAF7'/%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%2317202E' stroke-width='2'/%3E%3Crect x='13' y='13' width='6' height='6' fill='%23C14B2B'/%3E%3C/svg%3E"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23fafafa'/%3E%3Crect x='6.5' y='6.5' width='19' height='19' rx='4' fill='%23c2410c'/%3E%3Crect x='9.5' y='10' width='5.5' height='4.5' rx='1' fill='%23ffffff'/%3E%3Crect x='16' y='10' width='6.5' height='4.5' rx='1' fill='%23ffffff' opacity='0.8'/%3E%3Crect x='9.5' y='15.8' width='5.5' height='9.2' rx='1' fill='%23ffffff' opacity='0.9'/%3E%3Crect x='16' y='15.8' width='6.5' height='6.2' rx='1' fill='%23ffffff' opacity='0.75'/%3E%3C/svg%3E"
/>
</head>
@ -48,9 +52,15 @@
<span class="brand-seal" aria-hidden="true">申</span>
두레대학 수강신청
</a>
<p class="credits" aria-live="polite">
신청 학점 <strong id="credit-now">0</strong> / 18
</p>
<div class="appbar-actions">
<p class="kbd-hint"><kbd>1</kbd>–<kbd>6</kbd> 화면 전환 · <kbd>/</kbd> 과목 검색</p>
<p class="credits" aria-live="polite">
신청 학점 <strong id="credit-now">0</strong> / 18
</p>
<button type="button" class="icon-btn" id="print-btn" aria-label="현재 화면 인쇄">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/></svg>
</button>
</div>
</div>
<nav class="tabs" aria-label="화면">
<button type="button" class="tab is-current" data-view="catalog" aria-current="page">과목 찾기</button>
@ -259,7 +269,58 @@
</div>
</dialog>
<script src="calc.js"></script>
<!-- ── 바텀 내비(모바일) — 엄지 존의 앱 문법. 넷 탭 + 더보기 시트 ── -->
<nav class="bottom-nav" aria-label="화면">
<button type="button" class="bnav-item is-current" data-view="catalog" aria-current="page">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>과목 찾기</span>
</button>
<button type="button" class="bnav-item" data-view="starred">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
<span>관심</span>
</button>
<button type="button" class="bnav-item" data-view="enrollments">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/></svg>
<span>신청 내역</span>
</button>
<button type="button" class="bnav-item" data-view="credits">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
<span>이수</span>
</button>
<button type="button" class="bnav-item" data-more aria-expanded="false" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="5" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="19" cy="5" r="1"/><circle cx="5" cy="19" r="1"/><circle cx="12" cy="19" r="1"/><circle cx="19" cy="19" r="1"/></svg>
<span>더보기</span>
</button>
</nav>
<!-- ── 더보기 시트 — 남은 두 화면의 원장 ── -->
<dialog id="more-dialog" class="modal more-sheet" aria-labelledby="more-h">
<div class="more-head">
<h2 id="more-h">전체 화면</h2>
<button type="button" class="modal-close" data-close aria-label="닫기">
<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>
</div>
<div class="more-list">
<p class="more-group">과목</p>
<button type="button" class="more-item" data-view="catalog">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>과목 찾기</button>
<button type="button" class="more-item" data-view="starred">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>관심 과목</button>
<button type="button" class="more-item" data-view="enrollments">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/></svg>신청 내역</button>
<button type="button" class="more-item" data-view="credits">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>이수 현황</button>
<p class="more-group">학사</p>
<button type="button" class="more-item" data-view="archive">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 14h6"/><path d="M9 10h6"/></svg>과목 총람</button>
<button type="button" class="more-item" data-view="notices">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>공지 · 변경</button>
</div>
</dialog>
<script src="calc.js?v=2"></script>
<script>
/*
수강신청 상태 기계 — 겹침 검사, 학점 한도, 여석 감소까지 전부 진짜.
@ -303,39 +364,127 @@
const hhmm = (t) => `${Math.floor(t)}:${t % 1 ? "30" : "00"}`;
const timeLabel = (c) => c.days.map(fmtDay).join("·") + " " + hhmm(c.start) + "–" + hhmm(c.end);
// ── 탭(뷰) 전환 ──
// ── 뷰 전환(중앙) — 탭·바텀내비·더보기·히스토리가 같은 길을 쓴다 ──
const views = document.querySelectorAll(".view");
const gotoView = (name) => {
const currentView = () => (history.state && history.state.v) || "catalog";
const setCurrentView = (name) => {
document.querySelectorAll(".tab").forEach((b) => {
const on = b.dataset.view === name;
b.classList.toggle("is-current", on);
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
});
document.querySelectorAll(".bnav-item[data-view]").forEach((b) => {
const on = b.dataset.view === name;
b.classList.toggle("is-current", on);
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
});
const inMore = !document.querySelector(`.bnav-item[data-view="${name}"]`);
document.querySelector("[data-more]").classList.toggle("is-current", inMore);
document.querySelectorAll(".more-item[data-view]").forEach((b) => b.classList.toggle("is-current", b.dataset.view === name));
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + name));
$("view-" + name).focus({ preventScroll: true });
};
document.querySelectorAll(".tab").forEach((btn) => {
const gotoView = (name, push = true) => {
if (push) history.pushState({ v: name }, "");
setCurrentView(name);
};
history.replaceState({ v: currentView() }, "");
document.querySelectorAll(".tab, .bnav-item[data-view]").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.view));
});
document.querySelectorAll("[data-goto]").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.goto));
});
// ── 겹침 검사 ──
const conflictsWith = (course) =>
[...enrolled]
.map((id) => COURSES.find((c) => c.id === id))
.filter((o) => DURE.isOverlap(course, o))
.map((o) => o.name);
// ── 다이얼로그 × 히스토리 — 열림마다 pushState, 뒤로 가기는 시트 닫기→이전 화면 ──
const DIALOGS = ["course-dialog", "more-dialog"];
let suppressPop = false;
let popClosing = false;
const openDialog = (dlg) => {
if (dlg.open) return;
history.pushState({ v: currentView(), d: dlg.id }, "");
dlg.showModal();
if (dlg.id === "more-dialog") document.querySelector("[data-more]").setAttribute("aria-expanded", "true");
};
DIALOGS.forEach((id) => {
const dlg = document.getElementById(id);
dlg.addEventListener("close", () => {
if (dlg.id === "more-dialog") document.querySelector("[data-more]").setAttribute("aria-expanded", "false");
if (popClosing) return;
if (history.state && history.state.d === dlg.id) { suppressPop = true; history.back(); }
});
});
let pendingMoreSelect = null;
window.addEventListener("popstate", () => {
if (suppressPop) { suppressPop = false; return; }
const s = history.state || {};
popClosing = true;
DIALOGS.forEach((id) => { const d = document.getElementById(id); if (d.open && s.d !== id) d.close(); });
popClosing = false;
if (s.v) setCurrentView(s.v);
if (pendingMoreSelect) { const v = pendingMoreSelect; pendingMoreSelect = null; gotoView(v, true); }
});
document.querySelector("[data-more]").addEventListener("click", () => openDialog($("more-dialog")));
document.querySelectorAll(".more-item[data-view]").forEach((b) => {
b.addEventListener("click", () => {
pendingMoreSelect = b.dataset.view;
history.back();
});
});
const credits = () => [...enrolled].reduce((sum, id) => sum + COURSES.find((c) => c.id === id).credits, 0);
const creditsOf = (dept) => DURE.credits([...enrolled].map((id) => COURSES.find((c) => c.id === id)).filter((c) => c.dept === dept));
// ── 겹침 검사는 calc.js 의 canEnroll 이 일괄 판정한다(아래 모달·실행취소가 공유) ──
const enrolledList = () => [...enrolled].map((id) => COURSES.find((c) => c.id === id));
const setStatus = (msg, isWarn) => {
const credits = () => DURE.credits(enrolledList());
const creditsOf = (dept) => DURE.credits(enrolledList().filter((c) => c.dept === dept));
let undoCourse = null; // 방금 취소한 과목 — 실행취소의 재료. 다른 취소가 오면 갈음된다
const appendUndo = (el) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "undo-btn";
btn.textContent = "실행취소";
el.append(" ", btn);
};
const setStatus = (msg, isWarn, undo) => {
const el = $("status");
el.textContent = msg;
el.classList.toggle("is-warn", !!isWarn);
if (undo) appendUndo(el);
};
const setEnStatus = (msg, undo) => {
const el = $("en-status");
el.textContent = msg;
if (undo) appendUndo(el);
};
const announceCancel = (c, where) => {
undoCourse = c;
const msg = `${c.name} 취소 · ${credits()}학점`;
if (where === "en") setEnStatus(msg, true);
else setStatus(msg, false, true);
};
// 실행취소 — 취소 직후의 조건만 다시 심사해 되돌린다. 사이에 다른 신청이 끼면 정당하게 막힌다
const tryUndo = () => {
const c = undoCourse;
undoCourse = null;
if (!c) return;
const verdict = DURE.canEnroll(c, enrolledList(), credits(), MAX_CREDITS);
if (verdict.ok) {
c.seats -= 1;
enrolled.add(c.id);
setStatus(`${c.name} 재신청 · ${timeLabel(c)} · ${credits()}학점`, false);
renderAll();
} else if (verdict.reason === "overlap") {
setStatus(`재신청 불가 — ${verdict.names.join(", ")}와(과) 시간이 겹칩니다.`, true);
} else if (verdict.reason === "credits") {
setStatus(`재신청 불가 — 기준 학점(${MAX_CREDITS})을 초과합니다.`, true);
} else {
setStatus("재신청 불가 — 정원이 마감되었습니다.", true);
}
};
document.addEventListener("click", (e) => {
if (e.target.closest(".undo-btn")) tryUndo();
});
// ── 과목 행(과목 찾기 · 관심 과목 공용 문법) ──
const courseRow = (c) => {
@ -463,7 +612,7 @@
const c = COURSES.find((x) => x.id === btn.dataset.cancel);
enrolled.delete(c.id);
c.seats += 1;
$("en-status").textContent = `${c.name} 취소 · ${credits()}학점`;
announceCancel(c, "en");
renderAll();
});
@ -482,23 +631,12 @@
(a, r) => ({ need: a.need + r.need, done: a.done + r.done, now: a.now + r.now }),
{ need: 0, done: 0, now: 0 }
);
{
const done = sum.done, now = sum.now, need = sum.need;
$("cr-stats").innerHTML =
`<div>기준 <strong>${need}학점</strong></div>` +
`<div>이수 <strong>${done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${now}학점</strong></div>` +
`<div>합계 <strong>${done + now}학점</strong></div>` +
`<div>잔여 <strong>${need - done - now}학점</strong></div>`;
}
{
$("cr-stats").innerHTML =
`<div>기준 <strong>${sum.need}학점</strong></div>` +
`<div>이수 <strong>${sum.done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${sum.now}학점</strong></div>` +
`<div>합계 <strong>${sum.done + sum.now}학점</strong></div>` +
`<div>잔여 <strong>${sum.need - sum.done - sum.now}학점</strong></div>`;
}
$("cr-stats").innerHTML =
`<div>기준 <strong>${sum.need}학점</strong></div>` +
`<div>이수 <strong>${sum.done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${sum.now}학점</strong></div>` +
`<div>합계 <strong>${sum.done + sum.now}학점</strong></div>` +
`<div>잔여 <strong>${sum.need - sum.done - sum.now}학점</strong></div>`;
{
const pct = Math.min(100, Math.round(((sum.done + sum.now) / sum.need) * 100));
document.getElementById("cr-total-bar").style.width = pct + "%";
@ -699,24 +837,25 @@
} else {
apply.hidden = false;
apply.disabled = true;
const clash = conflictsWith(c);
if (c.seats === 0) {
// 판정은 calc.js 의 순수 함수가 내린다 — 화면은 근거를 옮기기만 한다
const verdict = DURE.canEnroll(c, enrolledList(), credits(), MAX_CREDITS);
if (verdict.ok) {
apply.disabled = false;
} else if (verdict.reason === "full") {
warn.textContent = "정원이 마감되었습니다. 다음 학기를 기다리거나 분반을 확인하세요.";
warn.hidden = false;
} else if (clash.length) {
warn.textContent = `${clash.join(", ")}와(과) 시간이 겹칩니다. 둘 중 하나만 담을 수 있습니다.`;
warn.hidden = false;
} else if (credits() + c.credits> MAX_CREDITS) {
warn.textContent = `기준 학점(${MAX_CREDITS})을 초과합니다. 지금 ${credits()}학점입니다.`;
} else if (verdict.reason === "overlap") {
warn.textContent = `${verdict.names.join(", ")}와(과) 시간이 겹칩니다. 둘 중 하나만 담을 수 있습니다.`;
warn.hidden = false;
} else {
apply.disabled = false;
warn.textContent = `기준 학점(${MAX_CREDITS})을 초과합니다. 지금 ${credits()}학점입니다.`;
warn.hidden = false;
}
}
const star = $("cd-star");
star.setAttribute("aria-pressed", String(starred.has(c.id)));
star.textContent = starred.has(c.id) ? "관심 빼기" : "관심 남기기";
dialog.showModal();
openDialog(dialog);
};
document.addEventListener("click", (e) => {
@ -735,9 +874,10 @@
$("cd-cancel").addEventListener("click", () => {
if (!current || !enrolled.has(current.id)) return;
enrolled.delete(current.id);
current.seats += 1;
setStatus(`${current.name} 취소 · ${credits()}학점`, false);
const c = current;
enrolled.delete(c.id);
c.seats += 1;
announceCancel(c);
dialog.close();
renderAll();
});
@ -761,6 +901,23 @@
dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); });
dialog.querySelector("[data-close]").addEventListener("click", () => dialog.close());
// ── 단축키 — 숫자키 화면 전환, / 검색. 입력 중에는 쓰지 않는다 ──
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.key === "/") {
e.preventDefault();
gotoView("catalog");
$("q").focus();
} else if (/^[1-6]$/.test(e.key)) {
gotoView(["catalog", "starred", "enrollments", "credits", "archive", "notices"][+e.key - 1]);
}
});
// ── 인쇄 — 활성 화면(강의계획서 모달이 열려 있으면 그 서류)을 종이로 ──
$("print-btn").addEventListener("click", () => window.print());
// ── 시작 ──
buildAxis();
renderAll();