Skip to content

Commit d98459b

Browse files
committed
Listings: Per-component date coloring, single date pipeline
- Year/month/day/time each carry their own age tier instead of one color per row. `tierForYear` colors every year (current/last/2y/3y+); `tierForMonth` only when same-year; `tierForDay` only when same-year+month; `tierForTime` only when same date, with hourly buckets. Future dates clamp to fresh. - One source of truth for date display: `formatDateForDisplay(ts, format, customFormat, nowMs?)` in `lib/settings/format-utils.ts` returns `{ text, parts: { left: DateSegment[]; right: DateSegment[] | null } }`. Token-based formats (`iso`, `short`, `custom`, default) emit segments via `applyTokens`; `system` walks `Intl.DateTimeFormat#formatToParts`. The reactive wrapper `formattedDate(ts)` in `reactive-settings.svelte.ts` is the canonical entry point. - New `<DateLabel modifiedAt={ts} />` in `$lib/ui/` — the default render-side equivalent. `SelectionInfo`, `RenameConflictDialog`, `SearchResults` use it. `FullList` reaches for `formattedDate(...).parts` directly because of its column-alignment layout. `buildDateTooltip` rebuilds HTML the same way. - Wilting light palette tightened — saturated dark green / olive yellow / mustard / brown, mirroring the new size palette. App palette spaced wider (0 / 45% / 70% / 90% mix toward `--color-text-secondary`) so the first-step gap reads clearly. Both clear AA on every site that renders modified dates. - Default for `appearance.dateColors` switched from `wilting` to `app`. Base `:root` block now carries the App palette so first paint matches the default; Wilting moved to a gated `:root[data-date-colors='wilting']` block (light + dark). - Dropped `age-ancient`: four tiers are enough for the per-component model. CSS token, global class, and FullList overrides cleaned up. - Selected+cursor neutralization added in `FullList`: when a row is both under the focused cursor and selected, the inner age spans collapse to `--color-selection-fg` (was reading `--color-text-primary` and showing white-on-gold).
1 parent c73fcf5 commit d98459b

30 files changed

Lines changed: 1045 additions & 480 deletions

apps/desktop/src/app.css

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,20 @@
149149
/* === Date age tier colors ===
150150
* Three palettes selectable via the `appearance.dateColors` setting,
151151
* applied as a `data-date-colors` attribute on `<html>` by the settings
152-
* applier. The default block here is the Wilting palette so first paint
153-
* matches the default setting value. Five buckets: <1mo (fresh) → <1yr
154-
* (recent) → <2yr (aging) → <3yr (old) → older (ancient). Hex bases are
155-
* tuned for ≥ AA 4.5:1 against `--color-bg-primary`; verified by
156-
* `scripts/check-a11y-contrast`. */
157-
--color-age-fresh: #1f7a1f;
158-
--color-age-recent: #3f6b1f;
159-
--color-age-aging: #6e5a14;
160-
--color-age-old: #8a541d;
161-
--color-age-ancient: #6b4a2a;
152+
* applier. The default block here is the App palette (matches the default
153+
* setting value, so first paint is correct even before the applier runs).
154+
* Four tiers — fresh / recent / aging / old — applied per date component
155+
* (year, month, day, time) by the formatter in
156+
* `lib/settings/format-utils.ts`.
157+
*
158+
* The App palette fades from the active app color (newest) toward the
159+
* secondary text color (oldest); since both source vars are scheme-aware,
160+
* one declaration covers both light and dark. The Wilting palette below
161+
* uses explicit per-scheme hex values. */
162+
--color-age-fresh: var(--color-accent);
163+
--color-age-recent: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-accent));
164+
--color-age-aging: color-mix(in srgb, var(--color-text-secondary) 70%, var(--color-accent));
165+
--color-age-old: color-mix(in srgb, var(--color-text-secondary) 90%, var(--color-accent));
162166
/* No per-tier selection token: selected rows route through the existing
163167
`.is-selected .col-date` (and equivalent) rules in each view, which
164168
collapse the whole cell to `--color-selection-fg`. */
@@ -253,15 +257,17 @@
253257
--color-size-tb: var(--color-text-secondary);
254258
}
255259

