Files
이창현 92bc93c01b
CI / Frontend - type-check & test (push) Canceled after 0s
악보 진행과 연습곡 분류를 바로잡음
양손 악보를 피아노 독주로 통일하고 난이도 정렬과 곡 목록 통신 복구를 보완했다.
재생 위치에 맞춰 악보 커서와 화면이 이동하도록 고쳤으며 높은음과 낮은음에는 8va와 8vb 표기를 적용했다.
상단 브랜드와 저장소 링크를 ITOSM 피아노 학습실 기준으로 바꿨다.

검사: 프런트엔드 테스트 67건 통과, 백엔드 테스트 4건 통과, 타입 검사와 빌드 통과, Docker 재빌드와 운영 화면 확인
운영 반영: 완료
백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1635-classification-scroll
2026-10-03 16:56:26 +09:00

100 lines
5.1 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- T4-1: Content Security Policy: restrict script/style/connect sources.
'unsafe-inline' for script-src is needed for the error overlay below;
tighten by moving it to a separate .js file if desired. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://smpldsnds.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>ITOSM 피아노 학습실</title>
<meta name="description" content="MIDI와 MusicXML 악보를 불러와 건반과 내려오는 음표를 보며 연습하는 한국어 피아노 학습 도구" />
<link rel="icon" type="image/svg+xml" href="/itosm-piano-mark.svg" />
<link rel="icon" type="image/png" href="/favicon-light.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script>
// Anti-flash + boot theme: apply the persisted/system theme to <html>
// BEFORE first paint, mirroring getInitialTheme() in the app exactly
// (localStorage "b2b-theme" -> prefers-color-scheme -> light fallback).
// React sets the same value on mount, so there's no conflict - this just
// makes the cold-load window (before the JS bundle parses) show the right
// background instead of a white flash. Wrapped in try/catch so a blocked
// localStorage (private mode) can never break boot.
(function () {
try {
var t = localStorage.getItem("b2b-theme");
if (t !== "dark" && t !== "light") {
t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.dataset.theme = t;
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
<style>
/* Boot placeholder shown during the cold-load window (bundle download +
parse + service-worker install) before React mounts. Lives inside #root
so React's createRoot().render() replaces it automatically on first
render - no cleanup code needed. Inline so it works before the CSS
chunk loads. Colors mirror --color-bg (dark #1a1a2e / light #ffffff). */
html { background: #1a1a2e; }
html[data-theme="light"] { background: #ffffff; }
#b2b-boot {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a2e;
}
html[data-theme="light"] #b2b-boot { background: #ffffff; }
#b2b-boot .b2b-spinner {
width: 38px;
height: 38px;
border-radius: 50%;
border: 3px solid rgba(147, 51, 234, 0.25);
border-top-color: #9333ea;
animation: b2b-spin 0.8s linear infinite;
}
@keyframes b2b-spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="root">
<div id="b2b-boot"><div class="b2b-spinner"></div></div>
</div>
<!-- Error catcher: shows any unhandled JS error directly on screen -->
<div id="error-overlay" style="display:none;position:fixed;inset:0;background:#1a0000;color:#ff6b6b;padding:24px;font:14px monospace;z-index:9999;white-space:pre-wrap;overflow:auto;"></div>
<script>
// T3-3: Only show detailed stack traces in development (localhost).
// Production displays a user-friendly message without file paths.
var isDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
window.onerror = function(msg, src, line, col, err) {
var el = document.getElementById('error-overlay');
el.style.display = 'block';
el.textContent = isDev
? 'JS ERROR:\n' + msg + '\n\nFile: ' + src + ':' + line + '\n\n' + (err && err.stack ? err.stack : '')
: '오류가 발생했습니다. 페이지를 새로고침해 주세요.';
};
window.addEventListener('unhandledrejection', function(e) {
var el = document.getElementById('error-overlay');
el.style.display = 'block';
if (isDev) {
el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason));
} else if (!el.textContent) {
el.textContent = '오류가 발생했습니다. 페이지를 새로고침해 주세요.';
}
});
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>