feat: add configurable operator appearance and layout

This commit is contained in:
2026-07-23 11:14:10 +09:00
parent cf117de144
commit f43483533a
34 changed files with 5942 additions and 740 deletions

View File

@@ -1278,7 +1278,16 @@ async function readWorkspaceLayout() {
const stock = document.getElementById("stock-workspace");
const catalog = document.getElementById("catalog-workspace");
const settings = document.getElementById("settings-workspace");
const splitter = document.getElementById("workspace-splitter");
const schedule = document.querySelector(".playlist-panel");
const publishedScheduleWidth = window.LegacyWorkspaceNavigation?.scheduleWidth?.();
let storedScheduleWidth = null;
try {
storedScheduleWidth = window.localStorage?.getItem(
"mbn-stock-webview.schedule-width.v1") ?? null;
} catch (_) {
storedScheduleWidth = null;
}
const scheduleBounds = schedule?.getBoundingClientRect() || null;
const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0
? document.elementFromPoint(
@@ -1294,6 +1303,24 @@ async function readWorkspaceLayout() {
region: rectangle(region),
navigation: rectangle(navigation),
surface: rectangle(surface),
splitter: rectangle(splitter),
splitterState: splitter ? {
role: splitter.getAttribute("role"),
orientation: splitter.getAttribute("aria-orientation"),
controls: splitter.getAttribute("aria-controls"),
valueMinimum: Number(splitter.getAttribute("aria-valuemin")),
valueMaximum: Number(splitter.getAttribute("aria-valuemax")),
valueNow: Number(splitter.getAttribute("aria-valuenow")),
valueText: splitter.getAttribute("aria-valuetext"),
tabIndex: splitter.tabIndex,
focused: document.activeElement === splitter,
dragging: splitter.classList.contains("dragging")
} : null,
scheduleWidthPercent: Number.isFinite(publishedScheduleWidth)
? publishedScheduleWidth
: null,
scheduleCssWidth: shell?.style?.getPropertyValue("--schedule-pane-width") || null,
storedScheduleWidth,
schedule: rectangle(schedule),
scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)),
activeWorkspace: region?.dataset?.activeWorkspace || null,
@@ -1380,21 +1407,36 @@ async function waitForWorkspaceLayout(label, predicate) {
function assertWorkspaceAndScheduleGeometry(layout, label) {
if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface ||
!layout.splitter || layout.splitter.width <= 0 || layout.splitter.height <= 0 ||
!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: ` +
failKnown(`${label} does not expose the complete workspace, splitter, and schedule geometry: ` +
JSON.stringify(layout));
}
const widthRatio = layout.region.width / layout.schedule.width;
const workspaceShare = layout.region.width / layout.shell.width;
if (!Number.isFinite(widthRatio) || widthRatio < 1.60 || widthRatio > 1.90 ||
!Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 ||
const scheduleShare = layout.schedule.width / layout.shell.width;
const splitterState = layout.splitterState;
if (!Number.isFinite(widthRatio) || widthRatio < 1.05 || widthRatio > 1.95 ||
!Number.isFinite(workspaceShare) || workspaceShare < 0.50 || workspaceShare > 0.67 ||
!Number.isFinite(scheduleShare) || scheduleShare < 0.33 || scheduleShare > 0.49 ||
splitterState?.role !== "separator" || splitterState.orientation !== "vertical" ||
splitterState.controls !== "workspace-region" || splitterState.tabIndex !== 0 ||
!Number.isFinite(splitterState.valueMinimum) || splitterState.valueMinimum < 34 ||
!Number.isFinite(splitterState.valueMaximum) || splitterState.valueMaximum > 48 ||
!Number.isFinite(splitterState.valueNow) ||
splitterState.valueNow < splitterState.valueMinimum ||
splitterState.valueNow > splitterState.valueMaximum ||
Math.abs(layout.region.right - layout.splitter.left) > 1.5 ||
Math.abs(layout.splitter.right - layout.schedule.left) > 1.5 ||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
Math.abs(layout.splitter.top - layout.shell.top) > 1.5 ||
Math.abs(layout.splitter.bottom - layout.shell.bottom) > 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 1.75:1 workspace/schedule split.`);
failKnown(`${label} does not preserve the adjustable workspace/schedule split.`);
}
return { widthRatio, workspaceShare };
return { widthRatio, workspaceShare, scheduleShare };
}
function assertFixedSchedule(baseline, current, label) {
@@ -1406,6 +1448,149 @@ function assertFixedSchedule(baseline, current, label) {
assertWorkspaceAndScheduleGeometry(current, label);
}
function assertSplitterResizeResult(layout, expectedPercent, label) {
assertWorkspaceAndScheduleGeometry(layout, label);
const storedPercent = Number.parseFloat(layout.storedScheduleWidth);
const cssPercent = Number.parseFloat(layout.scheduleCssWidth);
const geometryPercent = layout.schedule.width / layout.shell.width * 100;
if (!Number.isFinite(layout.scheduleWidthPercent) ||
Math.abs(layout.scheduleWidthPercent - expectedPercent) > 0.15 ||
!Number.isFinite(storedPercent) ||
Math.abs(storedPercent - layout.scheduleWidthPercent) > 0.01 ||
!Number.isFinite(cssPercent) ||
Math.abs(cssPercent - layout.scheduleWidthPercent) > 0.01 ||
Math.abs(geometryPercent - layout.scheduleWidthPercent) > 0.15 ||
!Number.isFinite(layout.splitterState?.valueNow) ||
Math.abs(layout.splitterState.valueNow -
Number(layout.scheduleWidthPercent.toFixed(1))) > 0.001 ||
layout.splitterState.valueText !==
`송출 스케줄 ${Math.round(layout.scheduleWidthPercent)}%` ||
layout.splitterState.focused !== true || layout.splitterState.dragging !== false) {
failKnown(`${label} did not synchronize splitter geometry, ARIA, CSS, and local storage: ` +
JSON.stringify(layout));
}
return layout.scheduleWidthPercent;
}
async function dragWorkspaceSplitterToPercent(beforeLayout, targetPercent, label) {
await waitForPointerReady(label);
const start = {
x: beforeLayout.splitter.left + beforeLayout.splitter.width / 2,
y: beforeLayout.splitter.top + beforeLayout.splitter.height / 2
};
const target = {
x: beforeLayout.shell.right - beforeLayout.shell.width * targetPercent / 100,
y: start.y
};
const before = await readSnapshot();
assertSafety(before, `${label} preflight`, false);
evidence.inputs.push({
type: "workspace-splitter-drag",
label,
start,
target,
targetPercent
});
try {
await pressMouse(start, 0);
await sleep(25);
await moveMouse(target, 0, 1);
await sleep(25);
await releaseMouse(target, 0);
} catch (error) {
await cleanupDragInputSession(false);
throw error;
}
const resized = await waitForWorkspaceLayout(label, layout =>
Number.isFinite(layout.scheduleWidthPercent) &&
Math.abs(layout.scheduleWidthPercent - targetPercent) <= 0.15 &&
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
layout.scheduleWidthPercent) <= 0.01);
const after = await readSnapshot();
assertSafety(after, label, false);
if (after.safety.outboundMessages.length !== before.safety.outboundMessages.length) {
failKnown(`${label} emitted a native WebView intent.`);
}
return resized;
}
async function exerciseWorkspaceSplitter(baseline) {
const baselinePercent = baseline.scheduleWidthPercent;
const minimum = Math.max(34, 600 / baseline.shell.width * 100);
const maximum = Math.min(48, (baseline.shell.width - 780 - 8) /
baseline.shell.width * 100);
const range = maximum - minimum;
if (!Number.isFinite(baselinePercent) || range < 3 ||
baselinePercent < minimum - 0.15 || baselinePercent > maximum + 0.15) {
failKnown("The rendered window has no safe splitter resize range.");
}
const lowerTarget = minimum + range / 3;
const upperTarget = maximum - range / 3;
const pointerTarget = Math.abs(baselinePercent - lowerTarget) >=
Math.abs(baselinePercent - upperTarget)
? lowerTarget
: upperTarget;
const pointerLayout = await dragWorkspaceSplitterToPercent(
baseline,
pointerTarget,
"drag workspace splitter");
const pointerPercent = assertSplitterResizeResult(
pointerLayout,
pointerTarget,
"workspace splitter pointer resize");
const leftExpected = Math.min(maximum, pointerPercent + 2);
const rightExpected = Math.max(minimum, pointerPercent - 2);
const keyboardKey = Math.abs(leftExpected - baselinePercent) >=
Math.abs(rightExpected - baselinePercent)
? "ArrowLeft"
: "ArrowRight";
const keyboardExpected = keyboardKey === "ArrowLeft" ? leftExpected : rightExpected;
if (Math.abs(keyboardExpected - pointerPercent) < 0.25) {
failKnown("The splitter has no safe keyboard resize step after pointer input.");
}
const beforeKeyboard = await readSnapshot();
assertSafety(beforeKeyboard, "workspace splitter keyboard resize preflight", false);
await pressKey(keyboardKey, "resize workspace splitter from keyboard");
const keyboardLayout = await waitForWorkspaceLayout(
"workspace splitter keyboard resize",
layout => Number.isFinite(layout.scheduleWidthPercent) &&
Math.abs(layout.scheduleWidthPercent - keyboardExpected) <= 0.15 &&
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
layout.scheduleWidthPercent) <= 0.01);
const keyboardPercent = assertSplitterResizeResult(
keyboardLayout,
keyboardExpected,
"workspace splitter keyboard resize");
const afterKeyboard = await readSnapshot();
assertSafety(afterKeyboard, "workspace splitter keyboard resize", false);
if (afterKeyboard.safety.outboundMessages.length !==
beforeKeyboard.safety.outboundMessages.length) {
failKnown("The workspace splitter keyboard resize emitted a native WebView intent.");
}
const restoredLayout = await dragWorkspaceSplitterToPercent(
keyboardLayout,
baselinePercent,
"restore workspace splitter width");
const restoredPercent = assertSplitterResizeResult(
restoredLayout,
baselinePercent,
"restored workspace splitter width");
assertFixedSchedule(baseline, restoredLayout, "restored workspace splitter geometry");
return {
baselinePercent,
pointerPercent,
keyboardKey,
keyboardPercent,
restoredPercent,
storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth)
};
}
async function ensureWorkspace(workspace, label) {
let layout = await readWorkspaceLayout();
if (!workspaceLayoutMatches(layout, workspace)) {
@@ -1430,9 +1615,14 @@ async function ensureWorkspace(workspace, label) {
}
async function verifyWorkspaceNavigationAndLayout(preflight) {
const baseline = await ensureWorkspace("stock", "workspace stock preflight");
let baseline = await ensureWorkspace("stock", "workspace stock preflight");
if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) {
failKnown("The workspace menu was not initially expanded.");
await pointerClick(
'document.getElementById("workspace-nav-toggle")',
"expand workspace menu before layout verification");
baseline = await waitForWorkspaceLayout("expanded workspace stock preflight", layout =>
workspaceLayoutMatches(layout, "stock", true) &&
layout.navigationCollapsedClass === false);
}
const firstMarket = baseline.marketTabs[0];
if (!firstMarket?.tabId || !firstMarket.label) {
@@ -1441,6 +1631,7 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
const baselineRatio = assertWorkspaceAndScheduleGeometry(
baseline,
"expanded stock workspace preflight");
const splitterResize = await exerciseWorkspaceSplitter(baseline);
await pointerClick(
'document.getElementById("workspace-nav-toggle")',
@@ -1602,9 +1793,11 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
await checkpoint("workspace-navigation-layout", {
widthRatio: Number(baselineRatio.widthRatio.toFixed(4)),
workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)),
scheduleShare: Number(baselineRatio.scheduleShare.toFixed(4)),
expandedNavigationWidth: baseline.navigation.width,
collapsedNavigationWidth: collapsed.navigation.width,
schedule: baseline.schedule,
splitterResize,
graphicMenuCount: baseline.marketTabs.length,
pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"],
verticalMenuDrag: [
@@ -2023,6 +2216,8 @@ async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label)
}
const keyDefinitions = Object.freeze({
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", virtualKey: 37 },
ArrowRight: { key: "ArrowRight", code: "ArrowRight", virtualKey: 39 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 },