747 lines
45 KiB
HTML
747 lines
45 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Vignette 개발 대시보드</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css">
|
|
<style>
|
|
:root {
|
|
--paper:#FBFAF8;
|
|
--paper-2:#F4F2EE;
|
|
--surface:#FFFFFF;
|
|
--hair:#E5E1DA;
|
|
--ink:#1C2A2A;
|
|
--ink-2:#5A6663;
|
|
--ink-3:#93A09C;
|
|
--accent:#3E7A6E;
|
|
--accent-deep:#316257;
|
|
--accent-tint:#EEF4F2;
|
|
--clay:#B0735C;
|
|
--pos:#2C6E49;
|
|
--warn:#8A5A00;
|
|
--crit:#A8352C;
|
|
--pos-bg:#E8F3EC;
|
|
--warn-bg:#FAF1E0;
|
|
--crit-bg:#FAEAE8;
|
|
--info-bg:#EAF1F6;
|
|
--font:"Pretendard Variable", Pretendard, -apple-system, "Apple SD Gothic Neo", "Noto Sans KR", system-ui, sans-serif;
|
|
--num:"Pretendard Variable", "JetBrains Mono", ui-monospace, monospace;
|
|
--radius:8px;
|
|
}
|
|
* { box-sizing:border-box; }
|
|
body {
|
|
margin:0;
|
|
background:var(--paper);
|
|
color:var(--ink);
|
|
font-family:var(--font);
|
|
line-height:1.55;
|
|
letter-spacing:0;
|
|
}
|
|
header {
|
|
position:sticky;
|
|
top:0;
|
|
z-index:2;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
gap:24px;
|
|
min-height:58px;
|
|
padding:0 32px;
|
|
background:rgba(255,255,255,.92);
|
|
border-bottom:1px solid var(--hair);
|
|
backdrop-filter:blur(10px);
|
|
}
|
|
.brand { display:flex; align-items:center; gap:10px; font-weight:760; }
|
|
.mark { width:24px; height:24px; border:2px solid var(--accent); border-radius:50%; position:relative; }
|
|
.mark:after { content:""; width:6px; height:6px; border-radius:50%; background:var(--clay); position:absolute; left:7px; top:6px; }
|
|
.stamp { color:var(--ink-3); font:600 12px/1 var(--num); }
|
|
main { width:min(1180px, calc(100% - 40px)); margin:0 auto; padding:42px 0 64px; }
|
|
.hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); gap:28px; align-items:start; margin-bottom:34px; }
|
|
.kicker { display:flex; align-items:center; gap:8px; color:var(--accent); font:700 12px/1.3 var(--num); letter-spacing:.08em; text-transform:uppercase; }
|
|
.kicker:before { content:""; width:6px; height:6px; border-radius:50%; background:var(--accent); }
|
|
h1 { margin:12px 0 0; font-size:30px; line-height:1.28; letter-spacing:0; }
|
|
h1 em { color:var(--accent-deep); font-style:normal; }
|
|
.lead { margin:12px 0 0; color:var(--ink-2); max-width:760px; font-size:15px; }
|
|
.summary {
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
gap:1px;
|
|
overflow:hidden;
|
|
border:1px solid var(--hair);
|
|
border-radius:12px;
|
|
background:var(--hair);
|
|
}
|
|
.metric { min-width:0; padding:18px; background:var(--surface); }
|
|
.metric span { display:block; color:var(--ink-3); font-size:12px; font-weight:650; }
|
|
.metric b { display:block; margin-top:6px; color:var(--ink); font:760 28px/1 var(--num); }
|
|
section { margin-top:30px; }
|
|
.section-head { display:flex; align-items:end; justify-content:space-between; gap:18px; margin-bottom:14px; }
|
|
.section-head h2 { margin:0; font-size:20px; line-height:1.3; }
|
|
.section-head p { margin:4px 0 0; color:var(--ink-3); font-size:13px; }
|
|
.grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
|
|
.panel {
|
|
min-width:0;
|
|
background:var(--surface);
|
|
border:1px solid var(--hair);
|
|
border-radius:12px;
|
|
padding:20px;
|
|
box-shadow:0 1px 2px rgba(28,42,42,.05);
|
|
}
|
|
.panel h3 { margin:0 0 10px; font-size:16px; line-height:1.35; }
|
|
.panel p { margin:0; color:var(--ink-2); font-size:13px; }
|
|
.tag { display:inline-flex; align-items:center; gap:6px; margin-bottom:12px; padding:3px 9px; border-radius:999px; font-size:12px; font-weight:680; }
|
|
.tag:before { content:""; width:7px; height:7px; border-radius:50%; }
|
|
.done { color:var(--pos); background:var(--pos-bg); }
|
|
.done:before { background:var(--pos); }
|
|
.doing { color:var(--warn); background:var(--warn-bg); }
|
|
.doing:before { background:var(--warn); }
|
|
.risk { color:var(--crit); background:var(--crit-bg); }
|
|
.risk:before { background:var(--crit); }
|
|
.info { color:#2E5A7A; background:var(--info-bg); }
|
|
.info:before { background:#3C7299; }
|
|
.list { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
|
|
.list li { display:grid; grid-template-columns:10px minmax(0,1fr); gap:10px; color:var(--ink-2); font-size:13px; }
|
|
.list li:before { content:""; width:6px; height:6px; margin-top:8px; border-radius:50%; background:var(--accent); }
|
|
.roadmap {
|
|
display:grid;
|
|
grid-template-columns:repeat(5,minmax(0,1fr));
|
|
gap:12px;
|
|
align-items:stretch;
|
|
}
|
|
.phase-card {
|
|
min-width:0;
|
|
background:var(--surface);
|
|
border:1px solid var(--hair);
|
|
border-radius:12px;
|
|
padding:16px;
|
|
box-shadow:0 1px 2px rgba(28,42,42,.05);
|
|
}
|
|
.phase-card.current { border-color:color-mix(in srgb, var(--accent) 42%, var(--hair)); background:linear-gradient(180deg, var(--accent-tint), var(--surface) 58%); }
|
|
.phase-card.next { border-color:color-mix(in srgb, var(--warn) 35%, var(--hair)); }
|
|
.phase-top { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:10px; }
|
|
.phase-id { font:760 13px/1 var(--num); color:var(--accent-deep); }
|
|
.phase-card h3 { margin:0; font-size:15px; line-height:1.35; }
|
|
.phase-card p { margin:8px 0 0; color:var(--ink-2); font-size:12px; }
|
|
.phase-list { display:grid; gap:6px; margin:12px 0 0; padding:0; list-style:none; }
|
|
.phase-list li { color:var(--ink-2); font-size:12px; line-height:1.45; }
|
|
.phase-list li b { color:var(--ink); font-weight:700; }
|
|
.source-note {
|
|
margin-top:14px;
|
|
padding:14px 16px;
|
|
border:1px solid var(--hair);
|
|
border-radius:12px;
|
|
background:var(--paper-2);
|
|
color:var(--ink-2);
|
|
font-size:13px;
|
|
}
|
|
.tabs {
|
|
border:1px solid var(--hair);
|
|
border-radius:12px;
|
|
background:var(--surface);
|
|
overflow:hidden;
|
|
box-shadow:0 1px 2px rgba(28,42,42,.05);
|
|
}
|
|
.tabbar {
|
|
display:flex;
|
|
gap:0;
|
|
overflow-x:auto;
|
|
border-bottom:1px solid var(--hair);
|
|
background:var(--paper-2);
|
|
}
|
|
.tab-button {
|
|
appearance:none;
|
|
border:0;
|
|
border-right:1px solid var(--hair);
|
|
background:transparent;
|
|
color:var(--ink-2);
|
|
padding:13px 16px;
|
|
font:700 13px/1.2 var(--font);
|
|
white-space:nowrap;
|
|
cursor:pointer;
|
|
}
|
|
.tab-button:hover { background:rgba(62,122,110,.08); color:var(--ink); }
|
|
.tab-button[aria-selected="true"] { background:var(--surface); color:var(--accent-deep); box-shadow:inset 0 -2px 0 var(--accent); }
|
|
.tab-panel { padding:20px; }
|
|
.tab-panel[hidden] { display:none; }
|
|
.detail-head { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:18px; align-items:start; margin-bottom:16px; }
|
|
.detail-head h3 { margin:0; font-size:18px; line-height:1.35; }
|
|
.detail-head p { margin:6px 0 0; color:var(--ink-2); font-size:13px; }
|
|
.mini-metrics { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }
|
|
.mini-metrics span { display:inline-flex; align-items:center; min-height:28px; padding:4px 9px; border:1px solid var(--hair); border-radius:999px; background:var(--paper); color:var(--ink-2); font:650 12px/1 var(--num); }
|
|
.task-list { display:grid; border:1px solid var(--hair); border-radius:12px; overflow:hidden; background:var(--hair); gap:1px; }
|
|
.task-row { display:grid; grid-template-columns:108px minmax(220px,1fr) minmax(180px,.8fr) minmax(190px,.9fr); gap:0; background:var(--surface); }
|
|
.task-row > div { padding:13px 14px; min-width:0; }
|
|
.task-row b { display:block; margin-bottom:4px; font-size:13px; color:var(--ink); }
|
|
.task-row p { margin:0; color:var(--ink-2); font-size:12px; line-height:1.45; }
|
|
.task-status { display:inline-flex; align-items:center; justify-content:center; min-width:76px; min-height:28px; padding:4px 8px; border-radius:999px; font:760 12px/1 var(--num); }
|
|
.s-done { background:var(--pos-bg); color:var(--pos); }
|
|
.s-doing { background:var(--warn-bg); color:var(--warn); }
|
|
.s-risk { background:var(--crit-bg); color:var(--crit); }
|
|
.s-plan { background:var(--info-bg); color:#2E5A7A; }
|
|
.detail-blocks { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-top:14px; }
|
|
.detail-block { border:1px solid var(--hair); border-radius:12px; padding:14px; background:var(--paper); }
|
|
.detail-block h4 { margin:0 0 8px; font-size:13px; }
|
|
.detail-block ul { margin:0; padding-left:18px; color:var(--ink-2); font-size:12px; line-height:1.6; }
|
|
.detail-block li + li { margin-top:4px; }
|
|
code { font-family:var(--num); font-size:12px; background:var(--paper-2); padding:2px 6px; border-radius:6px; }
|
|
.wide { grid-column:1 / -1; }
|
|
.commands { display:grid; gap:10px; }
|
|
pre { margin:0; overflow:auto; padding:14px; border-radius:8px; background:#1E2724; color:#F6F0EC; font:12px/1.6 var(--num); }
|
|
table { width:100%; border-collapse:collapse; background:var(--surface); border:1px solid var(--hair); border-radius:12px; overflow:hidden; display:table; }
|
|
th,td { padding:12px 14px; border-bottom:1px solid var(--hair); text-align:left; vertical-align:top; font-size:13px; }
|
|
th { color:var(--ink-3); font-weight:700; background:var(--paper-2); }
|
|
tr:last-child td { border-bottom:0; }
|
|
@media (max-width:900px) {
|
|
header { padding:0 20px; }
|
|
main { width:min(100% - 28px, 1180px); padding-top:28px; }
|
|
.hero { grid-template-columns:1fr; }
|
|
.grid { grid-template-columns:1fr; }
|
|
.roadmap { grid-template-columns:1fr; }
|
|
.detail-head { grid-template-columns:1fr; }
|
|
.mini-metrics { justify-content:flex-start; }
|
|
.task-row { grid-template-columns:1fr; }
|
|
.task-row > div { padding:12px 14px; border-bottom:1px solid var(--hair); }
|
|
.task-row > div:last-child { border-bottom:0; }
|
|
.detail-blocks { grid-template-columns:1fr; }
|
|
}
|
|
@media (max-width:520px) {
|
|
.summary { grid-template-columns:1fr; }
|
|
h1 { font-size:24px; }
|
|
.stamp { display:none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="brand"><span class="mark" aria-hidden="true"></span><span>Vignette 개발 대시보드</span></div>
|
|
<div class="stamp">2026-06-26 16:04 KST</div>
|
|
</header>
|
|
|
|
<main>
|
|
<div class="hero">
|
|
<div>
|
|
<div class="kicker">team lead review</div>
|
|
<h1>전체 로드맵 기준 현재 위치는 <em>Phase 0 종료 직전</em>이다.</h1>
|
|
<p class="lead">
|
|
이번 세션은 seed persona 결합 제거, 비개발 fail-closed 설정, compose OAuth 주입,
|
|
admin 모바일 overflow 수정, persona 인증 경계 보강, 전체 Playwright 재통과, 공개 API prod 전환까지 완료했다.
|
|
이후 로컬 5175 런타임 재기동, admin 사용자 관리 E2E 안정화, 학습자 홈 0회 기록 UX,
|
|
active session 실제 SSE 스트림 전환, 텍스트/음성 실패 턴 미저장, 직접 URL persona 우회 차단, Live2D demo 자산 제거,
|
|
AI 운영 fail-closed 보강까지 전체 Playwright로 확인했다.
|
|
2026-06-26 15:18 KST 기준 공개 API 터널을 복구한 뒤 전체 Playwright 72개가 desktop/mobile/single-run 모두 통과했다.
|
|
Cloudflare Pages production 배포 `0fe8f3cc`도 완료했고, 커스텀 도메인이 새 asset을 서빙하는 것을 확인했다.
|
|
과거 Mao/Haru Live2D demo 자산은 repo와 번들에서 제거했고, `/live2d/*` 운영 경로는 404로 차단했다.
|
|
로컬 프로세스는 prod API 8001, engine gateway 9099, cloudflared tunnel 1개만 남기고 dev API와 임시 Vite preview를 내렸다.
|
|
다만 이 HTML은 지금까지 Phase 0 상태판처럼 쓰였고, 아래에 Phase 1 이후 전체 계획을 보강했다.
|
|
공개 Google OAuth 실제 `/turn` 증명만 storageState 캡처 후 남아 있다.
|
|
</p>
|
|
</div>
|
|
<div class="summary" aria-label="현재 검증 요약">
|
|
<div class="metric"><span>Full Playwright</span><b>72 passed</b></div>
|
|
<div class="metric"><span>Pages Production</span><b>0fe8f3cc</b></div>
|
|
<div class="metric"><span>Public API</span><b>prod OK</b></div>
|
|
<div class="metric"><span>Backend unittest</span><b>20 OK</b></div>
|
|
<div class="metric"><span>Roadmap</span><b>P0-P3</b></div>
|
|
<div class="metric"><span>Decision</span><b>P0 조건부</b></div>
|
|
</div>
|
|
</div>
|
|
|
|
<section>
|
|
<div class="section-head">
|
|
<div>
|
|
<h2>전체 개발 로드맵</h2>
|
|
<p>`docs/MASTERPLAN.md`와 `docs/redteam/MASTERPLAN_REVISIONS.md`를 합친 팀장 판단용 단계표</p>
|
|
</div>
|
|
</div>
|
|
<div class="roadmap">
|
|
<article class="phase-card current">
|
|
<div class="phase-top"><span class="phase-id">Phase 0</span><span class="tag doing">closing</span></div>
|
|
<h3>기반 정렬 / 운영 안전장치</h3>
|
|
<p>인프라, 인증 경계, fail-closed, 공개 배포, 회귀 테스트 안정화.</p>
|
|
<ul class="phase-list">
|
|
<li><b>상태:</b> 대부분 완료, 공개 `/turn` proof만 남음</li>
|
|
<li><b>검증:</b> build + backend 20 + Playwright 72</li>
|
|
<li><b>운영:</b> prod API 8001, engine 9099, tunnel 1개</li>
|
|
</ul>
|
|
</article>
|
|
<article class="phase-card next">
|
|
<div class="phase-top"><span class="phase-id">Phase 1</span><span class="tag doing">next</span></div>
|
|
<h3>MVP 코어 루프</h3>
|
|
<p>1 페르소나 상담, 실제 턴 저장, 회기말 피드백, 저항 엔진 검증.</p>
|
|
<ul class="phase-list">
|
|
<li><b>DoD:</b> 로그인 → P1 상담 완주 → 피드백 수신</li>
|
|
<li><b>추가:</b> 음성 왕복 1턴 PoC 포함</li>
|
|
<li><b>증거:</b> cost/turn DB 적재 + openness 곡선 대조</li>
|
|
</ul>
|
|
</article>
|
|
<article class="phase-card">
|
|
<div class="phase-top"><span class="phase-id">Phase 2a</span><span class="tag info">planned</span></div>
|
|
<h3>3역할 / OAuth / 페르소나</h3>
|
|
<p>학습자, 교수자, 관리자 권한 분리와 페르소나 확장.</p>
|
|
<ul class="phase-list">
|
|
<li><b>DoD:</b> 타인 session 직접 접근 403</li>
|
|
<li><b>범위:</b> OAuth BFF, RBAC, 교수 검수 루프</li>
|
|
<li><b>주의:</b> visible_to와 인간 권한은 별도 레이어</li>
|
|
</ul>
|
|
</article>
|
|
<article class="phase-card">
|
|
<div class="phase-top"><span class="phase-id">Phase 2b</span><span class="tag info">planned</span></div>
|
|
<h3>음성 캐스케이드</h3>
|
|
<p>STT → 엔진 → TTS 왕복, WSS/터널 안정성, barge-in 로깅.</p>
|
|
<ul class="phase-list">
|
|
<li><b>DoD:</b> 50분 WSS 끊김 실측</li>
|
|
<li><b>범위:</b> Deepgram STT, OpenAI TTS, voice preset</li>
|
|
<li><b>리스크:</b> 실시간 레이턴시는 실측 전 완료 표기 금지</li>
|
|
</ul>
|
|
</article>
|
|
<article class="phase-card">
|
|
<div class="phase-top"><span class="phase-id">Phase 3</span><span class="tag info">planned</span></div>
|
|
<h3>교육용 파일럿 / 효과성</h3>
|
|
<p>수련생 파일럿, 사전사후 효과 측정, 재귀학습 데이터셋 산출.</p>
|
|
<ul class="phase-list">
|
|
<li><b>DoD:</b> 20명 완주 + KPI 충족</li>
|
|
<li><b>측정:</b> SUS, 자기효능감, κ/ICC, 환각률</li>
|
|
<li><b>정정:</b> IRB 게이트가 아니라 데이터 활용동의/개인정보 게이트</li>
|
|
</ul>
|
|
</article>
|
|
</div>
|
|
<div class="source-note">
|
|
정정 기준: `MASTERPLAN` 원본의 IRB 신청/승인 게이트는 `MASTERPLAN_REVISIONS` 기준으로 제거한다. Phase 2는 2a(3역할/OAuth/페르소나)와 2b(음성 캐스케이드)로 분리하고, Phase 1 DoD에는 얇은 음성 왕복 1턴 PoC를 포함한다.
|
|
</div>
|
|
</section>
|
|
|
|
<section aria-labelledby="detail-tabs-title">
|
|
<div class="section-head">
|
|
<div>
|
|
<h2 id="detail-tabs-title">상세 페이지</h2>
|
|
<p>탭별 할 일, 산출물, 검증 기준. 팀장 리뷰는 여기서 다음 작업을 고르면 된다.</p>
|
|
</div>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tabbar" role="tablist" aria-label="개발 상세 탭">
|
|
<button class="tab-button" id="tab-p0" role="tab" aria-selected="true" aria-controls="panel-p0" data-tab="p0">P0 마감</button>
|
|
<button class="tab-button" id="tab-p1" role="tab" aria-selected="false" aria-controls="panel-p1" data-tab="p1">P1 MVP</button>
|
|
<button class="tab-button" id="tab-p2a" role="tab" aria-selected="false" aria-controls="panel-p2a" data-tab="p2a">P2a 권한/페르소나</button>
|
|
<button class="tab-button" id="tab-p2b" role="tab" aria-selected="false" aria-controls="panel-p2b" data-tab="p2b">P2b 음성</button>
|
|
<button class="tab-button" id="tab-p3" role="tab" aria-selected="false" aria-controls="panel-p3" data-tab="p3">P3 파일럿</button>
|
|
<button class="tab-button" id="tab-ops" role="tab" aria-selected="false" aria-controls="panel-ops" data-tab="ops">운영/검증</button>
|
|
<button class="tab-button" id="tab-decisions" role="tab" aria-selected="false" aria-controls="panel-decisions" data-tab="decisions">결정 필요</button>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-p0" role="tabpanel" aria-labelledby="tab-p0">
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>Phase 0 마감 상세</h3>
|
|
<p>지금 단계는 구현을 더 벌리는 단계가 아니라, 운영 증거를 닫고 P1 진입 조건을 고정하는 단계다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>상태: 조건부</span><span>우선순위: P0</span><span>블로커: public /turn</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">BLOCK</span></div>
|
|
<div><b>공개 Google OAuth 실제 `/turn` proof 확보</b><p>로그인 가능한 계정으로 storageState를 저장한 뒤 public-auth-turn E2E를 실행한다.</p></div>
|
|
<div><b>산출물</b><p>`apps/web/node_modules/.tmp/public-auth.json`, public `/turn` 성공 로그</p></div>
|
|
<div><b>검증</b><p>`E2E_PUBLIC_AUTH=1` + `chromium-public-auth` 1회 통과</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-doing">CHECK</span></div>
|
|
<div><b>engine config 운영값 재확인</b><p>E2E가 admin engine config를 만질 수 있으므로 배포 전 `mode/url/model` 값을 확인한다.</p></div>
|
|
<div><b>산출물</b><p>admin AI 운영 화면 캡처 또는 config API 응답</p></div>
|
|
<div><b>검증</b><p>`claude_cli / http://127.0.0.1:9099 / gateway-default` 의도값 대조</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-done">DONE</span></div>
|
|
<div><b>과거 Live2D demo 자산 제거</b><p>Mao/Haru 샘플, Pixi/Cubism 런타임, 공개 `/live2d/*` 캐시 잔여 접근을 차단했다.</p></div>
|
|
<div><b>산출물</b><p>`apps/web/functions/live2d/[[path]].js`, SVG-only avatar</p></div>
|
|
<div><b>검증</b><p>운영 `/live2d/mao/*`, Cubism core `404`</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-done">DONE</span></div>
|
|
<div><b>프로세스 난립 정리</b><p>임시 Vite preview와 dev API를 내리고 운영 확인용 프로세스만 유지한다.</p></div>
|
|
<div><b>산출물</b><p>9099 engine, 8001 prod API, cloudflared tunnel 1개</p></div>
|
|
<div><b>검증</b><p>`Get-NetTCPConnection`에서 8000/517x/5537 비어 있음</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">GATE</span></div>
|
|
<div><b>한신대 공문/데이터 거버넌스 게이트 정리</b><p>SSO 클레임, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침을 P1 진입 전 외부 의존성으로 명확히 둔다.</p></div>
|
|
<div><b>산출물</b><p>공문 질의서, 동의 범위 체크리스트, SSO claim 표</p></div>
|
|
<div><b>검증</b><p>IRB 게이트가 아니라 데이터/SSO 게이트로 문서 전수 정합</p></div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-blocks">
|
|
<div class="detail-block">
|
|
<h4>팀장 판정</h4>
|
|
<ul>
|
|
<li>로컬 회귀와 공개 prod-safe 게이트는 통과했다.</li>
|
|
<li>Cloudflare Pages production `0fe8f3cc`이 `543d48c` 소스로 배포됐다.</li>
|
|
<li>운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 `/turn` smoke다.</li>
|
|
<li>런타임 mock/demo 자산은 운영 번들에서 제거하고, 테스트 fixture만 남긴다.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>제품/UX 완료</h4>
|
|
<ul>
|
|
<li>학습자 홈 0회 기록 상태는 fake 기록 없이 실제 서버 세션 0개로 표시한다.</li>
|
|
<li>active session은 실제 SSE token 이벤트로 자막을 쌓는다.</li>
|
|
<li>텍스트/음성 실패 턴은 transcript와 저장소에 남기지 않는다.</li>
|
|
<li>375px급 active session viewport overflow를 보정했다.</li>
|
|
<li>직접 URL persona 우회는 DB 원본 persona가 아니면 차단한다.</li>
|
|
<li>세션 화면은 SVG persona avatar만 렌더링한다.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>운영/테스트 완료</h4>
|
|
<ul>
|
|
<li>seed persona 결합을 제거하고 DB persona 기반 E2E로 전환했다.</li>
|
|
<li>비개발 런타임은 dev login, seed fallback, 기본 secret, localhost CORS를 fail-closed 한다.</li>
|
|
<li>compose OAuth/CORS/OpenAI base 설정을 API 컨테이너에 명시했다.</li>
|
|
<li>persona catalog는 비로그인 401로 인증 경계를 가진다.</li>
|
|
<li>admin 모바일 overflow, AI 운영 상태 문구, global engine config 테스트 격리를 정리했다.</li>
|
|
<li>handoff, E2E README, 이 대시보드를 최신 상태로 맞췄다.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-p1" role="tabpanel" aria-labelledby="tab-p1" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>Phase 1 MVP 코어 루프 상세</h3>
|
|
<p>목표는 “P1 내담자와 실제 상담 1회 완주 후 피드백을 받는 공개 시연 가능 상태”다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>우선순위: P0</span><span>범위: 텍스트+음성 1턴</span><span>완료보고: E2E 증거 후</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>LLMEngine 정규화 계약 고정</b><p>claude_cli와 Messages API 폴백이 같은 이벤트 스키마와 cost 규약을 반환하게 한다.</p></div>
|
|
<div><b>산출물</b><p>`delta.text`, `result`, `cost` 이벤트 계약과 contract test</p></div>
|
|
<div><b>검증</b><p>동일 입력 → 두 어댑터 정상화 출력 동일성 테스트</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>상주 `claude -p` 엔진풀 실증</b><p>`--input-format stream-json` 상주 프로세스를 회기당 1회 기동하는 구조로 검증한다.</p></div>
|
|
<div><b>산출물</b><p>TTFT p50/p95, 동시 4회기 RSS/OOM 로그</p></div>
|
|
<div><b>검증</b><p>실측 전 “빠름/검증됨” 표기 금지</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>마스킹 게이트 강제</b><p>claude_cli 경로도 Presidio/가명처리 후에만 stdin으로 들어가게 막는다.</p></div>
|
|
<div><b>산출물</b><p>엔진 어댑터 전단 마스킹 미들웨어, 우회 차단 테스트</p></div>
|
|
<div><b>검증</b><p>민감 텍스트 raw 외부 전송 경로 0개</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>P1 상담 완주 플로우</b><p>로그인, P1 선택, SSE 상담, 종료, 회기말 리뷰를 한 번에 통과시킨다.</p></div>
|
|
<div><b>산출물</b><p>turn DB 적재, session review, cost telemetry</p></div>
|
|
<div><b>검증</b><p>Playwright로 실제 로그인 → 상담 완주 → 피드백 수신</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>저항 엔진 정량 검증</b><p>공감 발화와 조언점프 발화 세트를 넣어 openness 곡선을 비교한다.</p></div>
|
|
<div><b>산출물</b><p>공감 5턴 vs 조언 5턴 DB 쿼리 결과</p></div>
|
|
<div><b>검증</b><p>공감 시 개방도 상승, 조언점프 시 하락 증거</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>음성 왕복 1턴 PoC</b><p>Phase 1에서 음성을 완전히 뒤로 미루지 않고, 얇은 1턴 왕복 증거를 만든다.</p></div>
|
|
<div><b>산출물</b><p>STT transcript, TTS audio, voice turn log</p></div>
|
|
<div><b>검증</b><p>실제 마이크/오디오 1턴 + 실패 시 턴 미저장</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-p2a" role="tabpanel" aria-labelledby="tab-p2a" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>Phase 2a 권한, OAuth, 페르소나 상세</h3>
|
|
<p>음성과 섞지 않고 3역할, 권한격리, 페르소나 확장을 독립 트랙으로 닫는다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>주차: 9-13</span><span>우선순위: P1</span><span>IDOR 검증 필수</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>OAuth BFF / HttpOnly 세션 완성</b><p>브라우저에 토큰을 저장하지 않는 BFF 세션을 역할 페이지 전체에 통일한다.</p></div>
|
|
<div><b>산출물</b><p>Auth Code + PKCE, stateful session, role claim mapping</p></div>
|
|
<div><b>검증</b><p>비로그인 401, 역할 없는 접근 403, 쿠키 보안 속성 확인</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>학습자/교수자/관리자 권한 격리</b><p>RBAC와 cohort 소유권 게이트를 모든 session/review/admin API에 적용한다.</p></div>
|
|
<div><b>산출물</b><p>권한 미들웨어, audit log, role route map</p></div>
|
|
<div><b>검증</b><p>타인 `session_id` 직접 입력 → 403 스크린샷</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>`visible_to`와 인간 권한 레이어 분리</b><p>AI 정보비대칭과 사용자 권한을 섞지 않는다. 인간 조회는 RBAC와 cohort AND 게이트다.</p></div>
|
|
<div><b>산출물</b><p>RLS 정책, enforcement middleware, 테스트 matrix</p></div>
|
|
<div><b>검증</b><p>AI-only hidden field가 인간 조회 권한을 열지 않음</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>페르소나 P2/P3 교수 검수 루프</b><p>P1 단일 시드에서 P2/P3로 확장하되 교수 검수 상태와 공개 가능 상태를 분리한다.</p></div>
|
|
<div><b>산출물</b><p>persona review workflow, versioning, approval state</p></div>
|
|
<div><b>검증</b><p>미승인 persona는 학습자 시작 불가</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>SAML SP 스캐폴드 선반영</b><p>1차는 Google OIDC로 가되 한신대 SSO 전환 지점을 Authlib provider로 남긴다.</p></div>
|
|
<div><b>산출물</b><p>provider abstraction, SAML placeholder config</p></div>
|
|
<div><b>검증</b><p>Google OIDC 경로 회귀 없이 provider 교체 가능</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-p2b" role="tabpanel" aria-labelledby="tab-p2b" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>Phase 2b 음성 캐스케이드 상세</h3>
|
|
<p>음성은 “되게 보이는 UI”가 아니라, 전송, STT, EOT, 엔진, TTS, 저장까지 끊기지 않아야 한다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>주차: 13-17</span><span>WSS 50분</span><span>실측 전 완료 금지</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>voice gateway WebSocket</b><p>브라우저 AudioWorklet PCM을 WSS로 보내고, TTS 오디오 청크를 내려받는다.</p></div>
|
|
<div><b>산출물</b><p>`voice.chanpaca.net` 또는 단일 도메인 WSS 경로</p></div>
|
|
<div><b>검증</b><p>50분 WSS 양방향 스트림 끊김 실측</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>Deepgram STT + EOT</b><p>interim/final transcript와 상담 튜닝 침묵 임계, semantic EOT를 연결한다.</p></div>
|
|
<div><b>산출물</b><p>transcript timestamp, silence_ms, final segment</p></div>
|
|
<div><b>검증</b><p>침묵/말 끊김/짧은 대답 케이스 회귀</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>OpenAI TTS voice preset 추상화</b><p>persona별 voice_id와 prosody instruction을 저장하고 교체 가능하게 한다.</p></div>
|
|
<div><b>산출물</b><p>`voice_persona_map`, provider-agnostic params</p></div>
|
|
<div><b>검증</b><p>P1/P2/P3 voice preset 분리, fallback 정상</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>barge-in / paralinguistic logging</b><p>중간 끼어들기, 침묵, 말속도 같은 음성 메타를 턴 로그와 분리 저장한다.</p></div>
|
|
<div><b>산출물</b><p>`turn_paralinguistic` 또는 turns nullable 확장</p></div>
|
|
<div><b>검증</b><p>barge-in 케이스가 audit 가능하게 남음</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>s2s 2차 PoC 판단</b><p>캐스케이드는 1차로 확정하고, s2s는 transcript 보존/몰입감 기준으로 별도 판단한다.</p></div>
|
|
<div><b>산출물</b><p>캐스케이드 vs s2s 비교 메모</p></div>
|
|
<div><b>검증</b><p>윤찬 결정 항목으로 기록, 람다 단독 폐기 금지</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-p3" role="tabpanel" aria-labelledby="tab-p3" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>Phase 3 교육용 파일럿 / 효과성 상세</h3>
|
|
<p>IRB 승인 게이트가 아니라 교육 활용 파일럿과 데이터/개인정보 게이트로 관리한다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>주차: 18-20+</span><span>표본: 20명</span><span>first measure</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>20명 교육용 파일럿 운영</b><p>수련생이 실제 계정으로 세션을 완주하고 사전/사후 설문을 남긴다.</p></div>
|
|
<div><b>산출물</b><p>참여자별 session, pre/post, consent state</p></div>
|
|
<div><b>검증</b><p>20명 완주율, 중도중단 사유, 세션 로그 대조</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>효과성/KPI 측정</b><p>SUS, 자기효능감, κ/ICC, 환각률, 피드백 타당도를 파일럿에서 처음 측정한다.</p></div>
|
|
<div><b>산출물</b><p>KPI report, metric query, anonymized export</p></div>
|
|
<div><b>검증</b><p>SUS≥80, κ≥0.6/ICC≥0.75 같은 기준 충족 여부</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>재귀학습 데이터셋 1차 산출</b><p>라벨링/검수/κ 누적은 영속 워크플로우로 관리하고 fine-tuning 여부는 별도 결정한다.</p></div>
|
|
<div><b>산출물</b><p>export_manifest, JSONL, annotation checkpoint</p></div>
|
|
<div><b>검증</b><p>train/test 누수 없음, 원문/가명본 경계 확인</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-plan">TODO</span></div>
|
|
<div><b>개인정보/동의 감사</b><p>미성년 원본 활용동의, 가명처리, 해외 전송 고지, 삭제/철회 절차를 실제 운영 기준으로 점검한다.</p></div>
|
|
<div><b>산출물</b><p>privacy checklist, audit log, withdrawal runbook</p></div>
|
|
<div><b>검증</b><p>동의 철회 후 접근/학습 사용 차단 재현</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-ops" role="tabpanel" aria-labelledby="tab-ops" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>운영 / 검증 상세</h3>
|
|
<p>실제 운영 도메인에 반영됐는지, 서버가 하나로 정리됐는지, 완료라고 말할 증거가 있는지 확인하는 탭이다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>웹: 200</span><span>API: prod OK</span><span>Live2D: 404</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-done">DONE</span></div>
|
|
<div><b>운영 웹 asset 확인</b><p>커스텀 도메인이 최신 JS/CSS asset을 서빙한다.</p></div>
|
|
<div><b>산출물</b><p>`index-D1Z5DH5J.js`, `index-BA9-xxfn.css`</p></div>
|
|
<div><b>검증</b><p>`Invoke-WebRequest https://vignette.chanpaca.net/`</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-done">DONE</span></div>
|
|
<div><b>공개 API prod health</b><p>터널은 prod API 8001을 보고, API는 DB와 engine을 모두 확인한다.</p></div>
|
|
<div><b>산출물</b><p>`environment:"prod"`, `db:true`, `engine:true`</p></div>
|
|
<div><b>검증</b><p>`https://api-vignette.chanpaca.net/health`</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-done">DONE</span></div>
|
|
<div><b>서버 정리 상태</b><p>운영 확인용 프로세스만 유지하고 임시 dev server는 내린다.</p></div>
|
|
<div><b>산출물</b><p>8001 API, 9099 engine, 20241 cloudflared</p></div>
|
|
<div><b>검증</b><p>`Get-NetTCPConnection` 대상 포트 확인</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">WATCH</span></div>
|
|
<div><b>재부팅 후 런타임 재기동</b><p>Windows 재부팅/업데이트 후에는 engine gateway와 prod API, cloudflared를 같이 올려야 한다.</p></div>
|
|
<div><b>산출물</b><p>runbook 또는 scheduled task</p></div>
|
|
<div><b>검증</b><p>재기동 후 public health와 `/turn` smoke 재확인</p></div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-blocks">
|
|
<div class="detail-block">
|
|
<h4>검증 실행 결과</h4>
|
|
<ul>
|
|
<li>Web typecheck: `npm run typecheck` passed.</li>
|
|
<li>Backend unittest: `app.test_runtime_policy`, `app.test_session_turn_persistence`, `engine_gateway.test_gateway_model` 20 OK.</li>
|
|
<li>Python compile: `python -m compileall app engine_gateway` passed.</li>
|
|
<li>Web build: `npm run build` passed.</li>
|
|
<li>Full E2E: `PLAYWRIGHT_HOST=127.0.0.1 npx playwright test` 72 passed.</li>
|
|
<li>Latest focused E2E: learner + session-layout + settings + admin 37 passed.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>공개 운영 증거</h4>
|
|
<ul>
|
|
<li>Pages deploy: `0fe8f3cc`, source `543d48c`, branch `main`.</li>
|
|
<li>Custom domain assets: `index-D1Z5DH5J.js`, `index-BA9-xxfn.css`.</li>
|
|
<li>Public readiness: `public-auth-turn.spec.ts --grep production-safe` 1 passed.</li>
|
|
<li>Public login: `auth.spec.ts --grep public login` 1 passed.</li>
|
|
<li>Legacy Live2D routes: Mao/Haru/Cubism core 404.</li>
|
|
<li>Persona auth boundary: local/public unauth `GET /personas` 401.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>다음 실행 명령</h4>
|
|
<ul>
|
|
<li>`npx playwright codegen https://vignette.chanpaca.net/login --save-storage=.\node_modules\.tmp\public-auth.json`</li>
|
|
<li>`$env:E2E_PUBLIC_AUTH="1"`</li>
|
|
<li>`$env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"`</li>
|
|
<li>`.\node_modules\.bin\playwright.cmd test e2e/public-auth-turn.spec.ts --project=chromium-public-auth`</li>
|
|
<li>`powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1`</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-panel" id="panel-decisions" role="tabpanel" aria-labelledby="tab-decisions" hidden>
|
|
<div class="detail-head">
|
|
<div>
|
|
<h3>윤찬 결정 필요 상세</h3>
|
|
<p>람다가 단독으로 정하면 월권인 항목이다. 결정되기 전에는 “완료”가 아니라 조건부로 둔다.</p>
|
|
</div>
|
|
<div class="mini-metrics"><span>결정 5개</span><span>조건부 GO 사유</span></div>
|
|
</div>
|
|
<div class="task-list">
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">DECIDE</span></div>
|
|
<div><b>s2s 2차 PoC 채택 여부</b><p>캐스케이드 1차는 확정. s2s는 몰입감과 transcript 보존 기준으로 유지/폐기를 정한다.</p></div>
|
|
<div><b>선택지</b><p>캐스케이드만 / s2s 2차 PoC 유지</p></div>
|
|
<div><b>영향</b><p>음성 아키텍처, 전송 비용, 로그 보존 방식</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">DECIDE</span></div>
|
|
<div><b>fast-loop 외부 API 허용 여부</b><p>로컬 상주 분류기를 기본으로 하되, 외부 Haiku/Solar API 폴백을 허용할지 정한다.</p></div>
|
|
<div><b>선택지</b><p>로컬 전용 / 외부 폴백 허용</p></div>
|
|
<div><b>영향</b><p>데이터주권, 레이턴시, 장애 대응</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">DECIDE</span></div>
|
|
<div><b>재귀학습 fine-tuning 범위</b><p>1차는 few-shot 자동갱신만 할지, fine-tuning까지 포함할지 결정한다.</p></div>
|
|
<div><b>선택지</b><p>few-shot only / fine-tuning 포함</p></div>
|
|
<div><b>영향</b><p>동의서, 데이터셋 품질, 운영 리스크</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">DECIDE</span></div>
|
|
<div><b>자유연습 기본값</b><p>수정안은 기본 ON + 피드백 권장 배지 + 교수자 토글을 제안한다.</p></div>
|
|
<div><b>선택지</b><p>기본 ON / 교수자 승인 후 ON</p></div>
|
|
<div><b>영향</b><p>학습자 자율성, 평가 품질, 안전 정책</p></div>
|
|
</div>
|
|
<div class="task-row">
|
|
<div><span class="task-status s-risk">DECIDE</span></div>
|
|
<div><b>추가 축어록 수급 규모/일정</b><p>n=1 시드의 한계를 해소하려면 추가 사례 수급 규모와 일정을 정해야 한다.</p></div>
|
|
<div><b>선택지</b><p>N건 목표/일정 확정</p></div>
|
|
<div><b>영향</b><p>평가 타당도, 페르소나 다양성, Phase 3 측정력</p></div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-blocks">
|
|
<div class="detail-block">
|
|
<h4>완료라고 말하는 기준</h4>
|
|
<ul>
|
|
<li>실제 로그인 기반 E2E 증거가 있어야 한다.</li>
|
|
<li>DB 쿼리나 로그로 저장/권한/비용이 확인되어야 한다.</li>
|
|
<li>시각 확인만으로 기능 완료를 선언하지 않는다.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>문서 기준</h4>
|
|
<ul>
|
|
<li>`MASTERPLAN`은 원본 보존 문서다.</li>
|
|
<li>`MASTERPLAN_REVISIONS`가 Phase/IRB/음성 정정 기준이다.</li>
|
|
<li>`dev_dashboard.html`은 팀장 판단용 현재판이다.</li>
|
|
</ul>
|
|
</div>
|
|
<div class="detail-block">
|
|
<h4>운영 원칙</h4>
|
|
<ul>
|
|
<li>mock/demo 자산은 운영 번들에서 제거한다.</li>
|
|
<li>ignored runtime 파일은 커밋하지 않는다.</li>
|
|
<li>서버는 목적별 1개씩만 유지한다.</li>
|
|
<li>`frontenddesign` 스킬이 있으면 먼저 쓰고, 없으면 `DESIGN_CONCEPT`를 SSOT로 따른다.</li>
|
|
<li>UX는 `docs/DESIGN_CONCEPT.md`를 기준으로 하고, fake 기록/통계는 금지한다.</li>
|
|
<li>OAuth secret은 출력하지 않고 환경 파일/인프라 설정에만 둔다.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
<script>
|
|
const tabButtons = Array.from(document.querySelectorAll("[role='tab'][data-tab]"));
|
|
const tabPanels = Array.from(document.querySelectorAll("[role='tabpanel']"));
|
|
function activateTab(nextButton) {
|
|
tabButtons.forEach((button) => {
|
|
button.setAttribute("aria-selected", String(button === nextButton));
|
|
button.tabIndex = button === nextButton ? 0 : -1;
|
|
});
|
|
tabPanels.forEach((panel) => {
|
|
panel.hidden = panel.id !== nextButton.getAttribute("aria-controls");
|
|
});
|
|
}
|
|
tabButtons.forEach((button, index) => {
|
|
button.addEventListener("click", () => activateTab(button));
|
|
button.addEventListener("keydown", (event) => {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault();
|
|
let nextIndex = index;
|
|
if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabButtons.length) % tabButtons.length;
|
|
if (event.key === "ArrowRight") nextIndex = (index + 1) % tabButtons.length;
|
|
if (event.key === "Home") nextIndex = 0;
|
|
if (event.key === "End") nextIndex = tabButtons.length - 1;
|
|
tabButtons[nextIndex].focus();
|
|
activateTab(tabButtons[nextIndex]);
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|