Add LOTL Timeline page for live tier progression visualization.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators get a dedicated Onion view with 14-tier stepper, fleet progress chips, and AI decision overlay when fleet AI control is enabled.
This commit is contained in:
@@ -35,12 +35,14 @@ function operatorDeckId(pathname: string): string {
|
||||
if (path.startsWith('/builds')) return 'builds';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||
@@ -103,6 +105,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M10 12l1.5 2L14 11" />
|
||||
</svg>
|
||||
);
|
||||
case 'onion':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<circle cx="12" cy="13" r="3" strokeOpacity="0.45" />
|
||||
<path d="M12 4v3M12 19v3M4 13h3M17 13h3" strokeOpacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'ember':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
|
||||
46
server/web/src/components/Lotl/LotlFleetOverview.tsx
Normal file
46
server/web/src/components/Lotl/LotlFleetOverview.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Agent } from '../../types';
|
||||
import { buildLotlTimelineModel, lotlTimelineProgressLabel } from '../../help/lotlTimeline';
|
||||
import type { TierAttempt } from '../../types/lotl';
|
||||
import './LotlTimeline.css';
|
||||
|
||||
interface Props {
|
||||
agents: Agent[];
|
||||
selectedId?: string;
|
||||
tierOrder: string[];
|
||||
onSelect: (agentId: string) => void;
|
||||
}
|
||||
|
||||
function agentAttempts(agent: Agent): TierAttempt[] {
|
||||
return agent.lotl_attempts ?? [];
|
||||
}
|
||||
|
||||
export default function LotlFleetOverview({ agents, selectedId, tierOrder, onSelect }: Props) {
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
if (online.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="lotl-fleet-overview" aria-label="Fleet tier progress">
|
||||
<div className="lotl-fleet-overview-title">FLEET ONION PROGRESS</div>
|
||||
{online.map((agent) => {
|
||||
const model = buildLotlTimelineModel(agent, tierOrder, agentAttempts(agent));
|
||||
const pct = model.total > 0 ? Math.round((model.succeeded / model.total) * 100) : 0;
|
||||
const selected = agent.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={`lotl-fleet-chip${selected ? ' lotl-fleet-chip--selected' : ''}`}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
title={`${agent.name} — ${lotlTimelineProgressLabel(model)} tiers`}
|
||||
>
|
||||
<span className="lotl-fleet-chip-name">{agent.name}</span>
|
||||
<div className="lotl-fleet-chip-bar" aria-hidden>
|
||||
<div className="lotl-fleet-chip-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="lotl-fleet-chip-meta">{lotlTimelineProgressLabel(model)} tiers</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
server/web/src/components/Lotl/LotlTierTimeline.test.tsx
Normal file
58
server/web/src/components/Lotl/LotlTierTimeline.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
||||
import LotlTierTimeline from './LotlTierTimeline';
|
||||
import { buildLotlTimelineModel, resolveLotlTierOrder } from '../../help/lotlTimeline';
|
||||
import { mockAgent } from '../../test/fixtures';
|
||||
|
||||
describe('LotlTierTimeline', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders 14 tiers from fixture attempts', () => {
|
||||
const order = resolveLotlTierOrder();
|
||||
expect(order).toHaveLength(14);
|
||||
|
||||
const agent = mockAgent({
|
||||
status: 'online',
|
||||
lotl_tier: 'wsl',
|
||||
lotl_attempts: [
|
||||
{ tier: 'vuln_recon', ok: true, duration_ms: 800, phase: 'recon' },
|
||||
{ tier: 'docker', ok: false, error: 'no daemon', duration_ms: 1200, phase: 'deploy' },
|
||||
{ tier: 'wsl', ok: false, error: 'distro missing', duration_ms: 900, phase: 'deploy' },
|
||||
],
|
||||
});
|
||||
|
||||
const model = buildLotlTimelineModel(agent, order, agent.lotl_attempts ?? []);
|
||||
expect(model.tiers).toHaveLength(14);
|
||||
expect(model.succeeded).toBe(1);
|
||||
expect(model.tiers[0].state).toBe('success');
|
||||
expect(model.tiers[1].state).toBe('failed');
|
||||
expect(model.tiers[2].state).toBe('trying');
|
||||
|
||||
render(<LotlTierTimeline model={model} agentName="Test Miner" />);
|
||||
|
||||
expect(screen.getByText('ONION TIER CHAIN')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/14 tiers succeeded')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(14);
|
||||
expect(screen.getByText('Vuln Recon')).toBeInTheDocument();
|
||||
expect(screen.getByText('GPO')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands attempt detail on tier click', () => {
|
||||
const order = resolveLotlTierOrder();
|
||||
const agent = mockAgent({
|
||||
lotl_attempts: [
|
||||
{ tier: 'docker', ok: false, error: 'AV blocked', duration_ms: 500, phase: 'deploy' },
|
||||
],
|
||||
});
|
||||
const model = buildLotlTimelineModel(agent, order, agent.lotl_attempts ?? []);
|
||||
|
||||
render(<LotlTierTimeline model={model} agentName="Node A" />);
|
||||
fireEvent.click(screen.getByText('Docker'));
|
||||
expect(screen.getByText('AV blocked')).toBeInTheDocument();
|
||||
expect(screen.getByText('deploy')).toBeInTheDocument();
|
||||
expect(screen.getByText('500ms')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
109
server/web/src/components/Lotl/LotlTierTimeline.tsx
Normal file
109
server/web/src/components/Lotl/LotlTierTimeline.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { LotlTimelineModel, LotlTimelineTierRow } from '../../help/lotlTimeline';
|
||||
import { formatDurationMs } from '../../types/lotl';
|
||||
import './LotlTimeline.css';
|
||||
import '../Fleet/LotlVisuals.css';
|
||||
|
||||
interface Props {
|
||||
model: LotlTimelineModel;
|
||||
agentName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function stateGlyph(state: LotlTimelineTierRow['state']): string {
|
||||
switch (state) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
case 'skipped':
|
||||
return '—';
|
||||
case 'trying':
|
||||
return '◉';
|
||||
default:
|
||||
return '·';
|
||||
}
|
||||
}
|
||||
|
||||
function TierDetail({ row }: { row: LotlTimelineTierRow }) {
|
||||
const { attempt } = row;
|
||||
return (
|
||||
<div className="lotl-tier-detail" role="region" aria-label={`${row.label} attempt detail`}>
|
||||
<div className="lotl-tier-detail-title">{row.label.toUpperCase()}</div>
|
||||
{row.hint && <div className="lotl-tier-detail-row">{row.hint}</div>}
|
||||
{attempt ? (
|
||||
<>
|
||||
<div className="lotl-tier-detail-row">
|
||||
<span className={`lotl-attempt-icon ${attempt.ok ? 'ok' : 'fail'}`}>
|
||||
{attempt.ok ? '✓' : '✗'}
|
||||
</span>
|
||||
<span>{attempt.ok ? 'Succeeded' : 'Failed'}</span>
|
||||
{attempt.duration_ms !== undefined && (
|
||||
<span className="lotl-attempt-dur">{formatDurationMs(attempt.duration_ms)}</span>
|
||||
)}
|
||||
{attempt.phase && <span className="lotl-tier-detail-tag">{attempt.phase}</span>}
|
||||
</div>
|
||||
{!attempt.ok && attempt.error && (
|
||||
<div className="lotl-tier-detail-err">{attempt.error}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="lotl-tier-detail-row">
|
||||
{row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LotlTierTimeline({ model, agentName, className = '' }: Props) {
|
||||
const [expandedTier, setExpandedTier] = useState<string | null>(null);
|
||||
|
||||
const expandedRow = useMemo(
|
||||
() => model.tiers.find((t) => t.tier === expandedTier),
|
||||
[model.tiers, expandedTier],
|
||||
);
|
||||
|
||||
const toggleTier = (tier: string) => {
|
||||
setExpandedTier((prev) => (prev === tier ? null : tier));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`lotl-tier-timeline ${className}`.trim()} aria-label="LOTL tier progression">
|
||||
<div className="lotl-tier-timeline-header">
|
||||
<div>
|
||||
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
|
||||
<div className="lotl-tier-timeline-agent">{agentName}</div>
|
||||
</div>
|
||||
<div className="lotl-tier-timeline-progress">
|
||||
{model.succeeded}/{model.total} tiers succeeded
|
||||
{model.tryingTier && (
|
||||
<span style={{ marginLeft: '0.5rem', color: '#ffb070' }}>
|
||||
· trying {model.tryingTier.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotl-tier-track" role="list">
|
||||
{model.tiers.map((row) => (
|
||||
<button
|
||||
key={row.tier}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`lotl-tier-step lotl-tier-step--${row.state}${expandedTier === row.tier ? ' lotl-tier-step--expanded' : ''}`}
|
||||
onClick={() => toggleTier(row.tier)}
|
||||
title={row.hint || row.label}
|
||||
aria-expanded={expandedTier === row.tier}
|
||||
aria-current={row.state === 'trying' ? 'step' : undefined}
|
||||
>
|
||||
<span className="lotl-tier-node">{stateGlyph(row.state)}</span>
|
||||
<span className="lotl-tier-label">{row.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{expandedRow && <TierDetail row={expandedRow} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
352
server/web/src/components/Lotl/LotlTimeline.css
Normal file
352
server/web/src/components/Lotl/LotlTimeline.css
Normal file
@@ -0,0 +1,352 @@
|
||||
/* LOTL Onion Timeline — ember-accent progression stepper */
|
||||
|
||||
.lotl-timeline-page {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.lotl-timeline-page .page-header h1 {
|
||||
background: linear-gradient(90deg, #e8c872 0%, #ff8c3a 45%, #e85d4a 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.lotl-fleet-overview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--deck-card-radius, 6px);
|
||||
border: 1px solid rgba(232, 140, 58, 0.22);
|
||||
background: rgba(8, 6, 4, 0.55);
|
||||
}
|
||||
|
||||
.lotl-fleet-overview-title {
|
||||
width: 100%;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(232, 180, 100, 0.85);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.lotl-fleet-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 120px;
|
||||
max-width: 160px;
|
||||
flex: 1 1 120px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lotl-fleet-chip:hover {
|
||||
border-color: rgba(255, 140, 58, 0.35);
|
||||
}
|
||||
|
||||
.lotl-fleet-chip--selected {
|
||||
border-color: rgba(255, 140, 58, 0.55);
|
||||
box-shadow: 0 0 12px rgba(232, 93, 74, 0.18);
|
||||
}
|
||||
|
||||
.lotl-fleet-chip-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lotl-fleet-chip-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lotl-fleet-chip-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #c4ad5a, #ff8c3a);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.lotl-fleet-chip-meta {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.lotl-tier-timeline {
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
border-radius: var(--deck-card-radius, 6px);
|
||||
border: 1px solid rgba(232, 140, 58, 0.2);
|
||||
background: linear-gradient(165deg, rgba(14, 10, 8, 0.92) 0%, rgba(6, 5, 8, 0.96) 100%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 180, 80, 0.06);
|
||||
}
|
||||
|
||||
.lotl-tier-timeline-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.lotl-tier-timeline-title {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(232, 180, 100, 0.9);
|
||||
}
|
||||
|
||||
.lotl-tier-timeline-agent {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lotl-tier-timeline-progress {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.75rem;
|
||||
color: #ffaa66;
|
||||
}
|
||||
|
||||
.lotl-tier-track {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 0;
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.lotl-tier-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 0;
|
||||
min-width: 72px;
|
||||
max-width: 96px;
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.15rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.lotl-tier-step:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: calc(50% + 14px);
|
||||
width: calc(100% - 28px);
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.lotl-tier-step--success:not(:last-child)::after,
|
||||
.lotl-tier-step--trying:not(:last-child)::after {
|
||||
background: linear-gradient(90deg, rgba(255, 140, 58, 0.5), rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.lotl-tier-node {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: border-color 0.2s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.lotl-tier-step--pending .lotl-tier-node {
|
||||
color: var(--text-muted);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.lotl-tier-step--skipped .lotl-tier-node {
|
||||
color: rgba(180, 180, 190, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.lotl-tier-step--success .lotl-tier-node {
|
||||
color: #7dffaa;
|
||||
border-color: rgba(100, 220, 140, 0.45);
|
||||
background: rgba(40, 80, 50, 0.35);
|
||||
}
|
||||
|
||||
.lotl-tier-step--failed .lotl-tier-node {
|
||||
color: #ff8866;
|
||||
border-color: rgba(255, 100, 60, 0.45);
|
||||
background: rgba(80, 30, 20, 0.35);
|
||||
}
|
||||
|
||||
.lotl-tier-step--trying .lotl-tier-node {
|
||||
color: #ffb84d;
|
||||
border-color: rgba(255, 160, 60, 0.7);
|
||||
background: rgba(60, 35, 15, 0.5);
|
||||
box-shadow:
|
||||
0 0 14px rgba(255, 120, 40, 0.45),
|
||||
0 0 28px rgba(232, 93, 74, 0.2);
|
||||
animation: lotl-tier-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes lotl-tier-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 120, 40, 0.35),
|
||||
0 0 20px rgba(232, 93, 74, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 18px rgba(255, 140, 58, 0.55),
|
||||
0 0 36px rgba(232, 93, 74, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
.lotl-tier-label {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.58rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
color: var(--text-muted);
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.lotl-tier-step--trying .lotl-tier-label,
|
||||
.lotl-tier-step--success .lotl-tier-label {
|
||||
color: rgba(240, 220, 200, 0.9);
|
||||
}
|
||||
|
||||
.lotl-tier-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-left: 2px solid rgba(255, 140, 58, 0.4);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.lotl-tier-detail-title {
|
||||
color: #ffb070;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.68rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.lotl-tier-detail-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.lotl-tier-detail-tag {
|
||||
font-size: 0.62rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 140, 58, 0.12);
|
||||
border: 1px solid rgba(255, 140, 58, 0.25);
|
||||
color: #ffaa66;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.lotl-tier-detail-err {
|
||||
color: #ffaa88;
|
||||
font-size: 0.68rem;
|
||||
margin-top: 0.25rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.lotl-ai-decision {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 232, 245, 0.2);
|
||||
background: rgba(0, 40, 50, 0.25);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.lotl-ai-decision-label {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--neon-cyan);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.lotl-timeline-links {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lotl-timeline-links a {
|
||||
color: rgba(255, 170, 100, 0.9);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lotl-timeline-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.lotl-timeline-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.lotl-tier-track {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.lotl-tier-step {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 100%;
|
||||
max-width: none;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.lotl-tier-step:not(:last-child)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lotl-tier-label {
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -923,5 +923,6 @@ describe('Layout', () => {
|
||||
});
|
||||
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user