feat: refresh workspace header

This commit is contained in:
2026-07-21 10:07:58 +09:00
parent 15ad9934a9
commit d9b477e543
5 changed files with 292 additions and 34 deletions

View File

@@ -132,13 +132,39 @@
<section class="workspace-surface" aria-labelledby="workspace-title">
<header class="workspace-header">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<div class="brand" role="img" aria-label="매일경제TV VRi">
<span class="brand-symbol" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 17V9M10 17V6M15 17v-4M20 17V8"></path>
<path d="M4 19h17"></path>
</svg>
</span>
<span class="brand-lockup">
<span>매일경제TV</span>
<strong>VRi</strong>
</span>
</div>
<div class="workspace-heading">
<small>작업 영역</small>
<span class="workspace-heading-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<rect x="3.5" y="4" width="17" height="16" rx="3"></rect>
<path d="M8.5 4v16M12 9h5M12 13h5"></path>
</svg>
</span>
<div class="workspace-heading-copy">
<small>현재 작업</small>
<h1 id="workspace-title">종목·컷</h1>
</div>
<div class="connection-state" id="playout-connection-state"
aria-label="Tornado2 연결 상태">미연결</div>
</div>
<div class="playout-connection-summary">
<span class="playout-connection-copy" aria-hidden="true">
<small>송출 연결</small>
<strong>Tornado2</strong>
</span>
<div class="connection-state" id="playout-connection-state" role="status"
aria-label="Tornado2 연결 상태" aria-live="polite"
aria-atomic="true">미연결</div>
</div>
</header>
<div class="workspace-content">

View File

@@ -100,7 +100,10 @@
const canPrepareToggle = playout.phase === "idle" ? hasPlaylist :
playout.phase === "prepared" || playout.phase === "program";
connection.textContent = connectionLabel(playout);
const nextConnectionText = connectionLabel(playout);
if (connection.textContent !== nextConnectionText) {
connection.textContent = nextConnectionText;
}
connection.classList.toggle("connected", playout.isConnected === true);
connection.classList.toggle("outcome-unknown", playout.outcomeUnknown === true);
connection.classList.toggle("dry-run", playout.mode === "dryRun");

View File

