feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage usage to admins in two places: - Admin System tab: total/covers/chapters totals, ratio of disk, average image sizes, and top-5 largest mangas/chapters leaderboards, behind a new GET /api/v1/admin/storage endpoint (separate from /admin/system so the cheap DB aggregates aren't coupled to its 250ms CPU sample). - Manga detail page: total chapter-content size and per-chapter size, with an em-dash for uncrawled or not-yet-measured chapters. Sizes are captured at write time (crawler put_stream return, uploaded page/cover byte length) into nullable pages.size_bytes / mangas.cover_size_bytes columns; NULL means "not yet measured", distinct from a real 0. A one-shot, idempotent admin "Backfill sizes" action (POST /api/v1/admin/storage/backfill) stats pre-existing blobs in keyset-paginated, capped batches and reports more_remaining so a large legacy library can be drained over multiple runs. Aggregates and leaderboards treat NULL honestly (only fully-measured entities are ranked); a dashboard banner flags unmeasured rows so partial figures aren't read as complete. persist_pages now prunes stale page rows on a shrinking re-crawl so totals and page_count stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ import {
|
||||
listAdminMangas,
|
||||
listAdminChapters,
|
||||
getSystemStats,
|
||||
getStorageStats,
|
||||
backfillStorage,
|
||||
resyncManga,
|
||||
resyncChapter,
|
||||
getCrawlerStatus,
|
||||
@@ -587,4 +589,66 @@ describe('admin crawler api client', () => {
|
||||
/\/v1\/admin\/analysis\/status\/stream$/
|
||||
);
|
||||
});
|
||||
|
||||
// ---- storage usage ----
|
||||
|
||||
it('getStorageStats GETs /v1/admin/storage and parses the envelope', async () => {
|
||||
const fixture = {
|
||||
total_bytes: 700,
|
||||
covers_bytes: 100,
|
||||
chapters_bytes: 600,
|
||||
disk_total_bytes: 1_000_000,
|
||||
ratio_of_disk: 0.0007,
|
||||
avg_image_bytes: 233.3,
|
||||
avg_cover_bytes: 100,
|
||||
avg_chapter_page_bytes: 300,
|
||||
top_mangas: [{ id: 'm-1', title: 'Big', total_bytes: 650 }],
|
||||
top_chapters: [
|
||||
{ id: 'c-1', manga_id: 'm-1', number: 1, title: null, total_bytes: 600 }
|
||||
],
|
||||
unmeasured_pages: 4,
|
||||
unmeasured_covers: 1
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(ok(fixture));
|
||||
const s = await getStorageStats();
|
||||
expect(s.total_bytes).toBe(700);
|
||||
expect(s.top_mangas[0].title).toBe('Big');
|
||||
expect(s.top_chapters[0].total_bytes).toBe(600);
|
||||
expect(s.unmeasured_pages).toBe(4);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/storage$/);
|
||||
});
|
||||
|
||||
it('getStorageStats keeps disk total + ratio null for a non-local store', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
total_bytes: 0,
|
||||
covers_bytes: 0,
|
||||
chapters_bytes: 0,
|
||||
disk_total_bytes: null,
|
||||
ratio_of_disk: null,
|
||||
avg_image_bytes: null,
|
||||
avg_cover_bytes: null,
|
||||
avg_chapter_page_bytes: null,
|
||||
top_mangas: [],
|
||||
top_chapters: [],
|
||||
unmeasured_pages: 0,
|
||||
unmeasured_covers: 0
|
||||
})
|
||||
);
|
||||
const s = await getStorageStats();
|
||||
expect(s.disk_total_bytes).toBeNull();
|
||||
expect(s.ratio_of_disk).toBeNull();
|
||||
});
|
||||
|
||||
it('backfillStorage POSTs /v1/admin/storage/backfill and returns counts', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false })
|
||||
);
|
||||
const r = await backfillStorage();
|
||||
expect(r).toEqual({ pages: 3, covers: 1, missing: 0, errored: 0, more_remaining: false });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/storage\/backfill$/);
|
||||
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,6 +180,61 @@ export async function getSystemStats(): Promise<SystemStats> {
|
||||
return request<SystemStats>('/v1/admin/system');
|
||||
}
|
||||
|
||||
// ---- storage usage ---------------------------------------------------------
|
||||
|
||||
export type TopManga = {
|
||||
id: string;
|
||||
title: string;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
export type TopChapter = {
|
||||
id: string;
|
||||
manga_id: string;
|
||||
number: number;
|
||||
title: string | null;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
export type StorageStats = {
|
||||
total_bytes: number;
|
||||
covers_bytes: number;
|
||||
chapters_bytes: number;
|
||||
disk_total_bytes: number | null;
|
||||
ratio_of_disk: number | null;
|
||||
avg_image_bytes: number | null;
|
||||
avg_cover_bytes: number | null;
|
||||
avg_chapter_page_bytes: number | null;
|
||||
top_mangas: TopManga[];
|
||||
top_chapters: TopChapter[];
|
||||
/** Pages still awaiting a size backfill. When > 0 the figures above are
|
||||
* partial and the leaderboards omit not-yet-measured content. */
|
||||
unmeasured_pages: number;
|
||||
/** Cover blobs still awaiting a size backfill. */
|
||||
unmeasured_covers: number;
|
||||
};
|
||||
|
||||
export type StorageBackfillResult = {
|
||||
pages: number;
|
||||
covers: number;
|
||||
/** Blobs storage reports as absent (orphaned). */
|
||||
missing: number;
|
||||
/** Blobs that errored for another reason (bad key, transient IO) — retryable. */
|
||||
errored: number;
|
||||
/** `true` when a per-run cap was hit before the backlog drained; run again. */
|
||||
more_remaining: boolean;
|
||||
};
|
||||
|
||||
export async function getStorageStats(): Promise<StorageStats> {
|
||||
return request<StorageStats>('/v1/admin/storage');
|
||||
}
|
||||
|
||||
export async function backfillStorage(): Promise<StorageBackfillResult> {
|
||||
return request<StorageBackfillResult>('/v1/admin/storage/backfill', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
// ---- force resync ----------------------------------------------------------
|
||||
|
||||
export type MangaResyncResponse = {
|
||||
|
||||
@@ -7,6 +7,10 @@ export type Chapter = {
|
||||
title: string | null;
|
||||
page_count: number;
|
||||
created_at: string;
|
||||
/** Total bytes of this chapter's stored pages. `0` for an uncrawled
|
||||
* chapter; `null` when the size is unknown (a page predates the size
|
||||
* backfill). Render both `null` and uncrawled as an em-dash. */
|
||||
size_bytes: number | null;
|
||||
};
|
||||
|
||||
export type ChaptersPage = {
|
||||
|
||||
@@ -20,6 +20,10 @@ export type MangaDetail = Manga & {
|
||||
tags: TagRef[];
|
||||
/** Deduped union of content warnings across the manga's pages. */
|
||||
content_warnings: ContentWarning[];
|
||||
/** Total bytes of all this manga's stored chapter pages (cover
|
||||
* excluded). `null` when any page is unmeasured (show an em-dash);
|
||||
* `0` when there are no pages. */
|
||||
chapter_storage_bytes: number | null;
|
||||
};
|
||||
|
||||
export type ListOptions = {
|
||||
|
||||
@@ -49,4 +49,9 @@ describe('formatBytes', () => {
|
||||
expect(formatBytes(2048)).toBe('2 KiB');
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB');
|
||||
});
|
||||
|
||||
it('scales up to GiB and TiB for large storage totals', () => {
|
||||
expect(formatBytes(6 * 1024 ** 3)).toBe('6.0 GiB');
|
||||
expect(formatBytes(Math.round(1.5 * 1024 ** 4))).toBe('1.5 TiB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ export const ALLOWED_IMAGE_TYPES = [
|
||||
|
||||
export type ImageType = (typeof ALLOWED_IMAGE_TYPES)[number];
|
||||
|
||||
/** Binary (1024-based) human byte size. Scales up to TiB so it can format
|
||||
* whole-manga / whole-chapter storage totals, not just upload limits. */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n >= 1024 ** 4) return `${(n / 1024 ** 4).toFixed(1)} TiB`;
|
||||
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GiB`;
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(0)} KiB`;
|
||||
return `${n} B`;
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSystemStats, type SystemStats } from '$lib/api/admin';
|
||||
import {
|
||||
getSystemStats,
|
||||
getStorageStats,
|
||||
backfillStorage,
|
||||
type SystemStats,
|
||||
type StorageStats
|
||||
} from '$lib/api/admin';
|
||||
import { formatBytes as fmtBytes } from '$lib/upload-validation';
|
||||
|
||||
let stats: SystemStats | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let storageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Storage usage is a cheap DB aggregate — refreshed alongside the
|
||||
// system poll so the totals/leaderboards don't go stale while crawls
|
||||
// and uploads land elsewhere.
|
||||
let storage: StorageStats | null = $state(null);
|
||||
let storageError: string | null = $state(null);
|
||||
let backfilling = $state(false);
|
||||
let backfillNote: string | null = $state(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
@@ -15,23 +31,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorage() {
|
||||
try {
|
||||
storage = await getStorageStats();
|
||||
storageError = null;
|
||||
} catch (e) {
|
||||
storageError = e instanceof Error ? e.message : 'failed to load storage usage';
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackfill() {
|
||||
backfilling = true;
|
||||
backfillNote = null;
|
||||
try {
|
||||
const r = await backfillStorage();
|
||||
const extra = [
|
||||
r.missing > 0 ? `${r.missing} missing` : '',
|
||||
r.errored > 0 ? `${r.errored} errored` : ''
|
||||
].filter(Boolean);
|
||||
backfillNote =
|
||||
`Backfilled ${r.pages} page(s), ${r.covers} cover(s)${
|
||||
extra.length ? ` (${extra.join(', ')})` : ''
|
||||
}.` + (r.more_remaining ? ' More remain — run again to finish.' : '');
|
||||
await refreshStorage();
|
||||
} catch (e) {
|
||||
backfillNote = e instanceof Error ? e.message : 'backfill failed';
|
||||
} finally {
|
||||
backfilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
refreshStorage();
|
||||
// System metrics (cheap) poll fast; storage stats run library-scaling
|
||||
// aggregations, so they refresh on a slower cadence — fresh enough for
|
||||
// an admin view without re-running the leaderboard queries every 5 s.
|
||||
timer = setInterval(refresh, 5000);
|
||||
storageTimer = setInterval(refreshStorage, 30000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (storageTimer) clearInterval(storageTimer);
|
||||
});
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(2)} ${units[i]}`;
|
||||
function fmtPercent(ratio: number): string {
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
}
|
||||
// Distinct from the shared chapterLabel() (which renders just the
|
||||
// title): the leaderboard needs the chapter number too.
|
||||
function chapterRef(c: { number: number; title: string | null }): string {
|
||||
return c.title ? `Ch. ${c.number} — ${c.title}` : `Ch. ${c.number}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -90,6 +140,97 @@
|
||||
<p>Loading…</p>
|
||||
{/if}
|
||||
|
||||
<section class="storage" aria-label="storage usage">
|
||||
<header class="storage-head">
|
||||
<h2>Storage</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={runBackfill}
|
||||
disabled={backfilling}
|
||||
aria-busy={backfilling}
|
||||
>
|
||||
{backfilling ? 'Backfilling…' : 'Backfill sizes'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if storageError}
|
||||
<p class="error" role="alert">{storageError}</p>
|
||||
{/if}
|
||||
{#if backfillNote}
|
||||
<p class="note" role="status">{backfillNote}</p>
|
||||
{/if}
|
||||
{#if storage && (storage.unmeasured_pages > 0 || storage.unmeasured_covers > 0)}
|
||||
<p class="unmeasured" role="status">
|
||||
{storage.unmeasured_pages + storage.unmeasured_covers} item(s) not yet measured —
|
||||
figures below are partial and leaderboards omit them. Run “Backfill sizes” to complete.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if storage}
|
||||
<div class="cards">
|
||||
<article class="card">
|
||||
<h3>Total content</h3>
|
||||
<p class="metric">{fmtBytes(storage.total_bytes)}</p>
|
||||
{#if storage.ratio_of_disk !== null}
|
||||
<p class="sub">{fmtPercent(storage.ratio_of_disk)} of disk</p>
|
||||
{/if}
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Covers</h3>
|
||||
<p class="metric">{fmtBytes(storage.covers_bytes)}</p>
|
||||
{#if storage.avg_cover_bytes !== null}
|
||||
<p class="sub">avg {fmtBytes(storage.avg_cover_bytes)}</p>
|
||||
{/if}
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Chapters</h3>
|
||||
<p class="metric">{fmtBytes(storage.chapters_bytes)}</p>
|
||||
{#if storage.avg_chapter_page_bytes !== null}
|
||||
<p class="sub">avg {fmtBytes(storage.avg_chapter_page_bytes)}/page</p>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{#if storage.avg_image_bytes !== null}
|
||||
<p class="hint">avg image (all): {fmtBytes(storage.avg_image_bytes)}</p>
|
||||
{/if}
|
||||
|
||||
<div class="boards">
|
||||
<article class="board">
|
||||
<h3>Largest mangas</h3>
|
||||
{#if storage.top_mangas.length === 0}
|
||||
<p class="muted">No content yet.</p>
|
||||
{:else}
|
||||
<ol>
|
||||
{#each storage.top_mangas as m (m.id)}
|
||||
<li>
|
||||
<a href="/manga/{m.id}">{m.title}</a>
|
||||
<span class="bytes">{fmtBytes(m.total_bytes)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</article>
|
||||
<article class="board">
|
||||
<h3>Largest chapters</h3>
|
||||
{#if storage.top_chapters.length === 0}
|
||||
<p class="muted">No content yet.</p>
|
||||
{:else}
|
||||
<ol>
|
||||
{#each storage.top_chapters as c (c.id)}
|
||||
<li>
|
||||
<a href="/manga/{c.manga_id}/chapter/{c.id}">{chapterRef(c)}</a>
|
||||
<span class="bytes">{fmtBytes(c.total_bytes)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{:else if !storageError}
|
||||
<p>Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#snippet Bar({ percent }: { percent: number })}
|
||||
<div
|
||||
class="bar"
|
||||
@@ -200,4 +341,107 @@
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.storage {
|
||||
margin-top: var(--space-5, 2rem);
|
||||
}
|
||||
.storage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.storage-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.storage-head button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.storage-head button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
.unmeasured {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--font-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.card {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.metric {
|
||||
margin: 0;
|
||||
font-size: var(--font-xl, 1.5rem);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.sub {
|
||||
margin: var(--space-1) 0 0 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.boards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.board {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.board h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.board ol {
|
||||
margin: 0;
|
||||
padding-left: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.board li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.board .bytes {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
} from '$lib/api/mangas';
|
||||
import { resyncManga } from '$lib/api/admin';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { formatBytes } from '$lib/upload-validation';
|
||||
import { listTags, type Tag } from '$lib/api/tags';
|
||||
import type { ContentWarning } from '$lib/api/page_tags';
|
||||
import { session } from '$lib/session.svelte';
|
||||
@@ -39,6 +40,12 @@
|
||||
const manga = $derived<MangaDetail>(mangaOverride ?? data.manga);
|
||||
const chapters = $derived(data.chapters);
|
||||
const readProgress = $derived(data.readProgress);
|
||||
/** Total stored bytes of this manga's chapter content (cover excluded).
|
||||
* Comes from the backend, which sums over ALL chapters (the loaded
|
||||
* chapter list is paginated, so summing it client-side would
|
||||
* undercount mangas with many chapters). `null` → unknown (a page is
|
||||
* un-backfilled) → show an em-dash; `0` → no content → hide. */
|
||||
const contentBytes = $derived(manga.chapter_storage_bytes);
|
||||
/** Chapter row from the local chapters list when present (so we
|
||||
* can also surface the chapter title). Falls back below to the
|
||||
* server-supplied `chapter_number` when the chapter sits past
|
||||
@@ -589,7 +596,14 @@
|
||||
{/if}
|
||||
|
||||
<section aria-label="chapters">
|
||||
<h2>Chapters</h2>
|
||||
<div class="chapters-head">
|
||||
<h2>Chapters</h2>
|
||||
{#if contentBytes === null || contentBytes > 0}
|
||||
<span class="content-size" data-testid="manga-content-size">
|
||||
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if continueChapterId != null && continueChapterNumber != null}
|
||||
<a
|
||||
class="continue"
|
||||
@@ -615,6 +629,11 @@
|
||||
{chapterLabel(c)}
|
||||
</a>
|
||||
<span class="pages">({c.page_count} pages)</span>
|
||||
<span class="size" data-testid="chapter-size">
|
||||
{c.page_count === 0 || c.size_bytes === null
|
||||
? '—'
|
||||
: formatBytes(c.size_bytes)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -996,6 +1015,26 @@
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.chapters-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.content-size {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.size {
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* Mobile hero + chrome — hidden above 640px so the existing
|
||||
desktop layout stays untouched. */
|
||||
.mobile-hero {
|
||||
|
||||
Reference in New Issue
Block a user