From b67c984d0aa7e0f187506a3170b7b9fbc5dceaee Mon Sep 17 00:00:00 2001 From: Trung Nguyen Date: Mon, 27 Jul 2026 20:24:02 +0000 Subject: [PATCH] Polish PatchLab visuals and rack interaction feedback Strengthen brand hierarchy, atmosphere, and motion; deepen rack chassis detail; improve arm/target/cable affordances; add Playwright polish checks. --- e2e/patchlab.spec.ts | 44 ++++++ src/styles/app.css | 303 ++++++++++++++++++++++++++++++++++++------ src/styles/tokens.css | 14 +- src/ui/RackView.tsx | 98 ++++++++++---- 4 files changed, 390 insertions(+), 69 deletions(-) diff --git a/e2e/patchlab.spec.ts b/e2e/patchlab.spec.ts index 2f1f649..99a2bf8 100644 --- a/e2e/patchlab.spec.ts +++ b/e2e/patchlab.spec.ts @@ -24,6 +24,50 @@ test.beforeAll(() => { fs.mkdirSync(shotDir, { recursive: true }); }); +test.describe('visual & interaction polish', () => { + test('home brand hierarchy and rack arm feedback', async ({ page }) => { + await clearApp(page); + await shot(page, 'polish-01-home'); + + const brand = page.locator('.brand-mark').first(); + await expect(brand).toContainText('PatchLab'); + const brandSize = await brand.evaluate((el) => + parseFloat(getComputedStyle(el).fontSize), + ); + const headlineSize = await page + .locator('.hero h1') + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(brandSize).toBeGreaterThanOrEqual(headlineSize); + + await startMission(page, /First Lights On/i); + await expect(page.locator('svg.rack-svg')).toBeVisible(); + await expect(page.locator('.device-chassis.focused')).toHaveCount(1); + await shot(page, 'polish-02-rack'); + + await tapPort(page, /Panel-A A-01/); + await expect(page.locator('.tip-bar.armed')).toBeVisible(); + await expect(page.locator('.armed-chip')).toContainText(/Armed:/i); + await expect(page.locator('.port-shell.selected')).toHaveCount(1); + await expect(page.locator('.port-shell.valid-target').first()).toBeVisible(); + await shot(page, 'polish-03-armed'); + + await tapPort(page, /ToR-SW-A Gi1\/0\/1 VLAN 10/); + await expect(page.locator('.tip-bar.armed')).toHaveCount(0); + await expect(page.locator('.cable-path').first()).toBeVisible(); + await shot(page, 'polish-04-patched'); + }); + + test('mobile home and brief remain usable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await clearApp(page); + await shot(page, 'polish-05-mobile-home'); + await expect(page.locator('.brand-mark')).toBeVisible(); + await page.getByRole('button', { name: /First Lights On/i }).click(); + await expect(page.getByRole('heading', { name: /First Lights On/i })).toBeVisible(); + await shot(page, 'polish-06-mobile-brief'); + }); +}); + test.describe('home & shell', () => { test('home, glossary, and sound toggle', async ({ page }) => { await clearApp(page); diff --git a/src/styles/app.css b/src/styles/app.css index 73633b9..e4ae291 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -15,9 +15,10 @@ body { color: var(--ink); font-family: var(--font-body); background: - radial-gradient(1200px 600px at 15% -10%, rgba(61, 220, 181, 0.12), transparent 55%), - radial-gradient(900px 500px at 100% 0%, rgba(59, 130, 246, 0.1), transparent 50%), - linear-gradient(180deg, #151a21 0%, var(--bg-0) 45%, #0e1217 100%); + radial-gradient(1100px 640px at 12% -8%, rgba(61, 220, 181, 0.14), transparent 58%), + radial-gradient(900px 520px at 92% 4%, rgba(59, 130, 246, 0.1), transparent 52%), + radial-gradient(700px 420px at 50% 110%, rgba(224, 161, 6, 0.06), transparent 55%), + linear-gradient(180deg, #161c24 0%, var(--bg-0) 42%, #0c1015 100%); background-attachment: fixed; } @@ -26,12 +27,22 @@ body::before { position: fixed; inset: 0; pointer-events: none; - opacity: 0.22; + opacity: 0.28; background-image: - linear-gradient(rgba(232, 238, 244, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(232, 238, 244, 0.04) 1px, transparent 1px); - background-size: 28px 28px; - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.8), transparent 85%); + linear-gradient(rgba(232, 238, 244, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(232, 238, 244, 0.035) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent 88%); +} + +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(ellipse 80% 55% at 50% 0%, transparent 40%, rgba(0, 0, 0, 0.35) 100%); + z-index: 0; } button, @@ -64,15 +75,29 @@ button { .brand-mark { display: inline-flex; align-items: center; - gap: 10px; + gap: 12px; + font-size: clamp(1.85rem, 5.5vw, 2.55rem); + font-weight: 700; + line-height: 1; } .brand-mark .dot { - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; background: var(--cyan); - box-shadow: 0 0 0 6px var(--cyan-dim); + box-shadow: 0 0 0 7px var(--cyan-dim); + animation: brand-breathe 2.8s ease-in-out infinite; +} + +@keyframes brand-breathe { + 0%, + 100% { + box-shadow: 0 0 0 6px var(--cyan-dim); + } + 50% { + box-shadow: 0 0 0 10px rgba(61, 220, 181, 0.28); + } } h1, @@ -90,10 +115,11 @@ p { } .panel { - background: linear-gradient(180deg, rgba(36, 48, 64, 0.92), rgba(27, 33, 40, 0.96)); + background: linear-gradient(165deg, rgba(42, 54, 70, 0.94), rgba(24, 30, 38, 0.97) 55%); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); + backdrop-filter: blur(8px); } .btn { @@ -102,30 +128,43 @@ p { border-radius: 12px; padding: 12px 16px; font-weight: 600; - transition: transform 120ms ease, background 120ms ease, border-color 120ms ease; + transition: + transform var(--dur-fast) var(--ease-out), + background var(--dur-fast) ease, + border-color var(--dur-fast) ease, + box-shadow var(--dur-fast) ease, + filter var(--dur-fast) ease; +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); } -.btn:active { - transform: translateY(1px); +.btn:active:not(:disabled) { + transform: translateY(1px) scale(0.98); } .btn-primary { - background: var(--cyan); + background: linear-gradient(180deg, #4ae6c2 0%, var(--cyan) 100%); color: #0b1512; + box-shadow: 0 6px 18px rgba(61, 220, 181, 0.28); } -.btn-primary:hover { - filter: brightness(1.05); +.btn-primary:hover:not(:disabled) { + filter: brightness(1.06); + box-shadow: 0 8px 22px rgba(61, 220, 181, 0.38); } .btn-ghost { - background: transparent; + background: rgba(255, 255, 255, 0.02); color: var(--ink); border-color: var(--line); } -.btn-ghost:hover { - background: rgba(255, 255, 255, 0.04); +.btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(232, 238, 244, 0.22); } .btn:disabled { @@ -133,6 +172,24 @@ p { cursor: not-allowed; } +.screen-home, +.screen-brief, +.screen-debrief, +.screen-rack { + animation: screen-in var(--dur-med) var(--ease-out); +} + +@keyframes screen-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + .screen-home { display: grid; gap: 22px; @@ -141,18 +198,21 @@ p { .hero { display: grid; - gap: 10px; - padding: 8px 4px 4px; + gap: 12px; + padding: 10px 4px 6px; } .hero h1 { - font-size: clamp(2.4rem, 8vw, 3.6rem); - line-height: 0.95; + font-size: clamp(1.55rem, 4.8vw, 2.15rem); + line-height: 1.12; + font-weight: 600; + color: var(--ink-muted); + max-width: 18ch; } .hero p { - max-width: 36ch; - font-size: 1.05rem; + max-width: 38ch; + font-size: 1.02rem; } .progress-strip { @@ -218,6 +278,7 @@ p { border-radius: inherit; background: linear-gradient(90deg, #2dd4bf, #3ddcb5 55%, #fbbf24); transition: width 0.35s ease; + box-shadow: 0 0 12px rgba(61, 220, 181, 0.35); } .chapter-rail { @@ -236,12 +297,18 @@ p { background: rgba(15, 23, 42, 0.45); font-size: 0.78rem; color: var(--ink-muted); + transition: + border-color var(--dur-fast) ease, + background var(--dur-fast) ease, + color var(--dur-fast) ease, + transform var(--dur-fast) var(--ease-out); } .chapter-chip.active { border-color: rgba(61, 220, 181, 0.55); color: var(--ink); background: rgba(61, 220, 181, 0.1); + transform: translateY(-1px); } .chapter-chip.complete { @@ -287,6 +354,18 @@ p { display: grid; gap: 12px; padding: 22px; + animation: coach-card-in 320ms var(--ease-out); +} + +@keyframes coach-card-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: none; + } } .coach-kicker { @@ -448,11 +527,28 @@ p { background: rgba(27, 33, 40, 0.85); border: 1px solid var(--line); border-radius: 14px; + transition: + transform var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) ease, + background var(--dur-fast) ease, + box-shadow var(--dur-fast) ease; } .mission-card:hover:not(:disabled) { - border-color: rgba(61, 220, 181, 0.45); - background: rgba(36, 48, 64, 0.95); + border-color: rgba(61, 220, 181, 0.5); + background: rgba(36, 48, 64, 0.96); + transform: translateY(-2px); + box-shadow: var(--shadow-soft); +} + +.mission-card:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: rgba(61, 220, 181, 0.55); +} + +.mission-card:active:not(:disabled) { + transform: translateY(0); } .mission-card:disabled { @@ -534,11 +630,36 @@ p { } .rack-frame { - border: 1px solid rgba(156, 163, 175, 0.25); - border-radius: 12px; - padding: 8px; + position: relative; + border: 1px solid rgba(156, 163, 175, 0.28); + border-radius: 14px; + padding: 10px; background: - linear-gradient(180deg, rgba(17, 24, 39, 0.65), rgba(10, 14, 20, 0.9)); + radial-gradient(ellipse 70% 40% at 50% 0%, rgba(61, 220, 181, 0.07), transparent 60%), + linear-gradient(180deg, rgba(22, 30, 40, 0.92), rgba(8, 12, 18, 0.96)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + inset 0 0 40px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.rack-frame::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.18; + background-image: repeating-linear-gradient( + 90deg, + transparent, + transparent 11px, + rgba(232, 238, 244, 0.03) 11px, + rgba(232, 238, 244, 0.03) 12px + ); +} + +.device-chassis.focused .device-face { + filter: drop-shadow(0 0 8px rgba(61, 220, 181, 0.22)); } .port-shell.console { @@ -558,6 +679,17 @@ p { fill: #7dd3fc; } +.port-hit:hover .port-shell:not(.selected):not(.snap-target) { + stroke: rgba(232, 238, 244, 0.55); + stroke-width: 2; + fill: #1b2430; +} + +.port-hit:hover .port-shell.valid-target:not(.snap-target) { + stroke: rgba(61, 220, 181, 0.85); + stroke-width: 2.4; +} + .config-panel { padding: 14px; display: grid; @@ -565,6 +697,18 @@ p { align-content: start; max-height: min(70vh, 720px); overflow: auto; + animation: panel-slide var(--dur-med) var(--ease-out); +} + +@keyframes panel-slide { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: none; + } } .config-head h3 { @@ -606,6 +750,14 @@ p { background: rgba(15, 18, 24, 0.8); color: var(--ink); padding: 8px 10px; + transition: border-color var(--dur-fast) ease, box-shadow var(--dur-fast) ease; +} + +.config-block input:focus, +.config-block select:focus { + outline: none; + border-color: rgba(61, 220, 181, 0.55); + box-shadow: var(--focus-ring); } .rule-list { @@ -793,12 +945,23 @@ p { stroke: rgba(61, 220, 181, 0.55); stroke-width: 2; filter: drop-shadow(0 0 5px rgba(61, 220, 181, 0.28)); + animation: valid-breathe 1.4s ease-in-out infinite; } .port-shell.flash { animation: port-flash 280ms ease-out; } +@keyframes valid-breathe { + 0%, + 100% { + filter: drop-shadow(0 0 4px rgba(61, 220, 181, 0.22)); + } + 50% { + filter: drop-shadow(0 0 10px rgba(61, 220, 181, 0.45)); + } +} + .rack-svg.aiming { cursor: crosshair; } @@ -844,36 +1007,40 @@ p { stroke-linecap: round; stroke-width: 4; pointer-events: none; - transition: opacity 100ms ease, stroke-width 100ms ease; + transition: opacity 100ms ease, stroke-width 100ms ease, filter 100ms ease; } .cable-hit:hover .cable-path { - opacity: 0.9; - stroke-width: 5; + opacity: 0.95; + stroke-width: 5.5; + filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.45)); } .cable-ghost { fill: none; - stroke: rgba(59, 130, 246, 0.7); + stroke: rgba(59, 130, 246, 0.78); stroke-width: 3.5; stroke-dasharray: 5 5; stroke-linecap: round; pointer-events: none; - animation: dash 0.55s linear infinite; + animation: dash 0.45s linear infinite; + filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.4)); } .cable-hint { fill: none; - stroke: rgba(61, 220, 181, 0.9); + stroke: rgba(61, 220, 181, 0.92); stroke-width: 4; stroke-dasharray: 8 7; stroke-linecap: round; pointer-events: none; animation: dash 1.1s linear infinite; + filter: drop-shadow(0 0 6px rgba(61, 220, 181, 0.35)); } .cable-path.cable-glow { stroke-width: 4.5; + filter: drop-shadow(0 0 6px rgba(61, 220, 181, 0.55)); } @keyframes dash { @@ -1016,11 +1183,32 @@ p { border-radius: 14px; border: 1px solid var(--line); background: rgba(27, 33, 40, 0.95); + transition: + border-color var(--dur-med) ease, + box-shadow var(--dur-med) ease, + background var(--dur-med) ease; } .tip-bar.armed { - border-color: rgba(61, 220, 181, 0.45); - box-shadow: inset 0 0 0 1px rgba(61, 220, 181, 0.12); + border-color: rgba(61, 220, 181, 0.55); + box-shadow: + inset 0 0 0 1px rgba(61, 220, 181, 0.14), + 0 0 24px rgba(61, 220, 181, 0.12); + animation: tip-arm-pulse 1.6s ease-in-out infinite; +} + +@keyframes tip-arm-pulse { + 0%, + 100% { + box-shadow: + inset 0 0 0 1px rgba(61, 220, 181, 0.12), + 0 0 16px rgba(61, 220, 181, 0.08); + } + 50% { + box-shadow: + inset 0 0 0 1px rgba(61, 220, 181, 0.22), + 0 0 28px rgba(61, 220, 181, 0.18); + } } .armed-chip { @@ -1033,6 +1221,18 @@ p { font-size: 0.8rem; font-weight: 600; white-space: nowrap; + animation: chip-pop var(--dur-med) var(--ease-spring); +} + +@keyframes chip-pop { + from { + transform: scale(0.86); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } } .tip-bar .row { @@ -1078,12 +1278,27 @@ p { border-radius: 999px; border: 1px solid var(--line); color: var(--ink-muted); + transition: + color var(--dur-fast) ease, + border-color var(--dur-fast) ease, + background var(--dur-fast) ease, + transform var(--dur-med) var(--ease-spring); } .goal-pill.met { color: var(--cyan); border-color: rgba(61, 220, 181, 0.4); background: var(--cyan-dim); + animation: goal-met var(--dur-med) var(--ease-spring); +} + +@keyframes goal-met { + from { + transform: scale(0.92); + } + to { + transform: scale(1); + } } .debrief-stars { @@ -1127,6 +1342,10 @@ p { } } +.port-led.up.pulse { + filter: drop-shadow(0 0 4px rgba(61, 220, 181, 0.7)); +} + @media (max-width: 640px) { .app-shell { padding: 14px 12px calc(20px + var(--safe-bottom)); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index e2cca95..e9e6962 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,8 +1,8 @@ :root { - --bg-0: #12161c; - --bg-1: #1b2128; + --bg-0: #10151b; + --bg-1: #1a2129; --bg-2: #243040; - --bg-3: #2a3340; + --bg-3: #2c3644; --ink: #e8eef4; --ink-muted: #9aa8b5; --line: rgba(232, 238, 244, 0.12); @@ -15,8 +15,14 @@ --font-display: 'Space Grotesk', sans-serif; --font-body: 'IBM Plex Sans', sans-serif; --radius: 14px; - --shadow: 0 18px 50px rgba(0, 0, 0, 0.35); + --shadow: 0 18px 50px rgba(0, 0, 0, 0.38); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.28); --safe-bottom: env(safe-area-inset-bottom, 0px); + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); + --dur-fast: 120ms; + --dur-med: 220ms; + --focus-ring: 0 0 0 3px rgba(61, 220, 181, 0.35); } @media (prefers-reduced-motion: reduce) { diff --git a/src/ui/RackView.tsx b/src/ui/RackView.tsx index 25bef2f..1b85c32 100644 --- a/src/ui/RackView.tsx +++ b/src/ui/RackView.tsx @@ -630,15 +630,25 @@ export function RackView({ > - - - + + + + + + + + + - - - + + + + + + + @@ -658,6 +668,16 @@ export function RackView({ fill="url(#rail)" rx="2" /> + {/* Mounting holes on rails */} + {Array.from({ length: Math.floor((height - 32) / 18) }, (_, i) => { + const hy = 20 + i * 18; + return ( + + + + + ); + })} {ordered.map((device, row) => { const y = 22 + row * ROW_H; @@ -676,7 +696,7 @@ export function RackView({ return ( - + {/* Faceplate screws */} + + + + + + {device.name} - + {device.model ?? device.role} · U{device.rackUnitStart} {powered ? ' · PWR' : ' · OFF'} @@ -724,20 +760,36 @@ export function RackView({ width={150} height={22} rx={3} - fill="#0b1220" - stroke="rgba(61,220,181,0.25)" + fill="#070d16" + stroke={ + powered + ? 'rgba(61,220,181,0.35)' + : 'rgba(148,163,184,0.2)' + } /> - {device.role === 'firewall' - ? `ACL ${(device.firewallRules ?? []).length}` - : 'PORTS OK'} + {powered + ? device.role === 'firewall' + ? `ACL ${(device.firewallRules ?? []).length}` + : 'PORTS OK' + : 'NO PWR'} + )}