Cohesive styling pass: design tokens, shared primitives, responsive groundwork

The UI had a good terracotta-on-dark system that wasn't applied
consistently — magic-number spacing, ad-hoc page headers, ~6 copied
panel surfaces, ~8 drifting label variants, and scattered accent use.

- Tokens: warm the neutral ramp, add spacing/radius/type/motion scales,
  --accent-quiet + panel highlight. Terracotta hue unchanged.
- Primitives: .panel, .section-label, .page-header/.toolbar — collapse
  the copied surfaces and label variants onto shared classes.
- Unify each filter-heavy page's display controls (group/sort/view,
  select-all, count) into a workspace toolbar inside the filter panel,
  so they read as one surface instead of floating above it.
- Labels: sentence-case, higher-contrast form labels; uppercase section
  markers bumped faint -> muted.
- Accent hierarchy: reserve full terracotta for primary actions/active
  state; demote decorative labels and the group-header underline.
- Responsive groundwork: reflow classes for the deck two-pane and nav
  rail (stacks / icon-collapse at breakpoints), touch-target sizing.
  Desktop unchanged in kind.
This commit is contained in:
2026-07-22 23:44:21 -05:00
parent ecb946328a
commit bde9cf8a98
6 changed files with 361 additions and 142 deletions

View File

@@ -325,9 +325,10 @@ export default function App() {
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
title={n.label}
>
<span className="nav-icon">{n.icon}</span>
{n.label}
<span className="nav-label">{n.label}</span>
</button>
))}
</div>

View File

