SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%

feat: v2.1 design overhaul — light-first 'Ivoire & Indigo' identity + real mobile UX

- light theme by default (warm ivory canvas, electric indigo accent, aurora wash)
- new token system: gradients, layered shadows, springs, radius scale, glass surfaces
- glass topbar, pill buttons/search, gradient CTAs, card hover lift, inverted pill toasts
- CVD-validated data palette for storage buckets (passes light AND dark checks)
- mobile: bottom app bar + gradient FAB, context menus as bottom sheets, info panel
  as bottom sheet, sidebar drawer with scrim, long-press menus, tap-to-open,
  safe-area insets, bigger touch targets
- fix: theme persistence was CSP-blocked (inline script) → external theme.js
- dark theme kept, harmonized warm charcoal; adaptive theme-color meta

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 5 h ago (Aug 10, 2026) parent 4a65d43

Showing 8 changed files with +444 and −141

modified src/web/assets/app.css +214 −67
@@ -16,31 +16,51 @@ body {
16 16 margin: 0;
17 17 background: var(--bg);
18 18 color: var(--text);
19 font: 14px/1.5 var(--font-sans);
19 + font: 14px/1.55 var(--font-sans);
20 + letter-spacing: -0.01em;
20 21 -webkit-font-smoothing: antialiased;
21 }
22 a { color: var(--accent); text-decoration: none; }
22 + text-rendering: optimizeLegibility;
23 +}
24 +/* Signature aurora wash — a whisper of the brand at the top of every page. */
25 +body::before {
26 + content: '';
27 + position: fixed; inset: 0; z-index: -1; pointer-events: none;
28 + background:
29 + radial-gradient(46rem 24rem at 12% -6%, var(--aurora-a), transparent 60%),
30 + radial-gradient(40rem 22rem at 88% -8%, var(--aurora-b), transparent 60%),
31 + radial-gradient(30rem 18rem at 55% -12%, var(--aurora-c), transparent 65%);
32 +}
33 +a { color: var(--accent-ink); text-decoration: none; }
23 34 a:hover { text-decoration: underline; }
24 button { font: inherit; color: inherit; }
35 +button { font: inherit; color: inherit; letter-spacing: inherit; }
25 36 ::selection { background: var(--accent-soft); }
26 37 :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
38 +@media (prefers-reduced-motion: reduce) {
39 + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
40 +}
27 41
28 42 /* ── Primitives ─────────────────────────────────────────────── */
29 43 .btn {
30 44 display: inline-flex; align-items: center; gap: 7px;
31 padding: 7px 14px; border-radius: var(--radius-sm);
32 border: 1px solid var(--border); background: var(--surface-2);
33 cursor: pointer; transition: border-color 150ms var(--ease), background 150ms var(--ease);
45 + padding: 7px 14px; border-radius: 999px;
46 + border: 1px solid var(--border); background: var(--surface);
47 + cursor: pointer;
48 + transition: border-color 150ms var(--ease), background 150ms var(--ease),
49 + box-shadow 150ms var(--ease), transform 150ms var(--spring);
34 50 white-space: nowrap; user-select: none;
35 51 }
36 .btn:hover { border-color: var(--muted); }
37 .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
38 .btn.primary:hover { filter: brightness(1.08); }
52 +.btn:hover { border-color: var(--border-strong); box-shadow: var(--shadow-1); }
53 +.btn:active { transform: scale(0.97); }
54 +.btn.primary {
55 + background: var(--grad-brand); border-color: transparent; color: #fff; font-weight: 600;
56 + box-shadow: 0 2px 10px rgba(79, 70, 229, 0.28);
57 +}
58 +.btn.primary:hover { filter: brightness(1.06); box-shadow: 0 4px 16px rgba(79, 70, 229, 0.36); }
39 59 .btn.danger { color: var(--danger); }
40 60 .btn.danger:hover { border-color: var(--danger); background: var(--danger-soft); }
41 .btn.ghost { background: transparent; border-color: transparent; }
42 .btn.ghost:hover { background: var(--surface-2); border-color: transparent; }
43 .btn.icon { padding: 7px; }
61 +.btn.ghost { background: transparent; border-color: transparent; box-shadow: none; }
62 +.btn.ghost:hover { background: var(--surface-2); border-color: transparent; box-shadow: none; }
63 +.btn.icon { padding: 7px; border-radius: 11px; }
44 64 .btn:disabled { opacity: 0.45; cursor: default; pointer-events: none; }
45 65 .btn svg { width: 16px; height: 16px; flex: none; }
46 66
@@ -52,6 +72,7 @@ input[type='datetime-local'], select, textarea {
52 72 transition: border-color 150ms var(--ease);
53 73 }
54 74 input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
75 +input[type='range'], input[type='checkbox'], input[type='radio'] { accent-color: var(--accent); }
55 76 label.field { display: block; margin-bottom: 12px; }
56 77 label.field > span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
57 78
@@ -61,7 +82,7 @@ label.field > span { display: block; font-size: 12px; color: var(--muted); margi
61 82 border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
62 83 cursor: pointer; user-select: none;
63 84 }
64 .chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
85 +.chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); }
65 86 .chip .dot { width: 8px; height: 8px; border-radius: 50%; }
66 87
67 88 kbd {
@@ -77,15 +98,16 @@ kbd {
77 98 /* ── Login ──────────────────────────────────────────────────── */
78 99 .login-wrap { min-height: 100%; display: grid; place-items: center; padding: 24px; }
79 100 .login-card {
80 width: 360px; max-width: 100%; background: var(--surface);
81 border: 1px solid var(--border); border-radius: var(--radius);
82 padding: 36px 32px;
101 + width: 372px; max-width: 100%; background: var(--surface);
102 + border: 1px solid var(--border); border-radius: var(--radius-lg);
103 + padding: 40px 34px; box-shadow: var(--shadow-2);
83 104 }
84 105 .login-logo {
85 width: 52px; height: 52px; border-radius: 14px; margin: 0 auto 18px;
86 background: linear-gradient(135deg, var(--accent), var(--accent-2));
106 + width: 56px; height: 56px; border-radius: 18px; margin: 0 auto 18px;
107 + background: var(--grad-brand);
87 108 display: grid; place-items: center; color: #fff;
88 109 font: 700 19px var(--font-mono); letter-spacing: -0.5px;
110 + box-shadow: 0 6px 20px rgba(79, 70, 229, 0.3);
89 111 }
90 112 .login-card h1 { font-size: 19px; text-align: center; margin: 0 0 4px; }
91 113 .login-card .sub { text-align: center; color: var(--muted); font-size: 13px; margin: 0 0 24px; }
@@ -98,20 +120,28 @@ kbd {
98 120 .login-card .btn { width: 100%; justify-content: center; padding: 10px; }
99 121
100 122 /* ── App shell ──────────────────────────────────────────────── */
101 .shell { display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
123 +.shell { display: grid; grid-template-rows: 60px 1fr; height: 100vh; height: 100dvh; }
102 124 .topbar {
103 125 display: flex; align-items: center; gap: 14px; padding: 0 16px;
104 background: var(--surface); border-bottom: 1px solid var(--border);
126 + background: var(--surface-glass);
127 + -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
128 + border-bottom: 1px solid var(--border);
129 + position: relative; z-index: 10;
105 130 }
106 .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; white-space: nowrap; }
131 +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; white-space: nowrap; letter-spacing: -0.02em; }
107 132 .brand .mark {
108 width: 30px; height: 30px; border-radius: 9px;
109 background: linear-gradient(135deg, var(--accent), var(--accent-2));
133 + width: 32px; height: 32px; border-radius: 10px;
134 + background: var(--grad-brand);
110 135 display: grid; place-items: center; color: #fff; font: 700 12px var(--font-mono);
136 + box-shadow: 0 3px 10px rgba(79, 70, 229, 0.28);
111 137 }
112 138 .searchbox { flex: 1; max-width: 560px; position: relative; }
113 .searchbox svg { position: absolute; left: 10px; top: 50%; translate: 0 -50%; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
114 .searchbox input { padding-left: 32px; background: var(--surface-2); border-radius: 8px; }
139 +.searchbox svg { position: absolute; left: 13px; top: 50%; translate: 0 -50%; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
140 +.searchbox input {
141 + padding-left: 36px; background: var(--surface-2); border-radius: 999px;
142 + border-color: transparent;
143 +}
144 +.searchbox input:focus { background: var(--surface); border-color: var(--accent); box-shadow: var(--shadow-1); }
115 145 .topbar .spacer { flex: 1; }
116 146
117 147 .body { display: grid; grid-template-columns: 248px 1fr; min-height: 0; }
@@ -119,10 +149,14 @@ kbd {
119 149
120 150 /* Sidebar */
121 151 .sidebar {
122 background: var(--surface); border-right: 1px solid var(--border);
152 + background: transparent; border-right: 1px solid var(--border);
123 153 padding: 14px 10px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
124 154 }
125 .new-btn { margin: 0 4px 12px; justify-content: center; font-weight: 600; }
155 +.sidebar-scrim {
156 + position: fixed; inset: 0; z-index: 64; background: var(--scrim);
157 + display: none; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
158 +}
159 +.new-btn { margin: 0 4px 12px; justify-content: center; font-weight: 600; padding: 10px; }
126 160 .nav-item {
127 161 display: flex; align-items: center; gap: 10px; padding: 7px 12px;
128 162 border-radius: var(--radius-sm); color: var(--text); cursor: pointer;
@@ -130,8 +164,8 @@ kbd {
130 164 }
131 165 .nav-item svg { width: 16px; height: 16px; color: var(--muted); flex: none; }
132 166 .nav-item:hover { background: var(--surface-2); }
133 .nav-item.active { background: var(--accent-soft); color: var(--accent); }
134 .nav-item.active svg { color: var(--accent); }
167 +.nav-item.active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 600; }
168 +.nav-item.active svg { color: var(--accent-ink); }
135 169 .nav-item .count { margin-left: auto; font-size: 11px; color: var(--muted); }
136 170 .nav-section { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); padding: 14px 12px 4px; }
137 171
@@ -154,8 +188,9 @@ kbd {
154 188 .tree-label svg { width: 15px; height: 15px; flex: none; }
155 189
156 190 .storage-meter { margin-top: auto; padding: 14px 12px 6px; border-top: 1px solid var(--border); }
157 .storage-meter .bar { height: 5px; border-radius: 3px; background: var(--surface-2); overflow: hidden; display: flex; margin: 8px 0 6px; }
158 .storage-meter .bar i { height: 100%; display: block; }
191 +/* 2px surface gaps between stacked segments (mark spec) */
192 +.storage-meter .bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; display: flex; gap: 2px; margin: 8px 0 6px; }
193 +.storage-meter .bar i { height: 100%; display: block; border-radius: 2px; }
159 194 .storage-meter .legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 11px; color: var(--muted); }
160 195 .storage-meter .legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
161 196
@@ -176,19 +211,21 @@ kbd {
176 211 .crumb-sep { color: var(--muted); opacity: 0.5; }
177 212
178 213 .sel-toolbar { display: flex; align-items: center; gap: 6px; }
179 .sel-count { font-size: 13px; color: var(--accent); font-weight: 600; margin-right: 6px; }
214 +.sel-count { font-size: 13px; color: var(--accent-ink); font-weight: 600; margin-right: 6px; }
180 215
181 216 .content { flex: 1; overflow-y: auto; padding: 14px 18px 80px; position: relative; }
182 217
183 218 /* Grid view */
184 .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card, 168px), 1fr)); gap: 12px; }
219 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card, 168px), 1fr)); gap: 14px; }
185 220 .card {
186 221 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
187 222 overflow: hidden; cursor: default; position: relative; user-select: none;
188 transition: border-color 150ms var(--ease), background 150ms var(--ease);
223 + box-shadow: var(--shadow-1);
224 + transition: border-color 150ms var(--ease), background 150ms var(--ease),
225 + box-shadow 180ms var(--ease), transform 180ms var(--spring);
189 226 }
190 .card:hover { border-color: var(--muted); }
191 .card.selected { border-color: var(--accent); background: var(--accent-soft); }
227 +.card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); transform: translateY(-2px); }
228 +.card.selected { border-color: transparent; box-shadow: var(--ring), var(--shadow-1); background: var(--accent-soft); }
192 229 .card.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
193 230 .card .thumb {
194 231 aspect-ratio: 4/3; display: grid; place-items: center; background: var(--surface-2);
@@ -261,11 +298,28 @@ kbd {
261 298
262 299 /* ── Context menu ───────────────────────────────────────────── */
263 300 .ctx-menu {
264 position: fixed; z-index: 80; min-width: 210px;
265 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
266 box-shadow: var(--shadow-pop); padding: 5px; animation: pop 120ms var(--ease);
267 }
268 @keyframes pop { from { opacity: 0; transform: scale(0.97); } }
301 + position: fixed; z-index: 80; min-width: 218px;
302 + background: var(--surface-glass);
303 + -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
304 + border: 1px solid var(--border); border-radius: var(--radius);
305 + box-shadow: var(--shadow-pop); padding: 6px; animation: pop 130ms var(--spring);
306 +}
307 +@keyframes pop { from { opacity: 0; transform: scale(0.96) translateY(3px); } }
308 +/* Mobile: context menu as a bottom sheet */
309 +.ctx-menu.sheet {
310 + left: 0 !important; right: 0; top: auto !important; bottom: 0;
311 + min-width: 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
312 + padding: 10px 12px calc(14px + env(safe-area-inset-bottom));
313 + animation: sheet-up 220ms var(--spring);
314 + max-height: 70vh; overflow-y: auto;
315 +}
316 +.ctx-menu.sheet::before {
317 + content: ''; display: block; width: 40px; height: 4px; border-radius: 2px;
318 + background: var(--border-strong); margin: 0 auto 10px;
319 +}
320 +.ctx-menu.sheet .ctx-item { padding: 13px 14px; font-size: 15px; border-radius: var(--radius-sm); }
321 +.ctx-menu.sheet .ctx-item svg { width: 18px; height: 18px; }
322 +@keyframes sheet-up { from { transform: translateY(40%); opacity: 0.4; } }
269 323 .ctx-item {
270 324 display: flex; align-items: center; gap: 10px; width: 100%;
271 325 padding: 7px 11px; border: none; background: none; border-radius: var(--radius-sm);
@@ -281,27 +335,35 @@ kbd {
281 335 .ctx-colors button { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
282 336 .ctx-colors button:hover { border-color: var(--text); }
283 337
284 /* ── Toasts ─────────────────────────────────────────────────── */
285 .toasts { position: fixed; bottom: 20px; left: 50%; translate: -50% 0; z-index: 95; display: flex; flex-direction: column; gap: 8px; align-items: center; }
338 +/* ── Toasts — inverted pills, readable over any content ─────── */
339 +.toasts {
340 + position: fixed; bottom: calc(20px + env(safe-area-inset-bottom));
341 + left: 50%; translate: -50% 0; z-index: 95;
342 + display: flex; flex-direction: column; gap: 8px; align-items: center;
343 +}
286 344 .toast {
287 345 display: flex; align-items: center; gap: 12px;
288 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
289 box-shadow: var(--shadow-pop); padding: 10px 16px; font-size: 13px;
290 animation: toast-in 200ms var(--ease);
346 + background: var(--text); color: var(--bg); border: none; border-radius: 999px;
347 + box-shadow: var(--shadow-pop); padding: 11px 20px; font-size: 13px; font-weight: 500;
348 + animation: toast-in 240ms var(--spring);
291 349 }
292 @keyframes toast-in { from { translate: 0 12px; opacity: 0; } }
293 .toast button { background: none; border: none; color: var(--accent); font-weight: 600; cursor: pointer; padding: 0; }
294 .toast.error { border-color: var(--danger); }
350 +@keyframes toast-in { from { translate: 0 16px; opacity: 0; } }
351 +.toast button { background: none; border: none; color: var(--accent-2); font-weight: 700; cursor: pointer; padding: 0; }
352 +.toast.error { box-shadow: 0 0 0 2px var(--danger), var(--shadow-pop); }
295 353
296 354 /* ── Modals ─────────────────────────────────────────────────── */
297 .modal-scrim { position: fixed; inset: 0; background: var(--scrim); z-index: 70; display: grid; place-items: center; animation: fade 150ms var(--ease); }
355 +.modal-scrim {
356 + position: fixed; inset: 0; background: var(--scrim); z-index: 70;
357 + display: grid; place-items: center; animation: fade 150ms var(--ease);
358 + -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
359 +}
298 360 @keyframes fade { from { opacity: 0; } }
299 361 .modal {
300 width: 440px; max-width: calc(100vw - 32px); max-height: 86vh; overflow-y: auto;
301 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
302 box-shadow: var(--shadow-pop); padding: 22px; animation: pop 150ms var(--ease);
362 + width: 448px; max-width: calc(100vw - 32px); max-height: 86vh; overflow-y: auto;
363 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
364 + box-shadow: var(--shadow-pop); padding: 24px; animation: pop 160ms var(--spring);
303 365 }
304 .modal h2 { font-size: 16px; margin: 0 0 16px; }
366 +.modal h2 { font-size: 16px; margin: 0 0 16px; letter-spacing: -0.02em; }
305 367 .modal .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
306 368 .modal.wide { width: 620px; }
307 369
@@ -316,7 +378,8 @@ kbd {
316 378
317 379 /* ── Upload panel ───────────────────────────────────────────── */
318 380 .upload-panel {
319 position: fixed; right: 18px; bottom: 18px; width: 360px; z-index: 60;
381 + position: fixed; right: 18px; bottom: calc(18px + env(safe-area-inset-bottom));
382 + width: 360px; z-index: 60;
320 383 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
321 384 box-shadow: var(--shadow-pop); overflow: hidden; display: none;
322 385 }
@@ -350,7 +413,7 @@ kbd {
350 413 .drop-overlay svg { width: 44px; height: 44px; }
351 414
352 415 /* ── Preview overlay ────────────────────────────────────────── */
353 .preview-overlay { position: fixed; inset: 0; z-index: 85; background: var(--scrim); display: flex; flex-direction: column; animation: fade 150ms var(--ease); }
416 +.preview-overlay { position: fixed; inset: 0; z-index: 85; background: var(--scrim-heavy); display: flex; flex-direction: column; animation: fade 150ms var(--ease); }
354 417 .preview-head { display: flex; align-items: center; gap: 12px; padding: 12px 18px; color: #fff; }
355 418 .preview-head .title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
356 419 .preview-head .size { color: rgba(255,255,255,0.6); font-size: 12px; white-space: nowrap; }
@@ -495,8 +558,15 @@ video.pv-video { max-width: 100%; max-height: 100%; border-radius: var(--radius)
495 558 @keyframes indeterminate { from { margin-left: -40%; } to { margin-left: 100%; } }
496 559
497 560 /* ── Share pages (public) ───────────────────────────────────── */
498 .share-page { min-height: 100vh; display: flex; flex-direction: column; }
499 .share-topbar { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--surface); }
561 +.share-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
562 +.share-topbar {
563 + display: flex; align-items: center; gap: 12px;
564 + padding: calc(14px + env(safe-area-inset-top)) 22px 14px;
565 + border-bottom: 1px solid var(--border);
566 + background: var(--surface-glass);
567 + -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
568 + position: sticky; top: 0; z-index: 5;
569 +}
500 570 .share-main { flex: 1; display: flex; flex-direction: column; padding: 26px 22px; max-width: 1080px; margin: 0 auto; width: 100%; }
501 571 .share-main.wide { max-width: 1280px; }
502 572 .share-file-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
@@ -599,27 +669,104 @@ video.pv-video { max-width: 100%; max-height: 100%; border-radius: var(--radius)
599 669 .request-card .upload-list { margin-top: 14px; max-height: 320px; }
600 670 .request-card .upload-item { border-top: 1px solid var(--border); padding: 9px 4px; }
601 671
672 +/* ── Bottom navigation (mobile app bar) ─────────────────────── */
673 +.bottomnav {
674 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 62;
675 + display: none; align-items: center; justify-content: space-around;
676 + padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
677 + background: var(--surface-glass);
678 + -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
679 + border-top: 1px solid var(--border);
680 +}
681 +.bottomnav button {
682 + background: none; border: none; cursor: pointer; color: var(--muted);
683 + width: 52px; height: 42px; border-radius: 13px;
684 + display: grid; place-items: center;
685 + transition: color 150ms var(--ease), background 150ms var(--ease), transform 150ms var(--spring);
686 +}
687 +.bottomnav button svg { width: 21px; height: 21px; }
688 +.bottomnav button:active { transform: scale(0.92); }
689 +.bottomnav button.active { color: var(--accent-ink); background: var(--accent-soft); }
690 +.bottomnav .bn-fab {
691 + width: 54px; height: 54px; border-radius: 18px; margin-top: -22px;
692 + background: var(--grad-brand); color: #fff;
693 + box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
694 +}
695 +.bottomnav .bn-fab svg { width: 24px; height: 24px; }
696 +.bottomnav .bn-fab.active { color: #fff; }
697 +
602 698 /* ── Responsive ─────────────────────────────────────────────── */
603 699 .sidebar-toggle { display: none; }
700 +
604 701 @media (max-width: 900px) {
605 702 .body, .body.info-open { grid-template-columns: 1fr; }
606 703 .sidebar {
607 position: fixed; z-index: 65; top: 56px; bottom: 0; left: 0; width: 264px;
608 translate: -100% 0; transition: translate 200ms var(--ease); box-shadow: var(--shadow-pop);
704 + position: fixed; z-index: 65; top: 0; bottom: 0; left: 0;
705 + width: min(300px, 84vw); padding-top: calc(14px + env(safe-area-inset-top));
706 + background: var(--surface);
707 + border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
708 + translate: -102% 0; transition: translate 240ms var(--spring);
709 + box-shadow: var(--shadow-pop);
609 710 }
610 711 .sidebar.open { translate: 0 0; }
712 + .sidebar-scrim.active { display: block; animation: fade 180ms var(--ease); }
611 713 .sidebar-toggle { display: inline-flex; }
612 .info-panel { position: fixed; z-index: 65; top: 56px; bottom: 0; right: 0; width: min(320px, 88vw); box-shadow: var(--shadow-pop); }
613 .body.info-open .info-panel { display: block; }
714 +
715 + /* Info panel becomes a bottom sheet */
716 + .info-panel {
717 + position: fixed; z-index: 66; left: 0; right: 0; bottom: 0; top: auto;
718 + width: auto; max-height: 72vh; border-left: none; border-top: 1px solid var(--border);
719 + border-radius: var(--radius-lg) var(--radius-lg) 0 0;
720 + box-shadow: var(--shadow-pop);
721 + padding-bottom: calc(18px + env(safe-area-inset-bottom));
722 + }
723 + .body.info-open .info-panel { display: block; animation: sheet-up 240ms var(--spring); }
724 + .info-panel::before {
725 + content: ''; display: block; width: 40px; height: 4px; border-radius: 2px;
726 + background: var(--border-strong); margin: 0 auto 12px;
727 + }
728 +
614 729 .searchbox { max-width: none; }
615 730 .brand span { display: none; }
616 .preview-body { padding: 0 8px 12px; }
617 .preview-nav.prev { left: 4px; } .preview-nav.next { right: 4px; }
618 .list-header, .row { grid-template-columns: 30px 1fr 80px; }
731 + .topbar { gap: 8px; }
732 + /* The FAB owns "new/upload" on phones */
733 + #uploadBtn { display: none; }
734 + .bottomnav { display: flex; }
735 + .content { padding: 12px 12px calc(120px + env(safe-area-inset-bottom)); }
736 + .toasts { bottom: calc(84px + env(safe-area-inset-bottom)); }
737 + .upload-panel { width: calc(100vw - 20px); right: 10px; bottom: calc(78px + env(safe-area-inset-bottom)); }
738 +
739 + /* Bigger touch targets everywhere */
740 + .nav-item { padding: 11px 12px; font-size: 14.5px; }
741 + .tree-toggle { height: 34px; }
742 + .tree-label { padding: 8px 6px 8px 0; font-size: 14px; }
743 + .btn { padding: 9px 16px; }
744 + .btn.icon { padding: 9px; }
745 + .grid { grid-template-columns: repeat(auto-fill, minmax(min(var(--card, 150px), 44vw), 1fr)); gap: 10px; }
746 + .card .meta { padding: 10px 12px; }
747 + .toolbar { padding: 8px 12px; min-height: 48px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; }
748 + .toolbar::-webkit-scrollbar { display: none; }
749 + .toolbar input[type='range'] { display: none; } /* card-size slider is pointless on touch */
750 + .crumbs { flex: none; max-width: 60vw; overflow-x: auto; scrollbar-width: none; }
751 +
752 + .list-header, .row { grid-template-columns: 26px 1fr 78px; }
619 753 .list-header > :nth-child(n+4), .row > :nth-child(n+4) { display: none; }
620 .upload-panel { width: calc(100vw - 24px); right: 12px; }
754 + .row { padding: 9px 10px; }
755 +
756 + .preview-body { padding: 0 6px calc(10px + env(safe-area-inset-bottom)); }
757 + .preview-nav { width: 40px; height: 40px; }
758 + .preview-nav.prev { left: 4px; } .preview-nav.next { right: 4px; }
759 + .preview-head { padding: calc(10px + env(safe-area-inset-top)) 12px 10px; }
621 760 .pdf-rail { display: none; }
761 + .pv-toolbar { bottom: calc(14px + env(safe-area-inset-bottom)); max-width: 96vw; overflow-x: auto; }
762 + .editor-body { padding: 0 10px calc(10px + env(safe-area-inset-bottom)); }
763 + .editor-text { padding: 14px; font-size: 14px; }
764 +
622 765 .storage-page { grid-template-columns: 1fr; }
766 + .stat-row { grid-template-columns: 1fr 1fr; gap: 10px; }
767 + .shares-table { display: block; overflow-x: auto; }
768 + .modal { max-height: 80vh; }
769 + .request-card { padding: 22px 18px; }
623 770 }
624 771
625 772 @media print {
modified src/web/assets/js/app.js +82 −12
@@ -43,6 +43,7 @@ const state = {
43 43 const $ = (id) => document.getElementById(id);
44 44 const content = $('content');
45 45 const toolbar = $('toolbar');
46 +const IS_TOUCH = window.matchMedia('(pointer: coarse)').matches;
46 47
47 48 // ── Boot ─────────────────────────────────────────────────────────────
48 49 // One conflict answer can apply to the whole upload batch.
@@ -121,6 +122,7 @@ function onRoute() {
121 122 };
122 123 (views[state.route.view] ?? views.folder)();
123 124 highlightSidebar();
125 + toggleDrawer(false);
124 126 }
125 127
126 128 const go = (hash) => { location.hash = `#/${hash}`; };
@@ -132,24 +134,32 @@ function wireChrome() {
132 134 $('themeToggle').innerHTML = document.documentElement.dataset.theme === 'light' ? UI.moon : UI.sun;
133 135 $('settingsBtn').innerHTML = UI.gear;
134 136
135 $('sidebarToggle').onclick = () => $('sidebar').classList.toggle('open');
137 + $('sidebarToggle').onclick = () => toggleDrawer();
138 + $('sidebarScrim').onclick = () => toggleDrawer(false);
136 139 $('uploadBtn').onclick = () => pickFiles();
137 140 $('viewToggle').onclick = toggleViewMode;
138 141 $('themeToggle').onclick = toggleTheme;
139 142 $('settingsBtn').onclick = () => go('settings');
140 143 $('newBtn').onclick = (e) => {
141 144 const r = e.currentTarget.getBoundingClientRect();
142 contextMenu(r.left, r.bottom + 4, [
143 { label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },
144 { label: 'New text file', icon: UI.edit, onClick: newTextFileDialog },
145 { sep: true },
146 { label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },
147 { label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },
148 { sep: true },
149 { label: 'Request files from someone…', icon: UI.inbox, onClick: () => newRequestDialog(currentFolderId()) },
150 ]);
145 + contextMenu(r.left, r.bottom + 4, newMenuItems());
151 146 };
152 147
148 + // Bottom navigation (mobile)
149 + const bn = $('bottomNav');
150 + const bnIcons = { drive: UI.move, search: UI.search, new: UI.plus, shared: UI.link, menu: UI.menu };
151 + bn.querySelectorAll('button').forEach((b) => { b.innerHTML = bnIcons[b.dataset.bn]; });
152 + bn.addEventListener('click', (e) => {
153 + const btn = e.target.closest('button');
154 + if (!btn) return;
155 + const k = btn.dataset.bn;
156 + if (k === 'drive') go(`folder/${state.rootId}`);
157 + else if (k === 'search') $('searchInput').focus();
158 + else if (k === 'new') contextMenu(window.innerWidth / 2, window.innerHeight - 90, newMenuItems());
159 + else if (k === 'shared') go('shared');
160 + else if (k === 'menu') toggleDrawer();
161 + });
162 +
153 163 $('filePick').onchange = (e) => {
154 164 uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.name })), currentFolderId());
155 165 e.target.value = '';
@@ -199,6 +209,25 @@ function wireChrome() {
199 209
200 210 function pickFiles() { $('filePick').click(); }
201 211
212 +function newMenuItems() {
213 + return [
214 + { label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },
215 + { label: 'New text file', icon: UI.edit, onClick: newTextFileDialog },
216 + { sep: true },
217 + { label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },
218 + { label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },
219 + { sep: true },
220 + { label: 'Request files from someone…', icon: UI.inbox, onClick: () => newRequestDialog(currentFolderId()) },
221 + ];
222 +}
223 +
224 +/** Mobile sidebar drawer open/close (with scrim). */
225 +function toggleDrawer(open) {
226 + const willOpen = open ?? !$('sidebar').classList.contains('open');
227 + $('sidebar').classList.toggle('open', willOpen);
228 + $('sidebarScrim').classList.toggle('active', willOpen);
229 +}
230 +
202 231 function toggleViewMode() {
203 232 state.viewMode = state.viewMode === 'grid' ? 'list' : 'grid';
204 233 localStorage.setItem('spbdrive-view', state.viewMode);
@@ -211,10 +240,11 @@ function toggleViewMode() {
211 240 }
212 241
213 242 function toggleTheme() {
214 const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
243 + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
215 244 document.documentElement.dataset.theme = next;
216 245 localStorage.setItem('spbdrive-theme', next);
217 246 $('themeToggle').innerHTML = next === 'light' ? UI.moon : UI.sun;
247 + document.getElementById('metaTheme')?.setAttribute('content', next === 'dark' ? '#131210' : '#f6f5f1');
218 248 }
219 249
220 250 // ── Sidebar ──────────────────────────────────────────────────────────
@@ -310,7 +340,12 @@ function renderTagList() {
310 340 }, h('span', { html: UI.plus, style: { display: 'contents' } }), 'Manage tags'));
311 341 }
312 342
313 const BUCKET_COLORS = { images: '#22d3aa', video: '#b48cff', audio: '#ff7ab8', docs: '#4f8cff', archives: '#ffb454', other: '#8b93a3' };
343 +// Storage bucket palette — validated (CVD + contrast) in both themes; the
344 +// color follows the bucket, never its rank. Values live in tokens.css.
345 +const BUCKET_COLORS = {
346 + images: 'var(--cat-images)', video: 'var(--cat-video)', audio: 'var(--cat-audio)',
347 + docs: 'var(--cat-docs)', archives: 'var(--cat-archives)', other: 'var(--cat-other)',
348 +};
314 349
315 350 function renderStorageMeter(stats) {
316 351 const host = $('storageMeter');
@@ -331,6 +366,10 @@ function highlightSidebar() {
331 366 document.querySelectorAll('[data-nav]').forEach((el) => {
332 367 el.classList.toggle('active', el.dataset.nav === key);
333 368 });
369 + document.querySelectorAll('#bottomNav [data-bn]').forEach((b) => {
370 + b.classList.toggle('active',
371 + (b.dataset.bn === 'drive' && state.route.view === 'folder') || b.dataset.bn === state.route.view);
372 + });
334 373 }
335 374
336 375 // ── Views: folder / recent / starred / tag / search / trash ──────────
@@ -622,6 +661,14 @@ function emptyState(art, title, subtitle) {
622 661 function wireNode(el, node) {
623 662 el.addEventListener('click', (e) => {
624 663 e.stopPropagation();
664 + // Touch UX: a tap opens directly (long-press = menu / select).
665 + if (IS_TOUCH && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
666 + state.selection = new Set([node.id]);
667 + state.anchor = node.id;
668 + paintSelection();
669 + openNode(node);
670 + return;
671 + }
625 672 if (e.shiftKey && state.anchor !== null) {
626 673 const ids = state.nodes.map((n) => n.id);
627 674 const a = ids.indexOf(state.anchor);
@@ -653,6 +700,29 @@ function wireNode(el, node) {
653 700 nodeContextMenu(e, node);
654 701 });
655 702
703 + // Touch: long-press opens the context menu (mobile has no right-click).
704 + let pressTimer = null;
705 + let pressFired = false;
706 + el.addEventListener('touchstart', (e) => {
707 + pressFired = false;
708 + const t = e.touches[0];
709 + pressTimer = setTimeout(() => {
710 + pressFired = true;
711 + navigator.vibrate?.(12);
712 + state.selection = new Set([node.id]);
713 + state.anchor = node.id;
714 + paintSelection();
715 + nodeContextMenu({ preventDefault() {}, clientX: t.clientX, clientY: t.clientY }, node);
716 + }, 460);
717 + }, { passive: true });
718 + for (const ev of ['touchend', 'touchmove', 'touchcancel']) {
719 + el.addEventListener(ev, (e) => {
720 + clearTimeout(pressTimer);
721 + // Swallow the tap that follows a long-press so it doesn't open the node.
722 + if (pressFired && ev === 'touchend') e.preventDefault();
723 + }, { passive: false });
724 + }
725 +
656 726 // Drag to move
657 727 el.draggable = state.route.view !== 'trash';
658 728 el.addEventListener('dragstart', (e) => {
added src/web/assets/js/theme.js +25 −0
@@ -0,0 +1,25 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/theme.js
8 + * Purpose : Apply the saved theme before first paint (external file —
9 + * inline scripts are blocked by the strict CSP)
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +(() => {
15 + try {
16 + const saved = localStorage.getItem('spbdrive-theme');
17 + if (saved === 'dark' || saved === 'light') {
18 + document.documentElement.dataset.theme = saved;
19 + }
20 + const meta = document.getElementById('metaTheme');
21 + if (meta) {
22 + meta.content = document.documentElement.dataset.theme === 'dark' ? '#131210' : '#f6f5f1';
23 + }
24 + } catch { /* private mode — keep the default light theme */ }
25 +})();
modified src/web/assets/js/ui.js +9 −4
@@ -159,10 +159,15 @@ export function contextMenu(x, y, items) {
159 159 menu.append(btn);
160 160 }
161 161 document.body.append(menu);
162 const { innerWidth: vw, innerHeight: vh } = window;
163 const rect = menu.getBoundingClientRect();
164 menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;
165 menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;
162 + // Mobile: the menu becomes a bottom sheet (thumb-reachable, app-like).
163 + if (window.matchMedia('(max-width: 700px)').matches) {
164 + menu.classList.add('sheet');
165 + } else {
166 + const { innerWidth: vw, innerHeight: vh } = window;
167 + const rect = menu.getBoundingClientRect();
168 + menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;
169 + menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;
170 + }
166 171 openMenu = menu;
167 172
168 173 // Keyboard accessibility: arrows + enter + escape.
modified src/web/assets/manifest.webmanifest +2 −2
@@ -5,8 +5,8 @@
5 5 "description": "Personal cloud drive of Simon-Pierre Boucher",
6 6 "start_url": "/app",
7 7 "display": "standalone",
8 "background_color": "#0b0e14",
9 "theme_color": "#0b0e14",
8 + "background_color": "#f6f5f1",
9 + "theme_color": "#f6f5f1",
10 10 "icons": [
11 11 {
12 12 "src": "/assets/favicon.svg",
modified src/web/assets/tokens.css +101 −48
@@ -5,7 +5,8 @@
5 5 * Author : Simon-Pierre Boucher
6 6 * Contact : contact@spboucher.ai
7 7 * File : src/web/assets/tokens.css
8 * Purpose : Design tokens — SPB design DNA, self-hosted fonts, themes
8 + * Purpose : Design tokens — "Ivoire & Indigo" light-first identity,
9 + * self-hosted fonts, validated data palette, dark theme
9 10 * License : MIT © Simon-Pierre Boucher
10 11 * ─────────────────────────────────────────────
11 12 */
@@ -53,65 +54,117 @@
53 54 src: url('/vendor/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2') format('woff2');
54 55 }
55 56
57 +/* ── Light (default) — warm ivory canvas, electric indigo ink ── */
56 58 :root {
57 --bg: #0b0e14;
58 --surface: #11151c;
59 --surface-2: #161b24;
60 --border: #1f2530;
61 --text: #e6e9ef;
62 --muted: #8b93a3;
63 --accent: #4f8cff;
64 --accent-2: #22d3aa;
65 --danger: #ff5d5d;
66 --warning: #ffb454;
67 --accent-soft: rgba(79, 140, 255, 0.14);
68 --danger-soft: rgba(255, 93, 93, 0.12);
69 --scrim: rgba(4, 6, 10, 0.78);
70 --radius: 10px;
71 --radius-sm: 7px;
59 + --bg: #f6f5f1;
60 + --surface: #ffffff;
61 + --surface-2: #efede6;
62 + --surface-glass: rgba(255, 255, 255, 0.78);
63 + --border: #e5e2d8;
64 + --border-strong: #d5d1c3;
65 + --text: #1b1a15;
66 + --muted: #6f6a5d;
67 + --accent: #4f46e5;
68 + --accent-ink: #4338ca;
69 + --accent-2: #0d9488;
70 + --danger: #d92d20;
71 + --warning: #b45309;
72 + --accent-soft: rgba(79, 70, 229, 0.09);
73 + --danger-soft: rgba(217, 45, 32, 0.08);
74 + --scrim: rgba(27, 26, 21, 0.45);
75 + --scrim-heavy: rgba(20, 19, 15, 0.82);
76 +
77 + --radius: 14px;
78 + --radius-sm: 10px;
79 + --radius-lg: 20px;
80 +
72 81 --font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
73 82 --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
74 --shadow-pop: 0 10px 32px rgba(0, 0, 0, 0.45);
75 --ease: cubic-bezier(0.25, 0.7, 0.35, 1);
76 --shiki-dark-bg: transparent;
77 color-scheme: dark;
78 }
79 83
80 [data-theme='light'] {
81 --bg: #f5f7fa;
82 --surface: #ffffff;
83 --surface-2: #eef1f6;
84 --border: #dde3ec;
85 --text: #17202e;
86 --muted: #5d6778;
87 --accent: #2f6de0;
88 --accent-2: #0da98a;
89 --danger: #d92f2f;
90 --accent-soft: rgba(47, 109, 224, 0.1);
91 --danger-soft: rgba(217, 47, 47, 0.08);
92 --scrim: rgba(15, 20, 30, 0.55);
93 --shadow-pop: 0 10px 32px rgba(30, 40, 60, 0.18);
84 + --shadow-1: 0 1px 2px rgba(27, 26, 21, 0.05), 0 2px 8px rgba(27, 26, 21, 0.04);
85 + --shadow-2: 0 2px 6px rgba(27, 26, 21, 0.06), 0 12px 28px rgba(27, 26, 21, 0.09);
86 + --shadow-pop: 0 4px 14px rgba(27, 26, 21, 0.08), 0 24px 60px rgba(27, 26, 21, 0.16);
87 + --ring: 0 0 0 2px var(--accent);
88 +
89 + --grad-brand: linear-gradient(135deg, #4f46e5 0%, #7c5ce0 55%, #0d9488 130%);
90 + --aurora-a: rgba(79, 70, 229, 0.07);
91 + --aurora-b: rgba(13, 148, 136, 0.06);
92 + --aurora-c: rgba(214, 54, 127, 0.045);
93 +
94 + --ease: cubic-bezier(0.25, 0.7, 0.35, 1);
95 + --spring: cubic-bezier(0.34, 1.4, 0.44, 1);
96 + --shiki-light-bg: transparent;
94 97 color-scheme: light;
98 +
99 + /* Data palette (storage buckets) — CVD-validated, same in both themes */
100 + --cat-images: #0f9488;
101 + --cat-video: #7c5ce0;
102 + --cat-audio: #d6367f;
103 + --cat-docs: #3b7de0;
104 + --cat-archives: #d97706;
105 + --cat-other: #5c66a8;
95 106 }
96 107
97 [data-theme='light'] .shiki,
98 [data-theme='light'] .shiki span {
99 color: var(--shiki-light) !important;
108 +/* ── Dark — warm charcoal, same DNA ── */
109 +[data-theme='dark'] {
110 + --bg: #131210;
111 + --surface: #1b1a17;
112 + --surface-2: #232019;
113 + --surface-glass: rgba(27, 26, 23, 0.78);
114 + --border: #2e2b23;
115 + --border-strong: #3d392e;
116 + --text: #edeae1;
117 + --muted: #9c968a;
118 + --accent: #8f88f8;
119 + --accent-ink: #a9a3fa;
120 + --accent-2: #2dd4bf;
121 + --danger: #f87168;
122 + --warning: #fbbf24;
123 + --accent-soft: rgba(143, 136, 248, 0.13);
124 + --danger-soft: rgba(248, 113, 104, 0.12);
125 + --scrim: rgba(6, 6, 4, 0.6);
126 + --scrim-heavy: rgba(6, 6, 4, 0.85);
127 +
128 + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.25);
129 + --shadow-2: 0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 28px rgba(0, 0, 0, 0.45);
130 + --shadow-pop: 0 4px 14px rgba(0, 0, 0, 0.45), 0 24px 60px rgba(0, 0, 0, 0.6);
131 +
132 + --grad-brand: linear-gradient(135deg, #6e66f0 0%, #8f76ec 55%, #14b8a6 130%);
133 + --aurora-a: rgba(110, 102, 240, 0.1);
134 + --aurora-b: rgba(20, 184, 166, 0.07);
135 + --aurora-c: rgba(214, 54, 127, 0.06);
136 + color-scheme: dark;
100 137 }
138 +
139 +/* Shiki dual-theme output: light by default, dark under [data-theme=dark] */
101 140 .shiki,
102 141 .shiki span {
103 color: var(--shiki-dark) !important;
142 + color: var(--shiki-light) !important;
104 143 background: transparent !important;
105 144 }
145 +[data-theme='dark'] .shiki,
146 +[data-theme='dark'] .shiki span {
147 + color: var(--shiki-dark) !important;
148 +}
106 149
107 /* Folder palette (8 choices) */
150 +/* Folder palette (8 choices) — deep enough to read on ivory */
108 151 :root {
109 --folder-blue: #4f8cff;
110 --folder-teal: #22d3aa;
111 --folder-green: #7bd88f;
112 --folder-yellow: #ffd166;
113 --folder-orange: #ff9f5a;
114 --folder-red: #ff5d5d;
115 --folder-purple: #b48cff;
116 --folder-pink: #ff7ab8;
152 + --folder-blue: #3b7de0;
153 + --folder-teal: #0f9488;
154 + --folder-green: #3f9e58;
155 + --folder-yellow: #d9a406;
156 + --folder-orange: #e8722a;
157 + --folder-red: #d92d20;
158 + --folder-purple: #7c5ce0;
159 + --folder-pink: #d6367f;
160 +}
161 +[data-theme='dark'] {
162 + --folder-blue: #6ba4f8;
163 + --folder-teal: #2dd4bf;
164 + --folder-green: #6fce8a;
165 + --folder-yellow: #f5ce56;
166 + --folder-orange: #fb9d67;
167 + --folder-red: #f87168;
168 + --folder-purple: #ab93f5;
169 + --folder-pink: #f279b2;
117 170 }
modified src/web/views/app.njk +8 −0
@@ -49,6 +49,14 @@
49 49 Drop to upload here
50 50 </div>
51 51 </div>
52 +<nav class="bottomnav" id="bottomNav" aria-label="Navigation">
53 + <button data-bn="drive" aria-label="My Drive"></button>
54 + <button data-bn="search" aria-label="Search"></button>
55 + <button class="bn-fab" data-bn="new" aria-label="New"></button>
56 + <button data-bn="shared" aria-label="Shares"></button>
57 + <button data-bn="menu" aria-label="Menu"></button>
58 +</nav>
59 +<div class="sidebar-scrim" id="sidebarScrim"></div>
52 60 <div class="upload-panel" id="uploadPanel"></div>
53 61 <div class="toasts" id="toasts"></div>
54 62 <input type="file" id="filePick" multiple hidden>
modified src/web/views/base.njk +3 −8
@@ -10,24 +10,19 @@
10 10 ─────────────────────────────────────────────
11 11 #}
12 12 <!doctype html>
13 <html lang="en" data-theme="dark">
13 +<html lang="en" data-theme="light">
14 14 <head>
15 15 <meta charset="utf-8">
16 16 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
17 17 <title>{% block title %}SPB Drive{% endblock %}</title>
18 <meta name="theme-color" content="#0b0e14">
18 + <meta name="theme-color" id="metaTheme" content="#f6f5f1">
19 19 <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
20 20 <link rel="apple-touch-icon" href="/assets/icon-192.png">
21 21 <link rel="manifest" href="/assets/manifest.webmanifest">
22 22 <link rel="stylesheet" href="/assets/tokens.css">
23 23 <link rel="stylesheet" href="/assets/app.css">
24 24 {% block og %}{% endblock %}
25 <script>
26 try {
27 const t = localStorage.getItem('spbdrive-theme');
28 if (t) document.documentElement.dataset.theme = t;
29 } catch (e) {}
30 </script>
25 + <script src="/assets/js/theme.js"></script>
31 26 </head>
32 27 <body>
33 28 {% block body %}{% endblock %}
34 29