diff --git a/Docs/assets/ui-redesign/after-grid-dark.webp b/Docs/assets/ui-redesign/after-grid-dark.webp new file mode 100644 index 0000000..c7a690a Binary files /dev/null and b/Docs/assets/ui-redesign/after-grid-dark.webp differ diff --git a/Docs/assets/ui-redesign/after-grid-light.webp b/Docs/assets/ui-redesign/after-grid-light.webp new file mode 100644 index 0000000..39330b7 Binary files /dev/null and b/Docs/assets/ui-redesign/after-grid-light.webp differ diff --git a/Docs/assets/ui-redesign/after-list-light.webp b/Docs/assets/ui-redesign/after-list-light.webp new file mode 100644 index 0000000..2d7990d Binary files /dev/null and b/Docs/assets/ui-redesign/after-list-light.webp differ diff --git a/Docs/assets/ui-redesign/after-rail-collapsed.webp b/Docs/assets/ui-redesign/after-rail-collapsed.webp new file mode 100644 index 0000000..336e72f Binary files /dev/null and b/Docs/assets/ui-redesign/after-rail-collapsed.webp differ diff --git a/Docs/assets/ui-redesign/before-grid.webp b/Docs/assets/ui-redesign/before-grid.webp new file mode 100644 index 0000000..8d271bf Binary files /dev/null and b/Docs/assets/ui-redesign/before-grid.webp differ diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 8a672c5..e399a0d 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -2,7 +2,7 @@ "maxInitialJavaScriptBytes": 550000, "maxJavaScriptChunkBytes": 550000, "maxTotalJavaScriptBytes": 3300000, - "maxTotalCssBytes": 140000, + "maxTotalCssBytes": 160000, "maxTotalLocaleDataBytes": 1600000, "maxLocaleDataBytes": 120000, "routeJavaScriptBudgets": { @@ -15,5 +15,5 @@ "src/components/desktop/dashboard/ArchiveViewerModal.tsx": 25000, "node_modules/hls.js/dist/hls.mjs": 540000 }, - "policy": "No-regression ceilings based on the lazy-feature and lazy-locale production build. Increases require an explicit performance review. PdfViewer raised 500000 -> 510000 for the pdfjs-dist 6.3.289 security upgrade (GHSA-hq66-cqwq-w95j)." + "policy": "No-regression ceilings based on the lazy-feature and lazy-locale production build. Increases require an explicit performance review. PdfViewer raised 500000 -> 510000 for the pdfjs-dist 6.3.289 security upgrade (GHSA-hq66-cqwq-w95j). Total CSS raised 140000 -> 160000 for the desktop shell theme layer (tinted desk, floating window, sidebar rail, content tiles and list rows), which adds ~16 kB of hand-written CSS so the shell derives every surface from the existing app-* tokens and keeps user themes intact." } diff --git a/app/src/App.css b/app/src/App.css index 6a26604..d461ee5 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -1155,6 +1155,797 @@ label.search-field input[type="search"]:focus-visible { min-width: 9rem; } +/* ═══════════════════════════════════════════════════════════════════════ + Shelf Daylight — desktop shell + One floating rounded window resting on a tinted desk: hairline dividers, + quiet pill controls, tinted file tiles with their label underneath. + Every colour is derived from the active --color-app-* palette, so the + built-in base themes and any stored custom theme keep working. + ═══════════════════════════════════════════════════════════════════════ */ + +.drive-shell { + --drive-canvas: color-mix(in srgb, var(--color-app-canvas) 93%, var(--color-app-text) 7%); + --drive-window: var(--color-app-surface-raised); + --drive-rail: color-mix(in srgb, var(--color-app-surface-raised) 93%, var(--color-app-canvas)); + --drive-tile: color-mix(in srgb, var(--color-app-text) 4%, var(--color-app-surface-raised)); + --drive-pill: color-mix(in srgb, var(--color-app-text) 6%, transparent); + --drive-pill-strong: color-mix(in srgb, var(--color-app-text) 10%, transparent); + --drive-hairline: color-mix(in srgb, var(--color-app-border) 78%, transparent); + --drive-hairline-strong: var(--color-app-border); + --drive-shadow-window: 0 1px 2px rgba(12, 24, 46, 0.04), 0 18px 40px -18px rgba(12, 24, 46, 0.24); + --drive-shadow-tile: 0 1px 2px rgba(12, 24, 46, 0.05); + --drive-shadow-tile-hover: 0 2px 4px rgba(12, 24, 46, 0.05), 0 14px 26px -14px rgba(12, 24, 46, 0.26); + --drive-shadow-art: 0 1px 2px rgba(12, 24, 46, 0.06), 0 8px 16px -8px rgba(12, 24, 46, 0.2); + --drive-radius-window: 1.375rem; + --drive-radius-tile: 0.875rem; + + display: flex; + height: 100%; + width: 100%; + min-height: 0; + overflow: hidden; + padding: 0.625rem; + background: var(--drive-canvas); +} + +/* Reference values for the two shipped base themes. Custom themes keep the + color-mix() derivation above so their own palette stays authoritative. */ +:root.light:not(.custom-theme) .drive-shell { + --drive-canvas: #e9ecf1; + --drive-window: #ffffff; + --drive-rail: #fcfcfd; + --drive-tile: #f4f5f7; + --drive-pill: #f0f2f5; + --drive-pill-strong: #e8ebef; + --drive-hairline: #eaedf1; + --drive-hairline-strong: #dfe3e9; + --drive-shadow-window: 0 1px 2px rgba(17, 32, 58, 0.06), 0 24px 56px -26px rgba(17, 32, 58, 0.3); + --drive-shadow-tile: 0 1px 2px rgba(17, 32, 58, 0.05); + --drive-shadow-tile-hover: 0 2px 3px rgba(17, 32, 58, 0.05), 0 12px 22px -14px rgba(17, 32, 58, 0.3); + --drive-shadow-art: 0 1px 2px rgba(17, 32, 58, 0.07), 0 8px 16px -8px rgba(17, 32, 58, 0.22); +} + +:root.dark:not(.custom-theme) .drive-shell { + --drive-canvas: #05080f; + --drive-window: #101725; + --drive-rail: #0d131f; + --drive-tile: #161e2e; + --drive-pill: rgba(255, 255, 255, 0.06); + --drive-pill-strong: rgba(255, 255, 255, 0.1); + --drive-hairline: rgba(255, 255, 255, 0.07); + --drive-hairline-strong: rgba(255, 255, 255, 0.13); + --drive-shadow-window: 0 24px 60px -26px rgba(0, 0, 0, 0.8); + --drive-shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.4); + --drive-shadow-tile-hover: 0 10px 24px -14px rgba(0, 0, 0, 0.8); + --drive-shadow-art: 0 6px 16px -8px rgba(0, 0, 0, 0.7); +} + +.drive-window { + display: flex; + min-height: 0; + width: 100%; + overflow: hidden; + border: 1px solid var(--drive-hairline); + border-radius: var(--drive-radius-window); + background: var(--drive-window); + box-shadow: var(--drive-shadow-window); +} + +.drive-window-sidebar { + display: flex; + flex: 0 0 auto; + min-height: 0; + border-inline-end: 1px solid var(--drive-hairline); + background: var(--drive-rail); +} + +.drive-window-main { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--drive-window); +} + +/* ── Navigation column ─────────────────────────────────────────────────── */ + +.drive-sidebar { + display: flex; + min-height: 0; + flex-direction: column; +} + +.drive-sidebar-head { + display: flex; + flex: none; + align-items: center; + gap: 0.625rem; + padding: 1rem 0.75rem 0.875rem 1rem; +} + +.drive-logo-tile { + display: grid; + flex: none; + place-items: center; + height: 2.25rem; + width: 2.25rem; + border: 1px solid var(--drive-hairline); + border-radius: 0.75rem; + background: + radial-gradient(120% 120% at 18% 0%, color-mix(in srgb, var(--color-app-accent) 18%, transparent), transparent 72%), + var(--drive-window); + box-shadow: var(--drive-shadow-tile); +} + +.drive-sidebar-identity { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.0625rem; +} + +.drive-sidebar-name { + font-size: 0.9375rem; + font-weight: 650; + line-height: 1.15; + letter-spacing: -0.015em; + color: var(--color-app-text); +} + +.drive-sidebar-sub { + font-size: 0.6875rem; + line-height: 0.9375rem; + color: var(--color-app-text-secondary); +} + +.drive-collapse { + display: grid; + flex: none; + place-items: center; + height: 1.875rem; + width: 1.875rem; + border-radius: 0.5rem; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.drive-collapse:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-sidebar-actions { + display: flex; + flex: none; + flex-direction: column; + gap: 0.25rem; + padding: 0 0.625rem 0.25rem; +} + +.drive-upload { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 2.5rem; + width: 100%; + border: 1px solid var(--drive-hairline-strong); + border-radius: 0.75rem; + background: var(--drive-window); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: -0.005em; + color: var(--color-app-text); + box-shadow: var(--drive-shadow-tile); + transition: border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.drive-upload:hover { + border-color: color-mix(in srgb, var(--color-app-text) 26%, transparent); + box-shadow: var(--drive-shadow-tile-hover); +} + +.drive-upload:active { + background: var(--drive-pill); +} + +.drive-icon-circle { + display: grid; + flex: none; + place-items: center; + height: 1.875rem; + width: 1.875rem; + border: 1px solid var(--drive-hairline-strong); + border-radius: 999px; + background: var(--drive-window); + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.drive-icon-circle:hover { + border-color: color-mix(in srgb, var(--color-app-text) 24%, transparent); + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-mini-btn { + display: grid; + flex: none; + place-items: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 0.375rem; + color: var(--color-app-text-tertiary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.drive-mini-btn:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + + + + + +.drive-nav-label { + padding: 0.875rem 0.5rem 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-app-text-tertiary); +} + +.drive-nav-row { + display: flex; + align-items: center; + gap: 0.625rem; + height: 2rem; + width: 100%; + padding-inline: 0.5rem; + border-radius: 0.625rem; + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard); +} + +.drive-nav-row:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-nav-row[data-active="true"] { + background: var(--drive-pill-strong); + color: var(--color-app-text); + font-weight: 600; + box-shadow: inset 0 0 0 1px var(--drive-hairline); +} + +.drive-nav-row[data-active="true"] .drive-nav-icon { + color: var(--color-app-text); +} + +.drive-nav-row[data-active="true"] .drive-nav-icon, +.drive-nav-row[data-active="true"]:hover .drive-nav-icon { + color: var(--color-app-accent); +} + +.drive-nav-row[data-drop="true"] { + background: color-mix(in srgb, var(--color-app-accent) 12%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-app-accent) 45%, transparent); + color: var(--color-app-text); +} + +.drive-divider { + height: 1px; + margin: 0.5rem 0.5rem; + background: var(--drive-hairline); +} + +.drive-sidebar-scroll { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 0.0625rem; + overflow-y: auto; + padding: 0 0.5rem 0.5rem; +} + +.drive-sidebar-utility { + flex: none; + padding: 0.5rem 0.625rem; + border-top: 1px solid var(--drive-hairline); +} + +.drive-new-folder { + display: flex; + align-items: center; + gap: 0.5rem; + height: 2.125rem; + width: 100%; + padding-inline: 0.625rem; + border: 1px dashed var(--drive-hairline-strong); + border-radius: 0.625rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.drive-new-folder:hover { + border-color: color-mix(in srgb, var(--color-app-accent) 48%, transparent); + background: color-mix(in srgb, var(--color-app-accent) 5%, transparent); + color: var(--color-app-accent); +} + +.drive-sidebar-footer { + display: flex; + flex: none; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border-top: 1px solid var(--drive-hairline); +} + +.drive-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.6875rem; + color: var(--color-app-text-secondary); +} + +.drive-footer-actions { + display: flex; + gap: 0.5rem; +} + +.drive-footer-btn { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 1.875rem; + padding-inline: 0.625rem; + border: 1px solid var(--drive-hairline-strong); + border-radius: 0.625rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard); +} + +.drive-footer-btn:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-footer-btn[data-tone="danger"]:hover { + border-color: color-mix(in srgb, var(--color-app-danger) 38%, var(--drive-hairline-strong)); + background: color-mix(in srgb, var(--color-app-danger) 10%, transparent); + color: var(--color-app-danger); +} + +/* ── Content column ────────────────────────────────────────────────────── */ + +.drive-header { + display: flex; + flex: none; + align-items: center; + gap: 1rem; + padding: 1.0625rem 1.375rem 0.8125rem; +} + +.drive-title { + font-size: 1.25rem; + font-weight: 650; + line-height: 1.5rem; + letter-spacing: -0.025em; + color: var(--color-app-text); +} + +.drive-search { + display: flex; + align-items: center; + gap: 0.5rem; + height: 2.125rem; + min-width: 0; + max-width: 24rem; + flex: 1 1 auto; + padding-inline: 0.8125rem; + border: 1px solid var(--drive-hairline); + border-radius: 999px; + background: var(--drive-tile); + color: var(--color-app-text-secondary); + transition: border-color var(--motion-fast) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard); +} + +.drive-search:hover { + border-color: var(--drive-hairline-strong); +} + +.drive-search:focus-within { + border-color: color-mix(in srgb, var(--color-app-accent) 45%, transparent); + background: var(--drive-window); + box-shadow: 0 0 0 3px var(--color-app-accent-soft); +} + +.drive-actions { + display: flex; + flex: none; + align-items: center; + gap: 0.4375rem; + margin-inline-start: auto; +} + +.drive-icon-btn { + display: grid; + flex: none; + place-items: center; + height: 2.125rem; + width: 2.125rem; + border-radius: 999px; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.drive-icon-btn:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-icon-btn[data-state="open"] { + background: var(--drive-pill-strong); + color: var(--color-app-text); +} + +.drive-pill-btn { + display: flex; + flex: none; + align-items: center; + gap: 0.4375rem; + height: 2.125rem; + padding-inline: 0.9375rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: -0.005em; + transition: background-color var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + box-shadow var(--motion-fast) var(--ease-standard); +} + +.drive-pill-btn[data-tone="primary"] { + background: var(--color-app-accent); + color: var(--color-app-accent-contrast); + box-shadow: 0 1px 2px rgba(12, 24, 46, 0.18), + 0 6px 14px -10px rgba(12, 24, 46, 0.45); +} + +.drive-pill-btn[data-tone="primary"]:hover { + background: var(--color-app-accent-hover); +} + +.drive-pill-btn[data-tone="secondary"] { + border-color: var(--drive-hairline-strong); + background: var(--drive-window); + color: var(--color-app-text); + box-shadow: var(--drive-shadow-tile); +} + +.drive-pill-btn[data-tone="secondary"]:hover { + border-color: color-mix(in srgb, var(--color-app-text) 26%, transparent); +} + +.drive-sortbar { + display: flex; + flex: none; + align-items: center; + gap: 0.5rem; + padding: 0 1.375rem 0.75rem; +} + +.drive-sort-label { + font-size: 0.75rem; + color: var(--color-app-text-secondary); +} + +.drive-sort-btn { + display: flex; + align-items: center; + gap: 0.375rem; + height: 1.75rem; + padding-inline: 0.5rem; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.drive-sort-btn:hover { + background: var(--drive-pill); + color: var(--color-app-text); +} + +.drive-content { + min-height: 0; + flex: 1 1 auto; + overflow: auto; + padding: 0.125rem 1.375rem 1.5rem; +} + + +/* ── File tiles ────────────────────────────────────────────────────────── */ + +.shelf-card { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + cursor: pointer; +} + +.shelf-tile { + position: relative; + width: 100%; + flex: none; + overflow: hidden; + border: 1px solid var(--drive-hairline); + border-radius: var(--drive-radius-tile); + background: var(--drive-tile); + transition: border-color var(--motion-standard) var(--ease-standard), + box-shadow var(--motion-standard) var(--ease-standard), + background-color var(--motion-standard) var(--ease-standard); +} + +.shelf-card:hover .shelf-tile { + border-color: var(--drive-hairline-strong); + box-shadow: var(--drive-shadow-tile-hover); +} + +.shelf-card:focus-visible .shelf-tile { + outline: 2px solid var(--color-app-accent); + outline-offset: 2px; +} + +.shelf-card[data-drag-over="true"] .shelf-tile { + border-color: var(--color-app-accent); + background: color-mix(in srgb, var(--color-app-accent) 10%, var(--drive-tile)); +} + +.shelf-card[data-selected="true"] .shelf-tile { + border-color: var(--color-app-accent); + background: color-mix(in srgb, var(--color-app-accent) 6%, var(--drive-tile)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-app-accent) 30%, transparent); +} + +.shelf-tile[data-tint="folder"] { + background: color-mix(in srgb, var(--color-app-accent) 5%, var(--drive-tile)); +} + +.shelf-tile[data-tint="rose"] { + background: color-mix(in srgb, #fb7185 6%, var(--drive-tile)); +} + +.shelf-tile[data-tint="violet"] { + background: color-mix(in srgb, #8b5cf6 6%, var(--drive-tile)); +} + +.shelf-tile[data-tint="emerald"] { + background: color-mix(in srgb, #10b981 6%, var(--drive-tile)); +} + +.shelf-tile[data-tint="sky"] { + background: color-mix(in srgb, #0ea5e9 6%, var(--drive-tile)); +} + +.shelf-tile[data-tint="amber"] { + background: color-mix(in srgb, #f59e0b 8%, var(--drive-tile)); +} + +.shelf-tile[data-tint="slate"] { + background: color-mix(in srgb, #64748b 5%, var(--drive-tile)); +} + +.shelf-art { + position: absolute; + inset: 0; + display: grid; + place-items: center; +} + +.shelf-art-badge { + display: grid; + place-items: center; + height: 2.75rem; + width: 2.75rem; + border: 1px solid var(--drive-hairline); + border-radius: 0.875rem; + background: color-mix(in srgb, var(--drive-window) 88%, transparent); + box-shadow: var(--drive-shadow-art); +} + +.shelf-stack { + position: relative; + display: grid; + height: 100%; + width: 100%; + place-items: center; +} + +.shelf-stack-sheet { + position: absolute; + height: 42%; + width: 46%; + border: 1px solid var(--drive-hairline); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--drive-window) 86%, transparent); + box-shadow: var(--drive-shadow-tile); +} + +.shelf-stack-sheet:nth-child(1) { + transform: translate(4%, -30%) rotate(-6deg); + opacity: 0.55; +} + +.shelf-stack-sheet:nth-child(2) { + transform: translate(-3%, -12%) rotate(4deg); + opacity: 0.8; +} + +.shelf-stack-face { + position: relative; + z-index: 1; + display: grid; + place-items: center; + height: 2.5rem; + width: 2.5rem; + margin-top: 12%; + border: 1px solid var(--drive-hairline); + border-radius: 0.75rem; + background: var(--drive-window); + box-shadow: var(--drive-shadow-art); +} + +.shelf-tile-meta { + display: flex; + align-items: flex-start; + gap: 0.375rem; + padding: 0.5rem 0.125rem 0; +} + +.shelf-name { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.0625rem; + letter-spacing: -0.008em; + color: var(--color-app-text); +} + +.shelf-sub { + margin-top: 0.0625rem; + font-size: 0.6875rem; + line-height: 0.9375rem; + color: var(--color-app-text-secondary); +} + +.shelf-more { + display: grid; + flex: none; + place-items: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 999px; + opacity: 0; + color: var(--color-app-text-secondary); + transition: opacity var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); +} + +.shelf-card:hover .shelf-more, +.shelf-card:focus-within .shelf-more { + opacity: 1; +} + +.shelf-more:hover { + background: var(--drive-pill-strong); + color: var(--color-app-text); +} + +@media (hover: none) { + .shelf-more { + opacity: 1; + } +} + + +.shelf-add { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.4375rem; + height: 100%; + width: 100%; + border: 1px dashed var(--drive-hairline-strong); + border-radius: var(--drive-radius-tile); + font-size: 0.75rem; + font-weight: 600; + color: var(--color-app-text-secondary); + transition: color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.shelf-add:hover { + border-color: color-mix(in srgb, var(--color-app-accent) 50%, transparent); + background: color-mix(in srgb, var(--color-app-accent) 5%, transparent); + color: var(--color-app-accent); +} + +.shelf-add-icon { + display: grid; + place-items: center; + height: 2.125rem; + width: 2.125rem; + border: 1px solid var(--drive-hairline-strong); + border-radius: 999px; + background: var(--drive-window); +} + +/* ── List view ─────────────────────────────────────────────────────────── */ + +.drive-list-head { + display: grid; + align-items: center; + gap: 0.75rem; + height: 2rem; + padding-inline: 0.625rem; + border-bottom: 1px solid var(--drive-hairline); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-app-text-tertiary); +} + +.drive-list-row { + display: grid; + align-items: center; + gap: 0.75rem; + height: 2.5rem; + padding-inline: 0.625rem; + border-bottom: 1px solid var(--drive-hairline); + border-radius: 0.5rem; + font-size: 0.8125rem; + color: var(--color-app-text); + transition: background-color var(--motion-fast) var(--ease-standard); +} + +.drive-list-row:hover { + background: var(--drive-pill); +} + +.drive-list-row[data-selected="true"] { + background: color-mix(in srgb, var(--color-app-accent) 8%, transparent); +} + @media (prefers-reduced-motion: reduce) { .tv-shell button:focus-visible, .tv-shell a[href]:focus-visible, diff --git a/app/src/App.tsx b/app/src/App.tsx index 497fd31..df4b53b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -22,6 +22,10 @@ const AccessibilityAudit = import.meta.env.DEV const AccessibilityFixtures = import.meta.env.DEV ? React.lazy(() => import("./components/dev/AccessibilityFixtures")) : null; +// Desktop shell preview with sample content: UI review without a Telegram session. +const ShellPreview = import.meta.env.DEV + ? React.lazy(() => import("./components/dev/ShellPreview")) + : null; // Mobile shell: ships in the production build for phones/tablets and is // also previewable in a browser via ?mobile without an Android device. const MobileDashboard = React.lazy(() => import("./components/mobile/MobileDashboard")); @@ -262,6 +266,9 @@ function App() { const showAccessibilityFixture = Boolean( AccessibilityFixtures && typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('a11y-fixture') ); + const showShellPreview = Boolean( + ShellPreview && typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('shell-preview') + ); return ( @@ -272,6 +279,10 @@ function App() { }> + ) : showShellPreview && ShellPreview ? ( + }> + + ) : showDesignGallery && DesignGallery ? ( }> diff --git a/app/src/components/desktop/DesktopDashboard.tsx b/app/src/components/desktop/DesktopDashboard.tsx index 1b965f6..7571770 100644 --- a/app/src/components/desktop/DesktopDashboard.tsx +++ b/app/src/components/desktop/DesktopDashboard.tsx @@ -25,6 +25,7 @@ import { formatBytes, isMediaFile, isPdfFile, isArchiveFile, isImageFile, copyTo // Components import { Sidebar } from './dashboard/Sidebar'; +import { DriveFrame } from './dashboard/DriveFrame'; import { TopBar } from './dashboard/TopBar'; import { FileExplorer, type SortDirection, type SortField } from './dashboard/FileExplorer'; import { TransferCenter } from './dashboard/TransferCenter'; @@ -727,7 +728,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { onDragCancel={() => setInternalDrag(null)} onDragEnd={handleInternalDragEnd} > -
+
void }) { )} - setRenameFolder({ id, name })} - onToggleVisibility={async (id, _name, isPublic) => { - try { - await handleFolderToggleVisibility(id, !isPublic); - queryClient.invalidateQueries({ queryKey: ['folders'] }); - } catch { /* toast handled in hook */ } - }} - onExportInvite={async (id, _name) => { - try { - const info = await handleExportFolderInvite(id); + setRenameFolder({ id, name })} + onToggleVisibility={async (id, _name, isPublic) => { try { - await copyToClipboard(info.link); - toast.success(`Invite link copied: ${info.link}`); - } catch (e) { - toast.error(`Failed to copy to clipboard: ${e}`); - } - } catch { /* backend error already toasted in hook */ } - }} - onCreate={handleCreateFolder} - isSyncing={isSyncing} - isConnected={isConnected} - onSync={() => void handleManualSync()} - onLogout={handleLogout} - bandwidth={bandwidth || null} - onAssignFolderToGroup={handleAssignFolderToGroup} - onCreateGroup={handleCreateGroup} - onUpdateGroup={handleUpdateGroup} - onDeleteGroup={handleDeleteGroup} - createFolderRequest={createFolderRequest} - activeSmartView={activeSmartView} - onSmartViewChange={setActiveSmartView} - /> + await handleFolderToggleVisibility(id, !isPublic); + queryClient.invalidateQueries({ queryKey: ['folders'] }); + } catch { /* toast handled in hook */ } + }} + onExportInvite={async (id, _name) => { + try { + const info = await handleExportFolderInvite(id); + try { + await copyToClipboard(info.link); + toast.success(`Invite link copied: ${info.link}`); + } catch (e) { + toast.error(`Failed to copy to clipboard: ${e}`); + } + } catch { /* backend error already toasted in hook */ } + }} + onCreate={handleCreateFolder} + isSyncing={isSyncing} + isConnected={isConnected} + onSync={() => void handleManualSync()} + onLogout={handleLogout} + bandwidth={bandwidth || null} + onAssignFolderToGroup={handleAssignFolderToGroup} + onCreateGroup={handleCreateGroup} + onUpdateGroup={handleUpdateGroup} + onDeleteGroup={handleDeleteGroup} + createFolderRequest={createFolderRequest} + activeSmartView={activeSmartView} + onSmartViewChange={setActiveSmartView} + onUpload={handleManualUpload} + /> + } + > -
-
void }) { onNewFolderClick={() => setCreateFolderRequest((value) => value + 1)} onShowShortcuts={() => setShowShortcuts(true)} onShowHelp={() => setShowHelp(true)} + onOpenLibrary={() => setWorkspaceKeys([])} /> {(searchTerm.trim().length > 0 || searchFilters.type !== 'all' || searchFilters.size !== 'any' || searchFilters.date !== 'any') && ( -
+

{displayedFiles.length.toLocaleString()} result{displayedFiles.length === 1 ? '' : 's'}{searchTerm.trim() ? <> for "{searchTerm}" : null}

@@ -898,7 +902,7 @@ export function Dashboard({ onLogout }: { onLogout: () => void }) { syncProgress={folderSyncProgress} selectionDisabled={isCrossFolderView} /> -
+
{workspaceKeys !== null && setWorkspaceKeys(null)} onOpen={(file, orderedFiles, localPath) => handlePreview(file, orderedFiles, localPath)} onFolder={id => { setWorkspaceKeys(null); setActiveSmartView(null); setActiveFolderId(id); }} />} diff --git a/app/src/components/desktop/dashboard/DriveFrame.tsx b/app/src/components/desktop/dashboard/DriveFrame.tsx new file mode 100644 index 0000000..e1ea80e --- /dev/null +++ b/app/src/components/desktop/dashboard/DriveFrame.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +interface DriveFrameProps { + sidebar: ReactNode; + children: ReactNode; +} + +/** + * Desktop window frame: one floating rounded window holding the navigation + * column and the content column. Shared by the live dashboard and the + * development shell preview so both stay pixel-identical. + */ +export function DriveFrame({ sidebar, children }: DriveFrameProps) { + return ( +
+
+ {sidebar} +
+
+ {children} +
+
+ ); +} diff --git a/app/src/components/desktop/dashboard/FileCard.tsx b/app/src/components/desktop/dashboard/FileCard.tsx index c0ab8c4..85c2725 100644 --- a/app/src/components/desktop/dashboard/FileCard.tsx +++ b/app/src/components/desktop/dashboard/FileCard.tsx @@ -1,6 +1,6 @@ import { sourceFolder } from '../../../services/fileIdentity'; import { useCallback, useState, useEffect } from 'react'; -import { Folder, Eye, Trash2, Link, Check } from 'lucide-react'; +import { Folder, Eye, Trash2, Link, Check, MoreVertical } from 'lucide-react'; import { useDraggable, useDroppable } from '@dnd-kit/core'; import { TelegramFile } from '../../../types'; import { forgetThumbnail, getCachedThumbnail, loadThumbnail } from '../../../services/imagePreviewCache'; @@ -13,6 +13,26 @@ import { EncryptionBadge } from '../../shared/EncryptionBadge'; import { describeFileActions } from './fileActionDescriptors'; import i18n from '../../../i18n'; +/** Height reserved under the artwork for the name and metadata lines. */ +export const FILE_CARD_LABEL_HEIGHT = 46; + +type TileTint = 'folder' | 'rose' | 'violet' | 'emerald' | 'sky' | 'amber' | 'slate'; + +const TINTS_BY_EXTENSION: Record = { + jpg: 'rose', jpeg: 'rose', png: 'rose', gif: 'rose', webp: 'rose', bmp: 'rose', heic: 'rose', avif: 'rose', svg: 'rose', + mp4: 'violet', mov: 'violet', mkv: 'violet', webm: 'violet', avi: 'violet', m4v: 'violet', + xls: 'emerald', xlsx: 'emerald', csv: 'emerald', ods: 'emerald', numbers: 'emerald', + pdf: 'sky', doc: 'sky', docx: 'sky', txt: 'sky', md: 'sky', rtf: 'sky', odt: 'sky', pages: 'sky', ppt: 'sky', pptx: 'sky', + zip: 'amber', rar: 'amber', '7z': 'amber', tar: 'amber', gz: 'amber', bz2: 'amber', +}; + +/** Quiet, type-coded wash so tiles read as content without shouting. */ +function tileTint(file: TelegramFile, isFolder: boolean): TileTint { + if (isFolder) return 'folder'; + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + return TINTS_BY_EXTENSION[ext] ?? 'slate'; +} + interface FileCardProps { file: TelegramFile; onDelete: () => void; @@ -108,27 +128,34 @@ export function FileCard({ file, onDelete, onDownload, onPreview, onShare, isSel return () => { cancelled = true; }; }, [file.id, file.name, sourceFolderId, isFolder]); + const openMenu = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu?.(event); + }; + return (
- {/* Thumbnail or Icon */} + {/* Artwork: real thumbnail when available, tinted glyph otherwise */} {thumbnail ? ( -
+ <> {!thumbnailReady && } - {/* Gradient overlay for text readability */} -
-
+ ) : ( -
+
{isFolder ? ( - - ) : thumbnailLoading && isImageFile(file.name) ? ( - + ) : ( - + )}
)} - {/* Selection Checkmark */} + {/* Selection toggle */} - {/* File info overlay at bottom */} -
-

{file.name}

-
-

{file.sizeStr}

+ {/* Quick actions on hover */} +
+ + + {actions.canShare && onShare && ( + + )} + +
+
+ + {/* Label and metadata live under the artwork, the way a shelf is read */} +
+
+

{file.name}

+
+ {file.sizeStr} {cachedQualities.length > 0 && ( {cachedQualities.map(q => ( - + {q} @@ -191,24 +246,18 @@ export function FileCard({ file, onDelete, onDownload, onPreview, onShare, isSel )}
- - {/* Quick actions on hover */} -
- - - {actions.canShare && onShare && ( - - )} - -
+ )}
) diff --git a/app/src/components/desktop/dashboard/FileExplorer.tsx b/app/src/components/desktop/dashboard/FileExplorer.tsx index c16d07c..af98b93 100644 --- a/app/src/components/desktop/dashboard/FileExplorer.tsx +++ b/app/src/components/desktop/dashboard/FileExplorer.tsx @@ -4,7 +4,7 @@ import { Plus, ArrowUpDown, ArrowUp, ArrowDown, FolderUp } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useTranslation } from 'react-i18next'; import { useSettings } from '../../../context/SettingsContext'; -import { FileCard } from './FileCard'; +import { FileCard, FILE_CARD_LABEL_HEIGHT } from './FileCard'; import { EmptyState } from './EmptyState'; import { TelegramFile, TelegramFolder } from '../../../types'; import { ContextMenu } from './ContextMenu'; @@ -102,7 +102,8 @@ export function FileExplorer({ const columns = Math.min(desiredColumns, safeColumns); const cardWidth = (containerWidth - (GRID_GAP * (columns - 1))) / columns; - const cardHeight = Math.max(MIN_CARD_HEIGHT, cardWidth * 0.75); // 4:3 until the protected minimum + // Artwork keeps a 4:3-ish frame; the label block underneath is fixed so rows stay even. + const cardHeight = Math.max(MIN_CARD_HEIGHT, cardWidth * 0.58) + FILE_CARD_LABEL_HEIGHT; const handleContextMenu = useCallback((e: React.MouseEvent, file: TelegramFile) => { e.preventDefault(); @@ -189,7 +190,7 @@ export function FileExplorer({ if (loading || (!error && syncProgress?.active && files.length === 0)) { return ( -
+
{viewMode === 'grid' ? (
{Array.from({ length: 12 }, (_, index) => ( @@ -216,12 +217,12 @@ export function FileExplorer({ } if (error) { - return
Error loading files
; + return
Error loading files
; } if (files.length === 0) { return ( -
+
); @@ -230,7 +231,7 @@ export function FileExplorer({ return (
{syncProgress?.active && syncProgress.count > 0 && (
@@ -263,10 +264,10 @@ export function FileExplorer({ ); @@ -276,10 +277,10 @@ export function FileExplorer({ ); @@ -310,9 +311,9 @@ export function FileExplorer({
) : ( -
+
{/* List Header */} -
+
#
); @@ -357,10 +358,10 @@ export function FileExplorer({ >
); diff --git a/app/src/components/desktop/dashboard/FileListItem.tsx b/app/src/components/desktop/dashboard/FileListItem.tsx index 9ea4741..4eceb36 100644 --- a/app/src/components/desktop/dashboard/FileListItem.tsx +++ b/app/src/components/desktop/dashboard/FileListItem.tsx @@ -71,10 +71,11 @@ export function FileListItem({ style={{ opacity: isDragging ? 0.45 : undefined }} {...(!isFolder ? attributes : {})} {...(!isFolder ? listeners : {})} - className={`group grid h-10 cursor-pointer grid-cols-[1.75rem_minmax(0,1fr)_2rem] items-center gap-3 border-b border-app-border-subtle px-3 transition-colors hover:bg-app-hover sm:grid-cols-[1.75rem_minmax(0,2fr)_6rem_8rem_2rem] + className={`drive-list-row grid-cols-[1.75rem_minmax(0,1fr)_2rem] group cursor-pointer sm:grid-cols-[1.75rem_minmax(0,2fr)_6rem_8rem_2rem] ${selectedIds.includes(file.id) ? 'bg-app-selected' : ''} - ${isFileDragOver ? 'bg-app-selected ring-2 ring-inset ring-app-accent' : ''} + ${isFileDragOver ? 'ring-2 ring-inset ring-app-accent' : ''} `} + data-selected={selectedIds.includes(file.id)} >
{isFolder ? : } diff --git a/app/src/components/desktop/dashboard/Sidebar.tsx b/app/src/components/desktop/dashboard/Sidebar.tsx index df29355..0c86e93 100644 --- a/app/src/components/desktop/dashboard/Sidebar.tsx +++ b/app/src/components/desktop/dashboard/Sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { HardDrive, Folder, Plus, RefreshCw, LogOut, ChevronLeft, ChevronRight, Settings2, Trash2, Check, X, Eye, EyeOff, Clock3, Star, FileWarning, Copy, Database, Files, Image, Film, FileText } from 'lucide-react'; +import { HardDrive, Folder, Plus, RefreshCw, LogOut, ChevronLeft, ChevronRight, Settings2, Trash2, Check, X, Eye, EyeOff, Clock3, Star, FileWarning, Copy, Database, Files, Image, Film, FileText, UploadCloud, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { SidebarItem } from './SidebarItem'; import { BandwidthWidget } from './BandwidthWidget'; @@ -65,10 +65,10 @@ function GroupTab({ id, groupId, label, colorHex, active, onClick, onEdit, isSor {...attributes} {...listeners} onClick={onClick} - className={`quiet-control flex h-7 flex-shrink-0 cursor-pointer select-none items-center gap-1.5 border px-2 text-badge font-medium ${ + className={`flex h-7 flex-shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-full border px-2.5 text-badge font-medium transition-colors ${ active - ? 'border-app-accent/30 bg-app-selected text-app-accent' - : 'border-app-border bg-app-surface text-app-text-secondary hover:border-app-border-strong hover:text-app-text' + ? 'border-app-border-strong bg-app-surface-raised text-app-text' + : 'border-app-border-subtle bg-transparent text-app-text-secondary hover:bg-app-hover hover:text-app-text' }`} > {colorHex && ( @@ -115,12 +115,14 @@ interface SidebarProps { createFolderRequest?: number; activeSmartView?: SmartView | null; onSmartViewChange?: (view: SmartView | null) => void; + onUpload?: () => void; } export function Sidebar({ folders, groups = [], activeFolderId, setActiveFolderId, onDelete, onRename, onToggleVisibility, onExportInvite, onCreate, isSyncing, isConnected, onSync, onLogout, bandwidth, - onAssignFolderToGroup, onCreateGroup, onUpdateGroup, onDeleteGroup, createFolderRequest = 0, activeSmartView = null, onSmartViewChange + onAssignFolderToGroup, onCreateGroup, onUpdateGroup, onDeleteGroup, createFolderRequest = 0, activeSmartView = null, onSmartViewChange, + onUpload, }: SidebarProps) { const [showNewFolderInput, setShowNewFolderInput] = useState(false); const { t } = useTranslation(); @@ -167,276 +169,356 @@ export function Sidebar({ return folder.group_id === activeGroupId; }); + /** Focus the shared search field in the header so the sidebar shortcut + * lands on the single source of truth for the query. */ + const focusSearch = () => { + const input = document.querySelector('input[data-file-search]'); + if (!input) return; + input.focus(); + input.select(); + }; + + const openSettings = () => window.dispatchEvent(new Event('shelf-drive-open-settings')); + const collapsed = settings.sidebarCollapsed; + return ( -