@@ -4,30 +4,38 @@
============================================================ */
:root {
/* Layered surfaces (darkest → lightest) */
--bg-rail: #16171a;
--bg-app: #1a1b1f;
--bg-topbar: #1e1f24;
--bg-content: #212228;
--bg-panel: #282a31;
--bg-input: #2f313a;
--bg-hover: #363841;
--bg-elev: #3a3c46;
/* Layered surfaces (darkest → lightest). A warm charcoal ramp (low-saturation
~28° hue) rather than cool blue-grey — reads as "collection in warm light"
and harmonizes with the terracotta accent. Elevation model:
rail/app (chrome) → content (page) → panel (surface) → input (inset). */
--bg-rail: #191714;
--bg-app: #1d1a16;
--bg-topbar: #221e19;
--bg-content: #27221d;
--bg-panel: #322c25;
--bg-input: #3b342d;
--bg-hover: #443c34;
--bg-elev: #4c433a;
--border: #34363e;
--border-soft: rgba(255, 255, 255, 0.055);
--border: #3d372f;
--border-soft: rgba(255, 248, 240, 0.06);
/* Tactile 1px top inner-highlight for raised surfaces (see .panel). */
--panel-highlight: inset 0 1px 0 rgba(255, 246, 236, 0.045);
--text: #dbdee4;
--text-strong: #f5f6f8;
--muted: #9a9ea8;
--faint: #71757f;
--text: #e3ddd3;
--text-strong: #f8f4ec;
--muted: #a69d90;
--faint: #7d7568;
/* Terracotta accent */
/* Terracotta accent (hue unchanged — the app's signature) */
--accent: #c8553d;
--accent-hover: #d86e56;
--accent-active: #a8412d;
--accent-subtle: rgba(200, 85, 61, 0.15);
--accent-glow: rgba(200, 85, 61, 0.35);
/* Quiet accent for decorative/label use, so full terracotta stays reserved
for primary actions + active state (accent hierarchy). */
--accent-quiet: #c69a86;
/* Complementary / status */
--info: #46a6bd; /* teal complement */
@@ -44,8 +52,39 @@
--mtg-g: #479b63;
--mtg-c: #9aa0ab;
--radius: 8px;
--radius-lg: 12px;
/* Spacing scale — every gap/margin/padding resolves to one of these. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 48px;
/* Radius scale */
--radius-sm: 6px;
--radius: 9px;
--radius-lg: 14px;
--radius-card: 12px; /* card-art corners (matches Scryfall image framing) */
--radius-pill: 999px;
/* Type scale */
--fs-micro: 10px; /* dense data labels (under stat numbers, chart axes) */
--fs-caption: 12px;
--fs-body: 14px;
--fs-label: 11px; /* canonical uppercase micro-label, see .section-label */
--fs-title: 16px;
--fs-display: 21px;
--tracking-label: 0.06em;
/* Motion */
--dur-fast: 0.12s;
--dur: 0.2s;
/* Interactive control height — bumped on coarse pointers (see bottom). */
--control-h: 34px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 14px 44px rgba(0, 0, 0, 0.55);
@@ -210,6 +249,68 @@ input[type="checkbox"] {
cursor: pointer;
}
/* ============================================================
Primitives: shared surface / label / page-header building blocks.
These replace treatments that were previously hand-copied (with drift)
across the views — edit here to change all of them at once.
============================================================ */
/* Canonical raised surface. Replaces the ~6 hand-copied panel treatments
(deck-meta, commander-hero, deck-stats, filters, legality-tile base,
preview-info). The top inner-highlight gives a subtle tactile edge. */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--panel-highlight);
}
/* Recessed variant for wells nested inside a panel. */
.panel-inset {
background: var(--bg-app);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: var(--space-3);
}
/* Canonical uppercase section marker (panel/section headers). Uppercase +
tracking reads as structural; `--muted` (not faint) keeps it legible. */
.section-label {
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--muted);
}
/* Standard page header: title on the left, control cluster on the right,
wrapping onto two lines on a narrow viewport. Replaces the ad-hoc
`<div class="row" style="justify-content:space-between">` each view used. */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.page-header-title {
display: flex;
align-items: center;
gap: var(--space-3);
margin: 0;
min-width: 0;
}
/* The right-hand control cluster (group-by + sort + view toggle + page
actions). Also usable standalone. */
.page-header-actions,
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-4);
}
/* ============================================================
App shell: nav rail + main
============================================================ */
@@ -338,11 +439,11 @@ input[type="checkbox"] {
margin-bottom: 10px;
}
.lib-stats-title {
font-size: 12px;
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
letter-spacing: var(--tracking-label);
color: var(--muted);
}
.lib-stats-loading {
font-size: 12px;
@@ -381,7 +482,7 @@ input[type="checkbox"] {
text-shadow: 0 0 14px var(--accent-glow);
}
.stat-tile-label {
font-size: 10px;
font-size: var(--fs-micro);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -582,7 +683,7 @@ input[type="checkbox"] {
.content {
flex: 1;
overflow: auto;
padding: 22px 26px;
padding: var(--space-5) var(--space-6);
}
/* Empty / welcome state */
@@ -614,27 +715,28 @@ input[type="checkbox"] {
============================================================ */
h2 {
margin: 2px 0 14px;
font-size: 20px;
margin: var(--space-1) 0 var(--space-4);
font-size: var(--fs-display);
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.2px;
}
h3 {
margin: 20px 0 12px;
font-size: 16px;
margin: var(--space-5) 0 var(--space-3);
font-size: var(--fs-title);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-strong);
border-bottom: 2px solid var(--border-soft);
padding-bottom: 10px;
padding-bottom: var(--space-3);
}
h4 {
margin: 14px 0 7px;
font-size: 14px;
margin: var(--space-4) 0 var(--space-2);
font-size: var(--fs-body);
font-weight: 600;
color: var(--accent-hover);
/* Quiet accent — full terracotta is reserved for primary actions. */
color: var(--accent-quiet);
}
/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a
@@ -646,15 +748,16 @@ h4 {
position: sticky;
top: 0;
z-index: 5;
margin: 14px 0 8px;
padding: 8px 0;
margin: var(--space-4) 0 var(--space-2);
padding: var(--space-2) 0;
background: var(--bg-app);
font-size: 15px;
font-size: var(--fs-body);
font-weight: 700;
text-transform: none;
letter-spacing: normal;
color: var(--text-strong);
border-bottom: 2px solid var(--accent);
/* Quiet warm underline rather than full terracotta (accent hierarchy). */
border-bottom: 2px solid var(--accent-quiet);
}
.muted {
color: var(--muted);
@@ -673,10 +776,10 @@ h4 {
display: inline-flex;
align-items: center;
background: var(--accent-subtle);
color: var(--accent-hover);
border-radius: 999px;
padding: 1px 9px;
font-size: 12px;
color: var(--accent-quiet);
border-radius: var(--radius-pill);
padding: 1px var(--space-2);
font-size: var(--fs-caption);
font-weight: 700;
}
@@ -1232,12 +1335,12 @@ table {
}
th {
text-align: left;
padding: 8px 12px;
font-size: 12px;
padding: var(--space-2) var(--space-3);
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
letter-spacing: var(--tracking-label);
color: var(--muted);
position: sticky;
top: 0;
background: var(--bg-content);
@@ -1339,27 +1442,53 @@ tbody tr:hover {
Filters panel
============================================================ */
/* The workspace panel unifies the filter grid and the results toolbar into
one surface (Library + Card Search), so the display controls read as part
of the filtering apparatus instead of floating loosely above it. */
.workspace {
margin-bottom: var(--space-4);
}
/* Just the responsive field grid; surface comes from the wrapping `.panel`. */
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
gap: var(--space-4);
}
/* The results toolbar strip at the bottom of the workspace panel: selection +
count on the left, group/sort/view on the right, divided from the filters. */
.workspace-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border-soft);
}
.workspace-bar-check {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--fs-caption);
color: var(--muted);
}
.workspace-bar-count {
font-size: var(--fs-caption);
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Form field labels: sentence-case and higher-contrast rather than tiny faint
all-caps — clearer to scan above each input than a structural section marker. */
.field > label {
font-size: 12px;
font-size: var(--fs-caption);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--faint);
color: var(--muted);
}
.location-input {
padding: 4px 8px;
@@ -1631,12 +1760,12 @@ tbody tr:hover {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: 18px;
padding: 18px;
box-shadow: var(--shadow-lg);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-lg), var(--panel-highlight);
color: var(--text);
}
.preview-info-name {
@@ -1671,11 +1800,11 @@ tbody tr:hover {
border-top: 1px solid var(--border-soft);
}
.preview-info-label {
font-size: 11px;
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--faint);
letter-spacing: var(--tracking-label);
color: var(--muted);
}
.preview-info-deck-chips {
display: flex;
@@ -1735,10 +1864,10 @@ tbody tr:hover {
.tile-img {
width: 100%;
display: block;
border-radius: 12px;
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
cursor: zoom-in;
transition: transform 0.12s, box-shadow 0.12s;
transition: transform var(--dur-fast), box-shadow var(--dur-fast);
}
.tile:hover .tile-img {
transform: translateY(-3px);
@@ -1747,7 +1876,7 @@ tbody tr:hover {
.tile-noimg {
aspect-ratio: 488 / 680;
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--radius-card);
background: var(--bg-panel);
display: flex;
align-items: center;
@@ -1890,15 +2019,25 @@ tbody tr:hover {
Deck metadata (format + commander)
============================================================ */
/* Two-pane deck screen: deck list + selected-deck detail. Stacks below the
tablet breakpoint (see the responsive section) so the list sits above the
detail on narrow/portrait viewports. */
.deck-layout {
display: flex;
gap: var(--space-5);
align-items: flex-start;
}
.deck-sidebar {
width: 240px;
flex-shrink: 0;
}
/* Surface from `.panel`; layout only here. */
.deck-meta {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 20px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
gap: var(--space-5);
}
/* Separates the deck-level info panels (commander, format, analysis,
legality) from the card-management controls + lists below. */
@@ -1957,15 +2096,12 @@ tbody tr:hover {
flex: 1;
}
/* Prominent commander card display, above the fold on a Commander/Brawl deck. */
/* Prominent commander card display, above the fold on a Commander/Brawl deck.
Surface from `.panel`; layout only here. */
.commander-hero {
display: flex;
align-items: center;
gap: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
gap: var(--space-4);
}
.commander-hero-img {
width: 84px;
@@ -1998,14 +2134,16 @@ tbody tr:hover {
gap: 5px;
}
.commander-hero-label {
font-size: 10px;
font-size: var(--fs-micro);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-hover);
letter-spacing: var(--tracking-label);
/* Quiet accent keeps a hint of brand on the hero without competing with
primary-action terracotta elsewhere. */
color: var(--accent-quiet);
}
.commander-hero-name {
font-size: 19px;
font-size: var(--fs-display);
font-weight: 700;
color: var(--text-strong);
}
@@ -2014,20 +2152,9 @@ tbody tr:hover {
color: var(--muted);
}
/* Per-deck analysis panel. */
.deck-stats {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
/* Per-deck analysis panel. Surface from `.panel`. */
.deck-stats-head {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
margin-bottom: 10px;
margin-bottom: var(--space-3);
}
.deck-stats-body {
display: flex;
@@ -2048,11 +2175,84 @@ tbody tr:hover {
flex-direction: column;
}
.deck-stats-curve-label {
font-size: 10px;
font-size: var(--fs-micro);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
margin-bottom: 2px;
margin-bottom: var(--space-1);
}
/* ============================================================
Responsive groundwork
Desktop (the primary target) is unchanged above; these breakpoints keep
the layout usable as the window narrows and lay the foundation for a
future mobile port. Breakpoints: 1000 (tablet) and 720 (compact).
============================================================ */
/* Tablet: the deck two-pane stacks (list above detail). */
@media (max-width: 1000px) {
.deck-layout {
flex-direction: column;
}
.deck-sidebar {
width: 100%;
}
}
/* Compact: tighten page gutters and collapse the nav rail to an icon strip
(same items — labels, wordmark, and the library summary hide; hover/title
still names each tab). No separate mobile navigation is introduced. */
@media (max-width: 720px) {
.content {
padding: var(--space-4);
}
.nav-rail {
width: 60px;
padding: var(--space-3) var(--space-2) var(--space-2);
}
.brand {
justify-content: center;
padding: var(--space-1) 0 var(--space-4);
}
.brand-text {
display: none;
}
.nav-item {
justify-content: center;
padding: var(--space-2);
}
.nav-label {
display: none;
}
.nav-item.active::before {
left: -8px;
}
.nav-foot {
display: none;
}
.topbar {
padding: 0 var(--space-3);
}
/* The library path is the first thing to go when horizontal space is tight —
the library actions stay reachable. */
.path {
display: none;
}
}
/* Touch input (future mobile / touchscreen laptops): grow tap targets to a
comfortable size without inflating the compact mouse-driven desktop look. */
@media (pointer: coarse) {
button,
input,
select,
.nav-item {
min-height: var(--control-h);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
}
}

View File

@@ -78,9 +78,11 @@ export function CardEntry() {
return (
<div>
<h2>Add Cards</h2>
<div className="page-header">
<h2 className="page-header-title">Add Cards</h2>
</div>
<div className="field" style={{ maxWidth: 260, marginBottom: 12 }}>
<div className="field" style={{ maxWidth: 260, marginBottom: "var(--space-3)" }}>
<label>Location for added cards (optional)</label>
<input
placeholder="e.g. Box 1 — prefilled onto each card"
@@ -102,7 +104,7 @@ export function CardEntry() {
{recent.length > 0 && (
<>
<h2 style={{ marginTop: 24 }}>Just added</h2>
<h2 style={{ marginTop: "var(--space-6)" }}>Just added</h2>
<ul className="result-list">
{recent.map((r, i) => (
<li key={i} style={{ cursor: "default" }}>

View File

@@ -267,11 +267,11 @@ export function CardSearch({
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>
<div className="page-header">
<h2 className="page-header-title">
Card Search {hasActiveFilter && <span className="badge">{rows.length}</span>}
</h2>
<div className="row" style={{ gap: 16 }}>
<div className="page-header-actions">
<select
value={deckId}
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
@@ -283,17 +283,10 @@ export function CardSearch({
</option>
))}
</select>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
@@ -357,6 +350,24 @@ export function CardSearch({
placeholder="e.g. landfall"
/>
</div>
</div>
<div className="workspace-bar">
{hasActiveFilter && (
<span className="workspace-bar-count">
{rows.length} result{rows.length === 1 ? "" : "s"}
</span>
)}
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{!hasActiveFilter ? (

View File

@@ -159,7 +159,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
<div className="commander-hero">
<div className="panel commander-hero">
{card.image_uri ? (
<img
className="commander-hero-img"
@@ -818,8 +818,8 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
}
return (
<div style={{ display: "flex", gap: 20 }}>
<div style={{ width: 240, flexShrink: 0 }}>
<div className="deck-layout">
<div className="deck-sidebar">
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>Decks</h2>
<IconButton icon={Icons.upload} label="Import deck…" onClick={pickImportFile} />
@@ -925,7 +925,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-top-meta">
<div className="deck-meta">
<div className="panel deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
@@ -958,8 +958,8 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
{contents.length > 0 && (
<div className="deck-top-stats">
<div className="deck-stats">
<div className="deck-stats-head">Deck analysis</div>
<div className="panel deck-stats">
<div className="deck-stats-head section-label">Deck analysis</div>
<div className="deck-stats-body">
<div className="deck-stats-numbers">
<div className="stat-tiles-row">

View File

@@ -399,34 +399,12 @@ export function LibraryBrowser({
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<div className="row" style={{ gap: 14 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all filtered cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
disabled={rows.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<h2 style={{ margin: 0 }}>
Library <span className="badge">{rows.length}</span>
</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<div className="page-header">
<h2 className="page-header-title">
Library <span className="badge">{rows.length}</span>
</h2>
<div className="page-header-actions">
<button onClick={pickImportFile}>Import cards…</button>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
@@ -538,7 +516,8 @@ export function LibraryBrowser({
/>
)}
<div className="filters">
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
@@ -620,6 +599,32 @@ export function LibraryBrowser({
placeholder="e.g. landfall"
/>
</div>
</div>
<div className="workspace-bar">
<label className="workspace-bar-check" title="Select all filtered cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
disabled={rows.length === 0}
/>
Select all
</label>
<span className="workspace-bar-count">
{rows.length} card{rows.length === 1 ? "" : "s"}
</span>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{rows.length === 0 ? (