256-
/* === Date age palette: App ===
257-
Newer files use the active app color, fading toward the secondary text
258-
color as files age. */
259-
:root[data-date-colors='app'] {
260-
--color-age-fresh: var(--color-accent);
261-
--color-age-recent: color-mix(in srgb, var(--color-text-secondary) 25%, var(--color-accent));
262-
--color-age-aging: color-mix(in srgb, var(--color-text-secondary) 50%, var(--color-accent));
263-
--color-age-old: color-mix(in srgb, var(--color-text-secondary) 70%, var(--color-accent));
264-
--color-age-ancient: color-mix(in srgb, var(--color-text-secondary) 85%, var(--color-accent));
260+
/* === Date age palette: Wilting (light) ===
261+
Saturated dark hues mirroring the size palette: dark green → greenish
262+
yellow → brownish yellow → brown. No "lighter green" step (user spec).
263+
Every value clears WCAG AA against `--color-bg-primary`; verified by
264+
`scripts/check-a11y-contrast`. Dark mode override lives in the
265+
`@media (prefers-color-scheme: dark)` block below. */
266+
:root[data-date-colors='wilting'] {
267+
--color-age-fresh: #097309;
268+
--color-age-recent: #7e7800;
269+
--color-age-aging: #8a5a00;
270+
--color-age-old: #6e2a08;
265271
}
266272

267273
/* === Date age palette: Off ===
@@ -271,7 +277,6 @@
271277
--color-age-recent: var(--color-text-secondary);
272278
--color-age-aging: var(--color-text-secondary);
273279
--color-age-old: var(--color-text-secondary);
274-
--color-age-ancient: var(--color-text-secondary);
275280
}
276281

277282
/* Dark Mode - automatically applied when user's system prefers dark mode */
@@ -369,14 +374,20 @@
369374
--color-size-gb: color-mix(in srgb, var(--color-text-secondary) 70%, #ff4444);
370375
--color-size-tb: color-mix(in srgb, var(--color-text-secondary) 70%, #c060ff);
371376

372-
/* === Date age tier colors — dark mode ===
373-
* Brighter values for legibility against `#1e1e1e`. Tuned for ≥ AA
374-
* 4.5:1; verified by `scripts/check-a11y-contrast`. */
377+
/* Date age palette: base (App) needs no override here — the color-mix
378+
formula above pulls from `--color-accent` and `--color-text-secondary`,
379+
which the dark mode block already redefined. Only Wilting carries its
380+
own dark values (see the gated rule below). */
381+
}
382+
383+
/* === Date age palette: Wilting (dark) ===
384+
* Brighter values for legibility against `#1e1e1e`. Tuned for ≥ AA 4.5:1;
385+
* verified by `scripts/check-a11y-contrast`. */
386+
:root[data-date-colors='wilting'] {
375387
--color-age-fresh: #5fd16a;
376388
--color-age-recent: #a8d040;
377389
--color-age-aging: #dcc24a;
378390
--color-age-old: #d49858;
379-
--color-age-ancient: #b08960;
380391
}
381392
}
382393

@@ -700,10 +711,6 @@ body {
700711
color: var(--color-age-old);
701712
}
702713

703-
.age-ancient {
704-
color: var(--color-age-ancient);
705-
}
706-
707714
/* =============================================================================
708715
Spinner utility classes
709716
============================================================================= */

apps/desktop/src/lib/file-explorer/pane/file-pane-keyboard.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,23 @@ vi.mock('$lib/icon-cache', async () => {
105105
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
106106
getRowHeight: vi.fn().mockReturnValue(24),
107107
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
108-
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
108+
formattedDate: vi.fn().mockReturnValue({
109+
text: '2025-01-01 00:00',
110+
parts: {
111+
left: [
112+
{ text: '2025', ageClass: 'age-fresh' as const },
113+
{ text: '-', ageClass: null },
114+
{ text: '01', ageClass: null },
115+
{ text: '-', ageClass: null },
116+
{ text: '01', ageClass: null },
117+
],
118+
right: [
119+
{ text: '00', ageClass: null },
120+
{ text: ':', ageClass: null },
121+
{ text: '00', ageClass: null },
122+
],
123+
},
124+
}),
109125
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
110126
getFileSizeFormat: vi.fn().mockReturnValue('binary'),
111127
getHumanFriendlySizeUnits: vi.fn().mockReturnValue(false),

apps/desktop/src/lib/file-explorer/pane/selection-consistency.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,23 @@ vi.mock('$lib/icon-cache', async () => {
107107
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
108108
getRowHeight: vi.fn().mockReturnValue(24),
109109
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
110-
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
110+
formattedDate: vi.fn().mockReturnValue({
111+
text: '2025-01-01 00:00',
112+
parts: {
113+
left: [
114+
{ text: '2025', ageClass: 'age-fresh' as const },
115+
{ text: '-', ageClass: null },
116+
{ text: '01', ageClass: null },
117+
{ text: '-', ageClass: null },
118+
{ text: '01', ageClass: null },
119+
],
120+
right: [
121+
{ text: '00', ageClass: null },
122+
{ text: ':', ageClass: null },
123+
{ text: '00', ageClass: null },
124+
],
125+
},
126+
}),
111127
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
112128
getFileSizeFormat: vi.fn().mockReturnValue('binary'),
113129
getHumanFriendlySizeUnits: vi.fn().mockReturnValue(false),

apps/desktop/src/lib/file-explorer/pane/volume-breadcrumb.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,23 @@ vi.mock('$lib/icon-cache', async () => {
103103
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
104104
getRowHeight: vi.fn().mockReturnValue(24),
105105
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
106-
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
106+
formattedDate: vi.fn().mockReturnValue({
107+
text: '2025-01-01 00:00',
108+
parts: {
109+
left: [
110+
{ text: '2025', ageClass: 'age-fresh' as const },
111+
{ text: '-', ageClass: null },
112+
{ text: '01', ageClass: null },
113+
{ text: '-', ageClass: null },
114+
{ text: '01', ageClass: null },
115+
],
116+
right: [
117+
{ text: '00', ageClass: null },
118+
{ text: ':', ageClass: null },
119+
{ text: '00', ageClass: null },
120+
],
121+
},
122+
}),
107123
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
108124
getFileSizeFormat: vi.fn().mockReturnValue('binary'),
109125
getHumanFriendlySizeUnits: vi.fn().mockReturnValue(false),

apps/desktop/src/lib/file-explorer/rename/RenameConflictDialog.a11y.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ vi.mock('$lib/tauri-commands', () => ({
1919
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
2020
formatDateTime: vi.fn((d: number | undefined) => (d ? '2025-03-14 10:30' : '')),
2121
formatFileSize: vi.fn((n: number) => `${String(n)} B`),
22+
formattedDate: vi.fn((d: number | undefined) =>
23+
d
24+
? {
25+
text: '2025-03-14 10:30',
26+
parts: {
27+
left: [
28+
{ text: '2025', ageClass: 'age-fresh' as const },
29+
{ text: '-', ageClass: null },
30+
{ text: '03', ageClass: null },
31+
{ text: '-', ageClass: null },
32+
{ text: '14', ageClass: null },
33+
],
34+
right: [
35+
{ text: '10', ageClass: null },
36+
{ text: ':', ageClass: null },
37+
{ text: '30', ageClass: null },
38+
],
39+
},
40+
}
41+
: { text: '', parts: { left: [], right: null } },
42+
),
2243
}))
2344

2445
describe('RenameConflictDialog a11y', () => {

apps/desktop/src/lib/file-explorer/rename/RenameConflictDialog.svelte

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts">
22
import ModalDialog from '$lib/ui/ModalDialog.svelte'
33
import Button from '$lib/ui/Button.svelte'
4-
import { formatDateTime, formatFileSize } from '$lib/settings/reactive-settings.svelte'
5-
import { tierClassForAge } from '../selection/age-tier-utils'
4+
import { formatFileSize } from '$lib/settings/reactive-settings.svelte'
5+
import DateLabel from '$lib/ui/DateLabel.svelte'
66
import type { ConflictFileInfo, RenameConflictResolution } from './rename-operations'
77
88
interface Props {
@@ -58,10 +58,9 @@
5858
</div>
5959
<div class="file-meta">
6060
<span class="meta-label">Modified</span>
61-
<span
62-
class="meta-value {tierClassForAge(renamedFile.modifiedAt) ?? ''}"
63-
class:newer={renamedIsNewer}>{formatDateTime(renamedFile.modifiedAt)}</span
64-
>
61+
<span class="meta-value" class:newer={renamedIsNewer}>
62+
<DateLabel modifiedAt={renamedFile.modifiedAt} />
63+
</span>
6564
</div>
6665
</div>
6766
</div>
@@ -77,11 +76,13 @@
7776
<div class="file-meta">
7877
<span class="meta-label">Modified</span>
7978
<span
80-
class="meta-value {tierClassForAge(existingFile.modifiedAt) ?? ''}"
79+
class="meta-value"
8180
class:newer={!renamedIsNewer &&
8281
renamedFile.modifiedAt !== existingFile.modifiedAt &&
83-
existingFile.modifiedAt != null}>{formatDateTime(existingFile.modifiedAt)}</span
82+
existingFile.modifiedAt != null}
8483
>
84+
<DateLabel modifiedAt={existingFile.modifiedAt} />
85+
</span>
8586
</div>
8687
</div>
8788
</div>
@@ -174,25 +175,6 @@
174175
font-variant-numeric: tabular-nums;
175176
}
176177
177-
/* Age tier overrides for the Modified row. Declared before `.newer` so
178-
the semantic "this is the newer file" green wins on the highlighted
179-
side (same specificity, later wins). */
180-
.meta-value.age-fresh {
181-
color: var(--color-age-fresh);
182-
}
183-
.meta-value.age-recent {
184-
color: var(--color-age-recent);
185-
}
186-
.meta-value.age-aging {
187-
color: var(--color-age-aging);
188-
}
189-
.meta-value.age-old {
190-
color: var(--color-age-old);
191-
}
192-
.meta-value.age-ancient {
193-
color: var(--color-age-ancient);
194-
}
195-
196178
.meta-value.newer {
197179
color: var(--color-allow);
198180
font-weight: 600;

apps/desktop/src/lib/file-explorer/selection/CLAUDE.md

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@ lives in `FilePane.svelte` as a `Set<number>`.
55

66
## Key files
77

8-
| File | Purpose |
9-
| ------------------------------ | ---------------------------------------------------------------------------------------- |
10-
| `selection-info-utils.ts` | Pure utilities — no DOM deps, fully tested |
11-
| `age-tier-utils.ts` | Maps a file's `modifiedAt` to one of five age tier classes (`age-fresh``age-ancient`) |
12-
| `SelectionInfo.svelte` | Status bar below each pane |
13-
| `FileIcon.svelte` | 16x16 icon with emoji fallback and overlay badges |
14-
| `SortableHeader.svelte` | Clickable column header with sort direction triangle |
15-
| `selection-info-utils.test.ts` | Unit tests for all util functions |
16-
| `age-tier-utils.test.ts` | Unit tests for the age-tier function (boundaries, clamp, null) |
17-
| `components.test.ts` | Component render tests |
8+
| File | Purpose |
9+
| ------------------------------ | ---------------------------------------------------- |
10+
| `selection-info-utils.ts` | Pure utilities — no DOM deps, fully tested |
11+
| `SelectionInfo.svelte` | Status bar below each pane |
12+
| `FileIcon.svelte` | 16x16 icon with emoji fallback and overlay badges |
13+
| `SortableHeader.svelte` | Clickable column header with sort direction triangle |
14+
| `selection-info-utils.test.ts` | Unit tests for all util functions |
15+
| `components.test.ts` | Component render tests |
16+
17+
Per-component age-tier mapping (`tierForYear` / `tierForMonth` / `tierForDay` / `tierForTime`) and the
18+
`appearance.dateColors` palette live in [`$lib/settings/age-tier-utils.ts`](../../settings/age-tier-utils.ts) — they
19+
belong with the setting, not the selection components. The renderer side is in
20+
[`$lib/ui/DateLabel.svelte`](../../ui/DateLabel.svelte). See `$lib/settings/CLAUDE.md` § "Date display" for the full
21+
pipeline.
1822

1923
## `selection-info-utils.ts`
2024

@@ -41,12 +45,6 @@ Exported functions:
4145
`sizeTierClasses` export: `['size-bytes', 'size-kb', 'size-mb', 'size-gb', 'size-tb']`. CSS rules for these classes must
4246
exist in the consuming view, not here.
4347

44-
`age-tier-utils.ts` exposes `tierClassForAge(modifiedAtSeconds, nowMs?)` returning one of `age-fresh`, `age-recent`,
45-
`age-aging`, `age-old`, `age-ancient` (or `null` when the timestamp is missing). Thresholds: 1 month / 1 year / 2 years
46-
/ 3 years. Tokens (`--color-age-*`) and global `.age-*` classes live in `app.css`; scoped views (FullList,
47-
SelectionInfo, RenameConflictDialog, SearchResults) re-apply the same colors at scoped specificity since Svelte's
48-
scope-class boost would otherwise let the local date-cell rule win over the global utility.
49-
5048
## `SelectionInfo.svelte`
5149

5250
Status bar rendered below each pane. Four display modes via `$derived displayMode`:
@@ -135,10 +133,10 @@ The utility file is pure TypeScript with no DOM or style dependencies. The CSS c
135133
for numeric grouping and look jarring in a compact status bar. Thin space matches typographic convention for digit
136134
grouping and renders consistently across platforms.
137135

138-
**Gotcha**: `buildDateTooltip` returns `{ html }`, not a plain string **Why**: Each timestamp is wrapped in an age-tier
139-
`<span>` so the modified-date tooltip picks up the active date palette. The `tooltip` action accepts either `text` or
140-
`html`; this one passes `html`. Caller-side change: previous plain-text usage broke when we switched the return type —
141-
the function is consumed only by `SelectionInfo.svelte` and the unit tests today.
136+
**Gotcha**: `buildDateTooltip(entry, formatter)` returns `{ html }` and takes a `formatter` callback **Why**: Each
137+
timestamp is rendered via `formatter` (the caller passes `formattedDate` from `reactive-settings.svelte.ts`), then the
138+
year portion of each line is wrapped in an age-tier `<span>` so the tooltip picks up the active date palette. The
139+
formatter callback keeps the util pure (no reactive imports here); the `tooltip` action accepts `{ html }` directly.
142140

143141
## Dependencies
144142

apps/desktop/src/lib/file-explorer/selection/SelectionInfo.a11y.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ vi.mock('$lib/indexing/index-state.svelte', () => ({
1818
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
1919
formatFileSize: (n: number) => `${String(n)} B`,
2020
formatDateTime: (t: number | undefined) => (t ? '2025-03-14 10:30' : ''),
21+
formattedDate: (t: number | undefined) =>
22+
t
23+
? {
24+
text: '2025-03-14 10:30',
25+
parts: {
26+
left: [
27+
{ text: '2025', ageClass: 'age-fresh' as const },
28+
{ text: '-', ageClass: null },
29+
{ text: '03', ageClass: null },
30+
{ text: '-', ageClass: null },
31+
{ text: '14', ageClass: null },
32+
],
33+
right: [
34+
{ text: '10', ageClass: null },
35+
{ text: ':', ageClass: null },
36+
{ text: '30', ageClass: null },
37+
],
38+
},
39+
}
40+
: { text: '', parts: { left: [], right: null } },
2141
getSizeDisplayMode: () => 'smart',
2242
getHumanFriendlySizeUnits: () => false,
2343
getFileSizeFormat: () => 'binary',

0 commit comments

Comments
 (0)