Replace startup mocks with loading skeletons
This commit is contained in:
@@ -47,3 +47,4 @@ Updated: 2026-04-02
|
|||||||
30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive.
|
30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive.
|
||||||
31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure.
|
31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure.
|
||||||
32. Restored proxy-link copying for plain-`http` deployments by falling back from the Clipboard API to `execCommand('copy')`, and added regression coverage for both clipboard paths.
|
32. Restored proxy-link copying for plain-`http` deployments by falling back from the Clipboard API to `execCommand('copy')`, and added regression coverage for both clipboard paths.
|
||||||
|
33. Replaced stale startup mock values with explicit skeleton loading states so the shell no longer flashes fallback dashboard/users/settings data before the first live snapshot arrives.
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ Updated: 2026-04-02
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- `src/main.tsx`: application bootstrap
|
- `src/main.tsx`: application bootstrap
|
||||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, skeleton-first startup hydration, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||||
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
|
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
|
||||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
- `src/App.test.tsx`: login-gate, startup skeleton, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||||
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling
|
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, startup skeleton shimmer states, and connection banner styling
|
||||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||||
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
await user.type(screen.getByLabelText(/login/i), 'admin');
|
await user.type(screen.getByLabelText(/login/i), 'admin');
|
||||||
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
||||||
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
||||||
|
await waitFor(() => expect(MockWebSocket.instances.length).toBeGreaterThan(0));
|
||||||
|
MockWebSocket.instances.at(-1)?.emitMessage({
|
||||||
|
type: 'snapshot.init',
|
||||||
|
snapshot: fallbackDashboardSnapshot,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /settings/i })).not.toBeDisabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
|
function mockClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
|
||||||
@@ -64,6 +70,19 @@ describe('App login gate', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows loading skeletons instead of stale mock values before the first snapshot arrives', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/login/i), 'admin');
|
||||||
|
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
||||||
|
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/edge\.example\.net/i)).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelectorAll('.skeleton-line').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('stores panel language in localStorage and restores it after a remount', async () => {
|
it('stores panel language in localStorage and restores it after a remount', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const firstRender = render(<App />);
|
const firstRender = render(<App />);
|
||||||
|
|||||||
269
src/App.tsx
269
src/App.tsx
@@ -831,6 +831,259 @@ function TrashIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkeletonLine({ className = '' }: { className?: string }) {
|
||||||
|
return <span className={`skeleton-line ${className}`.trim()} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShellLoadingState({
|
||||||
|
activeTab,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
activeTab: TabId;
|
||||||
|
text: ReturnType<typeof getPanelText>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="shell-header" aria-busy="true">
|
||||||
|
<div className="shell-title">
|
||||||
|
<h1>3proxy UI</h1>
|
||||||
|
<SkeletonLine className="skeleton-host" />
|
||||||
|
</div>
|
||||||
|
<div className="header-meta">
|
||||||
|
{[text.common.status, text.common.version, text.common.users].map((label) => (
|
||||||
|
<div key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<SkeletonLine className="skeleton-meta-value" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="button-secondary" disabled>
|
||||||
|
{text.common.signOut}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="tabbar" aria-label="Primary">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{text.tabs[tab.textKey]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{activeTab === 'dashboard' ? <DashboardSkeleton text={text} /> : null}
|
||||||
|
{activeTab === 'users' ? <UsersSkeleton text={text} /> : null}
|
||||||
|
{activeTab === 'system' ? <SystemSkeleton text={text} /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||||
|
return (
|
||||||
|
<section className="page-grid" aria-busy="true">
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.dashboard.service}</h2>
|
||||||
|
<SkeletonLine className="skeleton-pill" />
|
||||||
|
</div>
|
||||||
|
<dl className="kv-list">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<dt><SkeletonLine className="skeleton-label" /></dt>
|
||||||
|
<dd><SkeletonLine className="skeleton-value" /></dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="actions-row">
|
||||||
|
<button type="button" disabled>{text.dashboard.start}</button>
|
||||||
|
<button type="button" className="button-secondary" disabled>{text.dashboard.stop}</button>
|
||||||
|
<button type="button" className="button-secondary" disabled>{text.dashboard.restart}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.dashboard.traffic}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="stats-strip">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<SkeletonLine className="skeleton-label" />
|
||||||
|
<SkeletonLine className="skeleton-value" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.dashboard.dailyUsage}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="usage-list">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} className="usage-row">
|
||||||
|
<SkeletonLine className="skeleton-day" />
|
||||||
|
<div className="usage-bar skeleton-bar-track">
|
||||||
|
<div style={{ width: `${55 + index * 8}%` }} />
|
||||||
|
</div>
|
||||||
|
<SkeletonLine className="skeleton-bytes" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.dashboard.attention}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="event-list">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="event-row skeleton-event-row">
|
||||||
|
<span className="event-marker neutral" />
|
||||||
|
<div>
|
||||||
|
<SkeletonLine className="skeleton-value" />
|
||||||
|
<SkeletonLine className="skeleton-copy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||||
|
return (
|
||||||
|
<section className="page-grid single-column" aria-busy="true">
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div className="toolbar-title">
|
||||||
|
<h2>{text.users.title}</h2>
|
||||||
|
<SkeletonLine className="skeleton-copy short" />
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<div className="summary-pills">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<span key={index}><SkeletonLine className="skeleton-pill-text" /></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled>{text.users.newUser}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{text.users.user}</th>
|
||||||
|
<th>{text.users.endpoint}</th>
|
||||||
|
<th>{text.common.status}</th>
|
||||||
|
<th>{text.users.used}</th>
|
||||||
|
<th>{text.users.remaining}</th>
|
||||||
|
<th>{text.users.online}</th>
|
||||||
|
<th>{text.users.share}</th>
|
||||||
|
<th>{text.users.actions}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td><SkeletonLine className="skeleton-copy" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-copy" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-pill" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||||
|
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
{Array.from({ length: 4 }).map((__, actionIndex) => (
|
||||||
|
<span key={actionIndex} className="icon-button secondary skeleton-icon-button" aria-hidden="true" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||||
|
return (
|
||||||
|
<section className="page-grid single-column system-grid" aria-busy="true">
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.settings.panelTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="panel-settings-grid">
|
||||||
|
<div className="field-group panel-settings-wide">
|
||||||
|
<span>{text.settings.proxyHost}</span>
|
||||||
|
<SkeletonLine className="skeleton-input" />
|
||||||
|
</div>
|
||||||
|
<div className="field-group compact-field">
|
||||||
|
<span>{text.common.language}</span>
|
||||||
|
<SkeletonLine className="skeleton-input" />
|
||||||
|
</div>
|
||||||
|
<div className="field-group compact-field">
|
||||||
|
<span>{text.common.theme}</span>
|
||||||
|
<SkeletonLine className="skeleton-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.settings.title}</h2>
|
||||||
|
<button type="button" className="button-secondary" disabled>{text.common.addService}</button>
|
||||||
|
</div>
|
||||||
|
<div className="service-editor-list">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<section key={index} className="service-editor-row">
|
||||||
|
<div className="service-editor-header">
|
||||||
|
<div>
|
||||||
|
<SkeletonLine className="skeleton-value" />
|
||||||
|
<SkeletonLine className="skeleton-inline" />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="button-secondary button-small" disabled>{text.common.remove}</button>
|
||||||
|
</div>
|
||||||
|
<div className="service-editor-grid">
|
||||||
|
{Array.from({ length: 4 }).map((__, fieldIndex) => (
|
||||||
|
<div key={fieldIndex} className={`field-group${fieldIndex === 3 ? ' field-span-2' : ''}`}>
|
||||||
|
<SkeletonLine className="skeleton-label" />
|
||||||
|
<SkeletonLine className="skeleton-input" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="system-actions">
|
||||||
|
<button type="button" className="button-secondary" disabled>{text.common.reset}</button>
|
||||||
|
<button type="button" disabled>{text.common.saveSettings}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card wide-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>{text.settings.generatedConfig}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="config-skeleton">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<SkeletonLine key={index} className={`skeleton-config-line${index % 3 === 0 ? ' long' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [preferences, setPreferences] = useState<PanelPreferences>(() => {
|
const [preferences, setPreferences] = useState<PanelPreferences>(() => {
|
||||||
const loaded = loadPanelPreferences();
|
const loaded = loadPanelPreferences();
|
||||||
@@ -841,6 +1094,7 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
|
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
|
||||||
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
|
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
||||||
|
const [hasSnapshotHydrated, setHasSnapshotHydrated] = useState(false);
|
||||||
const text = getPanelText(preferences.language);
|
const text = getPanelText(preferences.language);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -874,6 +1128,7 @@ export default function App() {
|
|||||||
const resetSession = () => {
|
const resetSession = () => {
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
setConnectionNotice(null);
|
setConnectionNotice(null);
|
||||||
|
setHasSnapshotHydrated(false);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -918,6 +1173,8 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setHasSnapshotHydrated(false);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
@@ -941,6 +1198,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (!cancelled && payload) {
|
if (!cancelled && payload) {
|
||||||
setSnapshot(payload);
|
setSnapshot(payload);
|
||||||
|
setHasSnapshotHydrated(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1043,12 +1301,14 @@ export default function App() {
|
|||||||
|
|
||||||
if (message.type === 'snapshot.init') {
|
if (message.type === 'snapshot.init') {
|
||||||
setSnapshot(message.snapshot);
|
setSnapshot(message.snapshot);
|
||||||
|
setHasSnapshotHydrated(true);
|
||||||
setConnectionNotice(null);
|
setConnectionNotice(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'snapshot.patch') {
|
if (message.type === 'snapshot.patch') {
|
||||||
setSnapshot((current) => applySnapshotPatch(current, message.patch));
|
setSnapshot((current) => applySnapshotPatch(current, message.patch));
|
||||||
|
setHasSnapshotHydrated(true);
|
||||||
setConnectionNotice(null);
|
setConnectionNotice(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1347,15 @@ export default function App() {
|
|||||||
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
|
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasSnapshotHydrated) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<ShellLoadingState activeTab={activeTab} text={text} />
|
||||||
|
{connectionNotice ? <div className="connection-banner">{connectionNotice}</div> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const mutateSnapshot = async (
|
const mutateSnapshot = async (
|
||||||
request: () => Promise<Response>,
|
request: () => Promise<Response>,
|
||||||
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
||||||
|
|||||||
130
src/app.css
130
src/app.css
@@ -191,6 +191,132 @@ button,
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton-line,
|
||||||
|
.skeleton-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--surface-muted) 82%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--border) 85%, var(--surface-muted)) 50%,
|
||||||
|
color-mix(in srgb, var(--surface-muted) 82%, transparent) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-host {
|
||||||
|
width: 168px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-meta-value {
|
||||||
|
width: 72px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-label {
|
||||||
|
width: 84px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-value {
|
||||||
|
width: 140px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-copy {
|
||||||
|
width: 180px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-copy.short {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-inline {
|
||||||
|
width: 88px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-day {
|
||||||
|
width: 32px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bytes {
|
||||||
|
width: 72px;
|
||||||
|
height: 16px;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-pill {
|
||||||
|
width: 72px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-pill-text {
|
||||||
|
width: 64px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bar-track {
|
||||||
|
background: color-mix(in srgb, var(--surface-muted) 75%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bar-track div {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--accent) 28%, var(--surface-muted)) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 42%, var(--surface-muted)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-event-row {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-config-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-config-line.long {
|
||||||
|
width: 82%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-icon-button {
|
||||||
|
width: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.button-small {
|
.button-small {
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -453,6 +579,10 @@ button,
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-marker.neutral {
|
||||||
|
background: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-title {
|
.toolbar-title {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user