feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
<section class="left-panel" aria-label="종목 및 컷 선택">
<header class="brand-line">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<div class="connection-state" aria-label="Tornado2 연결 상태">미연결</div>
<div class="connection-state" id="playout-connection-state"
aria-label="Tornado2 연결 상태">미연결</div>
</header>
<div class="search-line">
@@ -23,10 +24,16 @@
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
<div class="manual-actions" aria-label="수동 입력">
<button type="button" disabled>주요매출 구성</button>
<button type="button" disabled>성장성 지표</button>
<button type="button" disabled>매출액</button>
<button type="button" disabled>영업이익</button>
<button type="button" data-manual-screen="revenue-composition">주요매출 구성</button>
<button type="button" data-manual-screen="growth-metrics">성장성 지표</button>
<button type="button" data-manual-screen="sales">매출액</button>
<button type="button" data-manual-screen="operating-profit">영업이익</button>
</div>
<div class="manual-list-actions" aria-label="수동 송출 목록" hidden>
<button type="button" data-manual-list-screen="individual">개인 순매도</button>
<button type="button" data-manual-list-screen="foreign">외국인 순매도</button>
<button type="button" data-manual-list-screen="institution">기관 순매도</button>
<button type="button" data-manual-list-screen="vi">VI 발동</button>
</div>
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
@@ -34,74 +41,195 @@
</section>
<section class="catalog-panel" aria-label="분류별 그래픽">
<nav class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" disabled>해외</button>
<button type="button" disabled>환율</button>
<button type="button" disabled>지수</button>
<button type="button" disabled>코스피</button>
<button type="button" disabled>코스닥</button>
<button type="button" disabled>비교</button>
<button type="button" disabled>테마</button>
<button type="button" disabled>해외종목</button>
<button type="button" disabled>전문가</button>
<button type="button" disabled>정지</button>
<nav id="market-tabs" class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button>
<button type="button" draggable="true" data-tab-id="exchange">환율</button>
<button type="button" draggable="true" data-tab-id="index">지수</button>
<button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button>
<button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button>
<button type="button" draggable="true" data-tab-id="comparison">비교</button>
<button type="button" draggable="true" data-tab-id="theme">테마</button>
<button type="button" draggable="true" data-tab-id="overseasStocks">해외종목</button>
<button type="button" draggable="true" data-tab-id="expert">전문가</button>
<button type="button" draggable="true" data-tab-id="tradingHalt">정지</button>
</nav>
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> Expand</label>
<div class="category-title">Category</div>
<div class="category-tree" aria-label="원본 카테고리">
<div class="tree-root"><strong>[1열판기본]</strong></div>
<div class="tree-item">▸ 다우</div>
<div class="tree-item">▸ 나스닥</div>
<div class="tree-item">▸ S&amp;P</div>
<div class="tree-item">▸ 독일</div>
<div class="tree-item">▸ 영국</div>
<div class="tree-item">▸ 프랑스</div>
<div class="tree-item">▸ 니케이</div>
<div class="tree-item">▸ 중국 상해</div>
<div class="tree-item">▸ 홍콩 항셍</div>
<div class="tree-item">▸ 대만 가권</div>
<div class="tree-item">▸ 싱가포르 지수</div>
<div class="tree-item">▸ 필리핀 지수</div>
<div class="tree-item">▸ 말레이시아 지수</div>
<div class="tree-root second"><strong>[2열판-]</strong></div>
<div class="tree-item">▸ 공산품 [원면, 목화(면화)]</div>
<div class="tree-item">▸ 원자재 [국제 금, 국제 은]</div>
</div>
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></div>
</section>
<section class="playlist-panel" aria-label="플레이리스트">
<div class="playlist-toolbar">
<label><input type="checkbox" checked disabled> 전체</label>
<label><input type="checkbox" checked disabled> 5일선</label>
<label><input type="checkbox" checked disabled> 20일선</label>
<button type="button" disabled></button>
<button type="button" disabled></button>
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
<label><input id="moving-average-5" type="checkbox" checked> 5일선</label>
<label><input id="moving-average-20" type="checkbox" checked> 20일선</label>
<button id="playlist-move-up" type="button" disabled aria-label="선택 행 위로 이동"></button>
<button id="playlist-move-down" type="button" disabled aria-label="선택 행 아래로 이동"></button>
<span class="toolbar-spacer"></span>
<label><input type="checkbox" disabled> 배경없음(F3)</label>
<button type="button" disabled>배경파일(F2)</button>
<input class="background-name" value="기본.vrv" readonly>
<button type="button" disabled>DB 저장</button>
<button type="button" disabled>DB 불러오기</button>
<button type="button" disabled>선택삭제</button>
<button type="button" disabled>전체삭제</button>
<label><input id="playout-background-none" type="checkbox" disabled> 배경없음(F3)</label>
<button id="playout-background-file" type="button" disabled>배경파일(F2)</button>
<input id="playout-background-name" class="background-name" value="배경없음" readonly>
<label class="fade-control" for="playout-fade-duration" hidden>효과</label>
<select id="playout-fade-duration" disabled aria-label="장면 효과 시간" hidden>
<option value="0">1</option><option value="1">2</option>
<option value="2">3</option><option value="3">4</option>
<option value="4">5</option><option value="5">6</option>
<option value="6">7</option><option value="7">8</option>
<option value="8">9</option><option value="9">10</option>
<option value="10">11</option><option value="11">12</option>
<option value="12">13</option><option value="13">14</option>
<option value="14">15</option><option value="15">16</option>
<option value="16">17</option><option value="17">18</option>
<option value="18">19</option><option value="19">20</option>
</select>
<button id="db-save-button" type="button" disabled>DB 저장</button>
<button id="db-load-button" type="button" disabled>DB 불러오기</button>
<button id="playlist-delete-selected" type="button" disabled>선택삭제(DEL)</button>
<button id="playlist-delete-all" type="button" disabled>전체삭제</button>
</div>
<div id="playlist-drop-zone" class="playlist-grid">
<div class="playlist-header playlist-row">
<div>송출</div><div>종류</div><div>종목</div><div>컷파일</div><div>세부사항</div><div>Page</div>
</div>
<div id="playlist-rows" class="playlist-rows"></div>
<div id="playlist-rows" class="playlist-rows" role="listbox" aria-label="송출 목록" aria-multiselectable="true"></div>
</div>
<div class="playout-actions">
<button class="take-in" type="button" disabled> TAKE IN [F8]</button>
<button class="next" type="button" disabled> NEXT</button>
<button class="take-out" type="button" disabled>TAKE OUT[Esc]</button>
<button id="playout-prepare" class="prepare" type="button" disabled hidden
aria-hidden="true">PREPARE</button>
<button id="playout-take-in" class="take-in" type="button" disabled>● TAKE IN [F8]</button>
<button id="playout-next" class="next" type="button" disabled> NEXT</button>
<button id="playout-take-out" class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
</div>
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
<div id="playout-status" class="playout-status" aria-live="polite">송출 상태를 확인하고 있습니다.</div>
</section>
</main>
<div id="named-playlist-modal" class="named-playlist-backdrop" hidden>
<section class="named-playlist-dialog" role="dialog" aria-modal="true"
aria-labelledby="named-playlist-title">
<header>
<h2 id="named-playlist-title">DB 재생목록</h2>
<button id="named-playlist-close" type="button" aria-label="닫기">×</button>
</header>
<div id="named-playlist-status" class="named-playlist-status" aria-live="polite"></div>
<div class="named-playlist-body">
<div id="named-playlist-definitions" class="named-playlist-definitions"
role="listbox" aria-label="저장된 DB 재생목록"></div>
<section class="named-playlist-create" aria-labelledby="named-playlist-create-title">
<h3 id="named-playlist-create-title">새 재생목록</h3>
<label for="named-playlist-new-title">이름</label>
<input id="named-playlist-new-title" maxlength="128" autocomplete="off"
spellcheck="false">
<button id="named-playlist-create-button" type="button" disabled>새로 만들기</button>
<p>DB 쓰기는 이 버튼 또는 저장·삭제 버튼을 누른 경우에만 한 번 실행됩니다.</p>
</section>
</div>
<footer>
<button id="named-playlist-delete-button" type="button" disabled>선택 삭제</button>
<span class="named-playlist-footer-spacer"></span>
<button id="named-playlist-cancel-button" type="button">취소</button>
<button id="named-playlist-confirm-button" type="button" disabled>확인</button>
</footer>
</section>
</div>
<div id="manual-financial-modal" class="manual-financial-backdrop" hidden>
<section class="manual-financial-dialog" role="dialog" aria-modal="true"
aria-labelledby="manual-financial-title">
<header>
<h2 id="manual-financial-title">수동 재무 입력</h2>
<button id="manual-financial-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-financial-workspace" class="manual-financial-workspace"></div>
</section>
</div>
<div id="manual-list-modal" class="manual-list-backdrop" hidden>
<section class="manual-list-dialog" role="dialog" aria-modal="true"
aria-labelledby="manual-list-title">
<header>
<h2 id="manual-list-title">수동 송출 목록</h2>
<button id="manual-list-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-list-workspace" class="manual-list-workspace"></div>
</section>
</div>
<div id="operator-catalog-modal" class="operator-catalog-backdrop" hidden>
<section class="operator-catalog-dialog" role="dialog" aria-modal="true"
aria-labelledby="operator-catalog-title">
<header>
<h2 id="operator-catalog-title">ThemeA / EList 관리</h2>
<button id="operator-catalog-close" type="button" aria-label="닫기">×</button>
</header>
<div id="operator-catalog-status" class="operator-catalog-status"
aria-live="polite"></div>
<div class="operator-catalog-toolbar">
<button id="operator-catalog-theme-tab" type="button">ThemeA</button>
<button id="operator-catalog-expert-tab" type="button">EList</button>
<input id="operator-catalog-query" type="search" maxlength="64"
autocomplete="off" placeholder="이름 검색">
<select id="operator-catalog-session" aria-label="NXT 세션">
<option value="preMarket">NXT 장전</option>
<option value="afterMarket">NXT 시간외</option>
</select>
<button id="operator-catalog-search" type="button">검색</button>
</div>
<div class="operator-catalog-body">
<section class="operator-catalog-list-panel" aria-labelledby="operator-catalog-list-title">
<div class="operator-catalog-panel-header">
<h3 id="operator-catalog-list-title">DB 목록</h3>
<select id="operator-catalog-new-market" aria-label="새 ThemeA 시장">
<option value="krx">KRX</option>
<option value="nxt">NXT</option>
</select>
<button id="operator-catalog-new" type="button">새 항목</button>
</div>
<div id="operator-catalog-results" class="operator-catalog-results"
role="listbox" aria-label="ThemeA EList 목록"></div>
</section>
<section id="operator-catalog-editor" class="operator-catalog-editor"
aria-labelledby="operator-catalog-editor-title">
<div class="operator-catalog-panel-header">
<h3 id="operator-catalog-editor-title">추가·편집</h3>
<span id="operator-catalog-code" class="operator-catalog-code"></span>
</div>
<label class="operator-catalog-name-field">
<span id="operator-catalog-name-label">이름</span>
<input id="operator-catalog-name" maxlength="128" autocomplete="off">
</label>
<div class="operator-catalog-stock-search">
<input id="operator-catalog-stock-query" type="search" maxlength="64"
autocomplete="off" placeholder="종목명 검색">
<button id="operator-catalog-stock-search" type="button">종목 검색</button>
</div>
<div id="operator-catalog-stock-results" class="operator-catalog-stock-results"
role="listbox" aria-label="stock master 검색 결과"></div>
<div class="operator-catalog-add-line">
<label id="operator-catalog-amount-label" for="operator-catalog-amount">매수가</label>
<input id="operator-catalog-amount" type="number" min="1" step="1"
inputmode="numeric">
<button id="operator-catalog-add-stock" type="button">선택 종목 추가</button>
</div>
<div class="operator-catalog-draft-header">
<strong>구성 종목</strong>
<span>위·아래 버튼으로 송출 순서를 변경합니다.</span>
</div>
<div id="operator-catalog-draft-rows" class="operator-catalog-draft-rows"
aria-label="ThemeA EList 구성 종목"></div>
<footer class="operator-catalog-editor-actions">
<button id="operator-catalog-delete" type="button">선택 삭제</button>
<span></span>
<button id="operator-catalog-cancel" type="button">편집 취소</button>
<button id="operator-catalog-save" type="button">저장</button>
</footer>
</section>
</div>
</section>
</div>
<div id="legacy-dialog" class="dialog-backdrop" hidden>
<div class="legacy-dialog" role="alertdialog" aria-modal="true" aria-labelledby="legacy-dialog-message">
<div id="legacy-dialog-message"></div>
@@ -110,5 +238,6 @@
</div>
<script src="app.js"></script>
<script src="playout-ui.js"></script>
</body>
</html>

