Merge design/overhaul-tasteskill: Flame Blue accent, home recomposition, motion layer

UI overhaul:
- Flame Blue (#1025A1) accent — official Wild Dragon brand standard
- Home: compact left-aligned header, primary ops + secondary 4-col rows
- Typography: hyphen fixes, sidebar version removed, topbar 48px
- Motion: staggered tile entry, button scale press, nav transitions
- Jobs stats: semantic card variants (active/failed/total)

🤖 Generated with Claude Code
This commit is contained in:
Claude 2026-06-02 13:28:54 +00:00
commit f8eda7fc37
11 changed files with 197 additions and 138 deletions

1
.claude/launch.json Normal file
View file

@ -0,0 +1 @@
{"version":"0.0.1","configurations":[{"name":"web-ui","runtimeExecutable":"npx","runtimeArgs":["serve","services/web-ui/public","--listen","47434","--no-clipboard"],"port":47434}]}

View file

@ -1,6 +1,6 @@
// app.jsx - main shell // app.jsx - main shell
const ACCENT = '#5B7CFA'; const ACCENT = '#1025A1';
function App() { function App() {
const [route, setRoute] = React.useState('home'); const [route, setRoute] = React.useState('home');
@ -41,8 +41,8 @@ function App() {
document.documentElement.style.setProperty('--accent', ACCENT); document.documentElement.style.setProperty('--accent', ACCENT);
document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14)); document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22)); document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25)); document.documentElement.style.setProperty('--accent-text', '#C7CFEA'); // Halo readable on dark
document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08)); document.documentElement.style.setProperty('--accent-hover', '#1830B8');
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {

View file

@ -436,10 +436,10 @@ function NewRecorderModal({ open, onClose }) {
<select className="field-input" value={growingOn ? 'h264_growing' : recCodec} <select className="field-input" value={growingOn ? 'h264_growing' : recCodec}
onChange={e => setRecCodec(e.target.value)} disabled={growingOn} onChange={e => setRecCodec(e.target.value)} disabled={growingOn}
style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}> style={{ appearance: 'auto', opacity: growingOn ? 0.6 : 1 }}>
{growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a) growing</option>} {growingOn && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">All-Intra HEVC (NVENC) GPU, growing</option> <option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option>
<option value="h264_nvenc">H.264 (NVENC) GPU</option> <option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ 4:2:2 CPU</option> <option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option> <option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option> <option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option> <option value="prores_proxy">ProRes 422 Proxy</option>
@ -461,8 +461,8 @@ function NewRecorderModal({ open, onClose }) {
) : ( ) : (
<Field label="Bitrate" value="Quality-based (profile)" select /> <Field label="Bitrate" value="Quality-based (profile)" select />
)} )}
<Field label="Resolution" value="Auto — from source" select /> <Field label="Resolution" value="Auto (from source)" select />
<Field label="Framerate" value="Auto — from source" select /> <Field label="Framerate" value="Auto (from source)" select />
{/* #3: warn when the configured bitrate exceeds the probed source {/* #3: warn when the configured bitrate exceeds the probed source
bitrate re-encoding above source adds storage, not quality. */} bitrate re-encoding above source adds storage, not quality. */}
{codecUsesBitrate && (() => { {codecUsesBitrate && (() => {

View file

@ -439,7 +439,7 @@ function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp
{!loading && !accessErr && (u.role === 'admin') && ( {!loading && !accessErr && (u.role === 'admin') && (
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="check" size={12} style={{ color: 'var(--success)' }} /> <Icon name="check" size={12} style={{ color: 'var(--success)' }} />
Admin full access to every project. Admin: full access to every project.
</div> </div>
)} )}
{!loading && !accessErr && u.role !== 'admin' && ( {!loading && !accessErr && u.role !== 'admin' && (
@ -1883,7 +1883,7 @@ function AddNodeModal({ onClose }) {
<React.Fragment> <React.Fragment>
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}> <div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
This token is shown only once copy the command now. This token is shown only once. Copy the command now.
</div> </div>
</div> </div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code> <code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
@ -2060,7 +2060,7 @@ function TotpSection() {
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}> <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
Scan the QR with Google Authenticator, Authy, or 1Password or enter this secret manually: Scan the QR with Google Authenticator, Authy, or 1Password, or enter this secret manually:
</div> </div>
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div> <div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
<div className="field"> <div className="field">
@ -2264,7 +2264,7 @@ function StorageWarningBanner() {
}}> }}>
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} /> <Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}> <div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
WARNING THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT. WARNING: THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS. CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
PLEASE USE WITH CAUTION. PLEASE USE WITH CAUTION.
</div> </div>

View file

@ -210,7 +210,7 @@
// An expired/used ticket means the user must start over. // An expired/used ticket means the user must start over.
if (r.status === 401 && /ticket/.test(body.error || '')) { if (r.status === 401 && /ticket/.test(body.error || '')) {
setTicket(null); setCode(''); setPassword(''); setTicket(null); setCode(''); setPassword('');
setError('Session timed out — please sign in again.'); setError('Session timed out. Please sign in again.');
} else { } else {
setError(body.error || ('Verification failed: ' + r.status)); setError(body.error || ('Verification failed: ' + r.status));
} }

View file

@ -134,7 +134,6 @@ function Home({ navigate }) {
<div className="launcher-inner"> <div className="launcher-inner">
<div className="launcher-hero"> <div className="launcher-hero">
<span className="launcher-logo-wrap"> <span className="launcher-logo-wrap">
<span className="launcher-logo-pulse" aria-hidden="true" />
<img <img
className="launcher-logo" className="launcher-logo"
src="img/dragon-logo.png" src="img/dragon-logo.png"
@ -142,15 +141,17 @@ function Home({ navigate }) {
draggable="false" draggable="false"
/> />
</span> </span>
<div className="launcher-hero-text">
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1> <h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
<p className="launcher-kicker">Let's Create</p>
<p className="launcher-tagline"> <p className="launcher-tagline">
Media Asset Management &amp; Production Platform Media Asset Management &amp; Production Platform
</p> </p>
</div> </div>
</div>
{/* Primary ops: Library, Recorders, Playout */}
<div className="launcher-grid"> <div className="launcher-grid">
{tiles.map(t => ( {tiles.slice(0, 3).map(t => (
<button <button
key={t.id} key={t.id}
className={'launcher-tile tone-' + t.tone} className={'launcher-tile tone-' + t.tone}
@ -167,38 +168,55 @@ function Home({ navigate }) {
</span> </span>
</button> </button>
))} ))}
</div>
{/* Secondary: Downloads, Jobs, Dashboard, Settings — smaller tiles */}
<div className="launcher-settings-row">
{tiles.slice(3).map(t => (
<button
key={t.id}
className={'launcher-tile tone-' + t.tone + ' launcher-tile-secondary'}
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
>
<span className="launcher-tile-icon">
<Icon name={t.icon} size={20} />
</span>
<span className="launcher-tile-label">{t.label}</span>
<span className="launcher-tile-sub">{t.sub}</span>
<span className="launcher-tile-desc">{t.desc}</span>
<span className="launcher-tile-arrow">
<Icon name="arrowRight" size={13} />
</span>
</button>
))}
<button <button
className="launcher-tile tone-ghost launcher-tile-secondary" className="launcher-tile tone-ghost launcher-tile-secondary"
onClick={() => navigate('dashboard')} onClick={() => navigate('dashboard')}
> >
<span className="launcher-tile-icon"> <span className="launcher-tile-icon">
<Icon name="layout" size={22} /> <Icon name="layout" size={20} />
</span> </span>
<span className="launcher-tile-label">Dashboard</span> <span className="launcher-tile-label">Dashboard</span>
<span className="launcher-tile-sub">Operations view</span> <span className="launcher-tile-sub">Operations view</span>
<span className="launcher-tile-desc"> <span className="launcher-tile-desc">
Recent activity, job queue, cluster health. Live recorders, job queue, cluster health.
</span> </span>
<span className="launcher-tile-arrow"> <span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} /> <Icon name="arrowRight" size={13} />
</span> </span>
</button> </button>
</div>
<div className="launcher-settings-row">
<button <button
className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-settings'} className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-secondary launcher-tile-settings'}
onClick={() => navigate(settingsTile.id)} onClick={() => navigate(settingsTile.id)}
> >
<span className="launcher-tile-icon"> <span className="launcher-tile-icon">
<Icon name={settingsTile.icon} size={26} /> <Icon name={settingsTile.icon} size={20} />
</span> </span>
<span className="launcher-tile-label">{settingsTile.label}</span> <span className="launcher-tile-label">{settingsTile.label}</span>
<span className="launcher-tile-sub">{settingsTile.sub}</span> <span className="launcher-tile-sub">{settingsTile.sub}</span>
<span className="launcher-tile-desc">{settingsTile.desc}</span> <span className="launcher-tile-desc">{settingsTile.desc}</span>
<span className="launcher-tile-arrow"> <span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} /> <Icon name="arrowRight" size={13} />
</span> </span>
</button> </button>
</div> </div>
@ -340,7 +358,7 @@ function DownloadsModal({ onClose }) {
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}> <span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
<Icon name="download" />Teams ISO (.exe) <Icon name="download" />Teams ISO (.exe)
</span> </span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon file pending</span> <span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon, file pending</span>
</> </>
)} )}
</div> </div>
@ -540,7 +558,7 @@ function Dashboard({ navigate }) {
const alerts = [ const alerts = [
...erroredRecorders.map(r => ({ ...erroredRecorders.map(r => ({
key: 'r-' + r.id, sev: 'danger', key: 'r-' + r.id, sev: 'danger',
title: r.name + ' recorder error', title: r.name + ': recorder error',
meta: r.error_message || r.url || 'signal lost', meta: r.error_message || r.url || 'signal lost',
action: 'Reconnect', to: 'recorders', action: 'Reconnect', to: 'recorders',
})), })),
@ -795,7 +813,7 @@ function OnAirEmpty({ sources, onStart }) {
<span className="onair-empty-icon"><Icon name="record" size={18} /></span> <span className="onair-empty-icon"><Icon name="record" size={18} /></span>
<div className="onair-empty-copy"> <div className="onair-empty-copy">
<div className="onair-empty-title">Nothing on air</div> <div className="onair-empty-title">Nothing on air</div>
<div className="onair-empty-sub">All recorders are idle start a source to begin capturing.</div> <div className="onair-empty-sub">All recorders are idle. Start a source to begin capturing.</div>
</div> </div>
<button className="btn primary" onClick={onStart}><Icon name="plus" size={14} />Start a recorder</button> <button className="btn primary" onClick={onStart}><Icon name="plus" size={14} />Start a recorder</button>
</div> </div>

View file

@ -172,26 +172,26 @@ function Jobs({ navigate }) {
<div className="page-body"> <div className="page-body">
<div className="jobs-stats"> <div className="jobs-stats">
<div className="stat-card"> <div className={'stat-card' + (counts.running > 0 ? ' stat-card--active' : '')}>
<div className="label">Running</div> <div className="label">Running</div>
<div className="value">{counts.running}</div> <div className="value">{counts.running}</div>
<div className="delta">{counts.queued} queued</div> <div className="delta">{counts.queued} queued</div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="label">Completed</div> <div className="label">Completed</div>
<div className="value">{counts.done}</div> <div className="value stat-value--muted">{counts.done}</div>
<div className="delta">Total done</div> <div className="delta">Total done</div>
</div> </div>
<div className="stat-card"> <div className={'stat-card' + (counts.failed > 0 ? ' stat-card--failed' : '')}>
<div className="label">Failed</div> <div className="label">Failed</div>
<div className="value">{counts.failed}</div> <div className={'value' + (counts.failed > 0 ? ' stat-value--danger' : ' stat-value--muted')}>{counts.failed}</div>
<div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}> <div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
{counts.failed > 0 ? 'Needs attention' : 'All clear'} {counts.failed > 0 ? 'Needs attention' : 'All clear'}
</div> </div>
</div> </div>
<div className="stat-card"> <div className="stat-card stat-card--quiet">
<div className="label">Total jobs</div> <div className="label">Total jobs</div>
<div className="value">{counts.all}</div> <div className="value stat-value--muted">{counts.all}</div>
<div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div> <div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
</div> </div>
</div> </div>

View file

@ -166,7 +166,6 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
> >
<img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" /> <img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" />
<div className="brand-name">Dragonflight</div> <div className="brand-name">Dragonflight</div>
<div className="brand-sub">v1.2.0</div>
</div> </div>
<button <button
className="icon-btn sidebar-toggle" className="icon-btn sidebar-toggle"

View file

@ -253,10 +253,10 @@
/* Convert the dark logo to white so it pops on the dark sidebar. /* Convert the dark logo to white so it pops on the dark sidebar.
brightness(0) collapses everything to black, invert(1) flips to white. brightness(0) collapses everything to black, invert(1) flips to white.
Works on both the original dark PNG and any transparent white PNG. */ Works on both the original dark PNG and any transparent white PNG. */
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(91, 124, 250, 0.25)); filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(16, 37, 161, 0.35));
} }
.sidebar-header:hover .brand-logo { .sidebar-header:hover .brand-logo {
filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(91, 124, 250, 0.45)); filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(16, 37, 161, 0.55));
} }
/* ============================================================ /* ============================================================
@ -268,139 +268,132 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
background: background:
radial-gradient(1100px 600px at 50% 0%, rgba(91, 124, 250, 0.10), transparent 65%), radial-gradient(1100px 500px at 50% -10%, rgba(16, 37, 161, 0.08), transparent 60%),
radial-gradient(900px 600px at 50% 100%, rgba(181, 124, 250, 0.06), transparent 60%),
var(--bg-0); var(--bg-0);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
padding: 48px 32px 64px; padding: 40px 32px 64px;
} }
.launcher-inner { .launcher-inner {
width: 100%; width: 100%;
max-width: 1160px; max-width: 1160px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
gap: 40px; gap: 32px;
} }
.launcher-hero { .launcher-hero {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
gap: 14px; gap: 18px;
text-align: center; text-align: left;
margin-top: 8px; padding-bottom: 24px;
border-bottom: 1px solid var(--border);
} }
/* Logo wrapper holds the animated pulse halo behind the image. */ /* Logo wrapper — compact, left-aligned mark. */
.launcher-logo-wrap { .launcher-logo-wrap {
position: relative; position: relative;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 180px; width: 52px;
height: 180px; height: 52px;
} flex-shrink: 0;
.launcher-logo-pulse {
position: absolute;
left: 50%;
top: 50%;
width: 200px;
height: 200px;
transform: translate(-50%, -50%) scale(0.85);
border-radius: 50%;
pointer-events: none;
z-index: 0;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
color-mix(in srgb, var(--accent) 22%, transparent) 38%,
transparent 68%
);
filter: blur(2px);
animation: launcherLogoPulse 3.4s ease-in-out infinite;
}
@keyframes launcherLogoPulse {
0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; }
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; }
} }
.launcher-logo-pulse { display: none; }
.launcher-logo { .launcher-logo {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 180px; width: 52px;
height: 180px; height: 52px;
object-fit: contain; object-fit: contain;
/* Convert to white - same approach as .brand-logo. */
filter: filter:
brightness(0) invert(1) brightness(0) invert(1)
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28)) drop-shadow(0 0 8px rgba(16, 37, 161, 0.35));
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18)); animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
} }
@keyframes launcherLogoIn { @keyframes launcherLogoIn {
from { opacity: 0; transform: translateY(8px) scale(0.96); } from { opacity: 0; transform: scale(0.88); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: scale(1); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.5; } .launcher-logo { animation: none; }
}
.launcher-hero-text {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
} }
.launcher-wordmark { .launcher-wordmark {
margin: 0; margin: 0;
font-size: 44px; font-size: 22px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.08em;
line-height: 1; line-height: 1;
color: var(--text-1); color: var(--text-1);
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15); text-shadow: none;
}
.launcher-kicker {
margin: 2px 0 0;
color: var(--accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
} }
.launcher-kicker { display: none; }
.launcher-tagline { .launcher-tagline {
margin: 0; margin: 0;
color: var(--text-3); color: var(--text-3);
font-size: 13.5px; font-size: 12.5px;
letter-spacing: 0.02em; letter-spacing: 0.01em;
white-space: nowrap;
}
@media (max-width: 480px) {
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
}
.launcher-tagline-motto {
margin-top: 6px;
color: var(--accent);
font-style: italic;
font-size: 15px;
letter-spacing: 0.04em;
} }
.launcher-tagline-motto { display: none; }
.launcher-grid { .launcher-grid {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px; gap: 12px;
} }
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } } @media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
/* Settings sits on its own centered row beneath the main grid. */ /* Secondary tiles: smaller vertical footprint, quieter treatment. */
.launcher-tile-secondary {
min-height: 120px;
padding: 16px 18px 18px;
}
.launcher-tile-secondary .launcher-tile-icon {
width: 34px;
height: 34px;
border-radius: 8px;
margin-bottom: 4px;
}
.launcher-tile-secondary .launcher-tile-icon svg {
width: 17px;
height: 17px;
}
.launcher-tile-secondary .launcher-tile-label {
font-size: 15px;
}
.launcher-tile-secondary .launcher-tile-desc {
font-size: 12px;
}
/* Settings row: holds the 4 secondary tiles. */
.launcher-settings-row { .launcher-settings-row {
width: 100%; width: 100%;
display: flex; display: grid;
justify-content: center; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
} }
.launcher-tile-settings { .launcher-tile-settings {
width: 100%; width: 100%;
max-width: calc((100% - 28px) / 3); max-width: 100%;
}
@media (max-width: 960px) {
.launcher-settings-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
.launcher-settings-row { grid-template-columns: 1fr; }
} }
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
.launcher-tile { .launcher-tile {
position: relative; position: relative;
@ -507,10 +500,10 @@
/* Tone variants - colour the icon tile + halo, leave the body text /* Tone variants - colour the icon tile + halo, leave the body text
neutral so the tile reads as a button, not a banner. */ neutral so the tile reads as a button, not a banner. */
.launcher-tile.tone-accent { .launcher-tile.tone-accent {
--tile-tint: rgba(91, 124, 250, 0.18); --tile-tint: rgba(16, 37, 161, 0.18);
--tile-icon-bg: var(--accent-soft); --tile-icon-bg: var(--accent-soft);
--tile-icon-fg: var(--accent-text); --tile-icon-fg: var(--accent-text);
--tile-icon-border: rgba(91, 124, 250, 0.30); --tile-icon-border: rgba(16, 37, 161, 0.30);
} }
.launcher-tile.tone-live { .launcher-tile.tone-live {
--tile-tint: rgba(255, 59, 48, 0.18); --tile-tint: rgba(255, 59, 48, 0.18);
@ -557,7 +550,7 @@
gap: 16px; gap: 16px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: flex-start;
margin-top: 4px; margin-top: 4px;
} }
.launcher-status-pip { .launcher-status-pip {
@ -585,12 +578,46 @@
.launcher-footer { .launcher-footer {
margin-top: 20px; margin-top: 20px;
text-align: center; text-align: left;
font-size: 11px; font-size: 11px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--text-4); color: var(--text-4);
} }
/* ============================================================
Motion layer entry stagger for launcher tiles.
Respects prefers-reduced-motion.
============================================================ */
@media (prefers-reduced-motion: no-preference) {
.launcher-tile {
animation: tileEnter 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.launcher-grid .launcher-tile:nth-child(1) { animation-delay: 60ms; }
.launcher-grid .launcher-tile:nth-child(2) { animation-delay: 110ms; }
.launcher-grid .launcher-tile:nth-child(3) { animation-delay: 160ms; }
.launcher-grid .launcher-tile:nth-child(4) { animation-delay: 210ms; }
.launcher-grid .launcher-tile:nth-child(5) { animation-delay: 250ms; }
.launcher-grid .launcher-tile:nth-child(6) { animation-delay: 290ms; }
.launcher-settings-row .launcher-tile { animation-delay: 320ms; }
@keyframes tileEnter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* Tactile press feedback on high-stakes operational buttons. */
.btn-record:active,
button.btn.primary:active {
transform: scale(0.97);
transition: transform 80ms ease-out;
}
/* Smooth active-item transition in sidebar nav. */
.nav-item {
transition: background 150ms ease-out, color 150ms ease-out;
}
/* ============================================================ /* ============================================================
Recorder row - signal indicator with a pulsing dot when Recorder row - signal indicator with a pulsing dot when
actually receiving frames. Closes part of #2. actually receiving frames. Closes part of #2.
@ -640,7 +667,7 @@
stroke-width: 1.5; stroke-width: 1.5;
} }
.bmd-card-trace { .bmd-card-trace {
stroke: rgba(91, 124, 250, 0.12); stroke: rgba(16, 37, 161, 0.12);
stroke-width: 0.5; stroke-width: 0.5;
fill: none; fill: none;
} }

View file

@ -171,7 +171,7 @@
gap: 20px; gap: 20px;
align-items: stretch; align-items: stretch;
background: background:
radial-gradient(ellipse at top, rgba(91,124,250,0.05), transparent 60%), radial-gradient(ellipse at top, rgba(16,37,161,0.05), transparent 60%),
var(--bg-0); var(--bg-0);
} }
.decklink-card-face { .decklink-card-face {
@ -971,7 +971,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.epg-week-day:last-child { border-bottom: 0; } .epg-week-day:last-child { border-bottom: 0; }
.epg-week-day.today { background: linear-gradient(180deg, rgba(91,124,250,0.04) 0%, transparent 80%); } .epg-week-day.today { background: linear-gradient(180deg, rgba(16,37,161,0.04) 0%, transparent 80%); }
.epg-week-dayhead { .epg-week-dayhead {
display: flex; align-items: baseline; gap: 10px; display: flex; align-items: baseline; gap: 10px;
padding: 10px 20px 6px; padding: 10px 20px 6px;
@ -1060,6 +1060,20 @@
line-height: 1.1; line-height: 1.1;
} }
/* Stat card semantic variants */
.stat-card--active {
border-color: var(--accent-soft-2);
background: linear-gradient(180deg, var(--accent-soft) 0%, var(--bg-1) 60%);
}
.stat-card--active .value { color: var(--accent-text); }
.stat-card--failed {
border-color: rgba(255,91,91,0.25);
background: linear-gradient(180deg, var(--danger-soft) 0%, var(--bg-1) 60%);
}
.stat-card--quiet { opacity: 0.75; }
.stat-value--muted { color: var(--text-2); }
.stat-value--danger { color: var(--danger); }
/* ========== Settings ========== */ /* ========== Settings ========== */
.settings-nav { .settings-nav {
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
@ -1222,7 +1236,7 @@
.token-tier.popular { .token-tier.popular {
border-color: var(--accent); border-color: var(--accent);
background: linear-gradient(180deg, var(--accent-soft) 0%, var(--bg-1) 40%); background: linear-gradient(180deg, var(--accent-soft) 0%, var(--bg-1) 40%);
box-shadow: 0 0 24px rgba(91,124,250,0.10); box-shadow: 0 0 24px rgba(16,37,161,0.12);
} }
.token-tier-badge { .token-tier-badge {
position: absolute; position: absolute;
@ -1345,7 +1359,7 @@
border-radius: 99px; border-radius: 99px;
flex-shrink: 0; flex-shrink: 0;
} }
.search-result-kind.kind-asset { color: #B4C3FF; border-color: rgba(91,124,250,0.25); } .search-result-kind.kind-asset { color: #C7CFEA; border-color: rgba(16,37,161,0.30); }
.search-result-kind.kind-project { color: #FFD89B; border-color: rgba(245,166,35,0.25); } .search-result-kind.kind-project { color: #FFD89B; border-color: rgba(245,166,35,0.25); }
.search-result-kind.kind-recorder { color: #FFAFAF; border-color: rgba(255,91,91,0.25); } .search-result-kind.kind-recorder { color: #FFAFAF; border-color: rgba(255,91,91,0.25); }
.search-result-kind.kind-job { color: #9EE7D2; border-color: rgba(45,212,168,0.25); } .search-result-kind.kind-job { color: #9EE7D2; border-color: rgba(45,212,168,0.25); }

View file

@ -18,12 +18,12 @@
--text-3: #8B92A0; /* WCAG AA (#133) - was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */ --text-3: #8B92A0; /* WCAG AA (#133) - was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */
--text-4: #6B7280; --text-4: #6B7280;
/* accent (blue, frame.io-ish) */ /* accent — Flame Blue (Wild Dragon brand, Pantone 286 C) */
--accent: #5B7CFA; --accent: #1025A1;
--accent-hover: #6E8BFF; --accent-hover: #1830B8;
--accent-soft: rgba(91, 124, 250, 0.14); --accent-soft: rgba(16, 37, 161, 0.14);
--accent-soft-2: rgba(91, 124, 250, 0.22); --accent-soft-2: rgba(16, 37, 161, 0.22);
--accent-text: #B4C3FF; --accent-text: #C7CFEA;
/* status */ /* status */
--success: #2DD4A8; --success: #2DD4A8;
@ -51,7 +51,7 @@
/* sizing (density tweakable) */ /* sizing (density tweakable) */
--sidebar-w: 232px; --sidebar-w: 232px;
--row-h: 44px; --row-h: 44px;
--topbar-h: 56px; --topbar-h: 48px;
--gap: 16px; --gap: 16px;
/* fonts */ /* fonts */
@ -244,10 +244,10 @@ a { color: inherit; text-decoration: none; }
.avatar { .avatar {
width: 28px; height: 28px; width: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #5B7CFA, #B57CFA); background: var(--accent);
display: grid; place-items: center; display: grid; place-items: center;
font-weight: 600; font-size: 11px; font-weight: 700; font-size: 11px;
color: white; color: #fff;
flex-shrink: 0; flex-shrink: 0;
} }
.user-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; } .user-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
@ -350,7 +350,7 @@ a { color: inherit; text-decoration: none; }
transition: background 80ms, border 80ms, color 80ms; transition: background 80ms, border 80ms, color 80ms;
white-space: nowrap; white-space: nowrap;
} }
.btn.primary { background: var(--accent); color: white; } .btn.primary { background: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent-hover); } .btn.primary:hover { background: var(--accent-hover); }
.btn.ghost { background: transparent; color: var(--text-2); } .btn.ghost { background: transparent; color: var(--text-2); }
.btn.ghost:hover { background: var(--hover); color: var(--text-1); } .btn.ghost:hover { background: var(--hover); color: var(--text-1); }