From f1fe65ebe401b5fa3d9db04a936962676e63d658 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 21 Jul 2026 00:54:46 +0900 Subject: [PATCH] feat: add task-manager workspace layout --- scripts/Test-LegacyPackageInput.mjs | 232 ++++++++++++++++++ scripts/Test-LegacyPackageUiOnlySmoke.mjs | 76 ++++++ .../Web/index.html | 138 +++++++---- .../Web/styles.css | 220 ++++++++++++++--- .../Web/workspace-navigation.js | 107 ++++++++ .../workspace-navigation.test.cjs | 72 ++++++ .../LegacyWorkspaceLayoutContractTests.cs | 86 +++++++ 7 files changed, 859 insertions(+), 72 deletions(-) create mode 100644 src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js create mode 100644 tests/LegacyParityWeb/workspace-navigation.test.cjs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs index f99d6d9..7a41546 100644 --- a/scripts/Test-LegacyPackageInput.mjs +++ b/scripts/Test-LegacyPackageInput.mjs @@ -1040,6 +1040,234 @@ async function elementGeometry(expression, label, verticalFraction = 0.5) { return geometry; } +async function readWorkspaceLayout() { + return await evaluate(`(() => { + function rectangle(element) { + if (!element) return null; + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + width: bounds.width, + height: bounds.height + }; + } + + const shell = document.querySelector(".operator-shell"); + const region = document.getElementById("workspace-region"); + const navigation = document.getElementById("workspace-navigation"); + const surface = document.querySelector(".workspace-surface"); + const toggle = document.getElementById("workspace-nav-toggle"); + const stockTab = document.getElementById("workspace-stock-tab"); + const catalogTab = document.getElementById("workspace-catalog-tab"); + const stock = document.getElementById("stock-workspace"); + const catalog = document.getElementById("catalog-workspace"); + const schedule = document.querySelector(".playlist-panel"); + const scheduleBounds = schedule?.getBoundingClientRect() || null; + const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0 + ? document.elementFromPoint( + scheduleBounds.left + Math.min(scheduleBounds.width - 2, scheduleBounds.width / 2), + scheduleBounds.top + Math.min(scheduleBounds.height - 2, 90)) + : null; + return { + viewport: { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight + }, + shell: rectangle(shell), + region: rectangle(region), + navigation: rectangle(navigation), + surface: rectangle(surface), + schedule: rectangle(schedule), + scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)), + activeWorkspace: region?.dataset?.activeWorkspace || null, + navigationExpanded: toggle?.getAttribute("aria-expanded") === "true", + navigationCollapsedClass: region?.classList.contains("nav-collapsed") === true, + title: (document.getElementById("workspace-title")?.textContent || "").trim(), + stock: { + hidden: stock?.hidden === true, + inert: stock?.hasAttribute("inert") === true, + ariaHidden: stock?.getAttribute("aria-hidden") || null, + tabCurrent: stockTab?.getAttribute("aria-current") || null, + width: stock?.getBoundingClientRect().width ?? null, + height: stock?.getBoundingClientRect().height ?? null + }, + catalog: { + hidden: catalog?.hidden === true, + inert: catalog?.hasAttribute("inert") === true, + ariaHidden: catalog?.getAttribute("aria-hidden") || null, + tabCurrent: catalogTab?.getAttribute("aria-current") || null, + width: catalog?.getBoundingClientRect().width ?? null, + height: catalog?.getBoundingClientRect().height ?? null + } + }; + })()`); +} + +function workspaceLayoutMatches(layout, workspace, expanded = null) { + if (!layout || layout.activeWorkspace !== workspace) return false; + if (expanded !== null && layout.navigationExpanded !== expanded) return false; + const selected = workspace === "stock" ? layout.stock : layout.catalog; + const other = workspace === "stock" ? layout.catalog : layout.stock; + return selected.hidden === false && selected.inert === false && + selected.ariaHidden === "false" && selected.tabCurrent === "page" && + selected.width > 0 && selected.height > 0 && + other.hidden === true && other.inert === true && + other.ariaHidden === "true" && other.tabCurrent === "false"; +} + +async function waitForWorkspaceLayout(label, predicate) { + const deadline = Math.min(Date.now() + 5_000, hardDeadlineEpochMilliseconds); + let lastLayout = null; + while (Date.now() < deadline) { + lastLayout = await readWorkspaceLayout(); + if (predicate(lastLayout)) return lastLayout; + await sleep(25); + } + failKnown(`Timed out waiting for ${label}. Last layout: ${JSON.stringify(lastLayout)}`); +} + +function assertWorkspaceAndScheduleGeometry(layout, label) { + if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface || + !layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 || + layout.scheduleHitOwned !== true) { + failKnown(`${label} does not expose the complete workspace and fixed schedule geometry.`); + } + const widthRatio = layout.region.width / layout.schedule.width; + const workspaceShare = layout.region.width / layout.shell.width; + if (!Number.isFinite(widthRatio) || widthRatio < 1.85 || widthRatio > 2.15 || + !Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 || + Math.abs(layout.schedule.right - layout.shell.right) > 1.5 || + Math.abs(layout.schedule.top - layout.shell.top) > 1.5 || + Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) { + failKnown(`${label} does not preserve the approximately 2:1 workspace/schedule split.`); + } + return { widthRatio, workspaceShare }; +} + +function assertFixedSchedule(baseline, current, label) { + for (const edge of ["left", "top", "right", "bottom", "width", "height"]) { + if (Math.abs(baseline.schedule[edge] - current.schedule[edge]) > 1.5) { + failKnown(`${label} moved or resized the always-visible schedule at ${edge}.`); + } + } + assertWorkspaceAndScheduleGeometry(current, label); +} + +async function ensureWorkspace(workspace, label) { + const tabId = workspace === "stock" + ? "workspace-stock-tab" + : workspace === "catalog" + ? "workspace-catalog-tab" + : null; + if (!tabId) failKnown(`${label} requested an unknown workspace.`); + + let layout = await readWorkspaceLayout(); + if (!workspaceLayoutMatches(layout, workspace)) { + await pointerClick(`document.getElementById("${tabId}")`, label); + layout = await waitForWorkspaceLayout(label, candidate => + workspaceLayoutMatches(candidate, workspace)); + } + assertWorkspaceAndScheduleGeometry(layout, label); + return layout; +} + +async function verifyWorkspaceNavigationAndLayout(preflight) { + const baseline = await ensureWorkspace("stock", "workspace stock preflight"); + if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) { + failKnown("The workspace menu was not initially expanded."); + } + const baselineRatio = assertWorkspaceAndScheduleGeometry( + baseline, + "expanded stock workspace preflight"); + + await pointerClick( + 'document.getElementById("workspace-nav-toggle")', + "collapse workspace menu"); + const collapsed = await waitForWorkspaceLayout("collapsed workspace menu", layout => + workspaceLayoutMatches(layout, "stock", false) && + layout.navigationCollapsedClass === true); + if (collapsed.navigation.width >= baseline.navigation.width - 100 || + collapsed.surface.width <= baseline.surface.width + 100) { + failKnown("The pointer collapse did not transfer menu width to the active workspace."); + } + assertFixedSchedule(baseline, collapsed, "collapsed workspace menu"); + + await pointerClick( + 'document.getElementById("workspace-catalog-tab")', + "select catalog workspace from collapsed menu"); + const catalog = await waitForWorkspaceLayout("catalog workspace", layout => + workspaceLayoutMatches(layout, "catalog", false) && + layout.title === "기타 그래픽"); + assertFixedSchedule(baseline, catalog, "catalog workspace transition"); + + await pointerClick( + 'document.getElementById("workspace-nav-toggle")', + "expand workspace menu"); + const expandedCatalog = await waitForWorkspaceLayout("expanded catalog workspace", layout => + workspaceLayoutMatches(layout, "catalog", true) && + layout.navigationCollapsedClass === false); + if (Math.abs(expandedCatalog.navigation.width - baseline.navigation.width) > 1.5 || + Math.abs(expandedCatalog.surface.width - baseline.surface.width) > 1.5) { + failKnown("The pointer expand did not restore the original menu/workspace widths."); + } + assertFixedSchedule(baseline, expandedCatalog, "expanded catalog workspace"); + + await pointerClick( + 'document.getElementById("workspace-stock-tab")', + "return to stock workspace"); + const restoredStock = await waitForWorkspaceLayout("restored stock workspace", layout => + workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); + assertFixedSchedule(baseline, restoredStock, "restored stock workspace"); + + if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { + failKnown("The pointer-selected stock menu item did not retain keyboard focus."); + } + await pressKey("ArrowDown", "focus catalog workspace menu item"); + if (await evaluate('document.activeElement?.id === "workspace-catalog-tab"') !== true) { + failKnown("ArrowDown did not move focus to the catalog workspace menu item."); + } + const focusOnlyStock = await readWorkspaceLayout(); + if (!workspaceLayoutMatches(focusOnlyStock, "stock", true)) { + failKnown("ArrowDown changed the workspace before explicit keyboard activation."); + } + assertFixedSchedule(baseline, focusOnlyStock, "keyboard catalog focus"); + + await pressKey("Enter", "activate catalog workspace from keyboard"); + const keyboardCatalog = await waitForWorkspaceLayout("keyboard catalog workspace", layout => + workspaceLayoutMatches(layout, "catalog", true) && layout.title === "기타 그래픽"); + assertFixedSchedule(baseline, keyboardCatalog, "keyboard catalog activation"); + + await pressKey("ArrowUp", "focus stock workspace menu item"); + if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { + failKnown("ArrowUp did not return focus to the stock workspace menu item."); + } + await pressKey("Enter", "activate stock workspace from keyboard"); + const keyboardStock = await waitForWorkspaceLayout("keyboard stock workspace", layout => + workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); + assertFixedSchedule(baseline, keyboardStock, "keyboard stock activation"); + + const after = await readSnapshot(); + assertSafety(after, "workspace navigation/layout input", false); + if (after.safety.outboundMessages.length !== preflight.safety.outboundMessages.length || + after.safety.blockedOutboundMessages.length !== + preflight.safety.blockedOutboundMessages.length) { + failKnown("Workspace-only pointer input crossed the native WebView message boundary."); + } + + await checkpoint("workspace-navigation-layout", { + widthRatio: Number(baselineRatio.widthRatio.toFixed(4)), + workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)), + expandedNavigationWidth: baseline.navigation.width, + collapsedNavigationWidth: collapsed.navigation.width, + schedule: baseline.schedule, + pointerTransitions: ["collapse", "catalog", "expand", "stock"], + keyboardTransitions: ["ArrowDown", "Enter catalog", "ArrowUp", "Enter stock"] + }); +} + async function playlistDropGeometry(label) { const geometry = await evaluate(`(() => { const zone = document.getElementById("playlist-drop-zone"); @@ -1230,6 +1458,7 @@ async function pressKey(definitionName, label, modifiers = 0) { } async function setSearchText(text) { + await ensureWorkspace("stock", "show stock workspace before stock search"); await pointerClick( 'document.getElementById("stock-search")', "focus stock search"); @@ -2035,6 +2264,7 @@ async function executeSafeSequence() { failKnown("A child modal was already open at preflight."); } await checkpoint("preflight"); + await verifyWorkspaceNavigationAndLayout(preflight); await setSearchText(evidence.fixture.query); await pointerClick( @@ -2698,6 +2928,7 @@ function manualScreenButtonExpression(screen) { } async function openManualFinancial(screen, label) { + await ensureWorkspace("stock", `${label} stock workspace`); await pointerClick(manualScreenButtonExpression(screen), label); return await waitFor(`${label} open`, snapshot => snapshot.state.isBusy === false && @@ -2931,6 +3162,7 @@ async function executeManualFinancialCrud() { } async function selectMainTab(tabId, label) { + await ensureWorkspace("catalog", `${label} catalog workspace`); const isLoaded = snapshot => snapshot.state.isBusy === false && snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true && diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs index 3cfcb5a..935ad12 100644 --- a/scripts/Test-LegacyPackageUiOnlySmoke.mjs +++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs @@ -190,6 +190,47 @@ async function readShell() { const element = document.getElementById(id); return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false }; }; + const rectangle = element => { + if (!element) return null; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + width: bounds.width, + height: bounds.height, + display: style.display, + visibility: style.visibility + }; + }; + const isVisible = (element, bounds) => Boolean(element && bounds && + element.hidden !== true && !element.closest("[hidden]") && + bounds.width > 0 && bounds.height > 0 && + bounds.right > 0 && bounds.bottom > 0 && + bounds.left < document.documentElement.clientWidth && + bounds.top < document.documentElement.clientHeight && + bounds.display !== "none" && bounds.visibility !== "hidden"); + const workspaceRegion = document.getElementById("workspace-region"); + const navigationToggle = document.getElementById("workspace-nav-toggle"); + const stockTab = document.getElementById("workspace-stock-tab"); + const catalogTab = document.getElementById("workspace-catalog-tab"); + const stockWorkspace = document.getElementById("stock-workspace"); + const catalogWorkspace = document.getElementById("catalog-workspace"); + const schedule = document.querySelector(".playlist-panel"); + const workspaceBounds = rectangle(workspaceRegion); + const scheduleBounds = rectangle(schedule); + const scheduleCenter = scheduleBounds ? { + x: scheduleBounds.left + scheduleBounds.width / 2, + y: scheduleBounds.top + scheduleBounds.height / 2 + } : null; + const scheduleHit = scheduleCenter && + scheduleCenter.x >= 0 && scheduleCenter.y >= 0 && + scheduleCenter.x < document.documentElement.clientWidth && + scheduleCenter.y < document.documentElement.clientHeight + ? document.elementFromPoint(scheduleCenter.x, scheduleCenter.y) + : null; return { url: location.href, readyState: document.readyState, @@ -204,6 +245,28 @@ async function readShell() { next: button("playout-next"), takeOut: button("playout-take-out") }, + layout: { + activeWorkspace: workspaceRegion?.dataset?.activeWorkspace || null, + navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" && + workspaceRegion?.classList.contains("nav-collapsed") !== true, + stockTabCurrent: stockTab?.getAttribute("aria-current") || null, + catalogTabCurrent: catalogTab?.getAttribute("aria-current") || null, + stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) && + stockWorkspace?.getAttribute("aria-hidden") === "false" && + stockWorkspace?.hasAttribute("inert") !== true, + catalogWorkspaceHidden: catalogWorkspace?.hidden === true && + catalogWorkspace?.getAttribute("aria-hidden") === "true" && + catalogWorkspace?.hasAttribute("inert") === true, + workspace: workspaceBounds, + schedule: scheduleBounds, + workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds && + scheduleBounds.width > 0 + ? workspaceBounds.width / scheduleBounds.width + : null, + scheduleVisible: isVisible(schedule, scheduleBounds), + scheduleHitTestVisible: Boolean(scheduleHit && + (scheduleHit === schedule || schedule?.contains(scheduleHit))) + }, visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']")) .filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"), postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper, @@ -220,6 +283,19 @@ function assertShell(shell) { shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) { fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents."); } + const layout = shell.layout; + if (!layout || layout.activeWorkspace !== "stock" || + layout.navigationExpanded !== true || + layout.stockTabCurrent !== "page" || layout.catalogTabCurrent !== "false" || + layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true) { + fail("KNOWN_FAILURE", "The package did not render the expanded navigation with stock as the sole initial workspace."); + } + if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true || + !Number.isFinite(layout.workspaceToScheduleWidthRatio) || + layout.workspaceToScheduleWidthRatio < 1.8 || + layout.workspaceToScheduleWidthRatio > 2.2) { + fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 2:1 workspace layout."); + } for (const [name, control] of Object.entries(shell.controls || {})) { if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 2f6f52e..543d2fd 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -8,57 +8,108 @@
-
-
-
매일경제TVVRi
-
미연결
-
+
+ -
- - - -
+
+
+
매일경제TVVRi
+
+ 작업 영역 +

종목·컷

+
+
미연결
+
-
+
+
+
+ + + +
-
- - - - -
- +
-
-
-
+
+ + + + +
+ -
- - -
Category
-
+
+
+
+ + +
+
+
+ +
항상 표시

송출 스케줄

+
@@ -265,6 +316,7 @@ + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index 7faa8f1..3c4c097 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -2,6 +2,11 @@ color-scheme: light; font-family: "NanumGothic", "맑은 고딕", sans-serif; font-size: 13px; + --workspace-nav-expanded: 210px; + --workspace-nav-collapsed: 62px; + --workspace-accent: #0f6cbd; + --workspace-border: #d2d2d2; + --workspace-nav-surface: #f2f2f2; background: #efefef; color: #111; } @@ -11,38 +16,154 @@ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } body.busy { cursor: wait; } body.busy .operator-shell { pointer-events: none; } -button, input { font: inherit; } +button, input, select { font: inherit; } button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; } button:not(:disabled) { cursor: pointer; } button:disabled { color: #555; opacity: 1; } .operator-shell { display: grid; - grid-template-columns: 430px 500px minmax(760px, 1fr); + grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr); width: 100%; - min-width: 1690px; + min-width: 1500px; height: 100vh; background: #efefef; } -.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; } -.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; } +.workspace-region { + --workspace-nav-width: var(--workspace-nav-expanded); + display: grid; + grid-template-columns: var(--workspace-nav-width) minmax(0, 1fr); + min-width: 0; + min-height: 0; + border-right: 1px solid var(--workspace-border); + background: #f7f7f7; +} +.workspace-region.nav-collapsed { --workspace-nav-width: var(--workspace-nav-collapsed); } +.workspace-navigation { + display: grid; + grid-template-rows: 66px minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--workspace-border); + background: var(--workspace-nav-surface); +} +.workspace-nav-header { display: flex; align-items: center; padding: 10px; } +.workspace-nav-toggle { + display: flex; + width: 100%; + min-width: 42px; + height: 42px; + align-items: center; + gap: 13px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + text-align: left; + white-space: nowrap; +} +.workspace-nav-toggle:hover { background: #e5e5e5; } +.workspace-nav-toggle:focus-visible { + outline: 2px solid var(--workspace-accent); + outline-offset: -2px; + background: #e5e5e5; +} +.workspace-nav-toggle svg, .workspace-nav-item svg { + flex: 0 0 22px; + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.workspace-nav-toggle-label { overflow: hidden; text-overflow: ellipsis; } +.workspace-nav-items { + display: flex; + min-height: 0; + flex-direction: column; + gap: 4px; + overflow: auto; + padding: 4px 8px 12px; +} +.workspace-nav-item { + position: relative; + display: flex; + width: 100%; + min-width: 44px; + height: 46px; + flex: 0 0 46px; + align-items: center; + gap: 13px; + padding: 0 12px; + overflow: hidden; + border: 0; + border-radius: 6px; + background: transparent; + text-align: left; + white-space: nowrap; +} +.workspace-nav-item:hover { background: #e7e7e7; } +.workspace-nav-item:focus-visible { + outline: 2px solid var(--workspace-accent); + outline-offset: -2px; + background: #e7e7e7; +} +.workspace-nav-item.workspace-current { background: #dedede; font-weight: 700; } +.workspace-nav-item.workspace-current::before { + position: absolute; + left: 0; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 2px; + background: var(--workspace-accent); + content: ""; +} +.nav-collapsed .workspace-nav-toggle { justify-content: center; padding: 0; } +.nav-collapsed .workspace-nav-toggle-label, .nav-collapsed .workspace-nav-item span { display: none; } +.nav-collapsed .workspace-nav-item { justify-content: center; padding: 0; } -.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; } +.workspace-surface { + display: grid; + grid-template-rows: 66px minmax(0, 1fr); + min-width: 0; + min-height: 0; + background: #f7f7f7; +} +.workspace-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 24px; + padding: 8px 20px; + border-bottom: 1px solid var(--workspace-border); + background: #fff; +} .brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; } -.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; } +.brand span { color: #ed671f; font-size: 20px; font-weight: 800; letter-spacing: -1px; } +.brand strong { font: italic 900 29px Arial, sans-serif; letter-spacing: -4px; } +.workspace-heading { min-width: 0; } +.workspace-heading small { display: block; color: #666; font-size: 11px; } +.workspace-heading h1 { margin: 1px 0 0; overflow: hidden; font-size: 21px; text-overflow: ellipsis; white-space: nowrap; } +.connection-state { padding: 8px 10px; border-radius: 4px; background: #c40000; color: white; font: 700 14px Consolas, monospace; } .connection-state.connected { background: #176c32; } .connection-state.outcome-unknown { background: #8a4d00; } +.workspace-content { position: relative; min-width: 0; min-height: 0; overflow: hidden; } +.workspace-view { width: 100%; height: 100%; min-width: 0; min-height: 0; } +.left-panel, .catalog-panel, .playlist-panel { min-height: 0; } +.left-panel { display: flex; flex-direction: column; padding: 16px 20px 12px; background: #f7f7f7; } -.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; } +.search-line { display: grid; grid-template-columns: 44px minmax(220px, 460px) 112px; align-items: center; justify-content: start; gap: 7px; min-height: 42px; } .search-line label { font-weight: 700; } -.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; } -.search-line button { grid-column: 4; height: 31px; font-size: 14px; } -.search-line button span { font-size: 21px; margin-right: 20px; } +.search-line input { grid-column: 2; height: 31px; padding: 2px 8px; border: 1px solid #888; font-size: 16px; } +.search-line button { grid-column: 3; height: 31px; font-size: 14px; } +.search-line button span { font-size: 19px; margin-right: 12px; } -.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; } +.stock-results { height: 190px; border: 1px solid #999; background: #fff; overflow-y: auto; } .stock-result { display: block; width: 100%; height: 17px; padding: 0 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 17px; } .stock-result.selected { background: #696969; color: #fff; } @@ -153,15 +274,15 @@ button:disabled { color: #555; opacity: 1; } .cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; } .operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } -.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; } -.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; } -.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; } -.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; } +.catalog-panel { display: flex; flex-direction: column; padding: 14px 16px 12px; background: #f7f7f7; } +.market-tabs { display: flex; min-height: 34px; border-bottom: 1px solid #999; } +.market-tabs button { min-width: 54px; padding: 4px 10px; border-width: 0 1px 0 0; background: #efefef; } +.market-tabs button.active { background: #fff; border-top: 2px solid var(--workspace-accent); font-weight: 700; } .market-tabs button[draggable="true"] { cursor: grab; } .market-tabs button.dragging { opacity: .58; cursor: grabbing; } -.expand-line { height: 31px; padding: 7px 8px 0; } +.expand-line { height: 34px; padding: 8px 8px 0; } .category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; } -.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; } +.category-tree { flex: 1; overflow: auto; padding: 10px 14px; background: #fff; border: 1px solid #aaa; border-top: 0; } .tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; } .tree-root.second { margin-top: 15px; } .catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; } @@ -317,16 +438,53 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; } .operator-catalog-editor-actions { display: grid; grid-template-columns: auto 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: 5px; white-space: nowrap; } -.playlist-toolbar button { height: 25px; padding: 2px 7px; } -.playlist-toolbar .toolbar-spacer { flex: 1; } -.background-name { width: 82px; height: 25px; border: 1px solid #888; background: #fff; } +.playlist-panel { + display: grid; + grid-template-rows: 66px minmax(84px, auto) minmax(0, 1fr) 76px 42px; + min-width: 0; + padding: 0 8px; + border-left: 1px solid var(--workspace-border); + background: #efefef; +} +.playlist-heading { + display: flex; + align-items: center; + gap: 11px; + margin: 0 -8px; + padding: 8px 14px; + border-bottom: 1px solid var(--workspace-border); + background: #fff; +} +.playlist-heading-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 7px; + background: #e6f1fb; + color: var(--workspace-accent); + font-size: 22px; +} +.playlist-heading small { display: block; color: #666; font-size: 11px; } +.playlist-heading h2 { margin: 1px 0 0; font-size: 19px; } +.playlist-toolbar { + display: flex; + align-content: center; + align-items: center; + gap: 5px; + overflow: auto; + flex-wrap: wrap; + padding: 6px 0; +} +.playlist-toolbar label { white-space: nowrap; } +.playlist-toolbar button { height: 25px; padding: 2px 6px; white-space: nowrap; } +.playlist-toolbar .toolbar-spacer { display: none; } +.background-name { width: 76px; 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: 30px 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 937px; } +.playlist-row { display: grid; grid-template-columns: 30px 40px 100px minmax(115px, 1.1fr) minmax(95px, .8fr) minmax(138px, 1.3fr) 52px; min-width: 600px; } .playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; } .playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; } .playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; } @@ -343,12 +501,16 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; } .playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; } .playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; } -.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; } +.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 7px; } +.playout-actions button { height: 51px; padding: 2px 4px; background: #fff; font-size: 20px; font-weight: 700; white-space: nowrap; } .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; } +.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #666; text-overflow: ellipsis; white-space: nowrap; } + +@media (prefers-reduced-motion: reduce) { + .workspace-region { scroll-behavior: auto; } +} .dialog-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .25); } .dialog-backdrop[hidden] { display: none; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js new file mode 100644 index 0000000..221f143 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js @@ -0,0 +1,107 @@ +(function () { + "use strict"; + + const region = document.getElementById("workspace-region"); + const navigation = document.getElementById("workspace-navigation"); + const navigationItems = document.getElementById("workspace-nav-items"); + const toggle = document.getElementById("workspace-nav-toggle"); + const toggleLabel = toggle && toggle.querySelector(".workspace-nav-toggle-label"); + const stockTab = document.getElementById("workspace-stock-tab"); + const catalogTab = document.getElementById("workspace-catalog-tab"); + const stockWorkspace = document.getElementById("stock-workspace"); + const catalogWorkspace = document.getElementById("catalog-workspace"); + const marketTabs = document.getElementById("market-tabs"); + const title = document.getElementById("workspace-title"); + + if (!region || !navigation || !navigationItems || !toggle || !toggleLabel || + !stockTab || !catalogTab || !stockWorkspace || !catalogWorkspace || + !marketTabs || !title) { + return; + } + + const views = Object.freeze({ + stock: Object.freeze({ tab: stockTab, view: stockWorkspace, title: "종목·컷" }), + catalog: Object.freeze({ tab: catalogTab, view: catalogWorkspace, title: "기타 그래픽" }) + }); + + function setNavigationExpanded(expanded) { + const isExpanded = expanded === true; + region.classList.toggle("nav-collapsed", !isExpanded); + toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + toggle.setAttribute("aria-label", isExpanded ? "작업 메뉴 접기" : "작업 메뉴 펼치기"); + toggle.title = isExpanded ? "메뉴 접기" : "메뉴 펼치기"; + toggleLabel.textContent = isExpanded ? "메뉴 접기" : "메뉴 펼치기"; + } + + function setWorkspace(name) { + const selected = views[name]; + if (!selected) return false; + + const currentFocus = document.activeElement; + const focusWillBeHidden = Object.keys(views).some(function (key) { + return key !== name && views[key].view.contains(currentFocus); + }); + if (focusWillBeHidden) selected.tab.focus(); + + Object.keys(views).forEach(function (key) { + const entry = views[key]; + const isCurrent = key === name; + entry.tab.classList.toggle("workspace-current", isCurrent); + entry.tab.setAttribute("aria-current", isCurrent ? "page" : "false"); + entry.view.hidden = !isCurrent; + entry.view.toggleAttribute("inert", !isCurrent); + entry.view.setAttribute("aria-hidden", isCurrent ? "false" : "true"); + }); + + region.dataset.activeWorkspace = name; + title.textContent = selected.title; + return true; + } + + function focusAdjacentItem(direction) { + const items = [stockTab, catalogTab].filter(function (button) { + return button.disabled !== true; + }); + const currentIndex = items.indexOf(document.activeElement); + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + direction + items.length) % items.length; + items[nextIndex].focus(); + } + + toggle.addEventListener("click", function () { + setNavigationExpanded(toggle.getAttribute("aria-expanded") !== "true"); + }); + stockTab.addEventListener("click", function () { setWorkspace("stock"); }); + catalogTab.addEventListener("click", function () { setWorkspace("catalog"); }); + + navigationItems.addEventListener("keydown", function (event) { + const menuButton = event.target.closest && event.target.closest(".workspace-nav-item"); + if ((event.key === "Enter" || event.key === " ") && menuButton) { + event.preventDefault(); + event.stopPropagation(); + setWorkspace(menuButton === stockTab ? "stock" : "catalog"); + return; + } + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + event.stopPropagation(); + focusAdjacentItem(event.key === "ArrowUp" ? -1 : 1); + }); + + // A market tab can also be activated by automation or accessibility tools. + // Reveal its owning workspace before app.js dispatches the native select-tab intent. + marketTabs.addEventListener("click", function (event) { + if (event.target.closest("button[data-tab-id]")) setWorkspace("catalog"); + }, true); + + setNavigationExpanded(true); + setWorkspace("stock"); + + window.LegacyWorkspaceNavigation = Object.freeze({ + select: setWorkspace, + setExpanded: setNavigationExpanded, + active: function () { return region.dataset.activeWorkspace; }, + expanded: function () { return toggle.getAttribute("aria-expanded") === "true"; } + }); +}()); diff --git a/tests/LegacyParityWeb/workspace-navigation.test.cjs b/tests/LegacyParityWeb/workspace-navigation.test.cjs new file mode 100644 index 0000000..a85c7e3 --- /dev/null +++ b/tests/LegacyParityWeb/workspace-navigation.test.cjs @@ -0,0 +1,72 @@ +"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 navigation = fs.readFileSync(path.join(webRoot, "workspace-navigation.js"), "utf8"); + +test("operator shell keeps one switchable workspace beside an always-present schedule", () => { + for (const id of [ + "workspace-region", + "workspace-navigation", + "workspace-nav-toggle", + "workspace-stock-tab", + "workspace-catalog-tab", + "workspace-title", + "stock-workspace", + "catalog-workspace", + "playlist-drop-zone" + ]) { + assert.match(markup, new RegExp(`id="${id}"`)); + } + + const workspaceStart = markup.indexOf('id="workspace-region"'); + const stockStart = markup.indexOf('id="stock-workspace"'); + const catalogStart = markup.indexOf('id="catalog-workspace"'); + const scheduleStart = markup.indexOf('class="playlist-panel"'); + assert.ok(workspaceStart >= 0 && stockStart > workspaceStart); + assert.ok(catalogStart > stockStart); + assert.ok(scheduleStart > catalogStart); + assert.match(markup, + /<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*
]*hidden inert/); + assert.match(markup, /항상 표시<\/small>

송출 스케줄<\/h2>/); +}); + +test("layout uses a two-to-one work and schedule split with compact schedule columns", () => { + assert.match(styles, + /grid-template-columns:\s*minmax\(900px, 2fr\) minmax\(600px, 1fr\)/); + assert.match(styles, + /\.workspace-region\s*\{[^}]*grid-template-columns:\s*var\(--workspace-nav-width\) minmax\(0, 1fr\)/); + assert.match(styles, /\.workspace-region\.nav-collapsed/); + assert.match(styles, /--workspace-nav-collapsed:\s*62px/); + assert.match(styles, + /\.workspace-nav-item:focus-visible\s*\{[\s\S]*?outline:\s*2px solid var\(--workspace-accent\)/); + assert.match(styles, /\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap/); + assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/); +}); + +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\)/); + assert.match(navigation, /if \(focusWillBeHidden\) selected\.tab\.focus\(\)/); + assert.match(navigation, /setAttribute\("aria-current", isCurrent \? "page" : "false"\)/); + assert.match(navigation, /region\.classList\.toggle\("nav-collapsed", !isExpanded\)/); + assert.match(navigation, /event\.key !== "ArrowUp" && event\.key !== "ArrowDown"/); + assert.match(navigation, /event\.key === "Enter" \|\| event\.key === " "/); + assert.match(navigation, /setWorkspace\(menuButton === stockTab \? "stock" : "catalog"\)/); + assert.match(navigation, /marketTabs\.addEventListener\("click"/); + assert.doesNotMatch(navigation, /postMessage|localStorage|sessionStorage|send\s*\(/); +}); + +test("workspace navigation loads before the native-authority application bridge", () => { + const navigationScript = markup.indexOf(''); + const applicationScript = markup.indexOf(''); + assert.ok(navigationScript >= 0 && applicationScript > navigationScript); +}); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs new file mode 100644 index 0000000..25c2e04 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWorkspaceLayoutContractTests.cs @@ -0,0 +1,86 @@ +using System.Text.RegularExpressions; + +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyWorkspaceLayoutContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string WebRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "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 Navigation = File.ReadAllText( + Path.Combine(WebRoot, "workspace-navigation.js")); + + [Fact] + public void ShellUsesSwitchableWorkspaceBesidePersistentSchedule() + { + var workspaceStart = Markup.IndexOf("id=\"workspace-region\"", StringComparison.Ordinal); + var stockStart = Markup.IndexOf("id=\"stock-workspace\"", StringComparison.Ordinal); + var catalogStart = Markup.IndexOf("id=\"catalog-workspace\"", StringComparison.Ordinal); + var scheduleStart = Markup.IndexOf("class=\"playlist-panel\"", StringComparison.Ordinal); + + Assert.True(workspaceStart >= 0); + Assert.True(stockStart > workspaceStart); + Assert.True(catalogStart > stockStart); + Assert.True(scheduleStart > catalogStart); + Assert.Matches( + @"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*
]*hidden inert", Markup); + Assert.Contains("항상 표시

송출 스케줄

", Markup, + StringComparison.Ordinal); + } + + [Fact] + public void WorkAndScheduleUseResponsiveTwoToOneColumns() + { + Assert.Contains( + "grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr);", + Styles, + StringComparison.Ordinal); + Assert.Matches( + new Regex( + @"\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap", + RegexOptions.CultureInvariant), + Styles); + Assert.Matches( + new Regex( + @"\.playlist-row\s*\{[^}]*min-width:\s*600px", + RegexOptions.CultureInvariant), + Styles); + } + + [Fact] + public void NavigationChangesPresentationWithoutNativeOrStoredState() + { + Assert.Contains("entry.view.hidden = !isCurrent", Navigation, StringComparison.Ordinal); + Assert.Contains( + "entry.view.toggleAttribute(\"inert\", !isCurrent)", + Navigation, + StringComparison.Ordinal); + Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal); + Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal); + Assert.DoesNotContain("localStorage", Navigation, StringComparison.Ordinal); + Assert.DoesNotContain("sessionStorage", Navigation, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Repository root could not be located."); + } +}