Skip to content

Commit 232a94b

Browse files
BeSovereignclaude
andauthored
fix(#384): Duplicates page: teal group frames, figures not in mono, 18 px checkboxes on pho
Duplicate groups are now list sheets (surface background, --border frame and row lines instead of the teal --line). The size and date are mono spans via i18n-t slots, each checkbox sits in a label.check-target that reuses the file list's 44 px phone rule, and the 'Where to look' legend has padding 0. A new Playwright spec checks all four on desktop and phone in the dark theme. Refs #384 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 65bbe37 commit 232a94b

3 files changed

Lines changed: 143 additions & 15 deletions

File tree

‎e2e/duplicates-design.spec.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { randomBytes } from 'node:crypto';
2+
import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test';
3+
import { openPlaces } from './places.ts';
4+
5+
// #384: the duplicates page against the design guide. A group is a list sheet (surface tone, `--border` hairline, rows
6+
// split by `--border`, never the teal `--line`), its size and dates are figures in the mono face, each copy's checkbox
7+
// sits in a 44 px label on the phone, and the "Where to look" legend starts at the column's edge. Dark theme, where
8+
// `--line` is the bright teal.
9+
const tag = (info: TestInfo) => `${info.project.name}-${info.repeatEachIndex}-${info.retry}`;
10+
const mobile = (info: TestInfo) => info.project.name.startsWith('mobile');
11+
const style = (l: Locator, prop: string) => l.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop);
12+
const MONO = /IBM Plex Mono/;
13+
14+
async function headers(page: Page) {
15+
const me = await (await page.request.get('/api/v1/me')).json() as { csrfToken: string };
16+
return { 'x-csrf-token': me.csrfToken, origin: new URL(page.url()).origin };
17+
}
18+
async function putTheme(page: Page, theme: 'dark' | 'system') {
19+
const r = await page.request.put('/api/v1/settings', { data: { locale: null, theme }, headers: await headers(page) });
20+
expect(r.status(), await r.text()).toBe(200);
21+
}
22+
async function put(page: Page, path: string, data: Buffer) {
23+
const r = await page.request.put(`/api/v1/sources/home/content?path=${encodeURIComponent(path)}&onCollision=fail`, {
24+
headers: { ...await headers(page), 'content-type': 'application/octet-stream' }, data });
25+
expect(r.status()).toBe(201);
26+
}
27+
/** A CSS colour expression as the page computes it in the current theme. */
28+
async function resolved(page: Page, css: string): Promise<string> {
29+
return page.evaluate((value) => {
30+
const probe = document.createElement('div');
31+
probe.style.color = value;
32+
document.body.append(probe);
33+
const out = getComputedStyle(probe).color;
34+
probe.remove();
35+
return out;
36+
}, css);
37+
}
38+
39+
test.afterEach(async ({ page }) => { await putTheme(page, 'system'); });
40+
41+
test('#384 duplicates: groups are list sheets, figures in mono, 44 px checkbox labels on the phone', async ({ page }, info) => {
42+
await page.goto('/');
43+
await expect(page.getByRole('navigation', { name: /Pfad|Path/ })).toBeVisible();
44+
await putTheme(page, 'dark');
45+
// Under one parent that sorts last, so the home's root keeps other tests' rows in place.
46+
const dir = `zz-duplicates-384/dups-${tag(info)}`;
47+
const h = await headers(page);
48+
expect([201, 409]).toContain((await page.request.post('/api/v1/sources/home/mkdir', { headers: h, data: { path: '/zz-duplicates-384' } })).status());
49+
expect((await page.request.post('/api/v1/sources/home/mkdir', { headers: h, data: { path: `/${dir}` } })).status()).toBe(201);
50+
const same = randomBytes(900);
51+
const first = `a-${tag(info)}.bin`;
52+
const second = `b-${tag(info)}.bin`;
53+
await put(page, `/${dir}/${first}`, same);
54+
await put(page, `/${dir}/${second}`, same);
55+
56+
await page.reload();
57+
const nav = await openPlaces(page);
58+
await nav.getByRole('link', { name: /^\s*(Doppelte Dateien|Duplicates)\s*$/ }).click();
59+
await expect(page.getByRole('heading', { name: /^(Doppelte Dateien|Duplicates)$/, level: 1 })).toBeVisible();
60+
61+
// The legend starts where the checkboxes below it start.
62+
const sources = page.locator('.duplicates-sources');
63+
const legendLeft = await sources.locator('legend').evaluate((el) => el.getBoundingClientRect().left);
64+
const fieldsetLeft = await sources.evaluate((el) => el.getBoundingClientRect().left);
65+
expect(legendLeft).toBeCloseTo(fieldsetLeft, 0);
66+
67+
await page.getByRole('button', { name: /^(Doppelte Dateien suchen|Find duplicates|Erneut suchen|Search again)$/ }).click();
68+
const group = page.locator('.duplicate-group', { has: page.getByText(second, { exact: true }) });
69+
await expect(group).toBeVisible({ timeout: 30_000 });
70+
await expect(group.locator('.duplicate-copy')).toHaveCount(2);
71+
72+
// A list sheet: surface tone and the warm hairline, rows split by it too; the teal `--line` is not a sheet edge.
73+
const line = await resolved(page, 'var(--line)');
74+
const border = await resolved(page, 'var(--border)');
75+
expect(border).not.toBe(line);
76+
expect(await style(group, 'border-top-color')).toBe(border);
77+
expect(await style(group, 'background-color')).toBe(await resolved(page, 'var(--surface)'));
78+
expect(await style(group.locator('.duplicate-copy').nth(1), 'border-top-color')).toBe(border);
79+
80+
// Figures in the mono face, inside their sentences.
81+
const sizeFigure = group.getByRole('heading').locator('.value-mono');
82+
await expect(sizeFigure).toHaveText(/900/);
83+
expect(await style(sizeFigure, 'font-family')).toMatch(MONO);
84+
expect(await style(group.getByRole('heading'), 'font-family')).not.toMatch(MONO);
85+
const dateFigure = group.locator('.duplicate-copy').first().locator('.origin .value-mono');
86+
await expect(dateFigure).toHaveText(/\d/);
87+
expect(await style(dateFigure, 'font-family')).toMatch(MONO);
88+
89+
// Each checkbox sits in a label; on the phone that label is a 44 px target.
90+
const checkbox = group.getByRole('checkbox', { name: new RegExp(second.replace(/\./g, '\\.')) });
91+
const label = checkbox.locator('xpath=ancestor::label[1]');
92+
await expect(label).toHaveClass(/check-target/);
93+
if (mobile(info)) {
94+
const b = (await label.boundingBox())!;
95+
expect(b.height).toBeGreaterThanOrEqual(44);
96+
expect(b.width).toBeGreaterThanOrEqual(44);
97+
}
98+
// The label hands a tap to its checkbox.
99+
await expect(checkbox).not.toBeChecked();
100+
await label.click();
101+
await expect(checkbox).toBeChecked();
102+
});

