vignette/apps/web/index.html
Yun Chan 4511383cd9 전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
2026-07-27 14:25:24 +09:00

264 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta
name="description"
content="Vignette는 상담 수련생이 AI 내담자와 회기를 연습하고, 평가 피드백과 사례개념화 워크시트를 확인하는 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼입니다."
/>
<meta name="robots" content="index,follow,max-snippet:160,max-image-preview:large" />
<link rel="canonical" href="https://vignette.chanpaca.net/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.svg" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:site_name" content="Vignette" />
<meta property="og:title" content="Vignette · AI 심리상담 시뮬레이션 훈련" />
<meta
property="og:description"
content="AI 내담자 회기 연습, 평가 피드백, 사례개념화 워크시트를 한 흐름으로 제공하는 상담 교육 플랫폼."
/>
<meta property="og:url" content="https://vignette.chanpaca.net/" />
<meta
property="og:image"
content="https://vignette.chanpaca.net/design-elements/clinical-paper-ambient.png"
/>
<meta property="og:image:width" content="1672" />
<meta property="og:image:height" content="941" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vignette · AI 심리상담 시뮬레이션 훈련" />
<meta
name="twitter:description"
content="상담 수련생을 위한 AI 내담자 회기 연습과 평가 피드백 플랫폼."
/>
<meta
name="twitter:image"
content="https://vignette.chanpaca.net/design-elements/clinical-paper-ambient.png"
/>
<title>Vignette · 상담 시뮬레이션</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Vignette",
"url": "https://vignette.chanpaca.net/",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web",
"inLanguage": "ko-KR",
"description": "상담 수련생이 AI 내담자와 회기를 연습하고 평가 피드백과 사례개념화 워크시트를 확인하는 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼.",
"audience": {
"@type": "EducationalAudience",
"educationalRole": "counseling trainee"
},
"provider": {
"@type": "Organization",
"name": "Vignette"
}
}
</script>
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) -->
<!-- 렌더 블로킹 CDN 스타일시트다. 연결 수립(DNS+TCP+TLS)을 미리 열어 LCP를 줄인다.
근본 해결은 Pretendard self-host 이며 별도 과제로 남긴다. -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css"
/>
<script>
(function () {
var errors = [];
var loadedAt = Date.now();
var sentDiagnostics = {};
function short(value) {
return String(value || "").slice(0, 900);
}
function assetLabel() {
var scripts = Array.prototype.slice.call(document.scripts || []);
var match = scripts
.map(function (script) {
return script.src || "";
})
.filter(function (src) {
return src.indexOf("/assets/index-") !== -1 && /\.js($|\?)/.test(src);
})
.pop();
return match ? match.split("/").pop() : "module-not-discovered";
}
function remember(kind, detail) {
errors.push({
kind: kind,
detail: short(detail),
at: new Date().toISOString(),
});
if (errors.length > 5) errors.shift();
render("runtime-error");
}
function visibleCount() {
var nodes = Array.prototype.slice.call(
document.querySelectorAll(
"main,header,h1,h2,p,a,button,input,select,article,section,.vg-shell,.ad-head,.ad-diagnostic,.lg-root",
),
);
return nodes.filter(function (node) {
var rect = node.getBoundingClientRect();
var style = window.getComputedStyle(node);
return (
rect.width > 1 &&
rect.height > 1 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}).length;
}
function diagnosticApiBase() {
var host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
if (host === "vnet.18ka.net") {
return "https://api-vnet.18ka.net";
}
return "/api";
}
function sendDiagnostic(reason, textLength, visible) {
var key = reason + ":" + window.location.pathname;
if (sentDiagnostics[key]) return;
sentDiagnostics[key] = true;
var root = document.getElementById("root");
var payload = {
reason: reason,
path: window.location.pathname,
href: window.location.href,
asset: assetLabel(),
elapsed_ms: Date.now() - loadedAt,
body_text_length: textLength,
root_text_length: root && root.innerText ? root.innerText.trim().length : 0,
root_child_count: root ? root.childElementCount : 0,
visible_nodes: visible,
document_ready_state: document.readyState,
viewport: window.innerWidth + "x" + window.innerHeight,
user_agent: short(window.navigator && window.navigator.userAgent),
errors: errors.slice(-5),
};
var url = diagnosticApiBase().replace(/\/$/, "") + "/client-diagnostics";
var body = JSON.stringify(payload);
try {
if (window.navigator && navigator.sendBeacon) {
var blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon(url, blob)) return;
}
} catch (error) {
/* fetch fallback below */
}
try {
fetch(url, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: body,
}).catch(function () {});
} catch (error) {
/* diagnostics must never affect app boot */
}
}
function render(reason) {
var path = window.location.pathname;
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
var visible = visibleCount();
var hasAdminContent =
!isAdmin ||
Boolean(
document.querySelector(
".ad-head,.ad-diagnostic,.ad-status,.ad-section,.ad-panel,.aic,[data-testid='admin-ai-page']",
),
);
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
var old = document.getElementById("vignette-boot-diagnostic");
if (old) old.remove();
return;
}
sendDiagnostic(reason, text.length, visible);
var panel = document.getElementById("vignette-boot-diagnostic");
if (!panel) {
if (!document.body) {
window.setTimeout(function () {
render(reason);
}, 100);
return;
}
panel = document.createElement("section");
panel.id = "vignette-boot-diagnostic";
panel.setAttribute("role", "alert");
panel.style.cssText =
"position:fixed;z-index:2147483647;inset:24px auto auto 24px;max-width:min(760px,calc(100vw - 48px));padding:18px;border:1px solid #d59b47;border-radius:8px;background:#fffaf0;color:#21312e;box-shadow:0 18px 48px rgba(10,20,18,.18);font:13px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;white-space:normal;";
document.body.appendChild(panel);
}
var lines = [
"<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>",
"<div>React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.</div>",
"<dl style=\"display:grid;grid-template-columns:132px minmax(0,1fr);gap:0;margin:12px 0 0;border:1px solid rgba(0,0,0,.12);border-radius:8px;overflow:hidden\">",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">reason</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
reason +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">path</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
path +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">asset</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
assetLabel() +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">bodyText</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
text.length +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">visibleNodes</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
visible +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">elapsed</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
(Date.now() - loadedAt) +
"ms</dd>",
];
if (errors.length) {
lines.push(
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">errors</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere;white-space:pre-wrap\">" +
errors
.map(function (item) {
return item.at + " " + item.kind + ": " + item.detail;
})
.join("\n") +
"</dd>",
);
}
lines.push("</dl>");
panel.innerHTML = lines.join("");
}
window.addEventListener("error", function (event) {
remember("error", event.message || event.error || "unknown error");
});
window.addEventListener("unhandledrejection", function (event) {
remember("unhandledrejection", event.reason && (event.reason.stack || event.reason.message || event.reason));
});
window.__VIGNETTE_BOOT_DIAG__ = {
check: function () {
render("manual");
},
};
window.setTimeout(function () {
render("watchdog");
}, 3500);
window.setTimeout(function () {
render("watchdog");
}, 8000);
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>