@@ -663,7 +663,10 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); }
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-header {
border-bottom: 1px solid rgba(216, 224, 234, .7);
background: linear-gradient(180deg, rgba(255, 255, 255, .78), rgba(246, 249, 253, .6));
}
.workspace-nav-toggle,
.workspace-nav-item {
border: 0;
@@ -700,20 +703,93 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); }
.left-panel,
.catalog-panel { background: var(--ui-canvas); }
.workspace-header {
position: relative;
border-bottom-color: var(--ui-border);
background: rgba(255, 255, 255, .97);
box-shadow: 0 1px 0 rgba(15, 23, 42, .03);
background:
radial-gradient(circle at 78% -90%, rgba(37, 99, 235, .12), transparent 48%),
linear-gradient(110deg, #fff 0%, #fbfcff 55%, #f5f8ff 100%);
box-shadow: 0 1px 0 rgba(15, 23, 42, .04), 0 8px 24px rgba(15, 23, 42, .025);
}
.brand { gap: 10px; }
.brand::after {
width: 1px;
height: 28px;
margin-left: 4px;
background: var(--ui-border);
.workspace-header::before {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: linear-gradient(90deg, #f26b38 0 13%, var(--ui-accent) 13% 52%, transparent 82%);
content: "";
opacity: .72;
pointer-events: none;
}
.brand {
min-width: 154px;
gap: 10px;
}
.brand .brand-symbol {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border: 1px solid rgba(255, 255, 255, .72);
border-radius: 11px;
background: linear-gradient(145deg, #ff8a4c, #e65320);
box-shadow: 0 7px 16px rgba(218, 78, 29, .2), inset 0 1px rgba(255, 255, 255, .25);
color: #fff;
}
.brand-symbol svg,
.workspace-heading-icon svg {
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.brand .brand-lockup {
display: flex;
min-width: 0;
flex-direction: column;
line-height: 1.08;
}
.brand-lockup span {
color: var(--ui-text);
font-size: 14px;
font-weight: 800;
letter-spacing: -.045em;
}
.brand-lockup strong {
margin-top: 4px;
color: #dc5826;
font-family: inherit;
font-size: 10px;
font-style: normal;
font-weight: 800;
letter-spacing: .18em;
}
.workspace-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.workspace-heading-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border: 1px solid #cedcf6;
border-radius: 11px;
background: rgba(234, 241, 255, .86);
box-shadow: inset 0 1px rgba(255, 255, 255, .85);
color: var(--ui-accent);
}
.workspace-heading-copy {
display: block;
min-width: 0;
}
.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);
@@ -722,29 +798,92 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); }
letter-spacing: .08em;
text-transform: uppercase;
}
.workspace-heading h1 { color: var(--ui-text); font-size: 20px; font-weight: 750; }
.workspace-heading h1 {
margin-top: 0;
color: var(--ui-text);
font-size: 20px;
font-weight: 760;
letter-spacing: -.04em;
}
.playout-connection-summary {
display: flex;
align-items: center;
gap: 11px;
padding-left: 16px;
border-left: 1px solid rgba(190, 202, 219, .8);
}
.playout-connection-copy {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.1;
white-space: nowrap;
}
.playout-connection-copy small {
color: var(--ui-text-muted);
font-size: 10px;
font-weight: 750;
letter-spacing: .08em;
}
.playout-connection-copy strong {
margin-top: 3px;
color: #344054;
font-size: 11px;
font-weight: 750;
}
.connection-state {
min-width: 66px;
padding: 7px 11px;
border: 1px solid rgba(255, 255, 255, .35);
display: inline-flex;
min-width: 86px;
min-height: 30px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 6px 11px;
border: 1px solid #efbcc5;
border-radius: 999px;
background: var(--ui-danger);
box-shadow: 0 3px 10px rgba(201, 54, 79, .2);
font: 700 12px "Segoe UI", sans-serif;
background: var(--ui-danger-soft);
box-shadow: inset 0 1px rgba(255, 255, 255, .8);
color: #a52940;
font: 750 11px "Segoe UI", sans-serif;
letter-spacing: .02em;
text-align: center;
white-space: nowrap;
}
.connection-state::before {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 3px rgba(201, 54, 79, .11);
content: "";
}
.connection-state.connected {
background: var(--ui-success);
box-shadow: 0 3px 10px rgba(22, 131, 74, .2);
border-color: #a9ddbf;
background: var(--ui-success-soft);
box-shadow: inset 0 1px rgba(255, 255, 255, .8);
color: #126b3e;
}
.connection-state.connected::before {
box-shadow: 0 0 0 3px rgba(22, 131, 74, .12);
}
.connection-state.outcome-unknown {
background: var(--ui-warning);
box-shadow: 0 3px 10px rgba(169, 99, 0, .2);
border-color: #e9cb8f;
background: var(--ui-warning-soft);
box-shadow: inset 0 1px rgba(255, 255, 255, .8);
color: #8a5100;
}
.connection-state.outcome-unknown::before {
box-shadow: 0 0 0 3px rgba(169, 99, 0, .12);
}
.connection-state.dry-run:not(.outcome-unknown) {
background: var(--ui-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, .2);
border-color: #b7caf2;
background: var(--ui-accent-soft);
box-shadow: inset 0 1px rgba(255, 255, 255, .8);
color: #174ea6;
}
.connection-state.dry-run:not(.outcome-unknown)::before {
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
}
.search-line { min-height: 46px; }
@@ -956,17 +1095,33 @@ summary[data-tree-root]:focus-visible,
box-shadow: -4px 0 18px rgba(15, 23, 42, .035);
}
.playlist-heading {
position: relative;
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);
background:
radial-gradient(circle at 72% -110%, rgba(37, 99, 235, .11), transparent 48%),
linear-gradient(110deg, #fff 0%, #fbfcff 60%, #f5f8ff 100%);
box-shadow: 0 1px 0 rgba(15, 23, 42, .04), 0 8px 24px rgba(15, 23, 42, .025);
}
.playlist-heading::before {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: linear-gradient(90deg, var(--ui-accent), #65a3ff 42%, transparent 88%);
content: "";
opacity: .62;
pointer-events: none;
}
.playlist-heading-icon {
width: 38px;
height: 38px;
border: 1px solid #cedcf6;
border-radius: 11px;
background: var(--ui-accent-soft);
background: rgba(234, 241, 255, .86);
box-shadow: inset 0 1px rgba(255, 255, 255, .85);
color: var(--ui-accent);
}
.playlist-heading-icon svg {
@@ -1409,6 +1564,17 @@ summary[data-tree-root]:focus-visible {
@media (forced-colors: active) {
* { scrollbar-color: auto; }
.brand .brand-symbol,
.workspace-heading-icon,
.playlist-heading-icon,
.connection-state {
border: 1px solid CanvasText;
background: Canvas;
box-shadow: none;
color: CanvasText;
}
.workspace-header::before,
.playlist-heading::before { background: Highlight; }
button:focus-visible,
input:focus-visible,
select:focus-visible,

View File

@@ -87,3 +87,28 @@ test("dry-run mode reads as information and clipped status keeps its full toolti
assert.match(styles, /\.connection-state\.dry-run:not\(\.outcome-unknown\)\s*\{/);
assert.match(playout, /status\.title = status\.textContent/);
});
test("workspace header uses a modern semantic lockup without changing native status text", () => {
assert.match(markup,
/class="brand"[^>]*role="img"[^>]*aria-label="매일경제TV VRi"[\s\S]*?class="brand-symbol"[\s\S]*?<svg/);
assert.match(markup,
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?<div class="workspace-heading-copy">[\s\S]*?<small>현재 작업<\/small>[\s\S]*?id="workspace-title"/);
assert.match(markup,
/class="playout-connection-summary"[\s\S]*?<small>송출 연결<\/small>[\s\S]*?<strong>Tornado2<\/strong>[\s\S]*?id="playout-connection-state"/);
assert.match(markup,
/id="playout-connection-state"[^>]*role="status"[^>]*aria-label="Tornado2 연결 상태"[^>]*aria-live="polite"[^>]*aria-atomic="true">미연결<\/div>/);
assert.match(styles,
/\.workspace-header\s*\{[^}]*radial-gradient[\s\S]*?linear-gradient/);
assert.match(styles, /\.workspace-header::before\s*\{/);
assert.match(styles, /\.playlist-heading::before\s*\{/);
assert.match(styles, /\.connection-state::before\s*\{/);
assert.match(styles,
/\.playout-connection-copy small\s*\{[^}]*color:\s*var\(--ui-text-muted\)/);
assert.match(styles, /\.connection-state\.connected::before\s*\{/);
assert.match(styles,
/\.connection-state\.dry-run:not\(\.outcome-unknown\)::before\s*\{/);
assert.match(styles,
/\.workspace-surface\s*\{[^}]*grid-template-rows:\s*66px minmax\(0, 1fr\)/);
assert.match(playout,
/const nextConnectionText = connectionLabel\(playout\);[\s\S]*?if \(connection\.textContent !== nextConnectionText\)[\s\S]*?connection\.textContent = nextConnectionText/);
});

View File

@@ -171,6 +171,44 @@ public sealed class LegacyWorkspaceLayoutContractTests
Markup);
}
[Fact]
public void WorkspaceHeaderUsesModernSemanticLockupAndStableNativeStatusTarget()
{
Assert.Matches(
@"class=""brand""[^>]*role=""img""[^>]*aria-label=""매일경제TV VRi""[\s\S]*?" +
@"class=""brand-symbol""[\s\S]*?<svg",
Markup);
Assert.Matches(
@"class=""workspace-heading""[\s\S]*?class=""workspace-heading-icon""" +
@"[\s\S]*?<div class=""workspace-heading-copy"">[\s\S]*?" +
@"<small>현재 작업</small>[\s\S]*?id=""workspace-title""",
Markup);
Assert.Matches(
@"class=""playout-connection-summary""[\s\S]*?<small>송출 연결</small>" +
@"[\s\S]*?<strong>Tornado2</strong>[\s\S]*?id=""playout-connection-state""",
Markup);
Assert.Matches(
@"id=""playout-connection-state""[^>]*role=""status""" +
@"[^>]*aria-label=""Tornado2 연결 상태""[^>]*aria-live=""polite""" +
@"[^>]*aria-atomic=""true"">미연결</div>",
Markup);
Assert.Contains(".workspace-header::before", Styles, StringComparison.Ordinal);
Assert.Contains(".playlist-heading::before", Styles, StringComparison.Ordinal);
Assert.Contains(".connection-state::before", Styles, StringComparison.Ordinal);
Assert.Matches(
@"\.playout-connection-copy small\s*\{[^}]*color:\s*var\(--ui-text-muted\)",
Styles);
Assert.Contains(".connection-state.connected::before", Styles, StringComparison.Ordinal);
Assert.Contains(
".connection-state.dry-run:not(.outcome-unknown)::before",
Styles,
StringComparison.Ordinal);
Assert.Contains(
"if (connection.textContent !== nextConnectionText)",
File.ReadAllText(Path.Combine(WebRoot, "playout-ui.js")),
StringComparison.Ordinal);
}
[Fact]
public void NavigationChangesPresentationWithoutNativeOrStoredState()
{