‎web/src/styles/app.css‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,7 +1228,7 @@ a.tag-chip:hover, .tag-chip a:hover { color: var(--line); }
12281228
.button { justify-content: center; }
12291229
.icon-button { min-width: 44px; min-height: 44px; }
12301230
.breadcrumbs a { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px; }
1231-
.file-list .check-target { width: 44px; height: 44px; margin: -13px; }
1231+
.file-list .check-target, .duplicate-copy .check-target { width: 44px; height: 44px; margin: -13px; }
12321232
.gallery-check { width: 44px; height: 44px; top: var(--space-1); left: var(--space-1); }
12331233
.gallery-menu { top: var(--space-1); right: var(--space-1); }
12341234
/* The name's 44 px reaches 10 px into the row's padding. It is padding around the text, not a minimum height (#237),
@@ -1248,7 +1248,7 @@ a.tag-chip:hover, .tag-chip a:hover { color: var(--line); }
12481248
.members-form { flex-wrap: wrap; }
12491249
.share-radio, .settings-radio { min-height: 44px; }
12501250
}
1251-
.file-list .check-target { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
1251+
.file-list .check-target, .duplicate-copy .check-target { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
12521252

12531253
/* ---- Batch rename (#215): the rules in a grid over the preview table, which scrolls inside the wide dialog ---- */
12541254
.batch-rename { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
@@ -1263,13 +1263,19 @@ a.tag-chip:hover, .tag-chip a:hover { color: var(--line); }
12631263

12641264
/* ---- Duplicates (#216): one group per card, its copies as rows with a checkbox, a thumbnail and where they are ---- */
12651265
.duplicates-sources { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); border: 0; padding: 0; margin: 0 0 var(--space-3); }
1266+
.duplicates-sources legend { padding: 0; }
12661267
.duplicates-note { color: var(--ink-muted); font-size: var(--fs-small); margin: 0 0 var(--space-4); }
12671268
.duplicates-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin-bottom: var(--space-4); }
12681269
.duplicates-progress { margin-bottom: var(--space-4); }
1269-
.duplicate-group { border: var(--border-w) solid var(--line); border-radius: var(--radius-tile); padding: var(--space-3); margin-bottom: var(--space-3); }
1270+
/* A group is a list sheet (#384): surface tone and the warm hairline; the teal `--line` stays for focus and selection. */
1271+
.duplicate-group {
1272+
background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-tile);
1273+
padding: var(--space-3); margin-bottom: var(--space-3);
1274+
}
12701275
.duplicate-group h2 { font-size: var(--fs-body); font-weight: 600; margin: 0 0 var(--space-2); }
12711276
.duplicate-copy { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
1272-
.duplicate-copy + .duplicate-copy { border-top: var(--border-w) solid var(--line); }
1277+
.duplicate-copy + .duplicate-copy { border-top: var(--border-w) solid var(--border); }
1278+
.duplicate-copy .check-target { flex: none; }
12731279
.duplicate-thumb { width: 48px; height: 48px; flex: none; display: flex; align-items: center; justify-content: center;
12741280
background: var(--bg); border-radius: var(--radius-control); overflow: hidden; }
12751281
.duplicate-thumb img { max-width: 100%; max-height: 100%; object-fit: cover; }

‎web/src/views/DuplicatesView.vue‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,20 +302,33 @@ const place = (c: DuplicateCopy) => folderLabel(c.source, parentOf(c.path));
302302
class="duplicate-group"
303303
:aria-label="$t('duplicates.groupTitle', { count: g.copies.length, size: size(g.size) })"
304304
>
305-
<h2>{{ $t('duplicates.groupTitle', { count: g.copies.length, size: size(g.size) }) }}</h2>
305+
<i18n-t
306+
keypath="duplicates.groupTitle"
307+
tag="h2"
308+
scope="global"
309+
>
310+
<template #count>
311+
{{ g.copies.length }}
312+
</template>
313+
<template #size>
314+
<span class="value-mono">{{ size(g.size) }}</span>
315+
</template>
316+
</i18n-t>
306317
<div
307318
v-for="c in g.copies"
308319
:key="`${c.source}:${c.path}`"
309320
class="duplicate-copy"
310321
>
311-
<input
312-
type="checkbox"
313-
:checked="isPicked(c)"
314-
:disabled="locked(g, c)"
315-
:title="locked(g, c) ? $t('duplicates.oneStays') : undefined"
316-
:aria-label="$t('duplicates.pick', { name: c.name, location: place(c) })"
317-
@change="toggle(c)"
318-
>
322+
<label class="check-target">
323+
<input
324+
type="checkbox"
325+
:checked="isPicked(c)"
326+
:disabled="locked(g, c)"
327+
:title="locked(g, c) ? $t('duplicates.oneStays') : undefined"
328+
:aria-label="$t('duplicates.pick', { name: c.name, location: place(c) })"
329+
@change="toggle(c)"
330+
>
331+
</label>
319332
<span
320333
class="duplicate-thumb"
321334
aria-hidden="true"
@@ -334,10 +347,17 @@ const place = (c: DuplicateCopy) => folderLabel(c.source, parentOf(c.path));
334347
<span class="duplicate-info">
335348
<span class="name">{{ c.name }}</span>
336349
<span class="origin">{{ place(c) }}</span>
337-
<span
350+
<i18n-t
338351
v-if="c.modified"
352+
keypath="duplicates.modified"
353+
tag="span"
339354
class="origin"
340-
>{{ $t('duplicates.modified', { date: date(c.modified) }) }}</span>
355+
scope="global"
356+
>
357+
<template #date>
358+
<span class="value-mono">{{ date(c.modified) }}</span>
359+
</template>
360+
</i18n-t>
341361
</span>
342362
</div>
343363
</section>

0 commit comments

Comments
 (0)