View File

@@ -0,0 +1,172 @@
(function () {
"use strict";
const webview = window.chrome && window.chrome.webview;
const connection = document.getElementById("playout-connection-state");
const prepare = document.getElementById("playout-prepare");
const takeIn = document.getElementById("playout-take-in");
const next = document.getElementById("playout-next");
const takeOut = document.getElementById("playout-take-out");
const backgroundNone = document.getElementById("playout-background-none");
const backgroundFile = document.getElementById("playout-background-file");
const backgroundName = document.getElementById("playout-background-name");
const fadeDuration = document.getElementById("playout-fade-duration");
const status = document.getElementById("playout-status");
const controls = [prepare, takeIn, next, takeOut, backgroundNone,
backgroundFile, fadeDuration];
let currentState = null;
let localBusy = false;
function hasOpenDialog() {
return Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
.some(function (dialog) {
const container = dialog.closest("[hidden]");
return !container && !dialog.hidden;
});
}
function post(type, payload) {
if (!webview || localBusy || hasOpenDialog()) return;
localBusy = true;
render(currentState);
webview.postMessage({ type: type, payload: payload || {} });
}
function setAllDisabled(disabled) {
controls.forEach(function (control) {
if (control) control.disabled = disabled;
});
}
function phaseLabel(phase) {
if (phase === "prepared") return "PREPARED";
if (phase === "program") return "PROGRAM";
return "IDLE";
}
function connectionLabel(playout) {
if (!playout) return "미연결";
if (playout.outcomeUnknown) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.isConnected) return "Tornado2 연결됨";
if (playout.isProcessRunning) return "Tornado2 감지됨";
return "미연결";
}
function pageLabel(playout) {
if (!playout || playout.pageCount <= 0) return "";
return " · Page " + String(playout.pageIndexZeroBased + 1) +
"/" + String(playout.pageCount);
}
function nextLabel(playout) {
if (!playout || playout.nextKind === "none") return "";
return playout.nextKind === "pageNext" ? " · 다음 페이지" : " · 다음 항목";
}
function render(state) {
currentState = state || currentState;
const playout = currentState && currentState.playout;
const nativeBusy = !!(currentState && currentState.isBusy) ||
!!(playout && playout.isBusy);
const busy = localBusy || nativeBusy;
if (!playout) {
connection.textContent = "미연결";
status.textContent = "송출 엔진 상태를 사용할 수 없습니다.";
setAllDisabled(true);
return;
}
const commandAvailable = playout.isCommandAvailable === true &&
playout.outcomeUnknown !== true;
const hasPlaylist = Array.isArray(currentState.playlist) &&
currentState.playlist.some(function (row) { return row.isEnabled === true; });
const liveTakeInAllowed = playout.mode !== "live" ||
playout.liveTakeInAllowed === true;
const canPrepareToggle = playout.phase === "idle" ? hasPlaylist :
playout.phase === "prepared" || playout.phase === "program";
connection.textContent = connectionLabel(playout);
connection.classList.toggle("connected", playout.isConnected === true);
connection.classList.toggle("outcome-unknown", playout.outcomeUnknown === true);
prepare.classList.toggle("active", playout.phase !== "idle");
prepare.disabled = busy || !commandAvailable || !canPrepareToggle ||
playout.isPlayCompletionPending === true;
const canTakeIn = playout.phase === "prepared" ||
(playout.phase === "idle" && hasPlaylist);
takeIn.disabled = busy || !commandAvailable || !liveTakeInAllowed ||
!canTakeIn || playout.isPlayCompletionPending === true;
next.disabled = busy || !commandAvailable ||
playout.phase !== "program" || playout.nextKind === "none" ||
playout.isPlayCompletionPending === true;
takeOut.disabled = busy || !commandAvailable ||
(playout.phase !== "prepared" && playout.phase !== "program");
backgroundNone.disabled = busy || playout.canChangeBackground !== true;
backgroundFile.disabled = busy || playout.canChangeBackground !== true;
fadeDuration.disabled = busy || playout.canChangeBackground !== true;
backgroundNone.checked = playout.backgroundEnabled !== true;
backgroundName.value = playout.backgroundEnabled === true
? (playout.backgroundFileName || "선택한 배경") : "배경없음";
fadeDuration.value = String(playout.fadeDuration);
const scene = playout.onAirCode || playout.preparedCode;
const sceneLabel = scene ? " · " + scene : "";
const refresh = playout.refreshActive
? " · 자동 갱신 " + String(playout.refreshCompletedCount) + "회" : "";
status.textContent = phaseLabel(playout.phase) + sceneLabel +
pageLabel(playout) + nextLabel(playout) + refresh +
(playout.message ? " · " + playout.message : "");
}
prepare.addEventListener("click", function () {
post("prepare-playout", {});
});
takeIn.addEventListener("click", function () {
post("take-in", {});
});
next.addEventListener("click", function () {
post("next-playout", {});
});
takeOut.addEventListener("click", function () {
post("take-out", {});
});
backgroundFile.addEventListener("click", function () {
post("choose-background", {});
});
backgroundNone.addEventListener("change", function () {
post("toggle-background", {});
});
fadeDuration.addEventListener("change", function () {
post("set-fade-duration", { duration: Number(fadeDuration.value) });
});
document.addEventListener("keydown", function (event) {
if (event.repeat || hasOpenDialog()) return;
if (event.key === "F2") {
event.preventDefault();
if (!backgroundFile.disabled) backgroundFile.click();
} else if (event.key === "F3") {
event.preventDefault();
if (!backgroundNone.disabled) backgroundNone.click();
} else if (event.key === "F8") {
event.preventDefault();
if (!takeIn.disabled) takeIn.click();
} else if (event.key === "Escape") {
event.preventDefault();
if (!takeOut.disabled) takeOut.click();
}
});
if (webview) {
webview.addEventListener("message", function (event) {
if (event.data && event.data.type === "state" && event.data.payload) {
localBusy = false;
render(event.data.payload);
}
});
} else {
setAllDisabled(true);
status.textContent = "WebView2 송출 브리지를 사용할 수 없습니다.";
}
}());

