feat: initialize WinUI 3 WebView migration

This commit is contained in:
2026-07-10 04:32:15 +09:00
commit 5aa90e4aaa
103 changed files with 10903 additions and 0 deletions

398
Web/app.js Normal file
View File

@@ -0,0 +1,398 @@
(() => {
"use strict";
const catalog = [
{ code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" },
{ code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" },
{ code: "5016", title: "3열판 · 주요 지수", detail: "국내외 지수 3열 구성", category: "plate", market: "index" },
{ code: "5023", title: "매매동향 · 지수별", detail: "코스피 / 코스닥", category: "market", market: "index" },
{ code: "5024", title: "매매동향 · 막대그래프", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
{ code: "5026", title: "비교분석 · 캔들형", detail: "두 종목 비교", category: "chart", market: "compare" },
{ code: "5074", title: "5단표", detail: "상승률 / 하락률 / 거래량", category: "plate", market: "kosdaq" },
{ code: "5083", title: "선그래프 · 주체별", detail: "개인 / 외국인 / 기관", category: "chart", market: "kospi" },
{ code: "5086", title: "수익률 그래프", detail: "기간별 수익률", category: "chart", market: "compare" },
{ code: "6001", title: "해외증시", detail: "미국 / 유럽 / 아시아", category: "market", market: "overseas" },
{ code: "8035", title: "캔들 그래프 · 5분", detail: "지수 / 종목 5분봉", category: "chart", market: "index" },
{ code: "8086", title: "유가 · 금값", detail: "WTI / 브렌트 / 국제금", category: "market", market: "exchange" },
{ code: "THEME", title: "테마 종목", detail: "테마별 5·6·12 종목", category: "plate", market: "theme" },
{ code: "EXPERT", title: "전문가 추천", detail: "추천 종목 그래픽", category: "plate", market: "expert" },
{ code: "HALT", title: "거래 정지", detail: "정지 종목 검색", category: "market", market: "halted" }
];
const marketTitles = {
dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지"
};
const storageKey = "mbn-stock-webview.playlist.v1";
const state = {
activeMarket: "dashboard",
activeCategory: "all",
search: "",
playlist: [],
selectedRows: new Set(),
currentIndex: -1,
dragIndex: -1,
playout: "IDLE"
};
const elements = {
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
catalogList: document.querySelector("#catalogList"), playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
toast: document.querySelector("#toast")
};
const nativeBridge = window.chrome?.webview;
function createId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function updateClock() {
elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
}).format(new Date());
}
function filteredCatalog() {
const term = state.search.trim().toLocaleLowerCase("ko-KR");
return catalog.filter(item => {
const marketMatches = state.activeMarket === "dashboard" || item.market === state.activeMarket;
const categoryMatches = state.activeCategory === "all" || item.category === state.activeCategory;
const searchMatches = !term || `${item.code} ${item.title} ${item.detail}`.toLocaleLowerCase("ko-KR").includes(term);
return marketMatches && categoryMatches && searchMatches;
});
}
function renderCatalog() {
elements.catalogList.replaceChildren();
const items = filteredCatalog();
if (!items.length) {
const empty = document.createElement("div");
empty.className = "catalog-empty";
empty.textContent = "조건에 맞는 그래픽 항목이 없습니다.";
elements.catalogList.append(empty);
return;
}
for (const item of items) {
const row = document.createElement("article");
row.className = "catalog-item";
const code = document.createElement("span");
code.className = "cut-code";
code.textContent = item.code;
const copy = document.createElement("div");
const title = document.createElement("strong");
const detail = document.createElement("small");
title.textContent = item.title;
detail.textContent = item.detail;
copy.append(title, detail);
const add = document.createElement("button");
add.className = "add-cut";
add.type = "button";
add.title = "플레이리스트에 추가";
add.textContent = "+";
add.addEventListener("click", () => addToPlaylist(item));
row.append(code, copy, add);
elements.catalogList.append(row);
}
}
function addToPlaylist(item) {
const entry = { ...item, id: createId() };
state.playlist.push(entry);
state.currentIndex = state.playlist.length - 1;
renderPlaylist();
updatePreview();
addLog(`${item.code} ${item.title} 항목 추가`);
showToast("플레이리스트에 추가했습니다.");
}
function renderPlaylist() {
elements.playlistBody.replaceChildren();
elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
state.playlist.forEach((item, index) => {
const row = document.createElement("tr");
row.draggable = true;
row.dataset.index = String(index);
row.classList.toggle("active", index === state.currentIndex);
const checkCell = document.createElement("td");
checkCell.className = "check-cell";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = state.selectedRows.has(item.id);
checkbox.setAttribute("aria-label", `${item.title} 선택`);
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => {
checkbox.checked ? state.selectedRows.add(item.id) : state.selectedRows.delete(item.id);
});
checkCell.append(checkbox);
const number = document.createElement("td");
number.className = "number-cell";
number.textContent = String(index + 1).padStart(2, "0");
const titleCell = document.createElement("td");
const title = document.createElement("strong");
const detail = document.createElement("small");
title.textContent = item.title;
detail.textContent = `${item.code} · ${item.detail}`;
titleCell.append(title, detail);
const typeCell = document.createElement("td");
const type = document.createElement("span");
type.className = "row-type";
type.textContent = item.category.toUpperCase();
typeCell.append(type);
const drag = document.createElement("td");
drag.className = "drag-cell";
drag.textContent = "⠿";
row.append(checkCell, number, titleCell, typeCell, drag);
row.addEventListener("click", () => selectPlaylistRow(index));
row.addEventListener("dragstart", onDragStart);
row.addEventListener("dragover", onDragOver);
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", onDrop);
row.addEventListener("dragend", onDragEnd);
elements.playlistBody.append(row);
});
}
function selectPlaylistRow(index) {
state.currentIndex = index;
renderPlaylist();
updatePreview();
}
function updatePreview() {
const item = state.playlist[state.currentIndex];
if (!item) {
elements.previewTitle.textContent = "선택된 그래픽 없음";
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
return;
}
elements.previewTitle.textContent = item.title;
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
}
function onDragStart(event) {
state.dragIndex = Number(event.currentTarget.dataset.index);
event.currentTarget.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(state.dragIndex));
}
function onDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("drag-over");
}
function onDrop(event) {
event.preventDefault();
const targetIndex = Number(event.currentTarget.dataset.index);
if (state.dragIndex < 0 || state.dragIndex === targetIndex) return;
const [moved] = state.playlist.splice(state.dragIndex, 1);
state.playlist.splice(targetIndex, 0, moved);
state.currentIndex = targetIndex;
state.dragIndex = -1;
renderPlaylist();
updatePreview();
addLog("플레이리스트 순서 변경");
}
function onDragEnd() {
state.dragIndex = -1;
document.querySelectorAll("tr.dragging, tr.drag-over").forEach(row => row.classList.remove("dragging", "drag-over"));
}
function toggleSelectAll() {
const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id));
state.selectedRows.clear();
if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id));
renderPlaylist();
}
function moveSelected(direction) {
const index = state.playlist.findIndex(item => state.selectedRows.has(item.id));
if (index < 0) return showToast("이동할 항목을 선택하세요.");
const target = Math.max(0, Math.min(state.playlist.length - 1, index + direction));
if (index === target) return;
[state.playlist[index], state.playlist[target]] = [state.playlist[target], state.playlist[index]];
state.currentIndex = target;
renderPlaylist();
updatePreview();
}
function deleteSelected() {
if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
const deleted = state.selectedRows.size;
state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
state.selectedRows.clear();
state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
renderPlaylist();
updatePreview();
addLog(`${deleted}개 항목 삭제`);
}
function savePlaylist() {
localStorage.setItem(storageKey, JSON.stringify(state.playlist));
addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
showToast("플레이리스트를 저장했습니다.");
}
function loadPlaylist() {
try {
const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
state.playlist = Array.isArray(stored) ? stored : [];
state.selectedRows.clear();
state.currentIndex = state.playlist.length ? 0 : -1;
renderPlaylist();
updatePreview();
addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
showToast("저장된 플레이리스트를 불러왔습니다.");
} catch {
showToast("저장 데이터를 읽을 수 없습니다.");
}
}
function setPlayout(nextState, message) {
state.playout = nextState;
elements.playoutState.textContent = nextState;
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
addLog(message);
}
function prepare() {
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
}
function takeIn() {
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
}
function next() {
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
renderPlaylist();
updatePreview();
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
}
function takeOut() {
setPlayout("IDLE", "TAKE OUT");
}
function addLog(message) {
const item = document.createElement("li");
const time = document.createElement("time");
time.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
item.append(time, document.createTextNode(message));
elements.eventLog.prepend(item);
while (elements.eventLog.children.length > 30) elements.eventLog.lastElementChild?.remove();
}
let toastTimer;
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
}
function postNative(type, payload = {}) {
nativeBridge?.postMessage({ type, payload });
}
function handleNativeMessage(event) {
const message = event.data;
if (!message || message.type !== "app-info") return;
const info = message.payload;
elements.bridgeState.textContent = "연결됨";
elements.databaseState.textContent = info.databaseConfigured ? "연결됨" : "미설정";
elements.runtimeLabel.textContent = `${info.framework} · ${info.architecture}`;
elements.packageLabel.textContent = `${info.package} · v${info.version}`;
addLog(`Native Bridge 연결 · Windows App SDK ${info.windowsAppSdk}`);
}
function bindEvents() {
document.querySelector("#marketNav").addEventListener("click", event => {
const button = event.target.closest("button[data-market]");
if (!button) return;
document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
state.activeMarket = button.dataset.market;
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
renderCatalog();
});
elements.catalogTabs.addEventListener("click", event => {
const button = event.target.closest("button[data-category]");
if (!button) return;
elements.catalogTabs.querySelectorAll("button").forEach(item => item.classList.toggle("active", item === button));
state.activeCategory = button.dataset.category;
renderCatalog();
});
elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll);
document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1));
document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1));
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
document.querySelector("#prepareButton").addEventListener("click", prepare);
document.querySelector("#takeInButton").addEventListener("click", takeIn);
document.querySelector("#nextButton").addEventListener("click", next);
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => postNative("request-app-info"));
document.addEventListener("keydown", event => {
if (event.ctrlKey && event.key.toLowerCase() === "k") {
event.preventDefault(); elements.catalogSearch.focus(); return;
}
if (event.key === "F2") { event.preventDefault(); prepare(); }
if (event.key === "F8") { event.preventDefault(); takeIn(); }
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
});
}
function initialize() {
bindEvents();
renderCatalog();
renderPlaylist();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
if (nativeBridge) {
nativeBridge.addEventListener("message", handleNativeMessage);
postNative("ready");
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
addLog("Native Bridge 없이 브라우저 모드로 실행");
}
}
initialize();
})();

139
Web/index.html Normal file
View File

@@ -0,0 +1,139 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>MBN Stock WebView</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar" aria-label="주요 메뉴">
<div class="brand-block">
<div class="brand-mark">M</div>
<div>
<strong>MBN STOCK</strong>
<span>방송 그래픽 콘솔</span>
</div>
</div>
<nav id="marketNav" class="market-nav">
<button class="nav-item active" data-market="dashboard"><span></span>대시보드</button>
<button class="nav-item" data-market="overseas"><span></span>해외</button>
<button class="nav-item" data-market="exchange"><span></span>환율</button>
<button class="nav-item" data-market="index"><span></span>지수</button>
<button class="nav-item" data-market="kospi"><span>K</span>코스피</button>
<button class="nav-item" data-market="kosdaq"><span>Q</span>코스닥</button>
<button class="nav-item" data-market="compare"><span></span>비교</button>
<button class="nav-item" data-market="theme"><span></span>테마</button>
<button class="nav-item" data-market="expert"><span></span>전문가</button>
<button class="nav-item" data-market="halted"><span></span>정지</button>
</nav>
<div class="sidebar-status">
<div class="status-row"><span class="status-dot ok"></span><span>Native Bridge</span><strong id="bridgeState">연결 중</strong></div>
<div class="status-row"><span class="status-dot warn"></span><span>Database</span><strong id="databaseState">미설정</strong></div>
<div class="runtime" id="runtimeLabel">.NET 8 · WinUI 3 · x64</div>
</div>
</aside>
<main class="workspace">
<header class="workspace-header">
<div>
<p class="eyebrow">OPERATOR WORKSPACE</p>
<h1 id="workspaceTitle">마이그레이션 대시보드</h1>
</div>
<div class="header-meta">
<div><span>현재 시각</span><strong id="clock">--:--:--</strong></div>
<div><span>패키지</span><strong id="packageLabel">MSIX</strong></div>
<button id="requestInfoButton" class="ghost-button" type="button">상태 새로고침</button>
</div>
</header>
<section class="summary-strip" aria-label="마이그레이션 상태">
<article><span class="metric-icon mint"></span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
<article><span class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small>안전한 어댑터 대기</small></div><b>PENDING</b></article>
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
</section>
<div class="console-grid">
<section class="panel catalog-panel">
<div class="panel-header">
<div><p class="panel-kicker">CUT CATALOG</p><h2>그래픽 항목</h2></div>
<span class="badge">Web UI</span>
</div>
<label class="search-box">
<span></span>
<input id="catalogSearch" type="search" autocomplete="off" placeholder="종목·지수·그래픽 검색">
<kbd>Ctrl K</kbd>
</label>
<div id="catalogTabs" class="catalog-tabs" role="tablist">
<button class="active" type="button" data-category="all">전체</button>
<button type="button" data-category="plate"></button>
<button type="button" data-category="chart">차트</button>
<button type="button" data-category="market">시장</button>
</div>
<div id="catalogList" class="catalog-list" aria-live="polite"></div>
</section>
<section class="panel playlist-panel">
<div class="panel-header">
<div><p class="panel-kicker">PLAYLIST</p><h2>송출 순서</h2></div>
<span id="playlistCount" class="badge neutral">0 CUTS</span>
</div>
<div class="playlist-actions">
<button id="selectAllButton" type="button">전체 선택</button>
<button id="moveUpButton" type="button" title="선택 항목 위로"></button>
<button id="moveDownButton" type="button" title="선택 항목 아래로"></button>
<button id="deleteButton" class="danger" type="button">삭제</button>
<span></span>
<button id="loadButton" type="button">불러오기</button>
<button id="saveButton" class="primary-soft" type="button">저장</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th class="check-cell"></th><th class="number-cell">#</th><th>그래픽 컷</th><th>구분</th><th class="drag-cell">정렬</th></tr></thead>
<tbody id="playlistBody"></tbody>
</table>
<div id="playlistEmpty" class="empty-state">
<strong>플레이리스트가 비어 있습니다.</strong>
<span>왼쪽 항목의 + 버튼으로 송출 순서를 구성하세요.</span>
</div>
</div>
</section>
<section class="panel playout-panel">
<div class="panel-header">
<div><p class="panel-kicker">PROGRAM / PREVIEW</p><h2>송출 제어</h2></div>
<span id="playoutState" class="badge neutral">IDLE</span>
</div>
<div class="preview-stage" id="previewStage">
<div class="safe-area">
<span>PREVIEW</span>
<strong id="previewTitle">선택된 그래픽 없음</strong>
<small id="previewSubtitle">플레이리스트 항목을 선택하세요.</small>
</div>
<div class="stage-corner top-left"></div><div class="stage-corner top-right"></div>
<div class="stage-corner bottom-left"></div><div class="stage-corner bottom-right"></div>
</div>
<div class="playout-controls">
<button id="prepareButton" class="prepare" type="button"><span>F2</span>PREPARE</button>
<button id="takeInButton" class="take-in" type="button"><span>F8</span>TAKE IN</button>
<button id="nextButton" type="button"><span></span>NEXT</button>
<button id="takeOutButton" class="take-out" type="button"><span>ESC</span>TAKE OUT</button>
</div>
<div class="event-log">
<div class="event-log-header"><strong>운영 로그</strong><button id="clearLogButton" type="button">지우기</button></div>
<ol id="eventLog"></ol>
</div>
</section>
</div>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>

220
Web/styles.css Normal file
View File

@@ -0,0 +1,220 @@
:root {
color-scheme: dark;
--bg: #07101d;
--surface: #0d1929;
--surface-2: #111f32;
--surface-3: #16263b;
--border: #1e314a;
--border-soft: #17283d;
--text: #e8eff8;
--muted: #8799af;
--muted-2: #5e728b;
--mint: #32d5a4;
--mint-soft: rgba(50, 213, 164, .13);
--blue: #56a8ff;
--amber: #ffba55;
--red: #ff6680;
--violet: #9b8cff;
--shadow: 0 18px 44px rgba(0, 0, 0, .24);
font-family: "Pretendard", "Noto Sans KR", "Malgun Gothic", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg); color: var(--text); font-size: 13px; }
button, input { font: inherit; }
button { color: inherit; }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); width: 100%; height: 100%; }
.sidebar {
display: flex;
min-height: 0;
flex-direction: column;
background: #091421;
border-right: 1px solid var(--border);
}
.brand-block { display: flex; align-items: center; gap: 11px; height: 82px; padding: 0 20px; border-bottom: 1px solid var(--border-soft); }
.brand-mark {
display: grid;
width: 37px;
height: 37px;
place-items: center;
border-radius: 11px;
background: linear-gradient(145deg, #49edbb, #1db989);
box-shadow: 0 8px 24px rgba(50, 213, 164, .2);
color: #05120e;
font-size: 18px;
font-weight: 900;
}
.brand-block strong { display: block; font-size: 13px; letter-spacing: .07em; }
.brand-block span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
.market-nav { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 3px; overflow-y: auto; padding: 14px 11px; }
.nav-item {
display: grid;
grid-template-columns: 29px 1fr;
align-items: center;
min-height: 40px;
padding: 0 11px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
transition: .16s ease;
}
.nav-item span { display: grid; width: 22px; height: 22px; place-items: center; color: #647991; font-size: 12px; font-weight: 700; }
.nav-item:hover { background: rgba(255,255,255,.035); color: var(--text); }
.nav-item.active { border-color: rgba(50,213,164,.18); background: var(--mint-soft); color: #dffdf4; font-weight: 650; }
.nav-item.active span { color: var(--mint); }
.sidebar-status { padding: 14px 15px 17px; border-top: 1px solid var(--border-soft); background: rgba(0,0,0,.09); }
.status-row { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; margin: 7px 0; color: var(--muted); font-size: 10px; }
.status-row strong { color: #b9c6d5; font-size: 10px; font-weight: 600; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.ok { background: var(--mint); box-shadow: 0 0 0 3px rgba(50,213,164,.1); }
.status-dot.warn { background: var(--amber); box-shadow: 0 0 0 3px rgba(255,186,85,.1); }
.runtime { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-soft); color: var(--muted-2); font: 9px/1.4 Consolas, monospace; }
.workspace { min-width: 0; min-height: 0; overflow: auto; padding: 0 24px 22px; background: radial-gradient(circle at 76% -10%, rgba(44,104,132,.13), transparent 35%), var(--bg); }
.workspace-header { display: flex; align-items: center; justify-content: space-between; min-height: 82px; gap: 24px; }
.eyebrow, .panel-kicker { margin: 0 0 5px; color: var(--mint); font: 9px/1.2 Consolas, monospace; letter-spacing: .14em; }
h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.header-meta { display: flex; align-items: center; gap: 25px; }
.header-meta > div { display: flex; flex-direction: column; gap: 4px; }
.header-meta span { color: var(--muted-2); font-size: 9px; text-transform: uppercase; }
.header-meta strong { color: #cbd7e5; font: 12px Consolas, monospace; }
.ghost-button, .playlist-actions button, .event-log button {
min-height: 30px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface-2);
color: #aebed0;
cursor: pointer;
}
.ghost-button { padding: 0 12px; }
.ghost-button:hover, .playlist-actions button:hover, .event-log button:hover { border-color: #36506e; color: white; }
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
.summary-strip article { display: grid; grid-template-columns: 36px 1fr auto; align-items: center; min-width: 0; min-height: 62px; gap: 11px; padding: 10px 13px; border: 1px solid var(--border-soft); border-radius: 11px; background: rgba(13,25,41,.78); }
.summary-strip strong, .summary-strip small { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary-strip strong { margin-bottom: 3px; font-size: 11px; }
.summary-strip small { color: var(--muted); font-size: 9px; }
.summary-strip b { align-self: start; margin-top: 4px; color: var(--muted-2); font: 8px Consolas, monospace; }
.metric-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; font: 10px Consolas, monospace; }
.metric-icon.mint { background: var(--mint-soft); color: var(--mint); }
.metric-icon.blue { background: rgba(86,168,255,.12); color: var(--blue); }
.metric-icon.amber { background: rgba(255,186,85,.12); color: var(--amber); }
.metric-icon.violet { background: rgba(155,140,255,.12); color: var(--violet); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
.panel { display: flex; min-width: 0; min-height: 0; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; background: rgba(13,25,41,.92); box-shadow: var(--shadow); overflow: hidden; }
.panel-header { display: flex; align-items: center; justify-content: space-between; min-height: 67px; padding: 0 16px; border-bottom: 1px solid var(--border-soft); }
.panel-header h2 { margin: 0; font-size: 15px; font-weight: 650; }
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
.search-box > span { color: var(--muted); font-size: 17px; }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 11px; }
.search-box input::placeholder { color: #52657d; }
kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; border-radius: 4px; color: #60748c; font: 8px Consolas, monospace; }
.catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); }
.catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }
.catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; }
.catalog-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; }
.catalog-item { display: grid; grid-template-columns: 34px 1fr 27px; align-items: center; min-height: 50px; gap: 9px; padding: 6px 7px; border: 1px solid transparent; border-radius: 8px; }
.catalog-item:hover { border-color: var(--border); background: rgba(255,255,255,.025); }
.cut-code { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: var(--surface-3); color: #8ca2bc; font: 9px Consolas, monospace; }
.catalog-item strong, .catalog-item small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.catalog-item strong { margin-bottom: 3px; font-size: 10px; }
.catalog-item small { color: var(--muted-2); font-size: 8px; }
.add-cut { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid #29415b; border-radius: 7px; background: #102034; color: var(--mint); cursor: pointer; }
.add-cut:hover { border-color: var(--mint); background: var(--mint-soft); }
.catalog-empty { padding: 35px 10px; color: var(--muted); text-align: center; }
.playlist-actions { display: grid; grid-template-columns: auto 30px 30px auto 1fr auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); }
.playlist-actions button { padding: 0 9px; font-size: 9px; }
.playlist-actions button.danger { color: #ff9aad; }
.playlist-actions button.primary-soft { border-color: rgba(50,213,164,.23); background: var(--mint-soft); color: var(--mint); }
.table-wrap { position: relative; min-height: 0; flex: 1; overflow: auto; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
thead { position: sticky; z-index: 2; top: 0; background: #0d1929; }
th { height: 35px; border-bottom: 1px solid var(--border); color: var(--muted-2); font-size: 8px; font-weight: 600; text-align: left; text-transform: uppercase; }
td { height: 47px; border-bottom: 1px solid var(--border-soft); color: #b9c7d8; font-size: 10px; }
th, td { padding: 0 8px; }
.check-cell { width: 35px; text-align: center; }
.number-cell { width: 34px; color: var(--muted-2); text-align: center; }
.drag-cell { width: 42px; color: var(--muted-2); text-align: center; }
tbody tr { cursor: pointer; transition: .12s ease; }
tbody tr:hover { background: rgba(255,255,255,.022); }
tbody tr.active { background: var(--mint-soft); }
tbody tr.dragging { opacity: .42; }
tbody tr.drag-over { box-shadow: inset 0 2px var(--mint); }
td strong, td small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
td strong { margin-bottom: 3px; color: #d7e2ee; font-size: 10px; }
td small { color: var(--muted-2); font-size: 8px; }
.row-type { display: inline-block; padding: 3px 5px; border-radius: 5px; background: var(--surface-3); color: #7f96b0; font: 8px Consolas, monospace; }
input[type="checkbox"] { accent-color: var(--mint); }
.empty-state { position: absolute; inset: 36px 0 0; display: none; place-content: center; padding: 20px; color: var(--muted); text-align: center; }
.empty-state.show { display: grid; }
.empty-state strong, .empty-state span { display: block; }
.empty-state strong { margin-bottom: 7px; color: #91a2b6; font-size: 11px; }
.empty-state span { color: var(--muted-2); font-size: 9px; }
.playout-panel { padding-bottom: 12px; }
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
.safe-area span, .safe-area strong, .safe-area small { display: block; }
.safe-area span { margin-bottom: 13px; color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .16em; }
.safe-area strong { margin-bottom: 7px; color: #e8f0fa; font-size: 16px; }
.safe-area small { color: #6f839b; font-size: 9px; }
.stage-corner { position: absolute; z-index: 2; width: 20px; height: 20px; border-color: var(--mint); border-style: solid; opacity: .7; }
.top-left { top: 10px; left: 10px; border-width: 1px 0 0 1px; }.top-right { top: 10px; right: 10px; border-width: 1px 1px 0 0; }.bottom-left { bottom: 10px; left: 10px; border-width: 0 0 1px 1px; }.bottom-right { right: 10px; bottom: 10px; border-width: 0 1px 1px 0; }
.playout-controls { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; padding: 0 13px 12px; }
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
.playout-controls button:hover { filter: brightness(1.15); }
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }
.event-log { display: flex; min-height: 0; flex: 1; flex-direction: column; margin: 0 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: #08131f; overflow: hidden; }
.event-log-header { display: flex; align-items: center; justify-content: space-between; min-height: 33px; padding: 0 10px; border-bottom: 1px solid var(--border-soft); }
.event-log-header strong { color: #7f92aa; font-size: 9px; }
.event-log button { min-height: 22px; padding: 0 7px; border: 0; background: transparent; color: var(--muted-2); font-size: 8px; }
.event-log ol { min-height: 0; margin: 0; padding: 6px 10px 8px 30px; overflow-y: auto; color: #7890aa; font: 9px/1.75 Consolas, monospace; }
.event-log li::marker { color: #40556d; }
.event-log time { margin-right: 8px; color: #455d77; }
.toast { position: fixed; z-index: 20; right: 22px; bottom: 20px; max-width: 320px; padding: 11px 14px; transform: translateY(25px); border: 1px solid rgba(50,213,164,.28); border-radius: 8px; background: #12273a; box-shadow: var(--shadow); color: #d9f7ef; font-size: 10px; opacity: 0; pointer-events: none; transition: .2s ease; }
.toast.show { transform: translateY(0); opacity: 1; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 8px; background: #263a51; background-clip: padding-box; }
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 178px minmax(0, 1fr); }
.brand-block { padding: 0 14px; }
.console-grid { grid-template-columns: minmax(220px, .8fr) minmax(380px, 1.25fr); height: auto; }
.playout-panel { grid-column: 1 / -1; min-height: 520px; }
.summary-strip { grid-template-columns: repeat(2, 1fr); }
}