From cb88b3977c399ca49881f9cdf42a9821d544180b Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 8 Feb 2026 18:35:25 -0600 Subject: [PATCH] feat(web): add user-selectable density mode with compact/comfortable toggle Implements #17, #18, #19, #20, #21 - Add CSS custom properties for density-dependent spacing (--d-* vars) in theme.css with comfortable (default) and compact modes - Create useDensity hook with localStorage persistence and DOM attribute sync - Add FOUC prevention in main.tsx (sync density before first paint) - Create shared PageFooter component merging stats + pagination - Refactor AppShell to flex layout with density toggle button (COM/CMP) - Consolidate inline pagination from ItemsPage/AuditPage into PageFooter - Delete FooterStats.tsx (replaced by PageFooter) - Replace all hardcoded padding/font/gap values in ItemTable, AuditTable, ItemsToolbar, and AuditToolbar with var(--d-*) references Comfortable mode is already tighter than the previous hardcoded values. Compact mode reduces further for power-user density. --- web/src/components/AppShell.tsx | 134 +++++++--- web/src/components/PageFooter.tsx | 69 +++++ web/src/components/audit/AuditTable.tsx | 62 ++++- web/src/components/audit/AuditToolbar.tsx | 12 +- web/src/components/items/FooterStats.tsx | 36 --- web/src/components/items/ItemTable.tsx | 305 ++++++++++++++-------- web/src/components/items/ItemsToolbar.tsx | 148 ++++++----- web/src/hooks/useDensity.ts | 22 ++ web/src/main.tsx | 23 +- web/src/pages/AuditPage.tsx | 56 ++-- web/src/pages/ItemsPage.tsx | 81 +++--- web/src/styles/theme.css | 115 ++++++-- 12 files changed, 685 insertions(+), 378 deletions(-) create mode 100644 web/src/components/PageFooter.tsx delete mode 100644 web/src/components/items/FooterStats.tsx create mode 100644 web/src/hooks/useDensity.ts diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 4da96e0..41b21fd 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,59 +1,79 @@ -import { NavLink, Outlet } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { NavLink, Outlet } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { useDensity } from "../hooks/useDensity"; const navLinks = [ - { to: '/', label: 'Items' }, - { to: '/projects', label: 'Projects' }, - { to: '/schemas', label: 'Schemas' }, - { to: '/audit', label: 'Audit' }, - { to: '/settings', label: 'Settings' }, + { to: "/", label: "Items" }, + { to: "/projects", label: "Projects" }, + { to: "/schemas", label: "Schemas" }, + { to: "/audit", label: "Audit" }, + { to: "/settings", label: "Settings" }, ]; const roleBadgeStyle: Record = { - admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' }, - editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' }, - viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' }, + admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" }, + editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" }, + viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" }, }; export function AppShell() { const { user, loading, logout } = useAuth(); + const [density, toggleDensity] = useDensity(); if (loading) { return ( -
+
); } return ( - <> +
-

Silo

+

+ Silo +

-
-
+
- +
); } diff --git a/web/src/components/PageFooter.tsx b/web/src/components/PageFooter.tsx new file mode 100644 index 0000000..f78f5c8 --- /dev/null +++ b/web/src/components/PageFooter.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; + +interface PageFooterProps { + stats?: ReactNode; + page?: number; + pageSize?: number; + itemCount?: number; + onPageChange?: (page: number) => void; +} + +export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: PageFooterProps) { + const hasPagination = page !== undefined && onPageChange !== undefined; + + return ( +
+
+ {stats} +
+ + {hasPagination && ( +
+ + + Page {page} + {itemCount !== undefined && ` \u00b7 ${itemCount} items`} + + +
+ )} +
+ ); +} + +const pageBtnStyle: React.CSSProperties = { + padding: '0.15rem 0.4rem', + fontSize: 'inherit', + border: 'none', + borderRadius: '0.25rem', + backgroundColor: 'var(--ctp-surface1)', + color: 'var(--ctp-text)', + cursor: 'pointer', +}; diff --git a/web/src/components/audit/AuditTable.tsx b/web/src/components/audit/AuditTable.tsx index e75c4c2..c471d9a 100644 --- a/web/src/components/audit/AuditTable.tsx +++ b/web/src/components/audit/AuditTable.tsx @@ -23,7 +23,13 @@ export function AuditTable({ }: AuditTableProps) { if (loading) { return ( -
+
Loading audit data...
); @@ -31,7 +37,13 @@ export function AuditTable({ if (items.length === 0) { return ( -
+
No items found
); @@ -39,16 +51,27 @@ export function AuditTable({ return (
- +
- {["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map( - (h) => ( - - ), - )} + {[ + "Score", + "Part Number", + "Description", + "Category", + "Sourcing", + "Missing", + ].map((h) => ( + + ))} @@ -68,7 +91,8 @@ export function AuditTable({ }} onMouseEnter={(e) => { if (!isSelected) - e.currentTarget.style.backgroundColor = "var(--ctp-surface0)"; + e.currentTarget.style.backgroundColor = + "var(--ctp-surface0)"; }} onMouseLeave={(e) => { if (!isSelected) @@ -100,7 +124,15 @@ export function AuditTable({ > {item.part_number} - @@ -119,7 +151,8 @@ export function AuditTable({ const thStyle: React.CSSProperties = { textAlign: "left", - padding: "0.5rem 0.75rem", + padding: "var(--d-th-py) var(--d-th-px)", + fontSize: "var(--d-th-font)", borderBottom: "1px solid var(--ctp-surface1)", color: "var(--ctp-subtext0)", fontWeight: 500, @@ -130,7 +163,8 @@ const thStyle: React.CSSProperties = { }; const tdStyle: React.CSSProperties = { - padding: "0.4rem 0.75rem", + padding: "var(--d-td-py) var(--d-td-px)", + fontSize: "var(--d-td-font)", borderBottom: "1px solid var(--ctp-surface0)", color: "var(--ctp-text)", }; diff --git a/web/src/components/audit/AuditToolbar.tsx b/web/src/components/audit/AuditToolbar.tsx index c83e851..34476cb 100644 --- a/web/src/components/audit/AuditToolbar.tsx +++ b/web/src/components/audit/AuditToolbar.tsx @@ -33,9 +33,9 @@ export function AuditToolbar({ style={{ display: "flex", flexWrap: "wrap", - gap: "0.5rem", + gap: "var(--d-toolbar-gap)", alignItems: "center", - marginBottom: "0.5rem", + marginBottom: "var(--d-toolbar-mb)", }} >
- {h} - + {h} +
+ {item.description} {item.category_name || item.category}
+
+
{cols.map((col) => ( ))} @@ -139,10 +189,10 @@ export function ItemTable({ {sortedItems.map((item, idx) => { const isSelected = item.part_number === selectedPN; const rowBg = isSelected - ? 'var(--ctp-surface1)' + ? "var(--ctp-surface1)" : idx % 2 === 0 - ? 'var(--ctp-base)' - : 'var(--ctp-surface0)'; + ? "var(--ctp-base)" + : "var(--ctp-surface0)"; return ( onSelect(item.part_number)} style={{ backgroundColor: rowBg, - cursor: 'pointer', - borderBottom: '1px solid var(--ctp-surface0)', + cursor: "pointer", + borderBottom: "1px solid var(--ctp-surface0)", }} onMouseEnter={(e) => { - if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)'; + if (!isSelected) + e.currentTarget.style.backgroundColor = + "var(--ctp-surface0)"; }} onMouseLeave={(e) => { - if (!isSelected) e.currentTarget.style.backgroundColor = rowBg; + if (!isSelected) + e.currentTarget.style.backgroundColor = rowBg; }} > {cols.map((col) => { switch (col.key) { - case 'part_number': + case "part_number": return ( ); - case 'item_type': { - const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' }; + case "item_type": { + const tc = typeColors[item.item_type] ?? { + bg: "var(--ctp-surface1)", + color: "var(--ctp-text)", + }; return ( ); } - case 'description': - return ; - case 'revision': - return ; - case 'projects': - return ; - case 'created': - return ; - case 'actions': + case "description": + return ( + + ); + case "revision": + return ( + + ); + case "projects": + return ( + + ); + case "created": + return ( + + ); + case "actions": return ( - @@ -239,12 +338,14 @@ export function ItemTable({ x={ctxMenu.x} y={ctxMenu.y} onClose={() => setCtxMenu(null)} - items={ALL_COLUMNS.map((col): ContextMenuItem => ({ - label: col.label, - checked: visibleColumns.includes(col.key), - onToggle: () => toggleColumn(col.key), - disabled: col.key === 'part_number', - }))} + items={ALL_COLUMNS.map( + (col): ContextMenuItem => ({ + label: col.label, + checked: visibleColumns.includes(col.key), + onToggle: () => toggleColumn(col.key), + disabled: col.key === "part_number", + }), + )} /> )} @@ -252,11 +353,11 @@ export function ItemTable({ } const actionBtnStyle: React.CSSProperties = { - background: 'none', - border: 'none', - color: 'var(--ctp-subtext1)', - cursor: 'pointer', - fontSize: '0.8rem', - padding: '0.15rem 0.4rem', - borderRadius: '0.25rem', + background: "none", + border: "none", + color: "var(--ctp-subtext1)", + cursor: "pointer", + fontSize: "0.8rem", + padding: "0.15rem 0.4rem", + borderRadius: "0.25rem", }; diff --git a/web/src/components/items/ItemsToolbar.tsx b/web/src/components/items/ItemsToolbar.tsx index 7bef3c8..d385339 100644 --- a/web/src/components/items/ItemsToolbar.tsx +++ b/web/src/components/items/ItemsToolbar.tsx @@ -1,13 +1,13 @@ -import { useEffect, useState } from 'react'; -import { get } from '../../api/client'; -import type { Project } from '../../api/types'; -import type { ItemFilters } from '../../hooks/useItems'; +import { useEffect, useState } from "react"; +import { get } from "../../api/client"; +import type { Project } from "../../api/types"; +import type { ItemFilters } from "../../hooks/useItems"; interface ItemsToolbarProps { filters: ItemFilters; onFilterChange: (partial: Partial) => void; - layout: 'horizontal' | 'vertical'; - onLayoutChange: (layout: 'horizontal' | 'vertical') => void; + layout: "horizontal" | "vertical"; + onLayoutChange: (layout: "horizontal" | "vertical") => void; onExport: () => void; onImport: () => void; onCreate: () => void; @@ -15,26 +15,40 @@ interface ItemsToolbarProps { } export function ItemsToolbar({ - filters, onFilterChange, layout, onLayoutChange, - onExport, onImport, onCreate, isEditor, + filters, + onFilterChange, + layout, + onLayoutChange, + onExport, + onImport, + onCreate, + isEditor, }: ItemsToolbarProps) { const [projects, setProjects] = useState([]); useEffect(() => { - get('/api/projects').then(setProjects).catch(() => {}); + get("/api/projects") + .then(setProjects) + .catch(() => {}); }, []); - const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => ( + const scopeBtn = (scope: ItemFilters["searchScope"], label: string) => ( {/* Export */} - + {/* Import (editor only) */} {isEditor && ( - + )} {/* Create (editor only) */} {isEditor && ( - )} @@ -133,20 +161,20 @@ export function ItemsToolbar({ } const selectStyle: React.CSSProperties = { - padding: '0.4rem 0.6rem', - backgroundColor: 'var(--ctp-surface0)', - border: '1px solid var(--ctp-surface1)', - borderRadius: '0.4rem', - color: 'var(--ctp-text)', - fontSize: '0.85rem', + padding: "var(--d-input-py) var(--d-input-px)", + backgroundColor: "var(--ctp-surface0)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "var(--d-input-font)", }; const toolBtnStyle: React.CSSProperties = { - padding: '0.4rem 0.75rem', - backgroundColor: 'var(--ctp-surface1)', - border: 'none', - borderRadius: '0.4rem', - color: 'var(--ctp-text)', - fontSize: '0.85rem', - cursor: 'pointer', + padding: "var(--d-input-py) var(--d-input-px)", + backgroundColor: "var(--ctp-surface1)", + border: "none", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "var(--d-input-font)", + cursor: "pointer", }; diff --git a/web/src/hooks/useDensity.ts b/web/src/hooks/useDensity.ts new file mode 100644 index 0000000..3c3a73d --- /dev/null +++ b/web/src/hooks/useDensity.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useLocalStorage } from './useLocalStorage'; + +export type Density = 'comfortable' | 'compact'; + +function applyDensity(density: Density) { + document.documentElement.setAttribute('data-density', density); +} + +export function useDensity(): [Density, () => void] { + const [density, setDensity] = useLocalStorage('silo-density', 'comfortable'); + + applyDensity(density); + + const toggle = useCallback(() => { + const next: Density = density === 'comfortable' ? 'compact' : 'comfortable'; + setDensity(next); + applyDensity(next); + }, [density, setDensity]); + + return [density, toggle]; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 5d5efd1..a497b34 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,20 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; -import { App } from './App'; -import './styles/global.css'; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { App } from "./App"; +import "./styles/global.css"; -createRoot(document.getElementById('root')!).render( +// Apply saved density before first paint to prevent FOUC +try { + const saved = localStorage.getItem("silo-density"); + const density = saved ? JSON.parse(saved) : "comfortable"; + document.documentElement.setAttribute("data-density", density); +} catch { + document.documentElement.setAttribute("data-density", "comfortable"); +} + +createRoot(document.getElementById("root")!).render( diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index eb98472..dc70813 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -6,6 +6,7 @@ import { AuditToolbar } from "../components/audit/AuditToolbar"; import { AuditTable } from "../components/audit/AuditTable"; import { AuditDetailPanel } from "../components/audit/AuditDetailPanel"; import { SplitPanel } from "../components/items/SplitPanel"; +import { PageFooter } from "../components/PageFooter"; type PaneMode = { type: "none" } | { type: "detail"; partNumber: string }; @@ -47,8 +48,8 @@ export function AuditPage() { style={{ display: "flex", flexDirection: "column", - height: "calc(100vh - 64px)", - paddingBottom: 28, + height: "100%", + paddingBottom: "var(--d-footer-h)", }} > {error && ( @@ -91,45 +92,18 @@ export function AuditPage() { storageKey="silo-audit-split" /> - {/* Pagination */} -
- - - Page {filters.page} · {items.length} items - - -
+ + {summary.total_items} items + Avg: {(summary.avg_score * 100).toFixed(1)}% + + } + page={filters.page} + pageSize={filters.pageSize} + itemCount={items.length} + onPageChange={(p) => updateFilters({ page: p })} + /> ); } - -const pageBtnStyle: React.CSSProperties = { - padding: "0.25rem 0.6rem", - fontSize: "0.8rem", - border: "none", - borderRadius: "0.3rem", - backgroundColor: "var(--ctp-surface0)", - color: "var(--ctp-text)", - cursor: "pointer", -}; diff --git a/web/src/pages/ItemsPage.tsx b/web/src/pages/ItemsPage.tsx index 4f77ab5..2c4a8da 100644 --- a/web/src/pages/ItemsPage.tsx +++ b/web/src/pages/ItemsPage.tsx @@ -14,7 +14,7 @@ import { EditItemPane } from "../components/items/EditItemPane"; import { DeleteItemPane } from "../components/items/DeleteItemPane"; import { ImportItemsPane } from "../components/items/ImportItemsPane"; import { SplitPanel } from "../components/items/SplitPanel"; -import { FooterStats } from "../components/items/FooterStats"; +import { PageFooter } from "../components/PageFooter"; type PaneMode = | { type: "none" } @@ -170,8 +170,8 @@ export function ItemsPage() { style={{ display: "flex", flexDirection: "column", - height: "calc(100vh - 64px)", - paddingBottom: 28, + height: "100%", + paddingBottom: "var(--d-footer-h)", }} > {error && ( @@ -217,47 +217,40 @@ export function ItemsPage() { secondary={secondaryPane} /> - {/* Pagination */} -
- - - Page {filters.page} · {items.length} items - - -
- - + + + Total:{" "} + + {items.length} + + + + Parts:{" "} + + {items.filter((i) => i.item_type === "part").length} + + + + Assemblies:{" "} + + {items.filter((i) => i.item_type === "assembly").length} + + + + Documents:{" "} + + {items.filter((i) => i.item_type === "document").length} + + + + } + page={filters.page} + pageSize={filters.pageSize} + itemCount={items.length} + onPageChange={(p) => updateFilters({ page: p })} + /> ); } - -const pageBtnStyle: React.CSSProperties = { - padding: "0.25rem 0.6rem", - fontSize: "0.8rem", - border: "none", - borderRadius: "0.3rem", - backgroundColor: "var(--ctp-surface0)", - color: "var(--ctp-text)", - cursor: "pointer", -}; diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 153240c..7b209fc 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -1,29 +1,92 @@ /* Catppuccin Mocha Theme */ :root { - --ctp-rosewater: #f5e0dc; - --ctp-flamingo: #f2cdcd; - --ctp-pink: #f5c2e7; - --ctp-mauve: #cba6f7; - --ctp-red: #f38ba8; - --ctp-maroon: #eba0ac; - --ctp-peach: #fab387; - --ctp-yellow: #f9e2af; - --ctp-green: #a6e3a1; - --ctp-teal: #94e2d5; - --ctp-sky: #89dceb; - --ctp-sapphire: #74c7ec; - --ctp-blue: #89b4fa; - --ctp-lavender: #b4befe; - --ctp-text: #cdd6f4; - --ctp-subtext1: #bac2de; - --ctp-subtext0: #a6adc8; - --ctp-overlay2: #9399b2; - --ctp-overlay1: #7f849c; - --ctp-overlay0: #6c7086; - --ctp-surface2: #585b70; - --ctp-surface1: #45475a; - --ctp-surface0: #313244; - --ctp-base: #1e1e2e; - --ctp-mantle: #181825; - --ctp-crust: #11111b; + --ctp-rosewater: #f5e0dc; + --ctp-flamingo: #f2cdcd; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-maroon: #eba0ac; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-sapphire: #74c7ec; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-text: #cdd6f4; + --ctp-subtext1: #bac2de; + --ctp-subtext0: #a6adc8; + --ctp-overlay2: #9399b2; + --ctp-overlay1: #7f849c; + --ctp-overlay0: #6c7086; + --ctp-surface2: #585b70; + --ctp-surface1: #45475a; + --ctp-surface0: #313244; + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --ctp-crust: #11111b; +} + +/* ── Density: comfortable (default) ── */ +[data-density="comfortable"], +:root { + --d-header-py: 0.625rem; + --d-header-px: 2rem; + --d-header-logo: 1.25rem; + --d-nav-gap: 1rem; + --d-nav-py: 0.35rem; + --d-nav-px: 0.75rem; + --d-nav-radius: 0.4rem; + --d-user-gap: 0.6rem; + --d-user-font: 0.85rem; + + --d-th-py: 0.35rem; + --d-th-px: 0.75rem; + --d-th-font: 0.75rem; + --d-td-py: 0.25rem; + --d-td-px: 0.75rem; + --d-td-font: 0.85rem; + + --d-toolbar-gap: 0.5rem; + --d-toolbar-py: 0.5rem; + --d-toolbar-mb: 0.35rem; + --d-input-py: 0.35rem; + --d-input-px: 0.6rem; + --d-input-font: 0.85rem; + + --d-footer-h: 28px; + --d-footer-font: 0.75rem; + --d-footer-px: 2rem; +} + +/* ── Density: compact ── */ +[data-density="compact"] { + --d-header-py: 0.35rem; + --d-header-px: 1.25rem; + --d-header-logo: 1.1rem; + --d-nav-gap: 0.5rem; + --d-nav-py: 0.2rem; + --d-nav-px: 0.5rem; + --d-nav-radius: 0.3rem; + --d-user-gap: 0.35rem; + --d-user-font: 0.8rem; + + --d-th-py: 0.2rem; + --d-th-px: 0.5rem; + --d-th-font: 0.7rem; + --d-td-py: 0.125rem; + --d-td-px: 0.5rem; + --d-td-font: 0.8rem; + + --d-toolbar-gap: 0.35rem; + --d-toolbar-py: 0.25rem; + --d-toolbar-mb: 0.15rem; + --d-input-py: 0.2rem; + --d-input-px: 0.4rem; + --d-input-font: 0.8rem; + + --d-footer-h: 24px; + --d-footer-font: 0.7rem; + --d-footer-px: 1.25rem; } -- 2.49.1
col.key !== 'actions' && onSort(col.key)} + onClick={() => col.key !== "actions" && onSort(col.key)} > {col.label} {sortKey === col.key && ( - {sortDir === 'asc' ? '▲' : '▼'} + + {sortDir === "asc" ? "▲" : "▼"} + )}
{ e.stopPropagation(); copyPN(item.part_number); }} + onClick={(e) => { + e.stopPropagation(); + copyPN(item.part_number); + }} title="Click to copy" style={{ fontFamily: "'JetBrains Mono', monospace", - color: 'var(--ctp-peach)', - cursor: 'copy', + color: "var(--ctp-peach)", + cursor: "copy", }} > {item.part_number} - + {item.item_type} {item.description}Rev {item.current_revision}{formatDate(item.created_at)} + {item.description} + + Rev {item.current_revision} + + — + + {formatDate(item.created_at)} + @@ -226,7 +318,14 @@ export function ItemTable({ })} {sortedItems.length === 0 && (
+ No items found