View File

@@ -7,6 +7,7 @@
}
* { box-sizing: border-box; }
*[hidden] { display: none !important; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body.busy { cursor: wait; }
body.busy .operator-shell { pointer-events: none; }
@@ -32,6 +33,8 @@ button:disabled { color: #555; opacity: 1; }
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; }
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; }
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; }
.connection-state.connected { background: #176c32; }
.connection-state.outcome-unknown { background: #8a4d00; }
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
.search-line label { font-weight: 700; }
@@ -45,6 +48,85 @@ button:disabled { color: #555; opacity: 1; }
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; }
.manual-list-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0 0 9px; }
.manual-list-actions button { height: 31px; padding: 2px 3px; font-size: 11px; font-weight: 700; }
.named-playlist-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .3); }
.named-playlist-backdrop[hidden] { display: none; }
.named-playlist-dialog { position: absolute; left: 50%; top: 50%; width: 650px; height: 500px; transform: translate(-50%, -50%); display: grid; grid-template-rows: 45px auto minmax(0, 1fr) 52px; border: 1px solid #666; background: #f2f2f2; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.named-playlist-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.named-playlist-dialog h2 { margin: 0; font-size: 18px; }
.named-playlist-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.named-playlist-status { min-height: 38px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #444; }
.named-playlist-status.error { background: #fff1f3; color: #9b001b; font-weight: 700; }
.named-playlist-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 9px; padding: 10px; }
.named-playlist-definitions { min-height: 0; overflow: auto; border: 1px solid #888; background: #fff; }
.named-playlist-definitions button { display: block; width: 100%; min-height: 28px; padding: 4px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.named-playlist-definitions button.selected { background: #d9e8f9; font-weight: 700; }
.named-playlist-create { align-self: start; display: grid; grid-template-columns: 1fr; gap: 7px; padding: 10px; border: 1px solid #aaa; background: #fff; }
.named-playlist-create h3 { margin: 0 0 5px; font-size: 15px; }
.named-playlist-create input, .named-playlist-create button { min-width: 0; height: 30px; }
.named-playlist-create p { margin: 5px 0 0; color: #555; font-size: 11px; line-height: 1.45; }
.named-playlist-dialog > footer { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border-top: 1px solid #aaa; }
.named-playlist-dialog > footer button { min-width: 96px; height: 32px; }
.named-playlist-footer-spacer { flex: 1; }
.named-playlist-dialog > footer #named-playlist-confirm-button { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); }
.manual-financial-backdrop[hidden] { display: none; }
.manual-financial-dialog { position: absolute; left: 50%; top: 50%; width: min(1120px, 92vw); height: min(800px, 90vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.manual-financial-dialog h2 { margin: 0; font-size: 18px; }
.manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); gap: 7px; padding: 8px; }
.manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; }
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; }
.manual-financial-list { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
.manual-financial-list button { display: block; width: 100%; min-height: 25px; padding: 3px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.manual-financial-list button.selected { background: #dcdcdc; }
.manual-financial-list button.manual-financial-list-header { position: sticky; top: 0; z-index: 1; background: #ececec; border-bottom: 1px solid #888; font-weight: 700; }
.manual-financial-editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 7px; padding: 8px; border: 1px solid #999; background: #fff; }
.manual-financial-stock-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; }
.manual-financial-stock-line input, .manual-financial-stock-line button { height: 30px; }
.manual-financial-stock-results { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; max-height: 84px; overflow: auto; padding: 3px; border: 1px solid #ccc; }
.manual-financial-stock-results button { padding: 3px 7px; }
.manual-financial-stock-results button.selected { border-color: #145ab4; background: #dceaff; }
.manual-financial-verification { min-height: 22px; color: #145ab4; }
.manual-financial-fields { display: grid; gap: 7px; }
.manual-financial-field { display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center; gap: 6px; }
.manual-financial-field input { min-width: 0; height: 28px; }
.manual-financial-grid { display: grid; gap: 4px; align-items: center; }
.manual-financial-grid input { min-width: 0; height: 27px; }
.manual-revenue-grid, .manual-quarter-grid { grid-template-columns: minmax(0, 1fr) minmax(120px, .45fr); }
.manual-growth-grid { grid-template-columns: 130px repeat(4, minmax(90px, 1fr)); }
.manual-financial-actions { margin-top: auto; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.manual-financial-actions button { min-height: 34px; }
.manual-financial-actions .manual-primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-financial-message { color: #444; }
.manual-financial-warning { padding: 7px; border: 1px solid #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .32); }
.manual-list-backdrop[hidden] { display: none; }
.manual-list-dialog { position: absolute; left: 50%; top: 50%; width: min(980px, 94vw); height: min(760px, 91vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-list-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; }
.manual-list-dialog h2 { margin: 0; font-size: 18px; }
.manual-list-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-list-workspace { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; }
.manual-list-tabs, .manual-list-actions-row, .manual-list-search { display: flex; gap: 6px; align-items: center; }
.manual-list-tabs button.active { border-color: #145ab4; background: #dceaff; font-weight: 700; }
.manual-list-table-wrap { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
.manual-list-table { width: 100%; border-collapse: collapse; }
.manual-list-table th, .manual-list-table td { padding: 4px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; }
.manual-list-table input { width: 100%; min-width: 0; height: 27px; }
.manual-list-table .manual-list-row-actions { width: 170px; white-space: nowrap; }
.manual-list-search input { flex: 1; min-width: 0; height: 30px; }
.manual-list-search-results { display: flex; flex-wrap: wrap; gap: 4px; max-height: 110px; overflow: auto; padding: 5px; border: 1px solid #aaa; background: #fff; }
.manual-list-search-results button.selected { background: #dcdcdc; font-weight: 600; }
.manual-list-status { padding: 7px; border: 1px solid #bbb; background: #fff; }
.manual-list-status.error { border-color: #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-actions-row { margin-top: auto; justify-content: flex-end; }
.manual-list-actions-row button { min-width: 100px; height: 34px; }
.manual-list-actions-row .primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
@@ -63,13 +145,123 @@ button:disabled { color: #555; opacity: 1; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; }
.tree-item { padding: 3px 0 3px 29px; font-size: 15px; }
.catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; }
.catalog-actions { display: grid; }
.tree-item { width: 100%; padding: 3px 0 3px 29px; border: 0; background: transparent; color: #111; font: inherit; font-size: 15px; text-align: left; cursor: default; }
.tree-item:hover, .tree-item:focus-visible { background: #dcdcdc; outline: none; }
.tree-item.unavailable { color: #777; }
.industry-workspace { display: grid; grid-template-rows: minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
.industry-selector { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.industry-slots { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px; margin-bottom: 5px; }
.industry-slots input { min-width: 0; }
.industry-list { overflow: auto; border: 1px solid #aaa; background: #fff; }
.industry-list button { display: block; width: 100%; padding: 2px 7px; border: 0; background: #fff; text-align: left; }
.industry-list button.selected { background: #dcdcdc; }
.industry-action-tree { min-height: 0; overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
.industry-action-tree summary { color: #168000; font-size: 15px; cursor: default; }
.overseas-workspace { display: grid; grid-template-rows: minmax(92px, 20%) minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
.overseas-panel { min-height: 0; }
.overseas-panel h3 { margin: 0 0 4px; color: #168000; font-size: 14px; font-weight: 600; }
.overseas-fixed-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.overseas-fixed-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; background: #fff; }
.overseas-fixed-list button { min-width: 0; padding: 2px 5px; border-width: 0 1px 1px 0; background: #fff; text-align: left; }
.overseas-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
.overseas-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 7px; }
.overseas-search-panel { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
.overseas-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
.overseas-search-line input { min-width: 0; }
.overseas-selected, .overseas-note { min-height: 18px; padding: 2px 3px; color: #444; font-size: 11px; }
.overseas-note.error { color: #a40000; }
.overseas-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.overseas-results button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
.overseas-results button.selected { background: #dcdcdc; }
.overseas-action-panel { overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
.overseas-action-panel summary { color: #168000; font-size: 15px; cursor: default; }
.comparison-workspace { display: grid; grid-template-rows: minmax(110px, 22%) minmax(70px, 16%) minmax(150px, 1fr) auto; height: 100%; gap: 6px; }
.comparison-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 6px; }
.comparison-search-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.comparison-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
.comparison-results, .comparison-saved { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.comparison-results button, .comparison-saved button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
.comparison-results button:hover, .comparison-results button.selected, .comparison-saved button.selected { background: #dcdcdc; }
.comparison-fixed { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.comparison-fixed-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; }
.comparison-fixed-list button { padding: 2px 4px; text-align: left; }
.comparison-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
.comparison-pair-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; gap: 4px; }
.comparison-draft { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto auto; gap: 4px; }
.comparison-draft input { min-width: 0; }
.comparison-saved-tools { display: flex; gap: 4px; justify-content: flex-end; }
.comparison-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
.theme-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
.theme-search-line { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; }
.theme-search-line #theme-search { grid-column: span 2; min-width: 0; }
.theme-search-line button[data-uc4-theme-catalog="create"] { grid-column: 2; }
.theme-body { display: grid; grid-template-columns: minmax(150px, 42%) minmax(0, 1fr); min-height: 0; gap: 7px; }
.theme-results, .theme-preview-panel { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.theme-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
.theme-results button.selected { background: #dcdcdc; }
.theme-preview-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 4px; padding: 5px; }
.theme-preview-list { overflow: auto; margin: 0; padding-left: 28px; }
.theme-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.expert-workspace, .halt-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
.expert-search-line { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; }
.expert-body { display: grid; grid-template-columns: minmax(150px, 40%) minmax(0, 1fr); min-height: 0; gap: 7px; }
.expert-results, .expert-preview, .halt-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.expert-results button, .halt-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
.expert-results button.selected, .halt-results button.selected { background: #dcdcdc; }
.expert-preview { padding: 6px; }
.expert-preview ol { margin: 5px 0 0; padding-left: 28px; }
.expert-actions, .halt-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.halt-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.operator-catalog-backdrop { position: fixed; inset: 0; z-index: 24; display: grid; place-items: center; background: rgba(0, 0, 0, .36); }
.operator-catalog-backdrop[hidden] { display: none; }
.operator-catalog-dialog { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); width: min(1160px, calc(100vw - 70px)); height: min(820px, calc(100vh - 60px)); overflow: hidden; border: 1px solid #555; background: #efefef; box-shadow: 0 12px 42px rgba(0, 0, 0, .38); }
.operator-catalog-dialog > header { display: flex; align-items: center; min-height: 48px; padding: 7px 12px; border-bottom: 1px solid #888; background: #fff; }
.operator-catalog-dialog > header h2 { flex: 1; margin: 0; font-size: 20px; }
.operator-catalog-dialog > header button { width: 38px; height: 32px; font-size: 22px; }
.operator-catalog-status { min-height: 34px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #333; background: #f8f8f8; }
.operator-catalog-status.error { color: #a40000; background: #fff0f0; font-weight: 600; }
.operator-catalog-toolbar { display: grid; grid-template-columns: 94px 94px minmax(240px, 1fr) 120px 80px; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #aaa; }
.operator-catalog-toolbar button.selected { color: #fff; background: #176b2d; font-weight: 700; }
.operator-catalog-body { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); min-height: 0; gap: 10px; padding: 10px 12px 12px; }
.operator-catalog-list-panel, .operator-catalog-editor { display: grid; min-height: 0; border: 1px solid #999; background: #fff; }
.operator-catalog-list-panel { grid-template-rows: auto minmax(0, 1fr); }
.operator-catalog-panel-header { display: flex; align-items: center; min-height: 40px; gap: 6px; padding: 6px 8px; border-bottom: 1px solid #bbb; background: #f3f3f3; }
.operator-catalog-panel-header h3 { flex: 1; margin: 0; font-size: 15px; }
.operator-catalog-code { color: #555; font-family: Consolas, monospace; }
.operator-catalog-results, .operator-catalog-stock-results, .operator-catalog-draft-rows { min-height: 0; overflow: auto; }
.operator-catalog-results button { display: grid; grid-template-columns: 84px minmax(0, 1fr); width: 100%; gap: 2px 8px; padding: 6px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.operator-catalog-results button small { grid-column: 2; color: #666; }
.operator-catalog-results button.selected { background: #dcebdc; }
.operator-catalog-editor { grid-template-rows: auto auto auto minmax(92px, 20%) auto auto minmax(120px, 1fr) auto; gap: 7px; padding-bottom: 8px; }
.operator-catalog-editor[hidden] { display: none; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-results { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-stock-results button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; padding: 4px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.operator-catalog-stock-results button.selected { background: #dcebdc; }
.operator-catalog-stock-results button:disabled { color: #999; }
.operator-catalog-add-line { display: grid; grid-template-columns: auto 150px minmax(130px, auto); justify-content: end; align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-add-line [hidden] { display: none; }
.operator-catalog-draft-header { display: flex; justify-content: space-between; gap: 8px; padding: 2px 9px; color: #555; }
.operator-catalog-draft-header span { font-size: 11px; }
.operator-catalog-draft-rows { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-draft-row { display: grid; grid-template-columns: 34px minmax(140px, 1fr) minmax(110px, auto) 36px 36px 58px; align-items: center; gap: 4px; min-height: 34px; padding: 3px 5px; border-bottom: 1px solid #ddd; }
.operator-catalog-draft-row > span { text-align: center; }
.operator-catalog-draft-row > small { color: #555; text-align: right; }
.operator-catalog-draft-row > input { min-width: 90px; width: 100%; height: 27px; text-align: right; }
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; }
.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 9px; }
.playlist-toolbar { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 7px; }
.playlist-toolbar .toolbar-spacer { flex: 1; }
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
.background-name { width: 82px; height: 25px; border: 1px solid #888; background: #fff; }
.fade-control { margin-left: 2px; }
#playout-fade-duration { width: 48px; height: 25px; }
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; }
@@ -78,12 +270,14 @@ button:disabled { color: #555; opacity: 1; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-data-row .enabled-cell { text-align: center; }
.playlist-data-row.selected { background: #dcdcdc; }
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }
.take-in { width: 215px; }
.next { width: 180px; }
.take-out { width: 212px; }
.playout-actions button:disabled { color: #888; background: #f7f7f7; }
.playout-actions .prepare { font-size: 21px; }
.playout-actions .prepare.active { color: #fff; background: #1a7700; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }