diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index b10d14c..c6fea98 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -1206,6 +1206,19 @@ element.setAttribute( "aria-current", presentation.isCurrent ? "true" : "false"); + const accessibleStates = []; + if (presentation.isOnAir) accessibleStates.push("현재 PGM"); + if (isCandidate) accessibleStates.push("송출 후보"); + if (presentation.isSelected) accessibleStates.push("삭제 선택됨"); + element.setAttribute( + "aria-label", + accessibleStates.concat([ + row.marketText, + row.stockName, + row.graphicType, + row.subtype, + presentation.pageText + ]).filter(Boolean).join(", ")); element.draggable = false; element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); [row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText] diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 543d2fd..b8bbd09 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -60,7 +60,13 @@
- +
@@ -107,7 +113,12 @@
- +
항상 표시

송출 스케줄

@@ -151,9 +162,12 @@
- - - + + +
송출 상태를 확인하고 있습니다.
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js index 1127fe8..336349b 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js @@ -85,6 +85,7 @@ if (!playout) { connection.textContent = "미연결"; status.textContent = "송출 엔진 상태를 사용할 수 없습니다."; + status.title = status.textContent; setAllDisabled(true); return; } @@ -102,6 +103,7 @@ connection.textContent = connectionLabel(playout); connection.classList.toggle("connected", playout.isConnected === true); connection.classList.toggle("outcome-unknown", playout.outcomeUnknown === true); + connection.classList.toggle("dry-run", playout.mode === "dryRun"); prepare.classList.toggle("active", playout.phase !== "idle"); prepare.disabled = busy || !commandAvailable || !canPrepareToggle || playout.isPlayCompletionPending === true; @@ -132,6 +134,7 @@ status.textContent = phaseLabel(playout.phase) + sceneLabel + pageLabel(playout) + nextLabel(playout) + refresh + (playout.message ? " · " + playout.message : ""); + status.title = status.textContent; } prepare.addEventListener("click", function () { @@ -188,5 +191,6 @@ } else { setAllDisabled(true); status.textContent = "WebView2 송출 브리지를 사용할 수 없습니다."; + status.title = status.textContent; } }()); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index 3c4c097..b711855 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -518,3 +518,733 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; } .legacy-dialog > strong { display: block; margin-bottom: 18px; } .legacy-dialog-actions { display: flex; justify-content: center; gap: 8px; margin-top: 24px; } .legacy-dialog button { min-width: 80px; padding: 6px 15px; } + +/* Modern operator theme: visual-only overrides; geometry and native contracts stay intact. */ +:root { + --workspace-accent: #2563eb; + --workspace-border: #d9e0ea; + --workspace-nav-surface: #f7f9fc; + --ui-canvas: #eef2f6; + --ui-surface: #ffffff; + --ui-surface-subtle: #f8fafc; + --ui-surface-muted: #f1f4f8; + --ui-surface-hover: #edf3fb; + --ui-border: #d8e0ea; + --ui-border-strong: #778397; + --ui-text: #172033; + --ui-text-muted: #667085; + --ui-text-subtle: #8490a3; + --ui-accent: #2563eb; + --ui-accent-hover: #1d4ed8; + --ui-accent-soft: #eaf1ff; + --ui-success: #16834a; + --ui-success-soft: #e9f8ef; + --ui-danger: #c9364f; + --ui-danger-soft: #fff0f3; + --ui-warning: #a96300; + --ui-warning-soft: #fff5df; + --ui-radius-sm: 7px; + --ui-radius: 10px; + --ui-radius-lg: 14px; + --ui-shadow-sm: 0 1px 2px rgba(15, 23, 42, .06); + --ui-shadow-card: 0 1px 3px rgba(15, 23, 42, .08), 0 8px 24px rgba(15, 23, 42, .04); + --ui-shadow-dialog: 0 24px 70px rgba(15, 23, 42, .24), 0 4px 18px rgba(15, 23, 42, .12); + font-family: "Segoe UI Variable", "Segoe UI", "Noto Sans KR", "맑은 고딕", sans-serif; + background: var(--ui-canvas); + color: var(--ui-text); +} + +body { + background: var(--ui-canvas); + color: var(--ui-text); + line-height: 1.35; + letter-spacing: -.01em; + text-rendering: optimizeLegibility; +} + +* { + scrollbar-color: #9eabbc transparent; + scrollbar-width: auto; +} +*::-webkit-scrollbar { width: 12px; height: 12px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 999px; + background: #b8c3d1; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { background-color: #96a4b6; } +::selection { background: #cfe0ff; color: var(--ui-text); } + +button { + border: 1px solid var(--ui-border-strong); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface); + background-image: none; + box-shadow: var(--ui-shadow-sm); + color: var(--ui-text); + font-weight: 600; + transition: background-color 120ms ease, border-color 120ms ease, + box-shadow 120ms ease, color 120ms ease; +} +button:not(:disabled):hover { + border-color: #9daabc; + background: var(--ui-surface-hover); +} +button:not(:disabled):active { + border-color: var(--ui-accent); + background: var(--ui-accent-soft); +} +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid #fff; + outline-offset: 0; + box-shadow: 0 0 0 4px var(--ui-accent); +} +button:disabled { + border-color: #dce2ea; + background: #f2f4f7; + box-shadow: none; + color: #98a2b3; + cursor: not-allowed; + opacity: 1; +} +input:not([type="checkbox"]):not([type="radio"]), +select { + border: 1px solid var(--ui-border-strong); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface); + box-shadow: inset 0 1px 2px rgba(15, 23, 42, .04); + color: var(--ui-text); + transition: border-color 120ms ease, box-shadow 120ms ease; +} +input:not([type="checkbox"]):not([type="radio"]):hover, +select:hover { border-color: #9daabc; } +input:not([type="checkbox"]):not([type="radio"]):focus, +select:focus { + border-color: var(--ui-accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, .13); +} +input[readonly] { background: var(--ui-surface-muted); color: var(--ui-text-muted); } +input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); } + +.operator-shell { background: var(--ui-canvas); } +.workspace-region { + border-right-color: var(--ui-border); + background: var(--ui-canvas); +} +.workspace-navigation { + border-right-color: var(--ui-border); + background: var(--workspace-nav-surface); + box-shadow: inset -1px 0 rgba(255, 255, 255, .8); +} +.workspace-nav-header { border-bottom: 1px solid rgba(216, 224, 234, .7); } +.workspace-nav-toggle, +.workspace-nav-item { + border: 0; + background: transparent; + box-shadow: none; + color: #445066; + font-weight: 600; +} +.workspace-nav-toggle:hover, +.workspace-nav-item:hover { background: #edf1f6; color: var(--ui-text); } +.workspace-nav-toggle:focus-visible, +.workspace-nav-item:focus-visible { + outline: 2px solid var(--ui-accent); + outline-offset: -2px; + background: var(--ui-accent-soft); +} +.workspace-nav-item.workspace-current { + background: var(--ui-accent-soft); + color: #174ea6; + font-weight: 700; +} +.workspace-nav-item.workspace-current::before { + top: 9px; + bottom: 9px; + width: 4px; + border-radius: 0 4px 4px 0; + background: var(--ui-accent); +} +.workspace-nav-toggle svg, +.workspace-nav-item svg { stroke-width: 1.9; } + +.workspace-surface, +.workspace-content, +.left-panel, +.catalog-panel { background: var(--ui-canvas); } +.workspace-header { + border-bottom-color: var(--ui-border); + background: rgba(255, 255, 255, .97); + box-shadow: 0 1px 0 rgba(15, 23, 42, .03); +} +.brand { gap: 10px; } +.brand::after { + width: 1px; + height: 28px; + margin-left: 4px; + background: var(--ui-border); + content: ""; +} +.brand span { color: #ed641f; font-size: 19px; letter-spacing: -1px; } +.brand strong { color: #101828; font-size: 27px; letter-spacing: -3px; } +.workspace-heading small, +.playlist-heading small { + color: var(--ui-text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.workspace-heading h1 { color: var(--ui-text); font-size: 20px; font-weight: 750; } +.connection-state { + min-width: 66px; + padding: 7px 11px; + border: 1px solid rgba(255, 255, 255, .35); + border-radius: 999px; + background: var(--ui-danger); + box-shadow: 0 3px 10px rgba(201, 54, 79, .2); + font: 700 12px "Segoe UI", sans-serif; + letter-spacing: .02em; + text-align: center; +} +.connection-state.connected { + background: var(--ui-success); + box-shadow: 0 3px 10px rgba(22, 131, 74, .2); +} +.connection-state.outcome-unknown { + background: var(--ui-warning); + box-shadow: 0 3px 10px rgba(169, 99, 0, .2); +} +.connection-state.dry-run:not(.outcome-unknown) { + background: var(--ui-accent); + box-shadow: 0 3px 10px rgba(37, 99, 235, .2); +} + +.search-line { min-height: 46px; } +.search-line label { color: #344054; font-weight: 700; } +.search-line input { height: 34px; padding: 4px 10px; border-color: var(--ui-border-strong); } +.search-line button { + display: inline-flex; + height: 34px; + align-items: center; + justify-content: center; + gap: 7px; + border-color: var(--ui-accent); + background: var(--ui-accent); + color: #fff; +} +.search-line button:not(:disabled):hover { + border-color: var(--ui-accent-hover); + background: var(--ui-accent-hover); +} +.search-line button svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; +} + +.stock-results, +.cut-list, +.category-tree, +.playlist-grid, +.industry-list, +.overseas-fixed-list, +.overseas-results, +.comparison-results, +.comparison-saved, +.comparison-fixed-list, +.theme-results-panel, +.theme-preview-panel, +.expert-results, +.expert-preview, +.halt-results { + border-color: var(--ui-border); + border-radius: var(--ui-radius); + background: var(--ui-surface); + box-shadow: var(--ui-shadow-card); +} +.stock-results { margin-top: 3px; } +.stock-results { height: 242px; } +.stock-result, +.category-tree button, +.industry-list button, +.overseas-fixed-list button, +.overseas-results button, +.comparison-results button, +.comparison-saved button, +.comparison-fixed-list button, +.theme-results button, +.expert-results button, +.halt-results button, +.named-playlist-definitions button, +.manual-financial-list button, +.operator-catalog-results button, +.operator-catalog-stock-results button { + min-height: 24px; + border-radius: 0; + box-shadow: none; +} +.stock-result { + height: 24px; + padding: 0 8px; + color: var(--ui-text); + font-size: 14px; + line-height: 24px; +} +.stock-result:hover { background: var(--ui-surface-hover); } +.stock-result.selected { + background: #344054; + color: #fff; + box-shadow: inset 3px 0 var(--ui-accent); +} +.manual-actions, +.manual-list-actions { gap: 7px; } +.manual-actions button, +.manual-list-actions button { + border-color: #c8d7ef; + background: #f5f8fd; + color: #29466f; +} +.manual-actions button:not(:disabled):hover, +.manual-list-actions button:not(:disabled):hover { + border-color: #9db8e4; + background: var(--ui-accent-soft); +} + +.cut-list { overflow: auto; } +.cut-row { + min-height: 24px; + border-bottom-color: #edf0f4; + background: var(--ui-surface); + color: var(--ui-text); +} +.cut-row:hover { background: #f7f9fc; } +.cut-row .cut-number { border-right-color: #e6eaf0; color: var(--ui-accent); font-variant-numeric: tabular-nums; } +.cut-row.selected { + background: #e8eef9; + box-shadow: inset 3px 0 var(--ui-accent); +} +.cut-row:focus { + outline: 2px solid var(--ui-accent); + outline-offset: -2px; +} +.cut-row.separator { background: var(--ui-surface-subtle); } +.playlist-grid.cut-drag-over { + border-color: var(--ui-success); + box-shadow: inset 0 0 0 3px rgba(22, 131, 74, .85), var(--ui-shadow-card); +} +.cut-drag-feedback { + border-color: var(--ui-border-strong); + border-radius: 999px; + background: rgba(255, 255, 255, .98); + box-shadow: 0 12px 32px rgba(15, 23, 42, .18); + color: var(--ui-text-muted); +} +.cut-drag-feedback.allowed { border-color: var(--ui-success); color: #11613a; } +.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: var(--ui-success); } +.operator-status { color: var(--ui-danger); font-weight: 600; } + +.market-tabs { + min-height: 38px; + gap: 2px; + padding: 3px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius); + background: #e9eef5; +} +.market-tabs button { + min-width: 54px; + padding: 4px 10px; + border: 0; + border-radius: var(--ui-radius-sm); + background: transparent; + box-shadow: none; + color: #526076; +} +.market-tabs button:not(:disabled):hover { background: rgba(255, 255, 255, .65); } +.market-tabs button.active { + border: 0; + background: var(--ui-surface); + box-shadow: 0 1px 3px rgba(15, 23, 42, .14); + color: #174ea6; + font-weight: 700; +} +.expand-line { color: var(--ui-text-muted); font-weight: 600; } +.category-title { + border-bottom-color: var(--ui-border); + color: var(--ui-text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.category-tree { margin-top: 0; border-top: 1px solid var(--ui-border); } +.tree-root, +.catalog-section > summary, +.industry-action-tree summary, +.overseas-action-panel summary { color: var(--ui-success); font-weight: 700; } +summary[data-tree-root].tree-node-selected, +.tree-item.tree-node-selected, +.tree-item:hover, +.industry-list button.selected, +.overseas-fixed-list button.selected, +.overseas-results button.selected, +.comparison-results button:hover, +.comparison-results button.selected, +.comparison-saved button.selected, +.comparison-fixed-list button.selected, +.theme-results button.selected, +.expert-results button.selected, +.halt-results button.selected { + background: #e8eef9; + color: var(--ui-text); +} +summary[data-tree-root]:focus-visible, +.tree-item:focus-visible { + outline: 2px solid var(--ui-accent); + outline-offset: -2px; + background: var(--ui-accent-soft); +} +.overseas-group { + border-color: var(--ui-border); + border-radius: var(--ui-radius); + background: var(--ui-surface-subtle); + box-shadow: var(--ui-shadow-sm); +} +.theme-column-title, +.operator-catalog-panel-header, +.manual-financial-list button.manual-financial-list-header { + border-bottom-color: var(--ui-border); + background: var(--ui-surface-muted); + color: #475467; +} + +.playlist-panel { + padding: 0 8px; + border-left-color: var(--ui-border); + background: var(--ui-canvas); + box-shadow: -4px 0 18px rgba(15, 23, 42, .035); +} +.playlist-heading { + margin: 0 -8px; + padding: 8px 14px; + border-bottom-color: var(--ui-border); + background: rgba(255, 255, 255, .97); + box-shadow: 0 1px 0 rgba(15, 23, 42, .03); +} +.playlist-heading-icon { + width: 38px; + height: 38px; + border-radius: 11px; + background: var(--ui-accent-soft); + color: var(--ui-accent); +} +.playlist-heading-icon svg { + width: 21px; + height: 21px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.playlist-heading h2 { color: var(--ui-text); font-size: 19px; font-weight: 750; } +.playlist-toolbar { gap: 5px; padding: 7px 0; overflow: auto; } +.playlist-toolbar label { + display: inline-flex; + min-height: 25px; + align-items: center; + gap: 3px; + padding: 1px 5px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: rgba(255, 255, 255, .72); + color: #475467; + font-size: 12px; + font-weight: 600; +} +.playlist-toolbar button { height: 25px; border-radius: 6px; } +.background-name { border-color: var(--ui-border-strong); border-radius: 6px; } +.playlist-grid { + border-color: var(--ui-border); + border-radius: var(--ui-radius); + background: #e8edf3; + box-shadow: var(--ui-shadow-card); + scroll-padding-top: 29px; +} +.stock-results, +.cut-list, +.category-tree, +.playlist-grid { scrollbar-gutter: stable; } +.playlist-header { + border-bottom-color: var(--ui-border-strong); + background: #f4f6f9; + color: #475467; + font-size: 12px; + font-weight: 700; +} +.playlist-header > div { border-right-color: var(--ui-border); } +.playlist-data-row { + border-bottom-color: #edf0f4; + background: var(--ui-surface); +} +.playlist-data-row > div { border-right-color: #edf0f4; } +.playlist-data-row:not([aria-selected="true"]):not([data-active="true"]):not(.on-air):hover { + background: #f7f9fc; +} +.playlist-data-row .playlist-row-header { + border-color: var(--ui-border); + background: #f1f4f7; + background-image: none; + color: #475467; + font-weight: 700; +} +.playlist-data-row.on-air { + background: #fdc8d2; + box-shadow: inset 4px 0 #d92d4b; +} +.playlist-data-row.selected { + background: var(--ui-surface); + box-shadow: none; +} +.playlist-data-row.on-air.selected { + background: #fdc8d2; + box-shadow: inset 4px 0 #d92d4b; +} +.playlist-data-row[data-active="true"]:not(.on-air) { + background: var(--ui-warning-soft); + box-shadow: inset 0 3px var(--ui-warning); +} +.playlist-data-row[aria-selected="true"]:not(.on-air) { + background: var(--ui-accent-soft); + box-shadow: inset 4px 0 var(--ui-accent); +} +.playlist-data-row[data-active="true"][aria-selected="true"]:not(.on-air) { + background: var(--ui-warning-soft); + box-shadow: inset 4px 0 var(--ui-accent), inset 0 3px var(--ui-warning); +} +.playlist-data-row.on-air[data-active="true"] { + box-shadow: inset 4px 0 #d92d4b, inset 0 3px var(--ui-warning); +} +.playlist-data-row.on-air[aria-selected="true"] { + box-shadow: inset 4px 0 #d92d4b, inset 0 0 0 2px rgba(37, 99, 235, .7); +} +.playlist-data-row.on-air[data-active="true"][aria-selected="true"] { + box-shadow: inset 4px 0 #d92d4b, inset 0 3px var(--ui-warning), + inset 0 0 0 2px rgba(37, 99, 235, .7); +} +.playlist-data-row.playout-current:not(.on-air):not([data-active="true"]) { + box-shadow: inset 0 3px var(--ui-warning); +} +.playlist-data-row.drag-before { box-shadow: inset 0 3px var(--ui-success); } +.playlist-data-row.drag-after { box-shadow: inset 0 -3px var(--ui-success); } + +.playout-actions { gap: 8px; } +.playout-actions button { + height: 51px; + border-radius: var(--ui-radius); + background: var(--ui-surface); + box-shadow: var(--ui-shadow-sm); + font-size: 18px; + font-weight: 750; + letter-spacing: -.02em; +} +.playout-actions .take-in:not(:disabled) { + border-color: #83c9a1; + background: var(--ui-success-soft); + color: #11613a; +} +.playout-actions .take-in:not(:disabled):hover { border-color: var(--ui-success); background: #dff4e7; } +.playout-actions .next:not(:disabled) { + border-color: #a9c1ec; + background: var(--ui-accent-soft); + color: #174ea6; +} +.playout-actions .next:not(:disabled):hover { border-color: var(--ui-accent); background: #dce8ff; } +.playout-actions .take-out:not(:disabled) { + border-color: #e5a1ad; + background: var(--ui-danger-soft); + color: #a9263e; +} +.playout-actions .take-out:not(:disabled):hover { border-color: var(--ui-danger); background: #ffe2e8; } +.playout-actions button:disabled { border-color: #dce2ea; background: #f2f4f7; color: #98a2b3; } +.playout-actions .prepare.active { + border-color: var(--ui-success); + background: var(--ui-success); + color: #fff; +} +.playout-status { + border-top-color: var(--ui-border); + color: var(--ui-text-muted); + font-size: 12px; +} + +.named-playlist-backdrop, +.named-playlist-confirmation-backdrop, +.manual-financial-backdrop, +.manual-list-backdrop, +.operator-catalog-backdrop, +.dialog-backdrop { + background: rgba(15, 23, 42, .42); + backdrop-filter: blur(2px); +} +.named-playlist-dialog, +.named-playlist-confirmation-dialog, +.manual-financial-dialog, +.manual-list-dialog, +.operator-catalog-dialog, +.legacy-dialog { + overflow: hidden; + border: 1px solid rgba(255, 255, 255, .72); + border-radius: var(--ui-radius-lg); + background: var(--ui-canvas); + box-shadow: var(--ui-shadow-dialog); +} +.named-playlist-dialog > header, +.manual-financial-dialog > header, +.manual-list-dialog > header, +.operator-catalog-dialog > header { + border-bottom-color: var(--ui-border); + background: var(--ui-surface); +} +.named-playlist-dialog > header button, +.manual-financial-dialog > header button, +.manual-list-dialog > header button, +.operator-catalog-dialog > header button { + border: 0; + background: transparent; + box-shadow: none; + color: var(--ui-text-muted); +} +.named-playlist-dialog > header button:hover, +.manual-financial-dialog > header button:hover, +.manual-list-dialog > header button:hover, +.operator-catalog-dialog > header button:hover { + background: var(--ui-surface-muted); + color: var(--ui-text); +} +.named-playlist-status, +.operator-catalog-status { + border-bottom-color: var(--ui-border); + background: var(--ui-surface-subtle); + color: var(--ui-text-muted); +} +.named-playlist-status.error, +.operator-catalog-status.error, +.manual-list-status.error, +.manual-financial-warning { + border-color: #f1b4bf; + background: var(--ui-danger-soft); + color: #9d2339; +} +.named-playlist-definitions, +.named-playlist-create, +.manual-financial-list, +.manual-financial-editor, +.manual-financial-stock-results, +.manual-list-table-wrap, +.manual-list-search-results, +.manual-list-status, +.operator-catalog-list-panel, +.operator-catalog-editor, +.operator-catalog-stock-results, +.operator-catalog-draft-rows { + border-color: var(--ui-border); + border-radius: var(--ui-radius); + background: var(--ui-surface); + box-shadow: var(--ui-shadow-sm); +} +.named-playlist-definitions button.selected, +.manual-financial-list button.selected, +.manual-financial-stock-results button.selected, +.manual-list-search-results button.selected, +.operator-catalog-results button.selected, +.operator-catalog-stock-results button.selected, +.operator-catalog-draft-row.selected { + border-color: #adc5ef; + background: var(--ui-accent-soft); + color: #174ea6; +} +.named-playlist-dialog > footer, +.operator-catalog-toolbar { + border-color: var(--ui-border); + background: var(--ui-surface-subtle); +} +.named-playlist-dialog > footer #named-playlist-confirm-button, +.named-playlist-confirmation-dialog #named-playlist-confirmation-yes, +.manual-financial-actions .manual-primary, +.manual-list-actions-row .primary, +.operator-catalog-editor-actions button:last-child { + border-color: var(--ui-accent); + background: var(--ui-accent); + background-image: none; + color: #fff; + font-weight: 700; +} +.operator-catalog-toolbar button.selected { + border-color: var(--ui-accent); + background: var(--ui-accent); + color: #fff; +} +.operator-catalog-code { color: var(--ui-text-muted); } +.operator-catalog-draft-row { border-bottom-color: #edf0f4; } +.operator-catalog-draft-row:focus { + outline-color: var(--ui-accent); + outline-width: 2px; +} +.legacy-dialog { background: var(--ui-surface); } + +#playlist-delete-selected:not(:disabled), +#playlist-delete-all:not(:disabled), +#named-playlist-delete-button:not(:disabled) { + border-color: #df8796; + background: var(--ui-danger-soft); + color: #9d2339; +} +#playlist-delete-selected:not(:disabled):hover, +#playlist-delete-all:not(:disabled):hover, +#named-playlist-delete-button:not(:disabled):hover { + border-color: var(--ui-danger); + background: #ffe2e8; +} + +.stock-result:focus-visible, +.cut-row:focus-visible, +.playlist-data-row:focus-visible, +.tree-item:focus-visible, +summary[data-tree-root]:focus-visible { + outline: 3px solid var(--ui-accent); + outline-offset: -3px; +} + +@media (forced-colors: active) { + * { scrollbar-color: auto; } + button:focus-visible, + input:focus-visible, + select:focus-visible, + .stock-result:focus-visible, + .cut-row:focus-visible, + .playlist-data-row:focus-visible, + .tree-item:focus-visible, + summary[data-tree-root]:focus-visible { + outline: 3px solid Highlight; + outline-offset: -3px; + box-shadow: none; + } + .playlist-data-row[data-active="true"] { border-top: 3px solid Highlight; } + .playlist-data-row[aria-selected="true"] { border-right: 3px solid Highlight; } + .playlist-data-row.on-air { border-left: 4px double Highlight; } +} + +@media (prefers-reduced-motion: reduce) { + button, + input, + select { transition: none !important; } +} diff --git a/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs new file mode 100644 index 0000000..d5643ed --- /dev/null +++ b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs @@ -0,0 +1,89 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const webRoot = path.join( + repositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web"); +const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); +const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); +const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); +const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8"); + +function luminance(hex) { + const channels = [1, 3, 5].map(index => + Number.parseInt(hex.slice(index, index + 2), 16) / 255); + const linear = channels.map(channel => channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrast(left, right) { + const bright = Math.max(luminance(left), luminance(right)); + const dark = Math.min(luminance(left), luminance(right)); + return (bright + 0.05) / (dark + 0.05); +} + +test("operator inputs retain a visible three-to-one boundary", () => { + const match = styles.match(/--ui-border-strong:\s*(#[0-9a-f]{6})/i); + assert.ok(match, "strong control border token must exist"); + assert.ok(contrast(match[1], "#ffffff") >= 3, + `control border contrast was ${contrast(match[1], "#ffffff").toFixed(2)}:1`); +}); + +test("playlist exposes independent candidate, selection, and PGM states", () => { + assert.match(app, /element\.dataset\.active = isCandidate \? "true" : "false"/); + assert.match(app, /"aria-selected",\s*presentation\.isSelected \? "true" : "false"/s); + assert.match(app, /if \(presentation\.isOnAir\) accessibleStates\.push\("현재 PGM"\)/); + assert.match(app, /if \(isCandidate\) accessibleStates\.push\("송출 후보"\)/); + assert.match(app, /if \(presentation\.isSelected\) accessibleStates\.push\("삭제 선택됨"\)/); + + for (const selector of [ + '.playlist-data-row[data-active="true"]:not(.on-air)', + '.playlist-data-row[aria-selected="true"]:not(.on-air)', + '.playlist-data-row[data-active="true"][aria-selected="true"]:not(.on-air)', + '.playlist-data-row.on-air', + '.playlist-data-row.on-air[data-active="true"]', + '.playlist-data-row.on-air[aria-selected="true"]', + '.playlist-data-row.on-air[data-active="true"][aria-selected="true"]' + ]) { + assert.ok(styles.includes(selector), `${selector} must have an explicit visual state`); + } + assert.match(styles, /\.playlist-data-row\.on-air\s*\{[^}]*background:\s*#fdc8d2/i); +}); + +test("keyboard focus, high contrast, and scroll affordances remain visible", () => { + assert.match(styles, + /\.playlist-data-row:focus-visible[\s\S]*?outline:\s*3px solid var\(--ui-accent\)/); + assert.match(styles, /@media \(forced-colors:\s*active\)/); + assert.match(styles, + /\.playlist-data-row\[data-active="true"\]\s*\{\s*border-top:\s*3px solid Highlight/); + assert.match(styles, + /\.playlist-data-row\[aria-selected="true"\]\s*\{\s*border-right:\s*3px solid Highlight/); + assert.match(styles, /\.playlist-data-row\.on-air\s*\{\s*border-left:\s*4px double Highlight/); + assert.match(styles, /scrollbar-width:\s*auto/); + assert.match(styles, /scrollbar-gutter:\s*stable/); + assert.match(styles, /scroll-padding-top:\s*29px/); +}); + +test("playout controls publish their real keyboard shortcuts", () => { + assert.match(markup, + /id="playout-take-in"[^>]*aria-label="TAKE IN"[^>]*aria-keyshortcuts="F8"/s); + assert.match(markup, + /id="playout-take-out"[^>]*aria-label="TAKE OUT"[^>]*aria-keyshortcuts="Escape"/s); + assert.match(markup, /id="playout-next"[^>]*aria-label="NEXT"/s); +}); + +test("dry-run mode reads as information and clipped status keeps its full tooltip", () => { + assert.match(playout, + /connection\.classList\.toggle\("dry-run", playout\.mode === "dryRun"\)/); + assert.match(styles, /\.connection-state\.dry-run:not\(\.outcome-unknown\)\s*\{/); + assert.match(playout, /status\.title = status\.textContent/); +}); diff --git a/tests/LegacyParityWeb/stock-result-keyboard.test.cjs b/tests/LegacyParityWeb/stock-result-keyboard.test.cjs index b9ccd1b..870b280 100644 --- a/tests/LegacyParityWeb/stock-result-keyboard.test.cjs +++ b/tests/LegacyParityWeb/stock-result-keyboard.test.cjs @@ -37,7 +37,7 @@ function option(index, selected) { const classes = new Set(selected ? ["selected"] : []); return { dataset: { resultIndex: String(index) }, - offsetHeight: 17, + offsetHeight: 24, tabIndex: selected ? 0 : -1, focused: false, scrolled: false, @@ -60,7 +60,7 @@ function keyboardHarness(selectedIndex = -1, count = 30) { { length: count }, (_, index) => option(index, index === selectedIndex))); const stockResults = { - clientHeight: 170, + clientHeight: 240, querySelectorAll() { return options; } }; const source = sliceBetween( @@ -140,7 +140,8 @@ test("stock option keydown consumes list navigation but lets Home and End bubble assert.doesNotMatch(factory, /stopPropagation|stopImmediatePropagation/); assert.doesNotMatch(factory, /dblclick|add-playlist|append-playlist|cut-pointer-down/); assert.match(styles, - /\.stock-result \{[^}]*height: 17px;[^}]*line-height: 17px;/s); + /\.stock-result \{[^}]*height: 24px;[^}]*line-height: 24px;/s); + assert.match(styles, /\.stock-results \{ height: 242px; \}/); }); test("rendering keeps exactly one tab stop, falling back to the first unselected row", () => { diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs index a95b767..752ede2 100644 --- a/tests/LegacyParityWeb/thin-client.test.cjs +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -170,7 +170,7 @@ test("state rendering preserves stock and playlist focus like native controls", assert.doesNotMatch(app, /playlistRows\.replaceChildren/); }); -test("playlist keeps the gray candidate separate from the pink on-air row and page", () => { +test("playlist keeps candidate, selection, and pink on-air states independent", () => { const sourceStart = app.indexOf("function playlistPlayoutPresentation"); const sourceEnd = app.indexOf("function presentCurrentPlaylistRow", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); diff --git a/tests/LegacyParityWeb/workspace-navigation.test.cjs b/tests/LegacyParityWeb/workspace-navigation.test.cjs index a85c7e3..f3efd33 100644 --- a/tests/LegacyParityWeb/workspace-navigation.test.cjs +++ b/tests/LegacyParityWeb/workspace-navigation.test.cjs @@ -52,6 +52,30 @@ test("layout uses a two-to-one work and schedule split with compact schedule col assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/); }); +test("modern operator theme keeps clear semantic states and accessible focus", () => { + assert.match(styles, + /Modern operator theme: visual-only overrides; geometry and native contracts stay intact/); + for (const token of [ + "--ui-canvas", + "--ui-accent", + "--ui-success", + "--ui-danger" + ]) { + assert.match(styles, new RegExp(`${token}:`)); + } + + assert.match(styles, + /font-family:\s*"Segoe UI Variable",\s*"Segoe UI",\s*"Noto Sans KR"/); + assert.match(styles, /button:focus-visible,[\s\S]*?outline:\s*2px solid var\(--ui-accent\)/); + assert.match(styles, /\.playlist-data-row\.on-air\.selected\s*\{/); + assert.match(styles, /\.playout-actions \.take-in:not\(:disabled\)\s*\{/); + assert.match(styles, /\.playout-actions \.next:not\(:disabled\)\s*\{/); + assert.match(styles, /\.playout-actions \.take-out:not\(:disabled\)\s*\{/); + assert.match(styles, /@media \(prefers-reduced-motion:\s*reduce\)/); + assert.match(markup, /id="stock-search-button"[\s\S]*?]*viewBox="0 0 24 24"/); + assert.match(markup, /class="playlist-heading-icon"[\s\S]*?]*viewBox="0 0 24 24"/); +}); + test("workspace navigation is local presentation state and preserves native contracts", () => { assert.match(navigation, /entry\.view\.hidden = !isCurrent/); assert.match(navigation, /entry\.view\.toggleAttribute\("inert", !isCurrent\)/); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs index 25c2e04..456da94 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs @@ -12,6 +12,7 @@ public sealed class LegacyWorkspaceLayoutContractTests "Web"); private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html")); private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css")); + private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js")); private static readonly string Navigation = File.ReadAllText( Path.Combine(WebRoot, "workspace-navigation.js")); @@ -54,6 +55,62 @@ public sealed class LegacyWorkspaceLayoutContractTests Styles); } + [Fact] + public void ModernOperatorThemePreservesAccessibleAndSemanticStates() + { + Assert.Contains( + "Modern operator theme: visual-only overrides; geometry and native contracts stay intact", + Styles, + StringComparison.Ordinal); + Assert.Contains("--ui-canvas:", Styles, StringComparison.Ordinal); + Assert.Contains("--ui-accent:", Styles, StringComparison.Ordinal); + Assert.Contains("--ui-success:", Styles, StringComparison.Ordinal); + Assert.Contains("--ui-danger:", Styles, StringComparison.Ordinal); + Assert.Contains( + "font-family: \"Segoe UI Variable\", \"Segoe UI\", \"Noto Sans KR\"", + Styles, + StringComparison.Ordinal); + Assert.Matches( + new Regex( + @"button:focus-visible,[\s\S]*?outline:\s*2px solid var\(--ui-accent\)", + RegexOptions.CultureInvariant), + Styles); + Assert.Contains(".playlist-data-row.on-air.selected", Styles, StringComparison.Ordinal); + Assert.Contains(".playout-actions .take-in:not(:disabled)", Styles, StringComparison.Ordinal); + Assert.Contains(".playout-actions .next:not(:disabled)", Styles, StringComparison.Ordinal); + Assert.Contains(".playout-actions .take-out:not(:disabled)", Styles, StringComparison.Ordinal); + Assert.Contains( + ".playlist-data-row[data-active=\"true\"]:not(.on-air)", + Styles, + StringComparison.Ordinal); + Assert.Contains( + ".playlist-data-row[aria-selected=\"true\"]:not(.on-air)", + Styles, + StringComparison.Ordinal); + Assert.Contains("@media (forced-colors: active)", Styles, StringComparison.Ordinal); + Assert.Contains("scrollbar-gutter: stable", Styles, StringComparison.Ordinal); + Assert.Contains("@media (prefers-reduced-motion: reduce)", Styles, StringComparison.Ordinal); + Assert.Contains("aria-keyshortcuts=\"F8\"", Markup, StringComparison.Ordinal); + Assert.Contains("aria-keyshortcuts=\"Escape\"", Markup, StringComparison.Ordinal); + Assert.Contains( + ".connection-state.dry-run:not(.outcome-unknown)", + Styles, + StringComparison.Ordinal); + Assert.Contains("accessibleStates.push(\"현재 PGM\")", App, StringComparison.Ordinal); + Assert.Contains("accessibleStates.push(\"송출 후보\")", App, StringComparison.Ordinal); + Assert.Contains("accessibleStates.push(\"삭제 선택됨\")", App, StringComparison.Ordinal); + Assert.Matches( + new Regex( + @"id=""stock-search-button""[\s\S]*?]*viewBox=""0 0 24 24""", + RegexOptions.CultureInvariant), + Markup); + Assert.Matches( + new Regex( + @"class=""playlist-heading-icon""[\s\S]*?]*viewBox=""0 0 24 24""", + RegexOptions.CultureInvariant), + Markup); + } + [Fact] public void NavigationChangesPresentationWithoutNativeOrStoredState() {